45 Commits
Author SHA1 Message Date
kyle dbfda83e14 Update src/features/login/hooks/use-create-user.ts
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 9s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 37s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m44s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m30s
2026-09-21 12:15:47 -05:00
kyle 8fde1c9362 Update src/features/login/hooks/use-consume-code.ts
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 9s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 43s
CI/CD Pipeline / Deploy to Kubernetes (push) Canceled after 0s
CI/CD Pipeline / Build and Push App Docker Image (push) Canceled after 12s
2026-09-21 12:15:00 -05:00
kyle 9febb8bb60 remove slide animation, use pill instead
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 9s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 38s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m11s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m31s
2026-09-14 00:53:24 -07:00
kyle a754fc0282 match cards h2h margins
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 11s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 37s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m11s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m30s
2026-09-11 11:53:27 -07:00
kyle 6f17a5d4a8 slide animation
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 7s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 44s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m10s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m31s
2026-09-11 10:28:45 -07:00
kyle 9ed3f7169e slide animation
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 13s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 31s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m25s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m29s
2026-09-11 08:32:42 -07:00
kyle c4a9d47180 slide animation
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 14s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 33s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m27s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m28s
2026-09-10 22:21:16 -07:00
kyle 8971748d5c i18n: refresh catalog line references
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 9s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 36s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m7s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m27s
2026-08-30 14:33:50 -05:00
kyle 0ac6636d08 h2h icon
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 16s
CI/CD Pipeline / i18n Catalog Check (push) Failing after 35s
CI/CD Pipeline / Build and Push App Docker Image (push) Skipped
CI/CD Pipeline / Deploy to Kubernetes (push) Skipped
2026-08-30 13:05:47 -05:00
kyle f6faf97cdf develpoer role
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 10s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 43s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m14s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m29s
2026-08-30 12:36:28 -05:00
kyle 6bb3ee4bc7 fix: render local-timezone dates client-side only
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 14s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 37s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m10s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m28s
- started-tournament header and tournament list formatted times in the
  server timezone during ssr, mismatching hydration on every load
2026-08-27 14:14:03 -07:00
kyle 7bda138d69 feat(telemetry): matchup and direction context in timelines; live error groups
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 9s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 47s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m18s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m29s
- h2h_viewed carries who-v-who, stats_sorted carries asc/desc via a
  timeline-only context prop
- error browser aggregates raw rows so new groups appear immediately
- catalogs re-extracted for shifted line references
2026-08-27 10:47:57 -07:00
kyle 9b51d61519 i18n: refresh catalog line references
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 42s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m28s
2026-08-27 10:32:14 -07:00
kyle 8e42862d20 fix(telemetry): review nits
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / i18n Catalog Check (push) Failing after 49s
CI/CD Pipeline / Build and Push App Docker Image (push) Skipped
CI/CD Pipeline / Deploy to Kubernetes (push) Skipped
- dock h2h source corrected to match_dock; badge_viewed breakdown via value
- stats filter guards unified, ref-based, double-tap safe
- emoji picker reuses handleReactionClick; comment and import tidy
2026-08-26 23:19:45 -07:00
kyle f593b2c7b8 feat(telemetry): product interaction events
- h2h_viewed from match card, dock, profile, stats, predictions
- reaction_sent with emoji and surface; score_report_opened from all
  three report entry points
- stats_filtered and stats_sorted on stats route and profile
- rules/teams/players/bracket/tournament view events with sources
- match_history_viewed, badge_viewed, player_searched (count only)
2026-08-26 23:04:36 -07:00
kyle fc65fdb451 feat(telemetry): resolve profile and team names in session timelines 2026-08-26 22:56:11 -07:00
kyle 8171354e2a feat(telemetry): generic event aggregates and usage range presets
- rollups emit event.count (dim name) and event.detail.count (dim name|detail
  from props.source/emoji/tournament)
- usage screen: events section with tap-through breakdown; 7d/30d/180d/custom
  date-range presets
2026-08-26 22:53:58 -07:00
kyle cb16d9757e fix(telemetry): idle-based session rotation; faster sse keepalive
- session id rotates after 30 idle minutes so installed pwas produce
  visit-sized sessions instead of one eternal id
- sse ping every 8s, under the idle timeout observed killing streams
2026-08-26 22:43:25 -07:00
kyle 96d5535293 fix: countdown hydration mismatch; rate-limit sse-error session refreshes
- suppressHydrationWarning on countdown time text (server second differs
  from client second; first tick corrects)
- sse reconnects attempt a session refresh at most once per minute so a
  flaky stream cannot hammer the refresh endpoint
2026-08-26 22:41:05 -07:00
kyle f5b49d49ee code fix
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 13s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 42s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m28s
2026-08-26 20:33:13 -07:00
kyle fd45ba1112 Merge branch 'admin-telemetry-redesign' into development
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 10s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 44s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m0s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m27s
2026-08-26 19:14:00 -07:00
kyle f13e5b752a refactor(enrollment): drop count header, keep player-count warnings 2026-08-26 19:12:33 -07:00
kyle 4106c3c846 fix(build): keep supertokens server init out of the client module graph
- ensureSuperTokensBackend moved to the health route handler; the dynamic
  imports in exclusions and alerts dragged recipes into the client build
2026-08-26 18:58:31 -07:00
kyle 044e573933 Merge branch 'admin-telemetry-redesign' into development
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 19s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 41s
CI/CD Pipeline / Build and Push App Docker Image (push) Failing after 51s
CI/CD Pipeline / Deploy to Kubernetes (push) Skipped
2026-08-25 23:36:10 -07:00
kyle 3d8ee52a15 feat(telemetry): opt-in admin alert pushes
- telemetryAlerts metadata flag, admin-gated server fn
- alerts sent only to admins with the flag enabled
- settings toggle visible to admins only, under device notifications
2026-08-25 23:33:01 -07:00
kyle 1ddcffc511 fix(telemetry): review findings
- wau/mau trailing scans run hourly and on day finalization, not every tick
- exclusion lookup failures cached 30s so an st outage cannot amplify
- unverified relation filter dropped; player emptiness checked in js
- anonymous unauthenticated read failures no longer logged
- alerts and exclusions ensure supertokens init on cold pods
2026-08-25 23:26:34 -07:00
kyle 31f6237446 refactor(admin): drop status strip from dashboard; full-bleed usage tab
- admin home is tournament section + tools list only
- usage route matches sibling tabs (fullWidth, no shell padding); screen
  pads its own content so the tab bar stays flush
2026-08-25 23:22:21 -07:00
kyle 6dff5f19c1 chore(k8s): optional telemetry_exclude_phones secret env 2026-08-25 23:04:31 -07:00
kyle 9f5fac2753 feat(telemetry): exclude configured phones from usage telemetry
- TELEMETRY_EXCLUDE_PHONES resolves to player ids at runtime, cached 10m
- excluded players write no activities, client events, or vitals
- their client errors are still captured
2026-08-25 23:04:14 -07:00
kyle c05bdec252 fix(sheet): native scroll container so vaul drag detection works 2026-08-25 22:59:00 -07:00
kyle e4d1ba720a feat(telemetry): session journey view replaces raw events table
- sessions listed from client_events grouped by session id (7d window)
- timeline sheet merges page views, client events, errors, and server-fn
  activities joined by player within the session window
- vitals excluded from timelines; gap markers over 5 minutes
2026-08-25 22:56:09 -07:00
kyle 5d7747839c refactor(telemetry): text-only list empty states, distinct p50/p95 series
- paginated-list empty prop is a plain string, no icon block
- second sparkline series drawn in dimmed color; area disabled on dual charts
- duration legend shows latest p50/p95 values
2026-08-25 22:38:45 -07:00
kyle fd0489a22f refactor(telemetry): chart scale labels, calmer vitals and error styling, usage-first tabs
- sparklines get start/end date and max-value footers
- vitals rating shown by value color instead of badges
- error rows use plain counts, subtle resolve button, terse empty state
- telemetry tab order: usage, players, functions, events, errors; usage is default
2026-08-25 22:35:16 -07:00
kyle 7ea189aa61 fix(telemetry): stop logging beacon requests as activities 2026-08-25 22:26:54 -07:00
kyle 1cc75591fa refactor(admin): align admin ui with app design language
- dashboard: card grid replaced by status strip + nav-tile section + list
- nav-tile promoted to shared component
- stat tiles and usage screen de-carded, sections divided like settings
- hub run tournament back to a list row
- telemetry link restored in tools list
2026-08-25 22:24:36 -07:00
kyle 895737febc style: trim comments to load-bearing invariants only 2026-08-25 22:13:41 -07:00
kyle 5e9d030297 fix(telemetry): enable client tracking in dev 2026-08-25 22:11:09 -07:00
kyle 2b02de2193 fix(telemetry): start scheduler from admin dashboard too, not only health probes 2026-08-25 22:09:08 -07:00
kyle 13481936f0 feat(start): csrf middleware for server functions 2026-08-25 22:05:25 -07:00
kyle 603d5a6f7f i18n: translate telemetry and admin strings for de/es/ja 2026-08-25 21:58:59 -07:00
kyle eb3dbe048b i18n: extract catalogs for telemetry and admin redesign strings 2026-08-25 21:54:51 -07:00
kyle 4af4a65527 bug
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 30s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m3s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m27s
2026-08-25 21:54:50 -07:00
kyle a52872bea8 feat(admin): dashboard home, telemetry section, enrollment ops screen
- mission-control dashboard: now strip, tournament quick actions, usage
  sparklines, error feed, live activity, tools list
- /admin/telemetry with functions/events/errors/players/usage child routes,
  hand-rolled svg chart primitives, decomposed activities table
- error browser with grouping and mark-resolved
- consolidated per-tournament enrollment screen; hub simplified with
  run tournament promoted
- /admin/activities redirects to /admin/telemetry/functions
2026-08-25 21:54:21 -07:00
kyle 06fe1f4ed7 feat(telemetry): rollups, alerts, scheduler, health checks, admin server fns
- pure rollup accumulators with tests; daily upserts keyed date/metric/dim
- alert checks with db-backed cooldown and push to admin players
- in-process scheduler started from health probes
- health endpoint checks pb + supertokens, stale-while-revalidate, always 200
- sse connection counter moved to emitter
- telemetry feature server fns: events/errors/groups/rollups/alerts/status
2026-08-25 21:38:08 -07:00
kyle e2863c1f6c feat(telemetry): collections, universal server-fn coverage, beacon, client tracker
- activities indexes + client_events/client_errors/rollups/alerts collections
- global function middleware via createStart; reads skipped on success
- actor stash, denial dedup, control-flow error guards
- api route logging, auth event overrides, beacon endpoint, web vitals
- centralized admin role checks, shared skeletons, award-badges fix
- extracted deriveRunState with tests
2026-08-25 21:33:25 -07:00
201 changed files with 9891 additions and 2677 deletions
+12 -9
View File
@@ -31,8 +31,8 @@
"facehash": "^0.0.7",
"framer-motion": "^12.23.12",
"pocketbase": "^0.26.2",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-imask": "^7.6.1",
"redaxios": "^0.5.1",
"sonner": "^2.0.7",
@@ -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": {
@@ -48,8 +49,8 @@
"@lingui/cli": "^6.5.0",
"@lingui/vite-plugin": "^6.5.0",
"@types/node": "^22.5.4",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@types/web-push": "^3.6.4",
"@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.20",
@@ -654,9 +655,9 @@
"@types/node": ["@types/node@22.19.10", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw=="],
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
"@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
@@ -1328,9 +1329,9 @@
"raf-schd": ["raf-schd@4.0.3", "", {}, "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="],
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
"react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
"react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="],
"react-imask": ["react-imask@7.6.1", "", { "dependencies": { "imask": "^7.6.1", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">=0.14.0" } }, "sha512-vLNfzcCz62Yzx/GRGh5tiCph9Gbh2cZu+Tz8OiO5it2eNuuhpA0DWhhSlOtVtSJ80+Bx+vFK5De8eQ9AmbkXzA=="],
@@ -1396,7 +1397,7 @@
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
"scmp": ["scmp@2.1.0", "", {}, "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q=="],
@@ -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=="],
+6
View File
@@ -55,6 +55,12 @@ spec:
value: "3000"
- name: NODE_ENV
value: "production"
- name: TELEMETRY_EXCLUDE_PHONES
valueFrom:
secretKeyRef:
name: flxn-secrets
key: telemetry_exclude_phones
optional: true
- name: TWILIO_ACCOUNT_SID
valueFrom:
+5 -4
View File
@@ -43,8 +43,8 @@
"facehash": "^0.0.7",
"framer-motion": "^12.23.12",
"pocketbase": "^0.26.2",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-imask": "^7.6.1",
"redaxios": "^0.5.1",
"sonner": "^2.0.7",
@@ -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": {
@@ -60,8 +61,8 @@
"@lingui/cli": "^6.5.0",
"@lingui/vite-plugin": "^6.5.0",
"@types/node": "^22.5.4",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@types/web-push": "^3.6.4",
"@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.20",
@@ -0,0 +1,23 @@
/// <reference path="../pb_data/types.d.ts" />
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)
})
@@ -0,0 +1,167 @@
/// <reference path="../pb_data/types.d.ts" />
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);
})
@@ -0,0 +1,207 @@
/// <reference path="../pb_data/types.d.ts" />
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);
})
@@ -0,0 +1,240 @@
/// <reference path="../pb_data/types.d.ts" />
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"));
})
-1
View File
@@ -13,7 +13,6 @@ const { count, size, warnings } = await generateSW({
'icon-192x192.png',
'icon-512x512.png',
'site.webmanifest',
'styles.css',
'push-handlers.js',
],
// Hand-written Web Push handlers (public/push-handlers.js) are pulled into
+17 -8
View File
@@ -1,5 +1,5 @@
// Grant or revoke the "Admin" role for a user by phone number (US +1 assumed).
// Usage: bun run scripts/make-admin.ts <phone> [--revoke]
// Grant or revoke a role for a user by phone number (US +1 assumed).
// Usage: bun run scripts/make-admin.ts <phone> [--role Admin|Developer] [--revoke]
import "dotenv/config";
import SuperTokens from "supertokens-node";
import Session from "supertokens-node/recipe/session";
@@ -26,10 +26,19 @@ SuperTokens.init({
],
});
const VALID_ROLES = ["Admin", "Developer"] as const;
const raw = process.argv[2];
const revoke = process.argv.includes("--revoke");
const roleFlagIndex = process.argv.indexOf("--role");
const role = roleFlagIndex === -1 ? "Admin" : process.argv[roleFlagIndex + 1];
if (!raw) {
console.error("Usage: bun run scripts/make-admin.ts <phone> [--revoke]");
console.error("Usage: bun run scripts/make-admin.ts <phone> [--role Admin|Developer] [--revoke]");
process.exit(1);
}
if (!VALID_ROLES.includes(role as (typeof VALID_ROLES)[number])) {
console.error(`Invalid role "${role}". Valid roles: ${VALID_ROLES.join(", ")}`);
process.exit(1);
}
@@ -60,15 +69,15 @@ if (!user) {
process.exit(1);
}
await UserRoles.createNewRoleOrAddPermissions("Admin", []);
await UserRoles.createNewRoleOrAddPermissions(role, []);
if (revoke) {
const res = await UserRoles.removeUserRole("public", user.id, "Admin");
console.log(`Removed Admin from ${matched} (user ${user.id}):`, res.status);
const res = await UserRoles.removeUserRole("public", user.id, role);
console.log(`Removed ${role} from ${matched} (user ${user.id}):`, res.status);
} else {
const res = await UserRoles.addRoleToUser("public", user.id, "Admin");
const res = await UserRoles.addRoleToUser("public", user.id, role);
console.log(
`Granted Admin to ${matched} (user ${user.id}):`,
`Granted ${role} to ${matched} (user ${user.id}):`,
res.status === "OK"
? res.didUserAlreadyHaveRole
? "already had it"
+206 -1
View File
@@ -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'
@@ -37,19 +38,27 @@ import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$'
import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId'
import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId'
import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId'
import { Route as AuthedAdminTelemetryRouteImport } from './routes/_authed/admin/telemetry'
import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview'
import { Route as AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges'
import { Route as AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities'
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
import { Route as AuthedAdminTelemetryIndexRouteImport } from './routes/_authed/admin/telemetry/index'
import { Route as AuthedTournamentsIdPredictionsRouteImport } from './routes/_authed/tournaments/$id.predictions'
import { Route as AuthedTournamentsIdGroupsRouteImport } from './routes/_authed/tournaments/$id.groups'
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
import { Route as AuthedAdminTelemetryUsageRouteImport } from './routes/_authed/admin/telemetry/usage'
import { Route as AuthedAdminTelemetrySessionsRouteImport } from './routes/_authed/admin/telemetry/sessions'
import { Route as AuthedAdminTelemetryPlayersRouteImport } from './routes/_authed/admin/telemetry/players'
import { Route as AuthedAdminTelemetryFunctionsRouteImport } from './routes/_authed/admin/telemetry/functions'
import { Route as AuthedAdminTelemetryErrorsRouteImport } from './routes/_authed/admin/telemetry/errors'
import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index'
import { Route as ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file'
import { Route as AuthedTournamentsIdPredictionsMakeRouteImport } from './routes/_authed/tournaments/$id.predictions_.make'
import { Route as AuthedTournamentsIdPredictionsPlayerIdRouteImport } from './routes/_authed/tournaments/$id.predictions_.$playerId'
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
import { Route as AuthedAdminTournamentsIdEnrollmentRouteImport } from './routes/_authed/admin/tournaments/$id/enrollment'
import { Route as AuthedAdminTournamentsIdAssignPartnersRouteImport } from './routes/_authed/admin/tournaments/$id/assign-partners'
const RefreshSessionRoute = RefreshSessionRouteImport.update({
@@ -117,6 +126,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',
@@ -193,6 +207,11 @@ const AuthedProfilePlayerIdRoute = AuthedProfilePlayerIdRouteImport.update({
path: '/profile/$playerId',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedAdminTelemetryRoute = AuthedAdminTelemetryRouteImport.update({
id: '/telemetry',
path: '/telemetry',
getParentRoute: () => AuthedAdminRoute,
} as any)
const AuthedAdminPreviewRoute = AuthedAdminPreviewRouteImport.update({
id: '/preview',
path: '/preview',
@@ -214,6 +233,12 @@ const AuthedAdminTournamentsIndexRoute =
path: '/tournaments/',
getParentRoute: () => AuthedAdminRoute,
} as any)
const AuthedAdminTelemetryIndexRoute =
AuthedAdminTelemetryIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AuthedAdminTelemetryRoute,
} as any)
const AuthedTournamentsIdPredictionsRoute =
AuthedTournamentsIdPredictionsRouteImport.update({
id: '/tournaments/$id/predictions',
@@ -232,6 +257,36 @@ const AuthedTournamentsIdBracketRoute =
path: '/tournaments/$id/bracket',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedAdminTelemetryUsageRoute =
AuthedAdminTelemetryUsageRouteImport.update({
id: '/usage',
path: '/usage',
getParentRoute: () => AuthedAdminTelemetryRoute,
} as any)
const AuthedAdminTelemetrySessionsRoute =
AuthedAdminTelemetrySessionsRouteImport.update({
id: '/sessions',
path: '/sessions',
getParentRoute: () => AuthedAdminTelemetryRoute,
} as any)
const AuthedAdminTelemetryPlayersRoute =
AuthedAdminTelemetryPlayersRouteImport.update({
id: '/players',
path: '/players',
getParentRoute: () => AuthedAdminTelemetryRoute,
} as any)
const AuthedAdminTelemetryFunctionsRoute =
AuthedAdminTelemetryFunctionsRouteImport.update({
id: '/functions',
path: '/functions',
getParentRoute: () => AuthedAdminTelemetryRoute,
} as any)
const AuthedAdminTelemetryErrorsRoute =
AuthedAdminTelemetryErrorsRouteImport.update({
id: '/errors',
path: '/errors',
getParentRoute: () => AuthedAdminTelemetryRoute,
} as any)
const AuthedAdminTournamentsIdIndexRoute =
AuthedAdminTournamentsIdIndexRouteImport.update({
id: '/tournaments/$id/',
@@ -268,6 +323,12 @@ const AuthedAdminTournamentsIdTeamsRoute =
path: '/tournaments/$id/teams',
getParentRoute: () => AuthedAdminRoute,
} as any)
const AuthedAdminTournamentsIdEnrollmentRoute =
AuthedAdminTournamentsIdEnrollmentRouteImport.update({
id: '/tournaments/$id/enrollment',
path: '/tournaments/$id/enrollment',
getParentRoute: () => AuthedAdminRoute,
} as any)
const AuthedAdminTournamentsIdAssignPartnersRoute =
AuthedAdminTournamentsIdAssignPartnersRouteImport.update({
id: '/tournaments/$id/assign-partners',
@@ -288,6 +349,7 @@ export interface FileRoutesByFullPath {
'/admin/activities': typeof AuthedAdminActivitiesRoute
'/admin/badges': typeof AuthedAdminBadgesRoute
'/admin/preview': typeof AuthedAdminPreviewRoute
'/admin/telemetry': typeof AuthedAdminTelemetryRouteWithChildren
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
@@ -303,14 +365,22 @@ 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
'/admin/telemetry/errors': typeof AuthedAdminTelemetryErrorsRoute
'/admin/telemetry/functions': typeof AuthedAdminTelemetryFunctionsRoute
'/admin/telemetry/players': typeof AuthedAdminTelemetryPlayersRoute
'/admin/telemetry/sessions': typeof AuthedAdminTelemetrySessionsRoute
'/admin/telemetry/usage': typeof AuthedAdminTelemetryUsageRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
'/admin/telemetry/': typeof AuthedAdminTelemetryIndexRoute
'/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/admin/tournaments/$id/enrollment': typeof AuthedAdminTournamentsIdEnrollmentRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
@@ -345,14 +415,22 @@ 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
'/admin/telemetry/errors': typeof AuthedAdminTelemetryErrorsRoute
'/admin/telemetry/functions': typeof AuthedAdminTelemetryFunctionsRoute
'/admin/telemetry/players': typeof AuthedAdminTelemetryPlayersRoute
'/admin/telemetry/sessions': typeof AuthedAdminTelemetrySessionsRoute
'/admin/telemetry/usage': typeof AuthedAdminTelemetryUsageRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
'/admin/telemetry': typeof AuthedAdminTelemetryIndexRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/admin/tournaments/$id/enrollment': typeof AuthedAdminTournamentsIdEnrollmentRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
@@ -375,6 +453,7 @@ export interface FileRoutesById {
'/_authed/admin/activities': typeof AuthedAdminActivitiesRoute
'/_authed/admin/badges': typeof AuthedAdminBadgesRoute
'/_authed/admin/preview': typeof AuthedAdminPreviewRoute
'/_authed/admin/telemetry': typeof AuthedAdminTelemetryRouteWithChildren
'/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute
'/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
@@ -390,14 +469,22 @@ 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
'/_authed/admin/telemetry/errors': typeof AuthedAdminTelemetryErrorsRoute
'/_authed/admin/telemetry/functions': typeof AuthedAdminTelemetryFunctionsRoute
'/_authed/admin/telemetry/players': typeof AuthedAdminTelemetryPlayersRoute
'/_authed/admin/telemetry/sessions': typeof AuthedAdminTelemetrySessionsRoute
'/_authed/admin/telemetry/usage': typeof AuthedAdminTelemetryUsageRoute
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/_authed/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
'/_authed/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
'/_authed/admin/telemetry/': typeof AuthedAdminTelemetryIndexRoute
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
'/_authed/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/_authed/admin/tournaments/$id/enrollment': typeof AuthedAdminTournamentsIdEnrollmentRoute
'/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/_authed/tournaments/$id/predictions_/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
@@ -420,6 +507,7 @@ export interface FileRouteTypes {
| '/admin/activities'
| '/admin/badges'
| '/admin/preview'
| '/admin/telemetry'
| '/profile/$playerId'
| '/teams/$teamId'
| '/tournaments/$tournamentId'
@@ -435,14 +523,22 @@ export interface FileRouteTypes {
| '/api/spotify/search'
| '/api/spotify/token'
| '/api/teams/upload-logo'
| '/api/telemetry/beacon'
| '/api/tournaments/upload-logo'
| '/admin/'
| '/tournaments/'
| '/admin/telemetry/errors'
| '/admin/telemetry/functions'
| '/admin/telemetry/players'
| '/admin/telemetry/sessions'
| '/admin/telemetry/usage'
| '/tournaments/$id/bracket'
| '/tournaments/$id/groups'
| '/tournaments/$id/predictions'
| '/admin/telemetry/'
| '/admin/tournaments/'
| '/admin/tournaments/$id/assign-partners'
| '/admin/tournaments/$id/enrollment'
| '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id'
| '/tournaments/$id/predictions/$playerId'
@@ -477,14 +573,22 @@ export interface FileRouteTypes {
| '/api/spotify/search'
| '/api/spotify/token'
| '/api/teams/upload-logo'
| '/api/telemetry/beacon'
| '/api/tournaments/upload-logo'
| '/admin'
| '/tournaments'
| '/admin/telemetry/errors'
| '/admin/telemetry/functions'
| '/admin/telemetry/players'
| '/admin/telemetry/sessions'
| '/admin/telemetry/usage'
| '/tournaments/$id/bracket'
| '/tournaments/$id/groups'
| '/tournaments/$id/predictions'
| '/admin/telemetry'
| '/admin/tournaments'
| '/admin/tournaments/$id/assign-partners'
| '/admin/tournaments/$id/enrollment'
| '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id'
| '/tournaments/$id/predictions/$playerId'
@@ -506,6 +610,7 @@ export interface FileRouteTypes {
| '/_authed/admin/activities'
| '/_authed/admin/badges'
| '/_authed/admin/preview'
| '/_authed/admin/telemetry'
| '/_authed/profile/$playerId'
| '/_authed/teams/$teamId'
| '/_authed/tournaments/$tournamentId'
@@ -521,14 +626,22 @@ export interface FileRouteTypes {
| '/api/spotify/search'
| '/api/spotify/token'
| '/api/teams/upload-logo'
| '/api/telemetry/beacon'
| '/api/tournaments/upload-logo'
| '/_authed/admin/'
| '/_authed/tournaments/'
| '/_authed/admin/telemetry/errors'
| '/_authed/admin/telemetry/functions'
| '/_authed/admin/telemetry/players'
| '/_authed/admin/telemetry/sessions'
| '/_authed/admin/telemetry/usage'
| '/_authed/tournaments/$id/bracket'
| '/_authed/tournaments/$id/groups'
| '/_authed/tournaments/$id/predictions'
| '/_authed/admin/telemetry/'
| '/_authed/admin/tournaments/'
| '/_authed/admin/tournaments/$id/assign-partners'
| '/_authed/admin/tournaments/$id/enrollment'
| '/_authed/admin/tournaments/$id/teams'
| '/_authed/admin/tournaments/run/$id'
| '/_authed/tournaments/$id/predictions_/$playerId'
@@ -555,6 +668,7 @@ export interface RootRouteChildren {
ApiSpotifySearchRoute: typeof ApiSpotifySearchRoute
ApiSpotifyTokenRoute: typeof ApiSpotifyTokenRoute
ApiTeamsUploadLogoRoute: typeof ApiTeamsUploadLogoRoute
ApiTelemetryBeaconRoute: typeof ApiTelemetryBeaconRoute
ApiTournamentsUploadLogoRoute: typeof ApiTournamentsUploadLogoRoute
ApiFilesCollectionRecordIdFileRoute: typeof ApiFilesCollectionRecordIdFileRoute
}
@@ -652,6 +766,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'
@@ -757,6 +878,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedProfilePlayerIdRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/admin/telemetry': {
id: '/_authed/admin/telemetry'
path: '/telemetry'
fullPath: '/admin/telemetry'
preLoaderRoute: typeof AuthedAdminTelemetryRouteImport
parentRoute: typeof AuthedAdminRoute
}
'/_authed/admin/preview': {
id: '/_authed/admin/preview'
path: '/preview'
@@ -785,6 +913,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport
parentRoute: typeof AuthedAdminRoute
}
'/_authed/admin/telemetry/': {
id: '/_authed/admin/telemetry/'
path: '/'
fullPath: '/admin/telemetry/'
preLoaderRoute: typeof AuthedAdminTelemetryIndexRouteImport
parentRoute: typeof AuthedAdminTelemetryRoute
}
'/_authed/tournaments/$id/predictions': {
id: '/_authed/tournaments/$id/predictions'
path: '/tournaments/$id/predictions'
@@ -806,6 +941,41 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedTournamentsIdBracketRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/admin/telemetry/usage': {
id: '/_authed/admin/telemetry/usage'
path: '/usage'
fullPath: '/admin/telemetry/usage'
preLoaderRoute: typeof AuthedAdminTelemetryUsageRouteImport
parentRoute: typeof AuthedAdminTelemetryRoute
}
'/_authed/admin/telemetry/sessions': {
id: '/_authed/admin/telemetry/sessions'
path: '/sessions'
fullPath: '/admin/telemetry/sessions'
preLoaderRoute: typeof AuthedAdminTelemetrySessionsRouteImport
parentRoute: typeof AuthedAdminTelemetryRoute
}
'/_authed/admin/telemetry/players': {
id: '/_authed/admin/telemetry/players'
path: '/players'
fullPath: '/admin/telemetry/players'
preLoaderRoute: typeof AuthedAdminTelemetryPlayersRouteImport
parentRoute: typeof AuthedAdminTelemetryRoute
}
'/_authed/admin/telemetry/functions': {
id: '/_authed/admin/telemetry/functions'
path: '/functions'
fullPath: '/admin/telemetry/functions'
preLoaderRoute: typeof AuthedAdminTelemetryFunctionsRouteImport
parentRoute: typeof AuthedAdminTelemetryRoute
}
'/_authed/admin/telemetry/errors': {
id: '/_authed/admin/telemetry/errors'
path: '/errors'
fullPath: '/admin/telemetry/errors'
preLoaderRoute: typeof AuthedAdminTelemetryErrorsRouteImport
parentRoute: typeof AuthedAdminTelemetryRoute
}
'/_authed/admin/tournaments/$id/': {
id: '/_authed/admin/tournaments/$id/'
path: '/tournaments/$id'
@@ -848,6 +1018,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminTournamentsIdTeamsRouteImport
parentRoute: typeof AuthedAdminRoute
}
'/_authed/admin/tournaments/$id/enrollment': {
id: '/_authed/admin/tournaments/$id/enrollment'
path: '/tournaments/$id/enrollment'
fullPath: '/admin/tournaments/$id/enrollment'
preLoaderRoute: typeof AuthedAdminTournamentsIdEnrollmentRouteImport
parentRoute: typeof AuthedAdminRoute
}
'/_authed/admin/tournaments/$id/assign-partners': {
id: '/_authed/admin/tournaments/$id/assign-partners'
path: '/tournaments/$id/assign-partners'
@@ -858,13 +1035,36 @@ declare module '@tanstack/react-router' {
}
}
interface AuthedAdminTelemetryRouteChildren {
AuthedAdminTelemetryErrorsRoute: typeof AuthedAdminTelemetryErrorsRoute
AuthedAdminTelemetryFunctionsRoute: typeof AuthedAdminTelemetryFunctionsRoute
AuthedAdminTelemetryPlayersRoute: typeof AuthedAdminTelemetryPlayersRoute
AuthedAdminTelemetrySessionsRoute: typeof AuthedAdminTelemetrySessionsRoute
AuthedAdminTelemetryUsageRoute: typeof AuthedAdminTelemetryUsageRoute
AuthedAdminTelemetryIndexRoute: typeof AuthedAdminTelemetryIndexRoute
}
const AuthedAdminTelemetryRouteChildren: AuthedAdminTelemetryRouteChildren = {
AuthedAdminTelemetryErrorsRoute: AuthedAdminTelemetryErrorsRoute,
AuthedAdminTelemetryFunctionsRoute: AuthedAdminTelemetryFunctionsRoute,
AuthedAdminTelemetryPlayersRoute: AuthedAdminTelemetryPlayersRoute,
AuthedAdminTelemetrySessionsRoute: AuthedAdminTelemetrySessionsRoute,
AuthedAdminTelemetryUsageRoute: AuthedAdminTelemetryUsageRoute,
AuthedAdminTelemetryIndexRoute: AuthedAdminTelemetryIndexRoute,
}
const AuthedAdminTelemetryRouteWithChildren =
AuthedAdminTelemetryRoute._addFileChildren(AuthedAdminTelemetryRouteChildren)
interface AuthedAdminRouteChildren {
AuthedAdminActivitiesRoute: typeof AuthedAdminActivitiesRoute
AuthedAdminBadgesRoute: typeof AuthedAdminBadgesRoute
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
AuthedAdminTelemetryRoute: typeof AuthedAdminTelemetryRouteWithChildren
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
AuthedAdminTournamentsIdAssignPartnersRoute: typeof AuthedAdminTournamentsIdAssignPartnersRoute
AuthedAdminTournamentsIdEnrollmentRoute: typeof AuthedAdminTournamentsIdEnrollmentRoute
AuthedAdminTournamentsIdTeamsRoute: typeof AuthedAdminTournamentsIdTeamsRoute
AuthedAdminTournamentsRunIdRoute: typeof AuthedAdminTournamentsRunIdRoute
AuthedAdminTournamentsIdIndexRoute: typeof AuthedAdminTournamentsIdIndexRoute
@@ -874,10 +1074,13 @@ const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
AuthedAdminActivitiesRoute: AuthedAdminActivitiesRoute,
AuthedAdminBadgesRoute: AuthedAdminBadgesRoute,
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
AuthedAdminTelemetryRoute: AuthedAdminTelemetryRouteWithChildren,
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
AuthedAdminTournamentsIdAssignPartnersRoute:
AuthedAdminTournamentsIdAssignPartnersRoute,
AuthedAdminTournamentsIdEnrollmentRoute:
AuthedAdminTournamentsIdEnrollmentRoute,
AuthedAdminTournamentsIdTeamsRoute: AuthedAdminTournamentsIdTeamsRoute,
AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute,
AuthedAdminTournamentsIdIndexRoute: AuthedAdminTournamentsIdIndexRoute,
@@ -944,6 +1147,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiSpotifySearchRoute: ApiSpotifySearchRoute,
ApiSpotifyTokenRoute: ApiSpotifyTokenRoute,
ApiTeamsUploadLogoRoute: ApiTeamsUploadLogoRoute,
ApiTelemetryBeaconRoute: ApiTelemetryBeaconRoute,
ApiTournamentsUploadLogoRoute: ApiTournamentsUploadLogoRoute,
ApiFilesCollectionRecordIdFileRoute: ApiFilesCollectionRecordIdFileRoute,
}
@@ -952,10 +1156,11 @@ export const routeTree = rootRouteImport
._addFileTypes<FileRouteTypes>()
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<ReturnType<typeof getRouter>>
config: Awaited<ReturnType<typeof startInstance.getOptions>>
}
}
+4
View File
@@ -3,6 +3,7 @@ import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
import { routeTree } from "./routeTree.gen";
import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary";
import RoutePending from "../components/route-pending";
import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
export function getRouter() {
@@ -30,6 +31,9 @@ export function getRouter() {
},
defaultPreload: "intent",
defaultPreloadStaleTime: 60_000,
defaultPendingMs: 100,
defaultPendingMinMs: 0,
defaultPendingComponent: RoutePending,
defaultErrorComponent: DefaultCatchBoundary,
});
+22 -4
View File
@@ -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";
@@ -29,6 +31,7 @@ import { getRootMeta } from "@/lib/i18n/meta";
import FullScreenLoader from "@/components/full-screen-loader";
import { CHROME_COLORS } from "@/lib/mantine/theme-colors";
import mantineCssUrl from '@mantine/core/styles.css?url'
import appStylesUrl from '../styles.css?url'
import mantineDatesCssUrl from '@mantine/dates/styles.css?url'
import mantineCarouselCssUrl from '@mantine/carousel/styles.css?url'
import mantineTiptapCssUrl from '@mantine/tiptap/styles.css?url'
@@ -99,6 +102,7 @@ export const Route = createRootRouteWithContext<{
},
{ rel: "manifest", href: "/site.webmanifest" },
{ rel: "icon", href: "/favicon.ico" },
{ rel: 'stylesheet', href: appStylesUrl },
{ rel: 'stylesheet', href: mantineCssUrl },
{ rel: 'stylesheet', href: mantineCarouselCssUrl },
{ rel: 'stylesheet', href: mantineDatesCssUrl },
@@ -175,12 +179,27 @@ 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(() => {});
// first install claims the page too; only reload on real updates
const hadController = !!navigator.serviceWorker.controller;
let reloading = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (!hadController || reloading) return;
reloading = true;
window.location.reload();
});
navigator.serviceWorker.register('/sw.js').then((registration) => {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') registration.update().catch(() => {});
});
}).catch(() => track('sw_register_failed'));
}
}, []);
}, [router]);
return (
<RootDocument>
@@ -193,7 +212,6 @@ function RootComponent() {
);
}
// todo: analytics -> process.env data-website-id
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html
@@ -227,7 +245,6 @@ function RootDocument({ children }: { children: React.ReactNode }) {
'try{var d=document.documentElement,t=localStorage.getItem("flxn-theme");if(t&&t!=="default"){d.setAttribute("data-theme",t);}var s=localStorage.getItem("flxn-theme-scheme");if(s){d.setAttribute("data-mantine-color-scheme",s);}var f=JSON.parse(localStorage.getItem("flxn-theme-fonts")||"[]");for(var i=0;i<f.length;i++){var l=document.createElement("link");l.rel="stylesheet";l.href=f[i];l.setAttribute("data-flxn-theme-font",t||"default");document.head.appendChild(l);}}catch(e){}',
}}
/>
<link rel="stylesheet" href="/styles.css" />
</head>
<body
style={{
@@ -239,6 +256,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
}}
>
<div className="app">{children}</div>
<div className="flxn-theme-overlay" aria-hidden="true" />
<Scripts />
</body>
</html>
+2 -1
View File
@@ -1,11 +1,12 @@
import { Outlet, redirect, createFileRoute } from "@tanstack/react-router";
import { msg } from "@lingui/core/macro";
import type { HeaderConfig } from "@/features/core/types/header-config";
import { isAdminRole } from "@/features/core/utils/roles";
export const Route = createFileRoute("/_authed/admin")({
component: Outlet,
beforeLoad: ({ context }) => {
if (!context.auth?.roles?.includes("Admin")) {
if (!isAdminRole(context.auth?.roles)) {
throw redirect({ to: "/" });
}
+3 -71
View File
@@ -1,75 +1,7 @@
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 { Suspense, useState } from "react";
import { Trans } from "@lingui/react/macro";
import { msg } from "@lingui/core/macro";
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/_authed/admin/activities")({
component: Stats,
beforeLoad: ({ context }) => {
const queryClient = context.queryClient;
prefetchServerQuery(queryClient, activityQueries.search());
prefetchServerQuery(queryClient, playerQueries.activity());
beforeLoad: () => {
throw redirect({ to: "/admin/telemetry/functions" });
},
loader: () => ({
withPadding: false,
fullWidth: true,
header: {
title: msg`Activities`,
withBackButton: true,
},
refresh: [activityQueries.search().queryKey, playerQueries.activity().queryKey],
}),
});
function ActivityRowsSkeleton({ withSearch = false }: { withSearch?: boolean }) {
return (
<Stack gap={0}>
{withSearch && (
<Box px="md" pb="sm">
<Skeleton height={42} radius="sm" />
</Box>
)}
{Array.from({ length: 8 }).map((_, index) => (
<div key={`activity-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.1, 0.35) }}>
<Group p="md" wrap="nowrap" w="100%" justify="space-between">
<Stack gap={6}>
<Skeleton height={14} width={160} radius="sm" />
<Skeleton height={10} width={100} radius="sm" />
</Stack>
<Skeleton height={10} width={60} radius="sm" />
</Group>
<Divider />
</div>
))}
</Stack>
);
}
function Stats() {
const [activeTab, setActiveTab] = useState<string | null>("server-functions");
return (
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List mb='md'>
<Tabs.Tab value="server-functions"><Trans>Server Functions</Trans></Tabs.Tab>
<Tabs.Tab value="player-activity"><Trans>Player Activity</Trans></Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="server-functions">
<Suspense fallback={<ActivityRowsSkeleton withSearch />}>
<ActivitiesTable />
</Suspense>
</Tabs.Panel>
<Tabs.Panel value="player-activity">
<Suspense fallback={<ActivityRowsSkeleton />}>
<PlayersActivityTable />
</Suspense>
</Tabs.Panel>
</Tabs>
);
}
+7 -2
View File
@@ -1,8 +1,13 @@
import { createFileRoute } from "@tanstack/react-router";
import { AdminPage } from "@/features/admin";
import { msg } from "@lingui/core/macro";
import { AdminDashboard } from "@/features/admin";
import { tournamentQueries } from "@/features/tournaments/queries";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
export const Route = createFileRoute("/_authed/admin/")({
beforeLoad: ({ context }) => {
prefetchServerQuery(context.queryClient, tournamentQueries.current());
},
loader: () => ({
header: {
withBackButton: true,
@@ -10,5 +15,5 @@ export const Route = createFileRoute("/_authed/admin/")({
},
withPadding: false,
}),
component: () => <AdminPage />,
component: () => <AdminDashboard />,
});
@@ -0,0 +1,70 @@
import { Outlet, createFileRoute, redirect, useNavigate, useRouterState } from "@tanstack/react-router";
import { Tabs } from "@mantine/core";
import { Trans } from "@lingui/react/macro";
import { msg } from "@lingui/core/macro";
import { isDeveloperRole } from "@/features/core/utils/roles";
const TAB_ROUTES = {
usage: "/admin/telemetry/usage",
players: "/admin/telemetry/players",
functions: "/admin/telemetry/functions",
sessions: "/admin/telemetry/sessions",
errors: "/admin/telemetry/errors",
} as const;
type TabValue = keyof typeof TAB_ROUTES;
export const Route = createFileRoute("/_authed/admin/telemetry")({
component: TelemetryLayout,
beforeLoad: ({ context }) => {
if (!isDeveloperRole(context.auth?.roles)) {
throw redirect({ to: "/admin" });
}
},
loader: () => ({
header: {
title: msg`Telemetry`,
withBackButton: true,
},
}),
});
function TelemetryLayout() {
const navigate = useNavigate();
const pathname = useRouterState({ select: (s) => s.location.pathname });
const activeTab =
(Object.keys(TAB_ROUTES) as TabValue[]).find((tab) =>
pathname.endsWith(`/${tab}`)
) ?? "usage";
return (
<Tabs
value={activeTab}
onChange={(value) => {
if (value && value in TAB_ROUTES) {
navigate({ to: TAB_ROUTES[value as TabValue] });
}
}}
>
<Tabs.List mb="md" style={{ flexWrap: "nowrap", overflowX: "auto", overflowY: "hidden" }}>
<Tabs.Tab value="usage">
<Trans>Usage</Trans>
</Tabs.Tab>
<Tabs.Tab value="players">
<Trans>Players</Trans>
</Tabs.Tab>
<Tabs.Tab value="functions">
<Trans>Functions</Trans>
</Tabs.Tab>
<Tabs.Tab value="sessions">
<Trans>Sessions</Trans>
</Tabs.Tab>
<Tabs.Tab value="errors">
<Trans>Errors</Trans>
</Tabs.Tab>
</Tabs.List>
<Outlet />
</Tabs>
);
}
@@ -0,0 +1,27 @@
import { createFileRoute } from "@tanstack/react-router";
import { Suspense } from "react";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { telemetryQueries, lastNDays, ErrorBrowser } from "@/features/telemetry";
import { ListSkeleton } from "@/components/skeletons";
const errorGroupParams = () => ({ ...lastNDays(30), includeResolved: false });
export const Route = createFileRoute("/_authed/admin/telemetry/errors")({
component: ErrorsTab,
beforeLoad: ({ context }) => {
prefetchServerQuery(context.queryClient, telemetryQueries.errorGroups(errorGroupParams()));
},
loader: () => ({
withPadding: false,
fullWidth: true,
refresh: [telemetryQueries.errorGroups(errorGroupParams()).queryKey],
}),
});
function ErrorsTab() {
return (
<Suspense fallback={<ListSkeleton rows={6} withSubtitle />}>
<ErrorBrowser />
</Suspense>
);
}
@@ -0,0 +1,28 @@
import { createFileRoute } from "@tanstack/react-router";
import { Suspense } from "react";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { ActivitiesTable, activityQueries } from "@/features/activities";
import { playerQueries } from "@/features/players";
import { ListSkeleton } from "@/components/skeletons";
export const Route = createFileRoute("/_authed/admin/telemetry/functions")({
component: FunctionsTab,
beforeLoad: ({ context }) => {
const queryClient = context.queryClient;
prefetchServerQuery(queryClient, activityQueries.search());
prefetchServerQuery(queryClient, playerQueries.list());
},
loader: () => ({
withPadding: false,
fullWidth: true,
refresh: [activityQueries.search().queryKey],
}),
});
function FunctionsTab() {
return (
<Suspense fallback={<ListSkeleton rows={8} withSearch withSubtitle withTrailing="text" />}>
<ActivitiesTable />
</Suspense>
);
}
@@ -0,0 +1,7 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/_authed/admin/telemetry/")({
beforeLoad: () => {
throw redirect({ to: "/admin/telemetry/usage" });
},
});
@@ -0,0 +1,25 @@
import { createFileRoute } from "@tanstack/react-router";
import { Suspense } from "react";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { PlayersActivityTable, playerQueries } from "@/features/players";
import { ListSkeleton } from "@/components/skeletons";
export const Route = createFileRoute("/_authed/admin/telemetry/players")({
component: PlayersTab,
beforeLoad: ({ context }) => {
prefetchServerQuery(context.queryClient, playerQueries.activity());
},
loader: () => ({
withPadding: false,
fullWidth: true,
refresh: [playerQueries.activity().queryKey],
}),
});
function PlayersTab() {
return (
<Suspense fallback={<ListSkeleton rows={8} withSubtitle withTrailing="text" />}>
<PlayersActivityTable />
</Suspense>
);
}
@@ -0,0 +1,25 @@
import { createFileRoute } from "@tanstack/react-router";
import { Suspense } from "react";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { telemetryQueries, SessionsList, defaultSessionParams } from "@/features/telemetry";
import { ListSkeleton } from "@/components/skeletons";
export const Route = createFileRoute("/_authed/admin/telemetry/sessions")({
component: SessionsTab,
beforeLoad: ({ context }) => {
prefetchServerQuery(context.queryClient, telemetryQueries.sessions(defaultSessionParams));
},
loader: () => ({
withPadding: false,
fullWidth: true,
refresh: [telemetryQueries.sessions(defaultSessionParams).queryKey],
}),
});
function SessionsTab() {
return (
<Suspense fallback={<ListSkeleton rows={8} withSubtitle withTrailing="text" />}>
<SessionsList />
</Suspense>
);
}
@@ -0,0 +1,21 @@
import { createFileRoute } from "@tanstack/react-router";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { telemetryQueries, lastNDays, USAGE_METRICS, UsageScreen } from "@/features/telemetry";
const defaultUsageParams = () => ({ metrics: USAGE_METRICS, ...lastNDays(7) });
export const Route = createFileRoute("/_authed/admin/telemetry/usage")({
component: UsageTab,
beforeLoad: ({ context }) => {
prefetchServerQuery(context.queryClient, telemetryQueries.rollups(defaultUsageParams()));
},
loader: () => ({
withPadding: false,
fullWidth: true,
refresh: [telemetryQueries.rollups(defaultUsageParams()).queryKey],
}),
});
function UsageTab() {
return <UsageScreen />;
}
@@ -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 (
<Stack gap="lg">
<Stack gap="xs">
<Group gap="xs" align="baseline">
<Skeleton height={28} width={36} radius="sm" />
<Skeleton height={14} width={110} radius="sm" />
</Group>
<StatTileSkeleton align="row" />
<Skeleton height={36} w="100%" radius="sm" style={{ opacity: 0.7 }} />
</Stack>
</Stack>
@@ -0,0 +1,56 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { tournamentQueries } from "@/features/tournaments/queries";
import Enrollment from "@/features/tournaments/components/enrollment";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { ListSkeleton, StatTileSkeleton } from "@/components/skeletons";
import { Skeleton, Stack } from "@mantine/core";
import { msg } from "@lingui/core/macro";
export const Route = createFileRoute("/_authed/admin/tournaments/$id/enrollment")({
beforeLoad: async ({ context, params }) => {
const { queryClient } = context;
const tournament = await ensureServerQueryData(
queryClient,
tournamentQueries.details(params.id)
);
if (!tournament) throw redirect({ to: "/admin/tournaments" });
if (tournament.regional) {
await ensureServerQueryData(
queryClient,
tournamentQueries.free_agents(params.id)
);
} else {
await ensureServerQueryData(
queryClient,
tournamentQueries.unenrolled(params.id)
);
}
return { tournament };
},
loader: () => ({
header: {
withBackButton: true,
title: msg`Enrollment`,
},
}),
component: RouteComponent,
pendingComponent: EnrollmentPending,
});
function EnrollmentPending() {
return (
<Stack gap="lg">
<StatTileSkeleton align="row" />
<Stack gap="xs">
<Skeleton height={14} width={80} radius="sm" />
<Skeleton height={42} radius="sm" />
</Stack>
<ListSkeleton rows={6} withAvatar avatarSize={32} withTrailing padding="xs" />
</Stack>
);
}
function RouteComponent() {
const { tournament } = Route.useRouteContext();
return <Enrollment tournament={tournament} />;
}
@@ -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 (
<Stack gap={0}>
{Array.from({ length: 5 }).map((_, index) => (
<div key={`manage-tournament-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.14, 0.4) }}>
<Group p="md" wrap="nowrap" w="100%">
<Skeleton height={20} width={20} radius="sm" />
<Skeleton height={16} width="45%" radius="sm" />
<Skeleton ml="auto" height={20} width={20} radius="sm" />
</Group>
<Divider />
</div>
))}
</Stack>
<ListSkeleton
rows={5}
withAvatar
avatarSize={20}
withTrailing
fadeStep={0.14}
fadeFloor={0.4}
/>
);
}
@@ -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() {
<Box px="md">
<Skeleton height={12} width={90} radius="sm" />
</Box>
<Stack gap={0}>
{Array.from({ length: 8 }).map((_, index) => (
<div key={`manage-teams-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.1, 0.35) }}>
<Group p="xs" wrap="nowrap" w="100%">
<Skeleton height={40} width={40} radius="sm" />
<Skeleton height={16} width="45%" radius="sm" />
<Stack ml="auto" gap={6} align="flex-end">
<Skeleton height={10} width={90} radius="sm" />
<Skeleton height={10} width={70} radius="sm" />
</Stack>
</Group>
<Divider />
</div>
))}
</Stack>
<ListSkeleton rows={8} withAvatar avatarSize={40} withTrailing="double" padding="xs" />
</Stack>
);
}
@@ -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 (
<Stack gap={0}>
{Array.from({ length: 6 }).map((_, index) => (
<div key={`manage-tournaments-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.12, 0.4) }}>
<Group p="md" wrap="nowrap" w="100%">
<Skeleton height={16} width="55%" radius="sm" />
<Skeleton ml="auto" height={20} width={20} radius="sm" />
</Group>
<Divider />
</div>
))}
</Stack>
);
}
function RouteComponent() {
return (
<Suspense fallback={<TournamentListSkeleton />}>
<Suspense
fallback={
<ListSkeleton rows={6} titleWidth="55%" withTrailing fadeStep={0.12} fadeFloor={0.4} />
}
>
<ManageTournaments />
</Suspense>
);
@@ -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<number, Match[]>();
const losersMap = new Map<number, Match[]>();
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 (
<Container size="md" px={0}>
+5 -7
View File
@@ -1,7 +1,8 @@
import { createFileRoute } from "@tanstack/react-router";
import { tournamentQueries, useCurrentTournament } from "@/features/tournaments/queries";
import UpcomingTournament from "@/features/tournaments/components/upcoming-tournament";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { getServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import StartedTournament from "@/features/tournaments/components/started-tournament";
import { Suspense } from "react";
import UpcomingTournamentSkeleton from "@/features/tournaments/components/upcoming-tournament/skeleton";
@@ -10,16 +11,13 @@ export const Route = createFileRoute("/_authed/")({
component: () => <Suspense fallback={<UpcomingTournamentSkeleton />}>
<Home />
</Suspense>,
beforeLoad: async ({ context }) => {
const queryClient = context.queryClient;
const tournament = await ensureServerQueryData(queryClient, tournamentQueries.current())
return { tournament }
beforeLoad: ({ context }) => {
prefetchServerQuery(context.queryClient, tournamentQueries.current());
},
loader: ({ context }) => ({
withPadding: false,
header: {
title: context.tournament.name || "FLXN"
title: getServerQueryData(context.queryClient, tournamentQueries.current())?.name || "FLXN"
}
}),
pendingComponent: () => <UpcomingTournamentSkeleton />
+5 -19
View File
@@ -14,26 +14,12 @@ const searchSchema = z.object({
export const Route = createFileRoute("/_authed/profile/$playerId")({
validateSearch: searchSchema,
beforeLoad: async ({ params, context }) => {
beforeLoad: ({ params, context }) => {
const { queryClient } = context;
await Promise.all([
prefetchServerQuery(
queryClient,
playerQueries.details(params.playerId)
),
prefetchServerQuery(
queryClient,
playerQueries.matches(params.playerId)
),
prefetchServerQuery(
queryClient,
playerQueries.stats(params.playerId)
),
prefetchServerQuery(
queryClient,
badgeQueries.playerBadges(params.playerId)
),
]);
prefetchServerQuery(queryClient, playerQueries.details(params.playerId));
prefetchServerQuery(queryClient, playerQueries.matches(params.playerId));
prefetchServerQuery(queryClient, playerQueries.stats(params.playerId));
prefetchServerQuery(queryClient, badgeQueries.playerBadges(params.playerId));
},
loader: ({ params, context }) => ({
header: {
+13 -4
View File
@@ -1,7 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { playerQueries } from "@/features/players/queries";
import PlayerStatsTable from "@/features/players/components/player-stats-table";
import { Suspense, useState, useDeferredValue, useEffect } from "react";
import { Suspense, useState, useDeferredValue, useEffect, useRef } from "react";
import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import LeagueHeadToHead from "@/features/players/components/league-head-to-head";
@@ -9,6 +9,7 @@ import { Box, Loader, Tabs, Button, Group, Container, Stack } from "@mantine/cor
import { useQueryClient } from "@tanstack/react-query";
import { Trans } from "@lingui/react/macro";
import { msg } from "@lingui/core/macro";
import { track } from "@/lib/telemetry/client";
export const Route = createFileRoute("/_authed/stats")({
component: Stats,
@@ -32,6 +33,14 @@ function Stats() {
const isStale = viewType !== deferredViewType;
const queryClient = useQueryClient();
const viewTypeRef = useRef(viewType);
const changeViewType = (next: typeof viewType) => {
if (viewTypeRef.current === next) return;
viewTypeRef.current = next;
track("stats_filtered", { value: next });
setViewType(next);
};
useEffect(() => {
const timeout = window.setTimeout(() => {
prefetchServerQuery(queryClient, playerQueries.allStats('mainline'));
@@ -54,21 +63,21 @@ function Stats() {
<Button
variant={viewType === 'all' ? 'filled' : 'light'}
size="compact-xs"
onClick={() => setViewType('all')}
onClick={() => changeViewType('all')}
>
<Trans>All</Trans>
</Button>
<Button
variant={viewType === 'mainline' ? 'filled' : 'light'}
size="compact-xs"
onClick={() => setViewType('mainline')}
onClick={() => changeViewType('mainline')}
>
<Trans>Mainline</Trans>
</Button>
<Button
variant={viewType === 'regional' ? 'filled' : 'light'}
size="compact-xs"
onClick={() => setViewType('regional')}
onClick={() => changeViewType('regional')}
>
<Trans>Regional</Trans>
</Button>
+2 -2
View File
@@ -12,9 +12,9 @@ const searchSchema = z.object({
export const Route = createFileRoute("/_authed/teams/$teamId")({
validateSearch: searchSchema,
beforeLoad: async ({ params, context }) => {
beforeLoad: ({ params, context }) => {
const { queryClient } = context;
await prefetchServerQuery(queryClient, teamQueries.details(params.teamId));
prefetchServerQuery(queryClient, teamQueries.details(params.teamId));
},
loader: ({ params }) => ({
header: {
@@ -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
-2
View File
@@ -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/$')({
+13 -7
View File
@@ -1,9 +1,14 @@
import { createFileRoute } from "@tanstack/react-router";
import { serverEvents, EVENT_TYPES, type ServerEvent } from "@/lib/events/emitter";
import {
serverEvents,
EVENT_TYPES,
incrementConnections,
decrementConnections,
type ServerEvent,
} from "@/lib/events/emitter";
import { logger } from "@/lib/logger";
import { superTokensRequestMiddleware } from "@/utils/supertokens";
let activeConnections = 0;
const encoder = new TextEncoder();
export const Route = createFileRoute("/api/events/$")({
@@ -11,9 +16,9 @@ export const Route = createFileRoute("/api/events/$")({
middleware: [superTokensRequestMiddleware],
handlers: {
GET: ({ request }) => {
activeConnections++;
const active = incrementConnections();
const connectionId = `conn_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
logger.info(`ServerEvents | New connection ${connectionId}. Active: ${activeConnections}`);
logger.info(`ServerEvents | New connection ${connectionId}. Active: ${active}`);
let cleanedUp = false;
let cleanup = () => {};
@@ -34,19 +39,20 @@ export const Route = createFileRoute("/api/events/$")({
serverEvents.on(type, handleEvent);
}
// Under the shortest idle timeout observed on proxies/PWA paths.
const pingInterval = setInterval(() => {
send({ type: "ping", timestamp: Date.now() });
}, 15000);
}, 8000);
cleanup = () => {
if (cleanedUp) return;
cleanedUp = true;
activeConnections--;
const remaining = decrementConnections();
for (const type of EVENT_TYPES) {
serverEvents.off(type, handleEvent);
}
clearInterval(pingInterval);
logger.info(`ServerEvents | Connection ${connectionId} cleanup completed. Active: ${activeConnections}`);
logger.info(`ServerEvents | Connection ${connectionId} cleanup completed. Active: ${remaining}`);
};
request.signal?.addEventListener("abort", cleanup);
+12 -5
View File
@@ -1,19 +1,26 @@
import { createFileRoute } from "@tanstack/react-router";
import { ensureTelemetryScheduler } from "@/lib/telemetry/scheduler.server";
import { getHealthSnapshot } from "@/lib/telemetry/health.server";
import { ensureSuperTokensBackend } from "@/lib/supertokens/server";
export const Route = createFileRoute("/api/health")({
server: {
handlers: {
GET: () => {
GET: async () => {
ensureSuperTokensBackend();
ensureTelemetryScheduler();
const snapshot = await getHealthSnapshot();
// Always 200: a dependency outage must not fail the k8s probes.
return new Response(
JSON.stringify({
status: "ok",
status: snapshot.status,
checks: snapshot.checks,
timestamp: new Date().toISOString(),
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
},
headers: { "Content-Type": "application/json" },
}
);
},
+2 -1
View File
@@ -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;
+2 -1
View File
@@ -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;
+2 -1
View File
@@ -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;
+4 -2
View File
@@ -1,6 +1,8 @@
import { createFileRoute } from "@tanstack/react-router";
import { msg } from "@lingui/core/macro";
import { superTokensRequestMiddleware } from "@/utils/supertokens";
import { isAdminRole } from "@/features/core/utils/roles";
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,13 +14,13 @@ 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);
try {
const userId = context.userAuthId;
const isAdmin = context.roles.includes("Admin");
const isAdmin = isAdminRole(context.roles);
if (!userId) return new Response("Unauthenticated", { status: 401 });
+132
View File
@@ -0,0 +1,132 @@
import { createFileRoute } from "@tanstack/react-router";
import { superTokensOptionalRequestMiddleware } from "@/utils/supertokens";
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<string, { tokens: number; updatedAt: number }>();
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 { isExcludedPlayerId } = await import("@/lib/telemetry/exclusions.server");
if (base.playerId && (await isExcludedPlayerId(base.playerId))) {
// Excluded players still report errors; their usage stays out.
events = events.filter((event) => event.kind === "error");
if (events.length === 0) return;
}
const { pbAdmin } = await import("@/lib/pocketbase/client");
await pbAdmin.authPromise;
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: [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 });
},
},
},
});
@@ -1,6 +1,8 @@
import { createFileRoute } from "@tanstack/react-router";
import { msg } from "@lingui/core/macro";
import { superTokensRequestMiddleware } from "@/utils/supertokens";
import { isAdminRole } from "@/features/core/utils/roles";
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,13 +14,13 @@ 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);
try {
const userId = context.userAuthId;
const isAdmin = context.roles.includes("Admin");
const isAdmin = isAdminRole(context.roles);
if (!userId) return new Response("Unauthenticated", { status: 401 });
if (!isAdmin) return new Response("Unauthorized", { status: 403 });
+11
View File
@@ -0,0 +1,11 @@
import { createCsrfMiddleware, createStart } from "@tanstack/react-start";
import { serverFnLoggingMiddleware } from "@/utils/activities";
const csrfMiddleware = createCsrfMiddleware({
filter: (ctx) => ctx.handlerType === "serverFn",
});
export const startInstance = createStart(() => ({
requestMiddleware: [csrfMiddleware],
functionMiddleware: [serverFnLoggingMiddleware],
}));
+6
View File
@@ -50,6 +50,12 @@ textarea,
transform: scale(0.90) translateY(-4px);
}
/* themes paint their full-page texture through this element */
.flxn-theme-overlay {
position: fixed;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.app,
[data-drawer-level] {
+2
View File
@@ -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`)
+3
View File
@@ -53,12 +53,14 @@ export function Countdown({ date, label, color }: CountdownProps) {
return (
<Group gap="xs">
{label && <Text size='sm' fw={500}>{label}:</Text>}
{/* Server and client render different seconds; the first client tick corrects it. */}
<Text
size='sm'
fw={600}
c={isFinalStretch ? 'red' : color}
ff="monospace"
className={classes.time}
suppressHydrationWarning
>
{prefix}
<span
@@ -66,6 +68,7 @@ export function Countdown({ date, label, color }: CountdownProps) {
className={`${classes.seconds} ${
isFinalStretch ? classes.finalTick : classes.tick
}`}
suppressHydrationWarning
>
{pad(timeLeft.seconds)}
</span>
+35
View File
@@ -0,0 +1,35 @@
import { Box } from "@mantine/core";
import { FootballHelmetIcon } from "@phosphor-icons/react";
const HeadToHeadIcon = ({ size = 14 }: { size?: number }) => (
<Box
component="span"
style={{
position: "relative",
display: "inline-block",
width: size * (27.5 / 14),
height: size * (16 / 14),
}}
>
<FootballHelmetIcon
size={size}
style={{
position: "absolute",
left: 0,
top: 0,
transform: "rotate(25deg)",
}}
/>
<FootballHelmetIcon
size={size}
style={{
position: "absolute",
right: 0,
top: 0,
transform: "scaleX(-1) rotate(25deg)",
}}
/>
</Box>
);
export default HeadToHeadIcon;
+6 -1
View File
@@ -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)
}
+28
View File
@@ -0,0 +1,28 @@
import { Text, UnstyledButton } from "@mantine/core";
import { Icon } from "@phosphor-icons/react";
import classes from "./nav-tile.module.css";
interface NavTileProps {
label: string;
Icon: Icon;
onClick?: () => void;
disabled?: boolean;
}
/** A single square-ish nav tile with icon-over-label and press feedback. */
export const NavTile = ({ label, Icon, onClick, disabled }: NavTileProps) => (
<UnstyledButton
className={`${classes.tile} flxn-tile`}
onClick={onClick}
disabled={disabled}
>
<span className={`${classes.icon} flxn-tile-icon`}>
<Icon size={22} weight="bold" />
</span>
<Text size="xs" fw={600} lineClamp={2} lh={1.2}>
{label}
</Text>
</UnstyledButton>
);
export default NavTile;
@@ -0,0 +1,43 @@
.tile {
-webkit-tap-highlight-color: transparent;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px 8px;
border-radius: var(--mantine-radius-md);
border: 1px solid var(--mantine-color-default-border);
background-color: var(--mantine-color-body);
text-align: center;
}
.icon {
color: var(--mantine-primary-color-filled);
display: flex;
}
@media (hover: hover) {
.tile:hover:not(:disabled) {
background-color: var(--mantine-color-default-hover);
}
}
.tile:active:not(:disabled) {
background-color: var(--mantine-color-default-hover);
}
.tile:disabled {
opacity: 0.5;
cursor: default;
}
@media (prefers-reduced-motion: no-preference) {
.tile {
transition: background-color 120ms ease-out, transform 120ms cubic-bezier(0.32, 0.72, 0, 1);
}
.tile:active:not(:disabled) {
transform: scale(0.97);
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Skeleton, Stack } from "@mantine/core";
const RoutePending = () => (
<Stack gap="md" p="md" w="100%">
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton
key={`route-pending-${index}`}
height={96}
w="100%"
radius="md"
style={{ opacity: Math.max(1 - index * 0.15, 0.3) }}
/>
))}
</Stack>
);
export default RoutePending;
+12 -7
View File
@@ -9,7 +9,7 @@ import {
import { useIsMobile } from "@/hooks/use-is-mobile";
import Drawer from "./drawer";
import Modal from "./modal";
import { ScrollArea, Flex, Loader } from "@mantine/core";
import { Box, Flex, Loader } from "@mantine/core";
interface SheetProps extends PropsWithChildren {
title?: string;
@@ -60,14 +60,19 @@ const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
<Loader size='lg' />
</Flex>
}>
<ScrollArea.Autosize
style={{ flex: 1, maxHeight: '75dvh' }}
scrollbarSize={8}
scrollbars="y"
type="scroll"
{/* Native overflow, not ScrollArea: vaul's drag-vs-scroll detection
only recognizes natively scrollable elements. */}
<Box
style={{
flex: 1,
maxHeight: "75dvh",
overflowY: "auto",
overscrollBehavior: "contain",
WebkitOverflowScrolling: "touch",
}}
>
{children}
</ScrollArea.Autosize>
</Box>
</Suspense>
</SheetComponent>
);
@@ -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 (
<Paper p="sm" withBorder radius={radius} style={{ opacity }}>
<Group gap="sm" align="center" wrap="nowrap">
{withAvatar && (
<Skeleton height={avatarSize} width={avatarSize} radius="sm" />
)}
<Box style={{ flex: 1 }}>
<Skeleton height={16} width="70%" mb={4} />
<Skeleton height={12} width="40%" />
</Box>
{withBadge && <Skeleton height={20} width={60} radius="xl" />}
</Group>
</Paper>
);
};
export default CardSkeleton;
+3
View File
@@ -0,0 +1,3 @@
export * from "./list-skeleton";
export * from "./card-skeleton";
export * from "./stat-tile-skeleton";
@@ -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 (
<Stack gap={0}>
{withSearch && (
<Box px={padding} pb="sm">
<Skeleton height={42} radius="sm" />
</Box>
)}
{Array.from({ length: rows }).map((_, index) => (
<div
key={index}
style={{ opacity: Math.max(1 - index * fadeStep, fadeFloor) }}
>
<Group
p={padding}
wrap="nowrap"
w="100%"
justify={withTrailing === "text" ? "space-between" : undefined}
>
{withAvatar && (
<Skeleton height={avatarSize} width={avatarSize} radius="sm" />
)}
{withSubtitle ? (
<Stack gap={6}>
<Skeleton height={14} width={160} radius="sm" />
<Skeleton height={10} width={100} radius="sm" />
</Stack>
) : (
<Skeleton height={16} width={titleWidth} radius="sm" />
)}
{withTrailing === "double" ? (
<Stack ml="auto" gap={6} align="flex-end">
<Skeleton height={10} width={90} radius="sm" />
<Skeleton height={10} width={70} radius="sm" />
</Stack>
) : withTrailing === "text" ? (
<Skeleton height={10} width={60} radius="sm" />
) : withTrailing ? (
<Skeleton ml="auto" height={20} width={20} radius="sm" />
) : null}
</Group>
<Divider />
</div>
))}
</Stack>
);
};
export default ListSkeleton;
@@ -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" ? (
<Group gap="xs" align="baseline">
<Skeleton height={numberHeight} width={numberWidth} radius="sm" />
<Skeleton height={labelHeight} width={labelWidth} radius="sm" />
</Group>
) : (
<Stack gap={4} align="center">
<Skeleton height={numberHeight} width={numberWidth} radius="sm" />
<Skeleton height={labelHeight} width={labelWidth} radius="sm" />
</Stack>
);
if (!withCard) return content;
return (
<Card withBorder radius="md" p="md">
{content}
</Card>
);
};
export default StatTileSkeleton;
+8 -2
View File
@@ -6,7 +6,7 @@ import {
ReactNode,
useRef,
useCallback,
useMemo,
startTransition,
} from "react";
import { useRouter } from "@tanstack/react-router";
@@ -48,6 +48,9 @@ function SwipeableTabs({
}, [search?.tab, tabs, defaultTab]);
const [activeTab, setActiveTab] = useState(getActiveTabFromUrl);
const [visitedTabs, setVisitedTabs] = useState<ReadonlySet<number>>(
() => new Set([getActiveTabFromUrl()])
);
const [stuck, setStuck] = useState(false);
const sentinelRef = useRef<HTMLDivElement>(null);
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
@@ -60,6 +63,9 @@ function SwipeableTabs({
if (index === activeTab || index < 0 || index >= tabs.length) return;
setActiveTab(index);
startTransition(() => {
setVisitedTabs((prev) => (prev.has(index) ? prev : new Set(prev).add(index)));
});
embla?.scrollTo(index);
onTabChange?.(index, tabs[index]);
@@ -225,7 +231,7 @@ function SwipeableTabs({
{tabs.map((tab, index) => (
<Carousel.Slide key={`${tab.label}-content-${index}`}>
<Box ref={setSlideRef(index)} style={{ height: "auto", width: "100%", maxWidth: "100vw", overflow: "hidden" }}>
{tab.content}
{(visitedTabs.has(index) || index === activeTab) ? tab.content : null}
</Box>
</Carousel.Slide>
))}
+1
View File
@@ -18,6 +18,7 @@ interface AuthData {
theme?: string;
locale?: string;
seenThemesHint?: boolean;
telemetryAlerts?: boolean;
};
roles: string[];
phone: string;
+2 -3
View File
@@ -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<PropsWithChildren> = ({ children }) => {
const { roles } = useAuth();
const isAdmin = roles?.includes('Admin') || false;
const isAdmin = useIsAdmin();
const config = useConfig();
const { t } = useLingui();
@@ -1,268 +1,32 @@
import { useState, useMemo, memo } from "react";
import { useState, useMemo } from "react";
import { Trans, Plural, useLingui } from "@lingui/react/macro";
import {
Text,
TextInput,
Stack,
Group,
Box,
Container,
Divider,
UnstyledButton,
Select,
Pagination,
Code,
Alert,
ThemeIcon,
Title,
} from "@mantine/core";
import {
MagnifyingGlassIcon,
CaretUpIcon,
CaretDownIcon,
CheckIcon,
XIcon,
ChecksIcon,
PulseIcon,
} from "@phosphor-icons/react";
import { Alert, Code, Container, Group, Stack, Text } from "@mantine/core";
import { CheckIcon, XIcon } from "@phosphor-icons/react";
import { Activity, ActivitySearchParams } from "../types";
import { useActivities } from "../queries";
import Sheet from "@/components/sheet/sheet";
import { usePlayers } from "@/features/players/queries";
import { useSheet } from "@/hooks/use-sheet";
import {
DetailSheet,
PaginatedList,
SearchFilterBar,
TelemetryListItem,
} from "@/features/telemetry/components/shared";
interface ActivityListItemProps {
activity: Activity;
onClick: () => void;
}
const ActivityListItem = memo(({ activity, onClick }: ActivityListItemProps) => {
const { t, i18n } = useLingui();
const playerName = typeof activity.player === "object" && activity.player
? `${activity.player.first_name} ${activity.player.last_name}`
: t`System`;
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleString(i18n.locale);
};
return (
<UnstyledButton
w="100%"
p="md"
onClick={onClick}
style={{
borderRadius: 0,
transition: "background-color 0.15s ease",
}}
styles={{
root: {
'&:hover': {
backgroundColor: 'var(--mantine-color-gray-0)',
},
},
}}
>
<Group justify="space-between" align="flex-start" w="100%">
<Stack gap={4} flex={1}>
<Group gap="xs">
<Text size="sm" fw={600}>
{activity.name}
</Text>
{activity.success ? (
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
) : (
<XIcon size={16} color="var(--mantine-color-red-6)" />
)}
</Group>
<Group gap="md">
<Text size="xs" c="dimmed">
{playerName}
</Text>
<Text size="xs" c="dimmed">
<Trans>{activity.duration}ms</Trans>
</Text>
<Text size="xs" c="dimmed">
{formatDate(activity.created)}
</Text>
</Group>
{activity.error && (
<Text size="xs" c="red" lineClamp={1}>
{activity.error}
</Text>
)}
</Stack>
</Group>
</UnstyledButton>
);
});
interface ActivityDetailsSheetProps {
activity: Activity | null;
isOpen: boolean;
onClose: () => void;
}
const ActivityDetailsSheet = memo(({ activity, isOpen, onClose }: ActivityDetailsSheetProps) => {
const { t, i18n } = useLingui();
if (!activity) return null;
const playerName = typeof activity.player === "object" && activity.player
? `${activity.player.first_name} ${activity.player.last_name}`
: t`System`;
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleString(i18n.locale);
};
return (
<Sheet title={t`Activity Details`} opened={isOpen} onChange={onClose}>
<Stack gap="md" p="md">
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>Function Name</Trans>
</Text>
<Text size="sm">{activity.name}</Text>
</Stack>
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>Status</Trans>
</Text>
<Group gap="xs">
{activity.success ? (
<>
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
<Text size="sm" c="green">
<Trans>Success</Trans>
</Text>
</>
) : (
<>
<XIcon size={16} color="var(--mantine-color-red-6)" />
<Text size="sm" c="red">
<Trans>Failed</Trans>
</Text>
</>
)}
</Group>
</Stack>
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>Player</Trans>
</Text>
<Text size="sm">{playerName}</Text>
</Stack>
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>Duration</Trans>
</Text>
<Text size="sm"><Trans>{activity.duration}ms</Trans></Text>
</Stack>
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>Created</Trans>
</Text>
<Text size="sm">{formatDate(activity.created)}</Text>
</Stack>
{activity.user_agent && (
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>User Agent</Trans>
</Text>
<Text size="xs" style={{ wordBreak: "break-word" }}>
{activity.user_agent}
</Text>
</Stack>
)}
{activity.error && (
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>Error Message</Trans>
</Text>
<Alert color="red" variant="light">
<Text size="sm" style={{ wordBreak: "break-word" }}>
{activity.error}
</Text>
</Alert>
</Stack>
)}
{activity.arguments && (
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>Arguments</Trans>
</Text>
<Code block style={{ fontSize: "11px" }}>
{JSON.stringify(activity.arguments, null, 2)}
</Code>
</Stack>
)}
</Stack>
</Sheet>
);
});
const ActivitiesResults = ({ searchParams, page, setPage, onActivityClick }: any) => {
const { data: result } = useActivities(searchParams);
return (
<>
<Stack gap={0}>
{result.items.map((activity: Activity, index: number) => (
<Box key={activity.id}>
<ActivityListItem
activity={activity}
onClick={() => onActivityClick(activity)}
/>
{index < result.items.length - 1 && <Divider />}
</Box>
))}
</Stack>
{result.items.length === 0 && (
<Stack align="center" gap="md" py="xl">
<ThemeIcon size="xl" variant="light" radius="md">
<PulseIcon size={32} />
</ThemeIcon>
<Title order={3} c="dimmed">
<Trans>No Activities Found</Trans>
</Title>
</Stack>
)}
{result.totalPages > 1 && (
<Group justify="center" py="md">
<Pagination
total={result.totalPages}
value={page}
onChange={setPage}
size="sm"
/>
</Group>
)}
</>
);
};
const SLOW_THRESHOLD_MS = 1000;
export const ActivitiesTable = () => {
const { t } = useLingui();
const { t, i18n } = useLingui();
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [successFilter, setSuccessFilter] = useState<string | null>(null);
const [playerFilter, setPlayerFilter] = useState<string | null>(null);
const [slowOnly, setSlowOnly] = useState(false);
const [sortBy, setSortBy] = useState("-created");
const [selectedActivity, setSelectedActivity] = useState<Activity | null>(null);
const { isOpen: detailsOpened, open: openDetails, close: closeDetails } = useSheet();
const {
isOpen: detailsOpened,
open: openDetails,
close: closeDetails,
} = useSheet();
const { data: players } = usePlayers();
const searchParams: ActivitySearchParams = useMemo(
() => ({
@@ -270,13 +34,24 @@ export const ActivitiesTable = () => {
perPage: 100,
name: search || undefined,
success: successFilter === "success" ? true : successFilter === "failure" ? false : undefined,
player: playerFilter || undefined,
sortBy,
}),
[page, search, successFilter, sortBy]
[page, search, successFilter, playerFilter, sortBy]
);
const { data: result } = useActivities(searchParams);
const items = useMemo(
() => (slowOnly ? result.items.filter((a) => a.duration > SLOW_THRESHOLD_MS) : result.items),
[result.items, slowOnly]
);
const playerName = (activity: Activity) =>
typeof activity.player === "object" && activity.player
? `${activity.player.first_name} ${activity.player.last_name}`
: t`System`;
const handleSort = (field: string) => {
setSortBy((prev) => {
if (prev === field) return `-${field}`;
@@ -285,12 +60,6 @@ export const ActivitiesTable = () => {
});
};
const getSortIcon = (field: string) => {
if (sortBy === field) return <CaretUpIcon size={14} />;
if (sortBy === `-${field}`) return <CaretDownIcon size={14} />;
return null;
};
const handleActivityClick = (activity: Activity) => {
setSelectedActivity(activity);
openDetails();
@@ -304,90 +73,163 @@ export const ActivitiesTable = () => {
return (
<Container size="100%" px={0}>
<Stack gap="xs">
<Stack gap="xs" px="md">
<TextInput
placeholder={t`serverFn name`}
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
setPage(1);
}}
leftSection={<MagnifyingGlassIcon size={16} />}
size="md"
/>
<Group>
<Select
placeholder={t`Status`}
value={successFilter}
onChange={(value) => {
<SearchFilterBar
searchValue={search}
onSearchChange={(value) => {
setSearch(value);
setPage(1);
}}
searchPlaceholder={t`serverFn name`}
filters={[
{
placeholder: t`Status`,
value: successFilter,
onChange: (value) => {
setSuccessFilter(value);
setPage(1);
}}
data={[
},
data: [
{ value: "all", label: t`All` },
{ value: "success", label: t`Success` },
{ value: "failure", label: t`Failure` },
]}
clearable
size="sm"
style={{ flex: 1 }}
/>
</Group>
</Stack>
<Group px="md" justify="space-between" align="center">
<Text size="10px" lh={0} c="dimmed">
],
},
{
placeholder: t`Player`,
value: playerFilter,
onChange: (value) => {
setPlayerFilter(value);
setPage(1);
},
data: players.map((player) => ({
value: player.id,
label: `${player.first_name} ${player.last_name}`,
})),
searchable: true,
},
]}
chips={[
{
label: t`Slow >1s (this page)`,
checked: slowOnly,
onChange: setSlowOnly,
},
]}
sortFields={[
{ field: "created", label: <Trans>Date</Trans> },
{ field: "duration", label: <Trans>Duration</Trans> },
]}
sortBy={sortBy}
onSort={handleSort}
summary={
<Plural value={result.totalItems} one="# total activity" other="# total activities" />
</Text>
<Group gap="xs">
<Text size="xs" c="dimmed">
<Trans>Sort:</Trans>
</Text>
<UnstyledButton
onClick={() => handleSort("created")}
style={{ display: "flex", alignItems: "center", gap: 4 }}
>
<Text
size="xs"
fw={sortBy.includes("created") ? 600 : 400}
c={sortBy.includes("created") ? "var(--mantine-color-text)" : "dimmed"}
>
<Trans>Date</Trans>
</Text>
{getSortIcon("created")}
</UnstyledButton>
<Text size="xs" c="dimmed">
•
</Text>
<UnstyledButton
onClick={() => handleSort("duration")}
style={{ display: "flex", alignItems: "center", gap: 4 }}
>
<Text
size="xs"
fw={sortBy.includes("duration") ? 600 : 400}
c={sortBy.includes("duration") ? "var(--mantine-color-text)" : "dimmed"}
>
<Trans>Duration</Trans>
</Text>
{getSortIcon("duration")}
</UnstyledButton>
</Group>
</Group>
}
/>
<ActivitiesResults
searchParams={searchParams}
<PaginatedList
items={items}
keyOf={(activity) => activity.id}
empty={t`No Activities Found`}
page={page}
setPage={setPage}
onActivityClick={handleActivityClick}
totalPages={result.totalPages}
onPageChange={setPage}
renderItem={(activity) => (
<TelemetryListItem
title={activity.name}
icon={
activity.success ? (
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
) : (
<XIcon size={16} color="var(--mantine-color-red-6)" />
)
}
meta={[
playerName(activity),
<Trans>{activity.duration}ms</Trans>,
new Date(activity.created).toLocaleString(i18n.locale),
]}
error={activity.error}
onClick={() => handleActivityClick(activity)}
/>
)}
/>
</Stack>
<ActivityDetailsSheet
activity={selectedActivity}
isOpen={detailsOpened}
<DetailSheet
title={t`Activity Details`}
opened={detailsOpened}
onClose={handleCloseDetails}
fields={
selectedActivity
? [
{ label: <Trans>Function Name</Trans>, value: selectedActivity.name },
{
label: <Trans>Status</Trans>,
value: (
<Group gap="xs">
{selectedActivity.success ? (
<>
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
<Text size="sm" c="green">
<Trans>Success</Trans>
</Text>
</>
) : (
<>
<XIcon size={16} color="var(--mantine-color-red-6)" />
<Text size="sm" c="red">
<Trans>Failed</Trans>
</Text>
</>
)}
</Group>
),
},
{ label: <Trans>Player</Trans>, value: playerName(selectedActivity) },
{
label: <Trans>Duration</Trans>,
value: (
<Text size="sm">
<Trans>{selectedActivity.duration}ms</Trans>
</Text>
),
},
{
label: <Trans>Created</Trans>,
value: new Date(selectedActivity.created).toLocaleString(i18n.locale),
},
...(selectedActivity.user_agent
? [{ label: <Trans>User Agent</Trans>, value: selectedActivity.user_agent }]
: []),
...(selectedActivity.error
? [
{
label: <Trans>Error Message</Trans>,
value: (
<Alert color="red" variant="light">
<Text size="sm" style={{ wordBreak: "break-word" }}>
{selectedActivity.error}
</Text>
</Alert>
),
},
]
: []),
...(selectedActivity.arguments
? [
{
label: <Trans>Arguments</Trans>,
value: (
<Code block style={{ fontSize: "11px" }}>
{JSON.stringify(selectedActivity.arguments, null, 2)}
</Code>
),
},
]
: []),
]
: []
}
/>
</Container>
);
+2 -2
View File
@@ -1,4 +1,4 @@
import { superTokensAdminFunctionMiddleware } from "@/utils/supertokens";
import { superTokensDeveloperFunctionMiddleware } from "@/utils/supertokens";
import { createServerFn } from "@tanstack/react-start";
import { pbAdmin } from "@/lib/pocketbase/client";
import { z } from "zod";
@@ -17,7 +17,7 @@ const activitySearchParamsSchema = z.object({
export const searchActivities = createServerFn()
.validator(activitySearchParamsSchema)
.middleware([superTokensAdminFunctionMiddleware])
.middleware([superTokensDeveloperFunctionMiddleware])
.handler(async ({ data }) =>
toServerResult<ActivityListResult>(async () => {
const result = await pbAdmin.searchActivities(data);
+19 -25
View File
@@ -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<string | null>(null);
const [selectedBadgeId, setSelectedBadgeId] = useState<string | null>(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 = () => {
<Button
onClick={handleAwardBadge}
disabled={!selectedPlayerId}
loading={isAwarding}
loading={awardBadge.isPending}
size="md"
>
<Trans>Award Badge</Trans>
@@ -0,0 +1,30 @@
import { Suspense } from "react";
import { SimpleGrid, Skeleton } from "@mantine/core";
import QuietBoundary from "./quiet-boundary";
import SectionBox from "./section-box";
import TournamentSection from "./tournament-section";
import ToolsList from "./tools-list";
const TournamentSkeleton = () => (
<SectionBox>
<Skeleton height={24} width={180} radius="sm" />
<SimpleGrid cols={3} spacing="sm" mt="sm">
<Skeleton height={72} radius="md" />
<Skeleton height={72} radius="md" />
<Skeleton height={72} radius="md" />
</SimpleGrid>
</SectionBox>
);
export const AdminDashboard = () => (
<>
<QuietBoundary>
<Suspense fallback={<TournamentSkeleton />}>
<TournamentSection />
</Suspense>
</QuietBoundary>
<ToolsList />
</>
);
export default AdminDashboard;
@@ -0,0 +1,32 @@
import { Component, ReactNode } from "react";
import { Text } from "@mantine/core";
import { Trans } from "@lingui/react/macro";
interface QuietBoundaryProps {
children: ReactNode;
}
interface QuietBoundaryState {
hasError: boolean;
}
export class QuietBoundary extends Component<QuietBoundaryProps, QuietBoundaryState> {
state: QuietBoundaryState = { hasError: false };
static getDerivedStateFromError(): QuietBoundaryState {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return (
<Text size="sm" c="dimmed">
<Trans>Unavailable right now</Trans>
</Text>
);
}
return this.props.children;
}
}
export default QuietBoundary;
@@ -0,0 +1,14 @@
import { ReactNode } from "react";
import { Box } from "@mantine/core";
export const SectionBox = ({ children }: { children: ReactNode }) => (
<Box
px="md"
py="sm"
style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}
>
{children}
</Box>
);
export default SectionBox;
@@ -1,21 +1,22 @@
import { List, Text } from "@mantine/core";
import { Plural, Trans, useLingui } from "@lingui/react/macro";
import ListLink from "@/components/list-link";
import {
DatabaseIcon,
TreeStructureIcon,
TrophyIcon,
MedalIcon,
CrownIcon,
ListIcon,
PulseIcon,
} from "@phosphor-icons/react";
import ListLink from "@/components/list-link";
import ListButton from "@/components/list-button";
import { useIsDeveloper } from "@/features/core/hooks/use-is-developer";
import { getBadgeMigrationStatus, migrateBadgeProgress } from "@/features/badges/server";
import { useServerQuery } from "@/lib/tanstack-query/hooks";
import toast from "@/lib/sonner";
const AdminPage = () => {
export const ToolsList = () => {
const { t } = useLingui();
const isDeveloper = useIsDeveloper();
const { data: migrationStatus, refetch: refetchMigrationStatus } = useServerQuery({
queryKey: ["admin", "badge-migration-status"],
@@ -51,6 +52,13 @@ const AdminPage = () => {
Icon={TrophyIcon}
to="/admin/tournaments"
/>
{isDeveloper && (
<ListLink
label={t`Telemetry`}
Icon={PulseIcon}
to="/admin/telemetry/usage"
/>
)}
<ListLink
label={t`Award Badges`}
Icon={CrownIcon}
@@ -86,18 +94,6 @@ const AdminPage = () => {
<Trans>Badge recalculation failed. Check the server logs.</Trans>
</Text>
)}
<ListLink
label={t`Activities`}
Icon={ListIcon}
to="/admin/activities"
/>
<ListButton
label={t`Open Pocketbase`}
Icon={DatabaseIcon}
onClick={() =>
window.location.replace(process.env.POCKETBASE_URL! + "/_/")
}
/>
<ListLink
label={t`Bracket Preview`}
Icon={TreeStructureIcon}
@@ -107,4 +103,4 @@ const AdminPage = () => {
);
};
export default AdminPage;
export default ToolsList;
@@ -0,0 +1,48 @@
import { SimpleGrid, Text, Title } from "@mantine/core";
import { useLingui } from "@lingui/react/macro";
import { useNavigate } from "@tanstack/react-router";
import { GearIcon, PlayIcon, UsersThreeIcon } from "@phosphor-icons/react";
import { useCurrentTournament } from "@/features/tournaments/queries";
import { NavTile } from "@/components/nav-tile";
import { Countdown } from "@/components/countdown";
import SectionBox from "./section-box";
export const TournamentSection = () => {
const { t } = useLingui();
const navigate = useNavigate();
const { data: tournament } = useCurrentTournament();
if (!tournament) return null;
const upcoming = !!tournament.start_time && Date.parse(tournament.start_time) > Date.now();
return (
<SectionBox>
<Title order={3}>{tournament.name}</Title>
{upcoming && (
<Text size="sm" c="dimmed" component="div">
<Countdown date={new Date(tournament.start_time)} label={t`Starts in`} />
</Text>
)}
<SimpleGrid cols={3} spacing="sm" mt="sm">
<NavTile
label={t`Run`}
Icon={PlayIcon}
onClick={() => navigate({ to: `/admin/tournaments/run/${tournament.id}` })}
/>
<NavTile
label={t`Enrollment`}
Icon={UsersThreeIcon}
onClick={() => navigate({ to: `/admin/tournaments/${tournament.id}/enrollment` })}
/>
<NavTile
label={t`Manage`}
Icon={GearIcon}
onClick={() => navigate({ to: `/admin/tournaments/${tournament.id}` })}
/>
</SimpleGrid>
</SectionBox>
);
};
export default TournamentSection;
+1 -1
View File
@@ -2,4 +2,4 @@ import { Logger } from "@/lib/logger";
export const logger = new Logger("Admin");
export { default as AdminPage } from "./components/admin-page";
export { default as AdminDashboard } from "./components/dashboard";
@@ -49,6 +49,7 @@ export const BadgeIcon = ({ badge, filled, size = 48 }: BadgeIconProps & { size?
width={size}
height={size}
loading="lazy"
decoding="async"
onError={() => setImageError(true)}
style={{
objectFit: 'contain',
@@ -19,6 +19,7 @@ import PlayerList from '@/features/players/components/player-list';
import { useAuth } from '@/contexts/auth-context';
import { Player } from '@/features/players/types';
import { Trans, useLingui } from '@lingui/react/macro';
import { track } from '@/lib/telemetry/client';
const BadgeStatsTable = () => {
const { data: allBadges } = useAllBadges();
@@ -141,7 +142,13 @@ const BadgeStatRow: React.FC<BadgeStatRowProps> = ({
return (
<Box key={badge.id}>
<UnstyledButton onClick={badgeSheet.open} w='100%'>
<UnstyledButton
onClick={() => {
track("badge_viewed", { value: badge.key });
badgeSheet.open();
}}
w='100%'
>
<Grid p={'xs'} align='center'>
<Grid.Col span={2} style={{ display: 'flex', alignItems: 'center' }}>
<BadgeIcon badge={badge} filled={true} />
@@ -0,0 +1,21 @@
import { useQueryClient } from "@tanstack/react-query";
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
import { awardManualBadge } from "@/features/badges/server";
import { badgeKeys } from "@/features/badges/queries";
interface AwardManualBadgeInput {
playerId: string;
badgeId: string;
}
export default function useAwardManualBadge() {
const queryClient = useQueryClient();
return useServerMutation({
mutationFn: (data: AwardManualBadgeInput) => awardManualBadge({ data }),
onSuccess: (_data, { playerId }) => {
queryClient.invalidateQueries({ queryKey: badgeKeys.playerBadges(playerId) });
queryClient.invalidateQueries({ queryKey: badgeKeys.allEarnedBadges() });
},
});
}
+2 -3
View File
@@ -1,6 +1,5 @@
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens";
import { serverFnLoggingMiddleware } from "@/utils/activities";
import { createServerFn } from "@tanstack/react-start";
import { pbAdmin } from "@/lib/pocketbase/client";
import { z } from "zod";
@@ -15,7 +14,7 @@ export const getPlayerBadges = createServerFn()
);
export const migrateBadgeProgress = createServerFn()
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.middleware([superTokensAdminFunctionMiddleware])
.handler(async () =>
toServerResult(async () => startBadgeMigration())
);
@@ -41,7 +40,7 @@ export const awardManualBadge = createServerFn()
playerId: z.string(),
badgeId: z.string(),
}))
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) =>
toServerResult(async () => {
const result = await pbAdmin.awardManualBadge(data.playerId, data.badgeId);
@@ -399,7 +399,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
</Sheet>
</Flex>
{!showControls && <MatchReport match={match} compact />}
{!showControls && <MatchReport match={match} compact source="match_card" />}
</Stack>
);
};
+14 -26
View File
@@ -13,7 +13,7 @@ import {
Tooltip,
} from "@mantine/core";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { FootballHelmetIcon } from "@phosphor-icons/react";
import HeadToHeadIcon from "@/components/head-to-head-icon";
import { useNavigate } from "@tanstack/react-router";
import { Match } from "@/features/matches/types";
import TeamAvatar from "@/components/team-avatar";
@@ -24,6 +24,7 @@ import { MatchReport } from "./match-report";
import Sheet from "@/components/sheet/sheet";
import { useSheet } from "@/hooks/use-sheet";
import { Trans, useLingui } from "@lingui/react/macro";
import { track } from "@/lib/telemetry/client";
const EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
@@ -155,7 +156,7 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
ended={ended}
/>
<MatchReport match={match} />
<MatchReport match={match} source="match_dock" />
<Group justify="space-between" wrap="nowrap" gap="sm">
<Box style={{ flex: 1, minWidth: 0 }}>
@@ -166,7 +167,7 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
</Center>
}
>
<EmojiBar matchId={match.id} />
<EmojiBar matchId={match.id} source="match_dock" />
</Suspense>
</Box>
{!hasPrivate && (
@@ -174,33 +175,20 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
<ActionIcon
variant="subtle"
size="sm"
onClick={h2hSheet.open}
onClick={() => {
track("h2h_viewed", {
source: "match_dock",
context: [match?.home?.name, match?.away?.name]
.filter(Boolean)
.join(" v "),
});
h2hSheet.open();
}}
aria-label={t`View head-to-head`}
w={40}
style={{ flexShrink: 0 }}
>
<Group
style={{ position: "relative", width: 27.5, height: 16 }}
>
<FootballHelmetIcon
size={14}
style={{
position: "absolute",
left: 0,
top: 0,
transform: "rotate(25deg)",
}}
/>
<FootballHelmetIcon
size={14}
style={{
position: "absolute",
right: 0,
top: 0,
transform: "scaleX(-1) rotate(25deg)",
}}
/>
</Group>
<HeadToHeadIcon />
</ActionIcon>
</Tooltip>
)}
@@ -13,6 +13,7 @@ import Sheet from "@/components/sheet/sheet";
import { MatchForm } from "./match-form";
import toast from "@/lib/sonner";
import { Trans, useLingui } from "@lingui/react/macro";
import { track } from "@/lib/telemetry/client";
const teamId = (t: Match["home"]): string | undefined =>
!t ? undefined : typeof t === "string" ? t : t.id;
@@ -26,9 +27,10 @@ const teamName = (t: Match["home"]): string | undefined =>
interface MatchReportProps {
match: Match;
compact?: boolean;
source?: string;
}
export const MatchReport: React.FC<MatchReportProps> = ({ match, compact }) => {
export const MatchReport: React.FC<MatchReportProps> = ({ match, compact, source }) => {
const { user } = useAuth();
const formSheet = useSheet();
const { t } = useLingui();
@@ -129,8 +131,9 @@ export const MatchReport: React.FC<MatchReportProps> = ({ match, compact }) => {
const openForm = useCallback(() => {
setAcknowledgedReport(pendingSignature);
track("score_report_opened", source ? { source } : undefined);
formSheet.open();
}, [pendingSignature, formSheet.open]);
}, [pendingSignature, formSheet.open, source]);
const dismissTakeover = useCallback(() => {
setAcknowledgedReport(pendingSignature);
+5 -8
View File
@@ -1,23 +1,22 @@
import { AppShell, Box } from '@mantine/core';
import { AppShell } from '@mantine/core';
import { PropsWithChildren } from 'react';
import { useLocation } from '@tanstack/react-router';
import Header from './header';
import Navbar from './navbar';
import Pullable from './pullable';
import useVisualViewportSize from '../hooks/use-visual-viewport-size';
import useRouterConfig from '../hooks/use-router-config';
import Page from '@/components/page';
import './route-transition.css';
const Layout: React.FC<PropsWithChildren> = ({ children }) => {
const { header, withPadding, fullWidth } = useRouterConfig();
const viewport = useVisualViewportSize();
const location = useLocation();
return (
<AppShell
id='app-shell'
layout='alt'
// 0 = header collapse is instant; Mantine's default animates main's padding
transitionDuration={0}
header={{
// Old installs froze black-translucent at add-to-home-screen time, so
// the webview can extend under the status bar; inset is 0 elsewhere.
@@ -48,16 +47,14 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
pos='relative'
h='100%'
mah='100%'
pb={{ base: 'calc(65px + env(safe-area-inset-bottom, 0px))', sm: 0 }}
pb={{ base: 'calc(73px + env(safe-area-inset-bottom, 0px))', sm: 0 }}
px={{ base: 0.01, sm: 100, md: 200, lg: 300 }}
maw='100dvw'
style={{ overflow: 'hidden' }}
>
<Pullable>
<Page noPadding={!withPadding} fullWidth={fullWidth}>
<Box key={location.pathname} className="flxn-route-enter">
{children}
</Box>
{children}
</Page>
</Pullable>
</AppShell.Main>
@@ -0,0 +1,68 @@
import { useLayoutEffect, useRef } from "react";
import { useRouterState } from "@tanstack/react-router";
const TRAVEL_MS = 333;
export const useNavIndicator = (isMobile: boolean) => {
const containerRef = useRef<HTMLDivElement>(null);
const indicatorRef = useRef<HTMLSpanElement>(null);
const shapeRef = useRef<HTMLSpanElement>(null);
const pathname = useRouterState({ select: (s) => s.location.pathname });
useLayoutEffect(() => {
const container = containerRef.current;
const indicator = indicatorRef.current;
const shape = shapeRef.current;
if (!container || !indicator || !shape) return;
let last: number | null = null;
const place = () => {
const active = container.querySelector<HTMLElement>("[data-active]");
if (!active) {
delete indicator.dataset.placed;
last = null;
return;
}
const x = active.offsetLeft;
const y = active.offsetTop;
const travelled = isMobile ? x : y;
const first = indicator.dataset.placed !== "1";
if (first) indicator.style.transition = "none";
indicator.style.setProperty("--nav-x", `${x}px`);
indicator.style.setProperty("--nav-y", `${y}px`);
indicator.style.setProperty("--nav-w", `${active.offsetWidth}px`);
indicator.style.setProperty("--nav-h", `${active.offsetHeight}px`);
indicator.dataset.placed = "1";
if (first) {
indicator.getBoundingClientRect();
indicator.style.transition = "";
} else if (
last !== null &&
Math.abs(travelled - last) > 1 &&
!window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
const axis = isMobile ? "scaleX" : "scaleY";
shape.animate(
[
{ transform: `${axis}(1)`, offset: 0 },
{ transform: `${axis}(0.88)`, offset: 0.4 },
{ transform: `${axis}(1)`, offset: 1 },
],
{ duration: TRAVEL_MS, easing: "ease-in-out" }
);
}
last = travelled;
};
place();
const observer = new ResizeObserver(place);
observer.observe(container);
return () => observer.disconnect();
}, [pathname, isMobile]);
return { containerRef, indicatorRef, shapeRef };
};
@@ -32,30 +32,19 @@ export const NavLink = ({
<Box
component={Link}
to={href}
className={styles.navLinkBox}
className={`flxn-nav-link ${styles.navLinkBox}`}
p={{ base: 0, sm: 8 }}
aria-label={label}
aria-current={isActive ? "page" : undefined}
data-active={isActive || undefined}
>
<Flex
direction={{ base: "column", md: "row" }}
align="center"
gap={{ base: 0, md: "xs" }}
>
<Icon
weight={isActive ? "fill" : "regular"}
size={28}
style={{
color: isActive ? "var(--mantine-primary-color-filled)" : undefined,
}}
/>
<Text
visibleFrom="md"
ta="center"
size="md"
fw={isActive ? 800 : 500}
c={isActive ? "var(--mantine-primary-color-filled)" : undefined}
>
<Icon weight={isActive ? "fill" : "regular"} size={28} />
<Text visibleFrom="md" ta="center" size="md" fw={isActive ? 800 : 500}>
{label}
</Text>
</Flex>
@@ -11,11 +11,6 @@
-webkit-tap-highlight-color: transparent;
}
.navLinkBox svg,
.navLinkBox p {
transition: color 150ms ease-out;
}
@media (prefers-reduced-motion: no-preference) {
.navLinkBox {
transition: transform 150ms cubic-bezier(0.32, 0.72, 0, 1);
+14 -4
View File
@@ -5,11 +5,13 @@ import { useAuth } from "@/contexts/auth-context";
import { useLinks } from "../hooks/use-links";
import { memo } from "react";
import { useIsPWA } from "@/hooks/use-is-pwa";
import { useNavIndicator } from "./nav-indicator/use-nav-indicator";
const Navbar = () => {
const { user, roles } = useAuth()
const isMobile = useIsMobile();
const isPWA = useIsPWA();
const { containerRef, indicatorRef, shapeRef } = useNavIndicator(isMobile);
const links = useLinks(user?.id, roles);
@@ -17,9 +19,16 @@ const Navbar = () => {
? 'max(env(safe-area-inset-bottom, 0px), 0.5rem)'
: 'env(safe-area-inset-bottom, 0px)';
const indicator = (
<span ref={indicatorRef} className="flxn-nav-indicator" aria-hidden="true">
<span ref={shapeRef} className="flxn-nav-indicator-shape" />
</span>
);
if (isMobile) return (
<Paper component='nav' role='navigation' withBorder shadow="sm" radius='lg' h='4rem' w='calc(100% - 1rem)' pos='fixed' m='0.5rem' bottom={bottomOffset} style={{ zIndex: 10 }}>
<Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
<Paper className="flxn-nav" component='nav' role='navigation' withBorder shadow="sm" radius='lg' h='4rem' w='calc(100% - 1rem)' pos='fixed' m='0.5rem' bottom={bottomOffset} style={{ zIndex: 10 }}>
<Group ref={containerRef} pos='relative' gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
{indicator}
{links.map((link) => (
<NavLink key={link.href} {...link} />
))}
@@ -27,9 +36,10 @@ const Navbar = () => {
</Paper>
)
return <AppShell.Navbar p="xs" role='navigation'>
return <AppShell.Navbar className="flxn-nav" p="xs" role='navigation'>
<AppShell.Section grow component={ScrollArea}>
<Stack gap='xs' mx='auto' w='fit-content' justify='end' mt='md'>
<Stack ref={containerRef} pos='relative' gap='xs' mx='auto' w='fit-content' justify='end' mt='md'>
{indicator}
{links.map((link) => (
<NavLink key={link.href} {...link} />
))}
@@ -1,11 +0,0 @@
@media (prefers-reduced-motion: no-preference) {
.flxn-route-enter {
animation: flxn-route-fade-in 180ms ease-out;
}
}
@keyframes flxn-route-fade-in {
from {
opacity: 0;
}
}
+7
View File
@@ -0,0 +1,7 @@
import { useAuth } from "@/contexts/auth-context";
import { isAdminRole } from "../utils/roles";
export const useIsAdmin = (): boolean => {
const { roles } = useAuth();
return isAdminRole(roles);
};
@@ -0,0 +1,7 @@
import { useAuth } from "@/contexts/auth-context";
import { isDeveloperRole } from "../utils/roles";
export const useIsDeveloper = (): boolean => {
const { roles } = useAuth();
return isDeveloperRole(roles);
};
+2 -1
View File
@@ -1,6 +1,7 @@
import { HouseIcon, RankingIcon, ShieldIcon, TrophyIcon, UserCircleIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
import { useLingui } from "@lingui/react/macro";
import { isAdminRole } from "../utils/roles";
export const useLinks = (userId: string | undefined, roles: string[]) => {
const { t } = useLingui();
@@ -31,7 +32,7 @@ export const useLinks = (userId: string | undefined, roles: string[]) => {
}
]
if (roles.includes('Admin')) {
if (isAdminRole(roles)) {
links.push({
label: t`Admin`,
href: '/admin',
+8
View File
@@ -0,0 +1,8 @@
export const ADMIN_ROLE = "Admin";
export const DEVELOPER_ROLE = "Developer";
export const isDeveloperRole = (roles?: string[] | null): boolean =>
!!roles?.includes(DEVELOPER_ROLE);
export const isAdminRole = (roles?: string[] | null): boolean =>
!!roles?.includes(ADMIN_ROLE) || isDeveloperRole(roles);
+1 -1
View File
@@ -21,7 +21,7 @@ const useConsumeCode = (onWrongCode: () => void) => {
} else {
queryClient.setQueryData(playerKeys.auth, response.data);
toast.success(t`Successfully logged in. Welcome back!`);
navigate({ to: '/' })
window.location.href = '/';
}
} else if (data.status === 'INCORRECT_USER_INPUT_CODE_ERROR') {
onWrongCode();
+1 -1
View File
@@ -21,7 +21,7 @@ const useCreateUser = () => {
...old,
user: data
}));
navigate({ to: '/' });
window.location.href = '/';
}
});
};
+8 -22
View File
@@ -1,5 +1,5 @@
import { Text, Group, Stack, Paper, Indicator, Box, Tooltip, ActionIcon } from "@mantine/core";
import { FootballHelmetIcon } from "@phosphor-icons/react";
import HeadToHeadIcon from "@/components/head-to-head-icon";
import { useNavigate } from "@tanstack/react-router";
import { Match } from "../types";
import TeamAvatar from "@/components/team-avatar";
@@ -10,6 +10,7 @@ import Sheet from "@/components/sheet/sheet";
import TeamHeadToHeadSheet from "./team-head-to-head-sheet";
import AnimatedScore from "./animated-score";
import { Trans, useLingui } from "@lingui/react/macro";
import { track } from "@/lib/telemetry/client";
interface MatchCardProps {
match: Match;
@@ -41,6 +42,10 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
const handleH2HClick = (e: React.MouseEvent) => {
e.stopPropagation();
track("h2h_viewed", {
source: "match_card",
context: [match.home?.name, match.away?.name].filter(Boolean).join(" v "),
});
h2hSheet.open();
};
@@ -89,26 +94,7 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
aria-label={t`View head-to-head`}
w={40}
>
<Group style={{ position: 'relative', width: 27.5, height: 16 }}>
<FootballHelmetIcon
size={14}
style={{
position: 'absolute',
left: 0,
top: 0,
transform: 'rotate(25deg)',
}}
/>
<FootballHelmetIcon
size={14}
style={{
position: 'absolute',
right: 0,
top: 0,
transform: 'scaleX(-1) rotate(25deg)',
}}
/>
</Group>
<HeadToHeadIcon />
</ActionIcon>
</Tooltip>
)}
@@ -218,7 +204,7 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
}}
>
<Suspense>
<EmojiBar matchId={match.id} />
<EmojiBar matchId={match.id} source="match_card" />
</Suspense>
</Paper>
</Box>
@@ -1,4 +1,4 @@
import { Stack, Text } from "@mantine/core";
import { MantineSpacing, Stack, Text } from "@mantine/core";
import { useMemo } from "react";
import { Match } from "../types";
import MatchCard from "./match-card";
@@ -7,9 +7,10 @@ import { Trans } from "@lingui/react/macro";
interface MatchListProps {
matches: Match[];
hideH2H?: boolean;
p?: MantineSpacing;
}
const MatchList = ({ matches, hideH2H = false }: MatchListProps) => {
const MatchList = ({ matches, hideH2H = false, p = "md" }: MatchListProps) => {
const filteredMatches = useMemo(
() =>
[...(matches ?? [])]
@@ -27,7 +28,7 @@ const MatchList = ({ matches, hideH2H = false }: MatchListProps) => {
const isRegional = filteredMatches[0]?.tournament?.regional;
return (
<Stack p="md" gap="sm">
<Stack p={p} gap="sm">
{isRegional && (
<Text size="xs" c="dimmed" ta="center" px="md">
<Trans>Matches for regionals are unordered</Trans>
@@ -200,10 +200,10 @@ const TeamHeadToHeadContent = ({ team1, team2, isOpen = true }: TeamHeadToHeadSh
</Stack>
<Stack gap="xs">
<Text size="sm" fw={600} px="md">
<Text size="sm" fw={600}>
<Trans>Match History (<Plural value={totalMatches} one="# match" other="# matches" />)</Trans>
</Text>
<MatchList matches={matches} hideH2H />
<MatchList matches={matches} hideH2H p={0} />
</Stack>
</Stack>
);
+7 -8
View File
@@ -9,7 +9,6 @@ import { Match, MatchInput } from "@/features/matches/types";
import { emitServerEvent } from "@/lib/events/emitter";
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
import { PlayerInfo } from "../players/types";
import { serverFnLoggingMiddleware } from "@/utils/activities";
const orderedTeamsSchema = z.object({
tournamentId: z.string(),
@@ -18,7 +17,7 @@ const orderedTeamsSchema = z.object({
export const generateTournamentBracket = createServerFn()
.validator(orderedTeamsSchema)
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
toServerResult(async () => {
logger.info("Generating tournament bracket", {
@@ -141,7 +140,7 @@ export const generateTournamentBracket = createServerFn()
export const startMatch = createServerFn()
.validator(z.string())
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) =>
toServerResult(async () => {
logger.info("Starting match", data);
@@ -170,7 +169,7 @@ export const populateKnockoutBracket = createServerFn()
.validator(z.object({
tournamentId: z.string(),
}))
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data: { tournamentId } }) =>
toServerResult(async () => {
const tournament = await pbAdmin.getTournament(tournamentId);
@@ -801,7 +800,7 @@ const endMatchSchema = z.object({
});
export const endMatch = createServerFn()
.validator(endMatchSchema)
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) =>
toServerResult(async () => {
logger.info("Ending match", matchId);
@@ -827,7 +826,7 @@ const reportScoreSchema = z.object({
export const reportMatchScore = createServerFn()
.validator(reportScoreSchema)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count }, context }) =>
toServerResult(async () => {
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
@@ -890,7 +889,7 @@ const matchIdSchema = z.object({ matchId: z.string() });
export const confirmMatchScore = createServerFn()
.validator(matchIdSchema)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: { matchId }, context }) =>
toServerResult(async () => {
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
@@ -929,7 +928,7 @@ export const confirmMatchScore = createServerFn()
export const clearMatchReport = createServerFn()
.validator(matchIdSchema)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: { matchId }, context }) =>
toServerResult(async () => {
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
+9
View File
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { matchInputSchema } from "./types";
describe("matchInputSchema.partial()", () => {
it("does not inject keys the caller did not send", () => {
const parsed = matchInputSchema.partial().parse({ home_cups: 7 });
expect(parsed).toEqual({ home_cups: 7 });
});
});
+9 -9
View File
@@ -42,20 +42,20 @@ export interface Match {
export const matchInputSchema = z.object({
order: z.number().int().min(1).optional(),
lid: z.number().int().min(1),
reset: z.boolean().optional().default(false),
reset: z.boolean().optional(),
round: z.number().int().min(1),
home_cups: z.number().int().min(0).optional().default(0),
away_cups: z.number().int().min(0).optional().default(0),
ot_count: z.number().int().min(0).optional().default(0),
home_cups: z.number().int().min(0).optional(),
away_cups: z.number().int().min(0).optional(),
ot_count: z.number().int().min(0).optional(),
start_time: z.iso.datetime().optional(),
end_time: z.iso.datetime().optional(),
bye: z.boolean().optional().default(false),
bye: z.boolean().optional(),
home_from_lid: z.number().int().min(1).optional(),
away_from_lid: z.number().int().min(1).optional(),
home_from_loser: z.boolean().optional().default(false),
away_from_loser: z.boolean().optional().default(false),
is_losers_bracket: z.boolean().optional().default(false),
status: z.enum(["tbd", "ready", "started", "ended"]).optional().default("tbd"),
home_from_loser: z.boolean().optional(),
away_from_loser: z.boolean().optional(),
is_losers_bracket: z.boolean().optional(),
status: z.enum(["tbd", "ready", "started", "ended"]).optional(),
tournament: z.string().min(1),
home: z.string().min(1).nullable().optional(),
away: z.string().min(1).nullable().optional(),
@@ -1,5 +1,5 @@
import { Stack, Text, TextInput, Box, Paper, Group, Divider, ActionIcon, Button } from "@mantine/core";
import { useState, useMemo } from "react";
import { useState, useMemo, useRef } from "react";
import { MagnifyingGlassIcon, XIcon, ArrowRightIcon, PlusIcon } from "@phosphor-icons/react";
import { Trans, useLingui } from "@lingui/react/macro";
import { useAllPlayerStats } from "../queries";
@@ -8,6 +8,7 @@ import { useSheet } from "@/hooks/use-sheet";
import Sheet from "@/components/sheet/sheet";
import PlayerHeadToHeadSheet from "./player-head-to-head-sheet";
import PlayerAvatar from "@/components/player-avatar";
import { track } from "@/lib/telemetry/client";
const LeagueHeadToHead = () => {
const { t } = useLingui();
@@ -15,6 +16,7 @@ const LeagueHeadToHead = () => {
const [player1Id, setPlayer1Id] = useState<string | null>(null);
const [player2Id, setPlayer2Id] = useState<string | null>(null);
const [search, setSearch] = useState("");
const searchedRef = useRef(false);
const { data: allPlayerStats } = useAllPlayerStats();
const h2hSheet = useSheet();
@@ -51,6 +53,12 @@ const LeagueHeadToHead = () => {
setPlayer1Id(playerId);
} else {
setPlayer2Id(playerId);
const opponent =
allPlayerStats?.find((p) => p.player_id === playerId)?.player_name || "";
track("h2h_viewed", {
source: "stats",
context: [player1Name, opponent].filter(Boolean).join(" v "),
});
h2hSheet.open();
}
};
@@ -128,7 +136,14 @@ const LeagueHeadToHead = () => {
<TextInput
placeholder={t`Search players`}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onChange={(e) => {
const value = e.currentTarget.value;
if (value && !searchedRef.current) {
searchedRef.current = true;
track("player_searched");
}
setSearch(value);
}}
leftSection={<MagnifyingGlassIcon size={16} />}
size="md"
px="md"
@@ -260,10 +260,10 @@ const PlayerHeadToHeadContent = ({
</Stack>
<Stack gap="xs">
<Text size="sm" fw={600} px="md">
<Text size="sm" fw={600}>
<Trans>Match History ({totalMatches})</Trans>
</Text>
<MatchList matches={matches} hideH2H />
<MatchList matches={matches} hideH2H p={0} />
</Stack>
</Stack>
);
@@ -28,6 +28,7 @@ import { useNavigate } from "@tanstack/react-router";
import { useAllPlayerStats } from "../queries";
import { PlayerListItemSkeleton } from "./player-stats-table-skeleton";
import { Trans, useLingui } from "@lingui/react/macro";
import { track } from "@/lib/telemetry/client";
type SortKey = keyof PlayerStats | "mmr";
type SortDirection = "asc" | "desc";
@@ -177,6 +178,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const deferredSearch = useDeferredValue(search);
const searchedRef = useRef(false);
const [sortConfig, setSortConfig] = useState<SortConfig>({
key: "mmr" as SortKey,
direction: "desc",
@@ -291,10 +293,10 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
}, [navigate]);
const handleSort = (key: SortKey) => {
setSortConfig((prev) => ({
key,
direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc",
}));
const direction =
sortConfig.key === key && sortConfig.direction === "desc" ? "asc" : "desc";
track("stats_sorted", { value: key, context: direction });
setSortConfig({ key, direction });
};
const getSortIcon = (key: SortKey) => {
@@ -328,7 +330,14 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
<TextInput
placeholder={t`Search players`}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onChange={(e) => {
const value = e.currentTarget.value;
if (value && !searchedRef.current) {
searchedRef.current = true;
track("player_searched");
}
setSearch(value);
}}
leftSection={<MagnifyingGlassIcon size={16} />}
size="md"
px="md"
@@ -1,7 +1,8 @@
import Sheet from "@/components/sheet/sheet";
import { useAuth } from "@/contexts/auth-context";
import { Flex, Title, ActionIcon, Stack, Button, Box } from "@mantine/core";
import { PencilIcon, FootballHelmetIcon } from "@phosphor-icons/react";
import { Flex, Title, ActionIcon, Stack, Button } from "@mantine/core";
import { PencilIcon } from "@phosphor-icons/react";
import HeadToHeadIcon from "@/components/head-to-head-icon";
import { useMemo } from "react";
import { useLingui } from "@lingui/react/macro";
import NameUpdateForm from "./name-form";
@@ -9,6 +10,7 @@ import PlayerAvatar from "@/components/player-avatar";
import { useSheet } from "@/hooks/use-sheet";
import { Player } from "../../types";
import PlayerHeadToHeadSheet from "../player-head-to-head-sheet";
import { track } from "@/lib/telemetry/client";
interface HeaderProps {
player: Player;
@@ -53,30 +55,17 @@ const Header = ({ player }: HeaderProps) => {
variant="subtle"
size="sm"
radius="xl"
onClick={h2hSheet.open}
onClick={() => {
track("h2h_viewed", {
source: "profile",
context: [player.first_name, player.last_name].filter(Boolean).join(" "),
});
h2hSheet.open();
}}
w={40}
display={!owner ? 'block' : 'none'}
>
<Box style={{ position: 'relative', width: 27.5, height: 16 }}>
<FootballHelmetIcon
size={14}
style={{
position: 'absolute',
left: 0,
top: 0,
transform: 'rotate(25deg)'
}}
/>
<FootballHelmetIcon
size={14}
style={{
position: 'absolute',
right: 0,
top: 0,
transform: 'scaleX(-1) rotate(25deg)'
}}
/>
</Box>
<HeadToHeadIcon />
</ActionIcon>
</Flex>
</Flex>
@@ -1,5 +1,5 @@
import { Box, Stack, Text, Divider, Group, Button } from "@mantine/core";
import { Suspense, useState, useDeferredValue } from "react";
import { Suspense, useState, useDeferredValue, useRef } from "react";
import { Trans, useLingui } from "@lingui/react/macro";
import { Link } from "@tanstack/react-router";
import Header from "./header";
@@ -10,6 +10,7 @@ import StatsOverview, { StatsSkeleton } from "@/components/stats-overview";
import MatchList from "@/features/matches/components/match-list";
import BadgeShowcase from "@/features/badges/components/badge-showcase";
import BadgeShowcaseSkeleton from "@/features/badges/components/badge-showcase-skeleton";
import { track } from "@/lib/telemetry/client";
interface ProfileProps {
id: string;
@@ -20,14 +21,22 @@ const StatsWithFilter = ({ id }: { id: string }) => {
const deferredViewType = useDeferredValue(viewType);
const isStale = viewType !== deferredViewType;
const viewTypeRef = useRef(viewType);
const changeViewType = (next: typeof viewType) => {
if (viewTypeRef.current === next) return;
viewTypeRef.current = next;
track("stats_filtered", { value: next, source: "profile" });
setViewType(next);
};
return (
<Stack>
<Group gap="xs" px="md" justify="space-between" align="center">
<Text size="md" fw={700}><Trans>Statistics</Trans></Text>
<Group gap="xs">
<Button variant={viewType === 'all' ? 'filled' : 'light'} size="compact-xs" onClick={() => setViewType('all')}><Trans>All</Trans></Button>
<Button variant={viewType === 'mainline' ? 'filled' : 'light'} size="compact-xs" onClick={() => setViewType('mainline')}><Trans>Mainline</Trans></Button>
<Button variant={viewType === 'regional' ? 'filled' : 'light'} size="compact-xs" onClick={() => setViewType('regional')}><Trans>Regional</Trans></Button>
<Button variant={viewType === 'all' ? 'filled' : 'light'} size="compact-xs" onClick={() => changeViewType('all')}><Trans>All</Trans></Button>
<Button variant={viewType === 'mainline' ? 'filled' : 'light'} size="compact-xs" onClick={() => changeViewType('mainline')}><Trans>Mainline</Trans></Button>
<Button variant={viewType === 'regional' ? 'filled' : 'light'} size="compact-xs" onClick={() => changeViewType('regional')}><Trans>Regional</Trans></Button>
</Group>
</Group>
<Box style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}>
@@ -90,7 +99,12 @@ const Profile = ({ id }: ProfileProps) => {
<>
<Header player={player} />
<Box mt="lg">
<SwipeableTabs tabs={tabs} />
<SwipeableTabs
tabs={tabs}
onTabChange={(_, tab) => {
if (tab.value === "matches") track("match_history_viewed", { source: "profile" });
}}
/>
</Box>
</>
);
+3 -4
View File
@@ -7,7 +7,6 @@ import { z } from "zod";
import { Logger } from "@/lib/logger";
import { getRequest } from "@tanstack/react-start/server";
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
import { serverFnLoggingMiddleware } from "@/utils/activities";
const logger = new Logger('Players');
@@ -52,7 +51,7 @@ export const getPlayer = createServerFn()
export const updatePlayer = createServerFn()
.validator(playerUpdateSchema)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) =>
toServerResult(async () => {
const userAuthId = context.userAuthId;
@@ -84,7 +83,7 @@ export const updatePlayer = createServerFn()
export const createPlayer = createServerFn()
.validator(playerInputSchema)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) =>
toServerResult(async () => {
const userAuthId = context.userAuthId;
@@ -117,7 +116,7 @@ export const createPlayer = createServerFn()
export const associatePlayer = createServerFn()
.validator(z.string())
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) =>
toServerResult(async () => {
const userAuthId = context.userAuthId;
@@ -7,7 +7,8 @@ import {
Stack,
Text,
} from "@mantine/core";
import { CaretLeftIcon, CaretRightIcon, InfoIcon } from "@phosphor-icons/react";
import { CaretLeftIcon, CaretRightIcon } from "@phosphor-icons/react";
import HeadToHeadIcon from "@/components/head-to-head-icon";
import WizardOrbIcon from "@/components/wizard-orb-icon";
import { Trans, useLingui } from "@lingui/react/macro";
import { useNavigate } from "@tanstack/react-router";
@@ -26,6 +27,7 @@ import {
import { PredictionBracket } from "./prediction-bracket";
import { MatchupSheet } from "./matchup-sheet";
import { WinnerSelector } from "./winner-selector";
import { track } from "@/lib/telemetry/client";
interface PredictionEditorProps {
tournament: Tournament;
@@ -215,11 +217,19 @@ export const PredictionEditor: React.FC<PredictionEditorProps> = ({
variant="default"
size="lg"
radius="md"
onClick={matchupSheet.open}
onClick={() => {
if (activeResolved?.home && activeResolved?.away) {
track("h2h_viewed", {
source: "predictions",
context: `${activeResolved.home.name} v ${activeResolved.away.name}`,
});
}
matchupSheet.open();
}}
disabled={!activeResolved?.home && !activeResolved?.away}
aria-label={t`Matchup details`}
>
<InfoIcon size={16} />
<HeadToHeadIcon />
</ActionIcon>
</Group>
<Group gap="sm" align="center" wrap="nowrap">

Some files were not shown because too many files have changed in this diff Show More