Author SHA1 Message Date
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
kyle 15aeb64b19 pwa fixes
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 40s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m6s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m27s
2026-08-23 21:51:38 -07:00
kyle 8a894e8365 Merge branch 'deep-ketch' into development
CI/CD Pipeline / i18n Catalog Check (push) Successful in 38s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m11s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 14s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m31s
2026-08-23 20:31:49 -07:00
kyle e3fea18ab2 badges improvements 2026-08-23 20:31:44 -07:00
kyle 58702da156 style: trim narration comments from recent changes 2026-08-23 20:06:05 -07:00
kyle c342b91029 feat(header): shrink long titles to fit instead of wrapping
The header keeps its size; a title that would overflow scales down to a
floor of 55% and ellipsizes only past that. Re-measures on title change,
resize, and web-font load.
2026-08-23 20:06:05 -07:00
kyle 792425a83e fix(i18n): hydrate in the SSR locale instead of regressing to en
The client's message cache held only en at hydration, so a non-en user
rendered English until the locale's catalog chunk loaded — and stayed
English if that import failed or lost a race. __root beforeLoad now
dehydrates the active locale's compiled catalog with the page and the
provider primes its cache from that payload, so first paint is already in
the user's locale with no dependency on a client-side chunk load.
ensureMessages dedupes concurrent loads and drops rejected ones so a
retry can succeed, and the provider's fallback loader retries instead of
silently stranding the UI in en.
2026-08-23 19:47:31 -07:00
kyle ac2e61731c Merge branch 'theme-discovery-presets' into development 2026-08-23 19:26:37 -07:00
kyle 62cbca4a9d theme tooltip 2026-08-23 19:26:21 -07:00
kyle 11bb70316a fix(pwa): simplify the install prompt copy 2026-08-23 19:03:57 -07:00
kyle 9fc79dfc07 fix(audit): readable names, single writer, denial rows, redacted args
The activity log recorded Start's hashed function id (a sha256 URL
segment) as the name; the middleware now reads compile-time
serverFnMeta.name with the path segment as fallback. toServerResult
resolves { success:false } instead of throwing, so the middleware logged
failures as successes while toServerResult wrote a duplicate row with its
own dead name parser — the middleware is now the single writer and reads
the envelope's success flag. Admin denials, which threw before the logging
middleware ran, get their own audit row. Arguments are redacted
(phone/otp/token keys) and truncated at 2KB. The admin activities search
binds its filter parameters (likePattern escaping) instead of
interpolating raw input. Adds vitest with node-env tests for the
middleware, redaction, and filter utils, and extends logging coverage to
mutating fns that lacked it.
2026-08-23 18:27:53 -07:00
kyle 60e91d1371 fix(pocketbase): self-heal admin auth instead of stranding requests
beforeSend now skips its gate for the auth endpoints (no self-await
deadlock), swallows a rejected authPromise and re-authenticates when the
store is invalid, re-stamps Authorization after the gate so requests built
mid-auth carry the fresh token, and guards startTokenRefresh against
stacking intervals. A failed boot auth logs a diagnostic instead of
crashing the process; recovery happens on the next request.
2026-08-23 18:27:45 -07:00
kyle a45c2aeafa fix(pwa): reword the install prompt and keep it off the bottom nav
The banner sat fixed at the viewport bottom at z-index 1000, covering the
authed shell's mobile nav; it now offsets above the nav's geometry when a
signed-in mobile user has one. New casual copy replaces the Twilio line,
the dismiss button gains a localized aria-label, and de/es/ja catalogs
carry the new strings.
2026-08-23 18:27:40 -07:00
kyle f63dfde85d fix(ui): defer the visual-viewport read out of render
Reading window during render made the client's first render disagree with
the SSR markup; start at zero and fill in after mount, matching Mantine's
useViewportSize contract.
2026-08-23 18:27:35 -07:00
kyle 5ac39ac7a2 Merge branch 'development' into player-picker-single-sele
# Conflicts:
#	src/locales/de/messages.po
#	src/locales/en/messages.po
#	src/locales/es/messages.po
#	src/locales/ja/messages.po
2026-08-23 17:59:55 -07:00
kyle 733615b9e0 redesign h2h player picker: single slot + Me quick-select 2026-08-23 17:59:24 -07:00
kyle d0507481cb harden sse errors 2026-08-23 17:35:45 -07:00
kyle 98343e55d1 Merge branch 'themes' into development 2026-08-23 17:17:12 -07:00
kyle 6f575d6a23 themes lol 2026-08-23 17:17:04 -07:00
kyle 24756128e0 i18n ci fix
CI/CD Pipeline / i18n Catalog Check (push) Successful in 39s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 6m43s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m27s
2026-08-08 16:33:02 -07:00
kyle 19dc698f46 i18n ci fix
CI/CD Pipeline / i18n Catalog Check (push) Failing after 9s
CI/CD Pipeline / Build and Push App Docker Image (push) Skipped
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 9s
CI/CD Pipeline / Deploy to Kubernetes (push) Skipped
2026-08-08 16:00:48 -07:00
kyle 0a7b5fa00f i18n
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / i18n Catalog Check (push) Failing after 10s
CI/CD Pipeline / Build and Push App Docker Image (push) Skipped
CI/CD Pipeline / Deploy to Kubernetes (push) Skipped
2026-08-08 15:29:40 -07:00
kyle 22c282d8fa more for the overhaul
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m49s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 30s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 9m40s
2026-07-22 16:41:44 -07:00
kyle 86cda294f9 social overhaul 2026-07-22 15:47:34 -07:00
kyle 9db3fa697d Merge branch 'loader' into development
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m55s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m24s
2026-07-14 15:00:50 -07:00
kyle 6565714ee4 Merge branch 'predictions' into development
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m36s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 25s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m23s
2026-07-14 14:22:50 -07:00
kyle 1783f0e6bc predictions 2026-07-14 14:15:34 -07:00
kyle 0a7f4de321 Merge branch 'style' into development 2026-07-14 00:39:43 -07:00
kyle e8b7647f3d styling 2026-07-14 00:39:38 -07:00
kyle 0284b33e50 Merge branch 'style' into development 2026-07-14 00:12:46 -07:00
kyle d3809b5805 styling 2026-07-14 00:12:39 -07:00
kyle a50f9b6644 pb 2026-07-13 23:59:10 -07:00
kyle f77b3a5c41 infinite scroll 2026-07-13 23:57:31 -07:00
kyle 3b087850e7 Merge branch 'perf' into development 2026-07-13 23:56:30 -07:00
kyle 19f61ac454 SSE 2026-07-13 23:56:19 -07:00
kyle e6c72a5789 more styles
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m36s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m23s
2026-07-12 23:23:14 -07:00
kyle e9040bb02f generate sw
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m38s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m24s
2026-07-12 21:51:48 -07:00
kyle 552525d272 remove elevated header
CI/CD Pipeline / Build and Push App Docker Image (push) Failing after 1m16s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Has been skipped
2026-07-12 21:43:41 -07:00
kyle 4420e4d0fe merge
CI/CD Pipeline / Build and Push App Docker Image (push) Failing after 1m54s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Has been skipped
2026-07-12 21:30:51 -07:00
kyle 3cc16bdeb2 Merge branch 'style' into development 2026-07-12 21:30:02 -07:00
kyle 48eb7d71b2 Merge branch 'perf' into development 2026-07-12 21:28:46 -07:00
kyle 4f81f3c0ca style upgrades 2026-07-12 21:26:39 -07:00
kyle 778f0f7994 perf upgrade 2026-07-12 21:26:16 -07:00
kyle b6085ff329 temp fix
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m24s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m24s
2026-07-12 20:10:56 -07:00
kyle ec334bbed4 auth fix?
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m55s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Failing after 2m36s
2026-07-11 13:11:45 -07:00
kyle e67f6b073c ready for regionals
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 44s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m46s
2026-03-07 01:22:21 -06:00
kyle 569ea8833b Merge pull request 'center' (#10) from development into main
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 49s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m28s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 11s
Reviewed-on: #10
2026-03-02 01:13:07 -06:00
kyle 74d83da466 center
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m12s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 43s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 11s
2026-03-02 01:12:42 -06:00
kyle aff5fa2ea4 Merge pull request 'more pwa' (#9) from development into main
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m10s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 7s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 51s
Reviewed-on: #9
2026-03-02 01:01:17 -06:00
kyle 299f32a2b7 more pwa
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m51s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 7s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 44s
2026-03-02 01:00:46 -06:00
kyle 3f125e5761 Merge pull request 'pwa' (#8) from development into main
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m28s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 9s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 44s
Reviewed-on: #8
2026-03-02 00:49:50 -06:00
kyle fb4f50b851 pwa
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m41s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 44s
2026-03-02 00:49:18 -06:00
kyle 5305dc37e7 Merge pull request 'center text/whitespace prewrap desc' (#7) from development into main
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m42s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 47s
Reviewed-on: #7
2026-03-02 00:00:16 -06:00
kyle 3e5ef04c9f center text/whitespace prewrap desc
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m59s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 50s
2026-03-01 23:59:50 -06:00
kyle e51ff24944 Merge pull request 'fix' (#6) from development into main
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m31s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 53s
Reviewed-on: #6
2026-03-01 21:47:34 -06:00
kyle c138442530 fix
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m0s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 45s
2026-03-01 21:46:59 -06:00
kyle 3baec5ac0f Merge pull request 'regionals' (#5) from development into main
CI/CD Pipeline / Build and Push App Docker Image (push) Failing after 2m16s
CI/CD Pipeline / Deploy to Kubernetes (push) Has been skipped
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 13s
Reviewed-on: #5
2026-03-01 21:23:52 -06:00
kyle 8f84dddc64 fix team avatars
CI/CD Pipeline / Build and Push App Docker Image (push) Failing after 2m39s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 16s
CI/CD Pipeline / Deploy to Kubernetes (push) Has been skipped
2026-03-01 20:59:31 -06:00
kyle 9571980898 fix team avatars 2026-03-01 20:20:24 -06:00
kyle 1ce8a6a982 fix team avatars 2026-03-01 20:10:06 -06:00
kyle f0d8297f34 new badges 2026-03-01 19:58:18 -06:00
kyle f49234702c regionals on tournament profile 2026-03-01 19:49:46 -06:00
kyle f6da7be404 tiebreakers 2026-03-01 19:43:29 -06:00
kyle c7487e37f2 tiebreakers 2026-03-01 19:41:34 -06:00
kyle 873ca3e4c4 cross mix knockout seeds 2026-03-01 19:36:26 -06:00
kyle c5b3ad50ca fixed b2b 2026-03-01 18:04:45 -06:00
kyle 938418cec5 fixed b2b 2026-03-01 17:52:47 -06:00
kyle 5d6136a445 new badges 2026-03-01 17:04:06 -06:00
kyle 9a64f316b9 b2b 2026-03-01 16:30:49 -06:00
kyle 6199afc687 regionals 2026-03-01 16:21:27 -06:00
kyle f83a7d69c8 groups init 2026-02-25 19:54:51 -06:00
kyle 2dd3e5b170 enrollment polish 2026-02-21 23:30:58 -06:00
kyle b9e16e2b64 regionals enrollments 2026-02-21 23:12:21 -06:00
kyle 7f60b4d200 idk 2026-02-14 15:46:31 -06:00
kyle 6e9e014fcc cookie and pwa stuff 2026-02-14 12:59:01 -06:00
kyle a54a74d7de Merge pull request 'development' (#4) from development into main
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m16s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 53s
Reviewed-on: #4
2026-02-10 14:03:25 -06:00
kyle 236fcda671 session fixes
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m52s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 11s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 44s
2026-02-09 23:53:54 -06:00
kyle 9ed054e5d0 regionals on tournament form 2026-02-09 23:44:07 -06:00
kyle 63853f22de regional teams 2026-02-09 23:36:04 -06:00
kyle 5dd41d8022 fix scroll bug 2026-02-09 22:20:00 -06:00
kyle 937758bd49 facehash avatars 2026-02-09 14:31:55 -06:00
kyle 7b95998b05 Merge pull request 'new prod env' (#3) from development into main
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m43s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 13s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 46s
Reviewed-on: #3
2026-02-09 12:59:07 -06:00
kyle f069ba3827 include supertokens api key
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m5s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 7s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 49s
2026-02-09 12:14:38 -06:00
kyle 70a032db20 more env stuff
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m18s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 7s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 41s
2026-02-09 12:07:45 -06:00
kyle 243fb094e1 env
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m19s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 7s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 44s
2026-02-09 01:03:30 -06:00
kyle 84202cdbe9 hopefully fix
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m16s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 6s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 41s
2026-02-09 00:46:36 -06:00
kyle 6370ebe48a hopefully fix
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m15s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 6s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 43s
2026-02-09 00:42:44 -06:00
kyle 8b271f93ac hopefully fix
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m15s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 7s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 41s
2026-02-09 00:37:01 -06:00
kyle 2326693bfb hopefully fix
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m16s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 6s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 43s
2026-02-09 00:06:47 -06:00
kyle b209bbf4ef copy server.ts
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 58s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 6s
CI/CD Pipeline / Deploy to Kubernetes (push) Failing after 5m12s
2026-02-08 23:30:51 -06:00
kyle c4bf13744c fix migrations?
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m45s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 11s
CI/CD Pipeline / Deploy to Kubernetes (push) Failing after 5m9s
2026-02-08 23:21:21 -06:00
kyle 1e3eaf0c35 server.ts 2026-02-08 23:19:48 -06:00
kyle d4b52e762b dist
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m47s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Failing after 5m9s
2026-02-08 23:05:31 -06:00
kyle a4e618f327 fix
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Failing after 16m45s
CI/CD Pipeline / Build and Push App Docker Image (push) Failing after 19m23s
CI/CD Pipeline / Deploy to Kubernetes (push) Has been cancelled
2026-02-08 22:32:54 -06:00
kyle d1951afb3c testing cicd
CI/CD Pipeline / Build and Push App Docker Image (push) Failing after 1m30s
CI/CD Pipeline / Deploy to Kubernetes (push) Has been skipped
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 14s
2026-02-08 16:01:21 -06:00
kyle dce31905fc fixes 2025-10-18 23:09:57 -05:00
kyle e58ed86d3b fixes 2025-10-17 19:22:07 -05:00
kyle d833e5f1a1 Merge branch 'caro/badges-stats' 2025-10-16 15:41:36 -05:00
506 changed files with 49747 additions and 4604 deletions
+56
View File
@@ -0,0 +1,56 @@
---
name: add-language
description: Add or remove a UI language/locale — config, catalog generation, translation agents, SMS/date/meta integration, and verification. Use when asked to add, enable, support, or remove a language.
---
# Adding a language
Example: adding French (`fr`). Five places name the locale set — update all:
1. `src/lib/i18n/index.ts` — `SUPPORTED_LOCALES` + `LOCALE_LABELS` (label in
the language itself, no region clarifier: `fr: "Français"`).
2. `lingui.config.ts` — `locales`.
3. `src/lib/mantine/mantine-provider.tsx` — `DAYJS_LOCALE` map + the matching
`import 'dayjs/locale/fr'` (calendar month/weekday names come from dayjs;
without this, calendars stay English).
4. `src/lib/i18n/meta.ts` — `OG_LOCALE_BY_LOCALE` (fr → fr_FR).
5. `src/lib/twilio/index.ts` — `TWILIO_VERIFY_LOCALES` ONLY if Twilio Verify
supports the code (https://www.twilio.com/docs/verify/supported-languages).
Unsupported codes must stay out — the allowlist silently falls back to the
service-default English SMS instead of failing the login send.
Then generate + translate:
6. `bun run extract` creates `src/locales/fr/messages.po` (every msgid, empty
msgstr).
7. Translate via a chunked agent workflow (this repo's proven recipe):
- Split en.po entry blocks (split on blank lines, skip header) into ~60-entry
chunks; one sonnet agent per chunk RETURNS translated entries (never let
parallel agents edit one .po). Validate each returned chunk by msgid count;
retry failures once.
- Merge by msgid back into the locale .po. Match msgids by exact bytes —
watch non-breaking spaces (\xa0) in msgids, which agents normalize away;
patch those few by hand.
- Agent rules: msgstr single-line, ICU placeholders/plural keywords and
`<0>` tags preserved (translate only human words inside branches; plural
categories follow the target language's CLDR set), FLXN/Flexxon/Spotify
untranslated, casual sporty tone, register decided up front (tú/du/です・ます).
8. `bun run i18n:check` must pass (0 missing). Optionally run per-locale
native-reviewer agents over the full .po (fidelity, register, glossary
consistency, ICU integrity) and apply their FIX lines.
9. `bunx tsc --noEmit && bun run build`; confirm the new locale appears as its
own lazy `messages-*.js` chunk in dist/client/assets.
10. Manual check: switch language in Settings (persists to SuperTokens
metadata), confirm UI + calendar + SMS behavior.
# Removing a language
Reverse of the above: delete from the five locale-set sites (skip Twilio if it
was never listed), `rm -rf src/locales/<code>`, drop the dayjs import, then
`bun run i18n:check` + tsc. Users with the removed locale saved in metadata
fall back to English automatically via `resolveLocale`.
Fallback semantics: untranslated entries render English (msgid); unauth
visitors get Accept-Language detection (SSR first paint on public routes is
English, corrected after hydration); authed users get their saved locale
server-rendered.
+101
View File
@@ -0,0 +1,101 @@
---
name: i18n
description: Rules and workflow for user-facing strings — wrapping new text in Lingui macros, updating catalogs, and translating new entries. Use whenever adding, editing, or removing any user-visible text (components, hooks, toasts, route titles, server errors).
---
# i18n workflow (Lingui v6)
Every user-facing string must go through Lingui. English source text IS the
translation key (msgid); catalogs live in `src/locales/<locale>/messages.po`
(en, es, de, ja — es is Mexican Spanish). msgstr mirrors msgid in en.po; that
is normal for the source locale.
## Wrapping rules
Allowed APIs — context-bound only (SSR-safe):
- `import { Trans, Plural, useLingui } from "@lingui/react/macro"`
- `import { msg } from "@lingui/core/macro"` (descriptor only)
- NEVER `t`/`plural`/`select` from `@lingui/core/macro` — they bind a global
i18n and bleed locale across SSR requests.
- Carve-out: a component that only needs `i18n._()` (no `t`` macro) may use
`useLingui` from plain `@lingui/react` (see header.tsx, mantine-provider) —
it's the same context-bound hook, just without macro sugar.
Patterns:
| Context | Pattern |
|---|---|
| JSX text | `<Trans>Match starts soon</Trans>` |
| String props / expressions in a component or hook | `const { t } = useLingui()` at TOP LEVEL (unconditional — React Compiler), then `t`Saved!`` |
| Countable text in JSX | `<Plural value={n} one="# point" other="# points" />` |
| Countable text in a string expression | `i18n._({ ...msg`{n, plural, one {# team} other {# teams}}`, values: { n } })` — a bare `t`Teams (${n})`` gives translators no plural branch |
| Route header titles (loader/beforeLoad) | `title: msg`Manage {name}`, titleValues: { name }` — resolved by `Header` |
| Plain .ts utils | take an `i18n: I18n` param and use msg descriptors (see `src/features/predictions/utils.ts`) |
| Server code (API routes, push, meta) | `localizedFor(context.metadata)` from `src/lib/i18n/server-messages.ts` (or `createI18n(resolveLocale(...))`) + msg descriptors |
| Server-fn error toasts | localized centrally via the ErrorType map in `src/lib/i18n/error-messages.ts` — add new ErrorTypes there, not per-call-site |
v6 signature: values ride inside the descriptor — `i18n._({ ...msg`Hi {name}`,
values: { name } })`. `i18n._(descriptor, values)` does NOT exist in v6.
Homographs: when the same English word means different things in different
places (e.g. "Home" = nav screen vs. home team), give one of them a context so
translators get separate entries: `t({ message: "Home", context: "match team" })`
— Spanish needs "Inicio" vs "Local" there. Same-spelling msgids silently share
one translation otherwise.
Never wrap: logger/console output, query keys, route paths, URLs, CSS values,
PB collection/field names, ids/slugs, `===`-compared values, object keys,
brand names FLXN/Flexxon/Spotify, data-derived names, thrown errors that are
only logged (mapKnownError discards their text — only the ErrorType map's
strings reach users).
## Architecture invariants (violating these breaks the app, not just a string)
- **Provider order**: `LinguiProvider` must stay ABOVE `AuthProvider`
(`src/features/core/components/providers.tsx`). The localized query hooks
(`useServerQuery`/`useMe`/etc.) call `useLingui`, so anything using them —
including AuthProvider — must render inside it. LinguiProvider therefore
reads the auth query with raw `useQuery`, never `useAuth`/`useMe`.
- **`__root` head() must not read `match`/context** — doing so creates
circular route-type inference that breaks `beforeLoad` typing in child
routes. Meta stays DEFAULT_LOCALE (crawlers have no session anyway).
- **Babel order** in vite.config.ts: lingui macro plugin BEFORE
react-compiler. Reversed order fails the build on `<Plural>`.
- Catalog loading: `__root` beforeLoad preloads the active locale on authed
routes; `LinguiProvider` self-loads a missing catalog client-side (public
routes). Unauthenticated non-en visitors get an English SSR first paint on
/login that corrects after hydration — known, accepted.
- `useMemo`/`useCallback` bodies that CACHE a computed string from `t`/`i18n`
must list them in deps. (Callbacks that merely *call* `t` when invoked read
the live locale and are safe.)
- Tab labels: `SwipeableTabs` items need a stable `value` slug; `label` is
the translated display string.
## Catalog workflow — run after any string change
1. `bun run extract` — regenerates ALL locale .po files from source, adds new
msgids, removes stale ones. Never hand-edit msgids or en.po.
2. Translate the new empty `msgstr ""` entries in es/de/ja. Spawn one
translation agent per locale (batch ~60 entries; have them RETURN the
translated entries and merge by msgid — don't let parallel agents edit one
file). Translation rules for agents:
- msgid byte-identical; translate msgstr only, single line.
- Preserve ICU placeholders/plural structure (translate only the words
inside branches) and `<0>`/`</0>` tags exactly.
- Watch invisible characters: msgids can contain non-breaking spaces
(\xa0) that agents silently normalize — verify by byte comparison,
not visual.
- Registers: es = informal tú (Mexican), de = informal du, ja =
polite-casual です/ます. FLXN/Flexxon/Spotify untranslated.
3. `bun run i18n:check` — fails if any locale has missing translations. CI
(`.gitea/workflows/ci-cd.yaml`, `i18n-check` job) enforces this AND fails
if committed catalogs are stale vs. source — always commit `src/locales`
with string changes.
4. `bun run build` runs extract automatically, so a forgotten extract can't
ship hash-ids — but only step 3 catches untranslated entries.
Production strips English defaults from components; text lives only in the
hash-keyed compiled catalog, so a msgid missing from the catalog renders as
its hash. Non-en catalogs are separate lazy chunks — English users download
nothing extra.
+175
View File
@@ -0,0 +1,175 @@
name: CI/CD Pipeline
on:
push:
branches:
- development
- main
jobs:
i18n-check:
name: i18n Catalog Check
runs-on: ubuntu-latest
container: node:22-bookworm
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Bun
run: |
npm install -g bun
bun --version
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Catalogs in sync with source
run: |
git config --global --add safe.directory "$(pwd)"
bunx lingui extract --clean
git diff --exit-code src/locales || {
echo "::error::Locale catalogs are stale — run 'bun run extract' and commit src/locales"
exit 1
}
- name: No missing translations
run: bunx lingui compile --strict
build-app:
name: Build and Push App Docker Image
runs-on: ubuntu-latest
needs: i18n-check
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set environment variables
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "DOCKER_TAG=latest" >> $GITHUB_ENV
echo "ENVIRONMENT=prod" >> $GITHUB_ENV
else
echo "DOCKER_TAG=dev" >> $GITHUB_ENV
echo "ENVIRONMENT=dev" >> $GITHUB_ENV
fi
- name: Build and push app Docker image
run: |
docker login git.yohler.net -u ${{ github.actor }} -p ${{ secrets.PACKAGES_TOKEN }}
docker build \
-f Dockerfile \
-t git.yohler.net/kyle/flxn-app:${{ env.DOCKER_TAG }} \
-t git.yohler.net/kyle/flxn-app:${{ github.sha }} \
.
docker push git.yohler.net/kyle/flxn-app:${{ env.DOCKER_TAG }}
docker push git.yohler.net/kyle/flxn-app:${{ github.sha }}
build-pocketbase:
name: Build and Push PocketBase Docker Image
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check if PocketBase Dockerfile or migrations changed
id: check_changes
run: |
if [ "${{ github.event.before }}" == "0000000000000000000000000000000000000000" ] || ! git cat-file -e ${{ github.event.before }} 2>/dev/null; then
echo "changed=true" >> $GITHUB_OUTPUT
elif git diff --name-only ${{ github.event.before }} ${{ github.sha }} | grep -qE "(Dockerfile.pocketbase|pb_migrations/)"; then
echo "changed=true" >> $GITHUB_OUTPUT
else
echo "changed=false" >> $GITHUB_OUTPUT
fi
- name: Build and push PocketBase Docker image
if: steps.check_changes.outputs.changed == 'true' || github.event.before == '0000000000000000000000000000000000000000'
run: |
docker login git.yohler.net -u ${{ github.actor }} -p ${{ secrets.PACKAGES_TOKEN }}
docker build \
-f Dockerfile.pocketbase \
-t git.yohler.net/kyle/flxn-pocketbase:latest \
-t git.yohler.net/kyle/flxn-pocketbase:${{ github.sha }} \
.
docker push git.yohler.net/kyle/flxn-pocketbase:latest
docker push git.yohler.net/kyle/flxn-pocketbase:${{ github.sha }}
deploy:
name: Deploy to Kubernetes
needs: [build-app, build-pocketbase]
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: pin rancher host
run: echo "192.168.4.43 rancher.yohler.net" >> /etc/hosts
- name: Set environment variables
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "ENVIRONMENT=prod" >> $GITHUB_ENV
echo "NAMESPACE=flxn-prod" >> $GITHUB_ENV
else
echo "ENVIRONMENT=dev" >> $GITHUB_ENV
echo "NAMESPACE=flxn-dev" >> $GITHUB_ENV
fi
- name: Install kubectl
run: |
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
chmod +x kubectl
mv kubectl /usr/local/bin/
- name: Configure kubectl
run: |
mkdir -p $HOME/.kube
echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > $HOME/.kube/config
chmod 600 $HOME/.kube/config
kubectl config set-cluster local --insecure-skip-tls-verify=true
- name: Verify kubectl access
run: |
kubectl version --client
kubectl get nodes
- name: Deploy shared services (SuperTokens)
run: |
kubectl apply -k k8s/overlays/shared/
- name: Deploy to ${{ env.ENVIRONMENT }}
run: |
kubectl apply -k k8s/overlays/${{ env.ENVIRONMENT }}/
- name: Force rollout to pull latest image
run: |
kubectl rollout restart deployment/flxn-app -n ${{ env.NAMESPACE }}
kubectl rollout restart deployment/flxn-pocketbase -n ${{ env.NAMESPACE }}
- name: Wait for rollout
run: |
kubectl rollout status deployment/flxn-app -n ${{ env.NAMESPACE }} --timeout=5m
kubectl rollout status deployment/flxn-pocketbase -n ${{ env.NAMESPACE }} --timeout=5m
- name: Verify deployment
run: |
kubectl get pods -n ${{ env.NAMESPACE }} -l app=flxn
kubectl get svc -n ${{ env.NAMESPACE }} -l app=flxn
kubectl get ingress -n ${{ env.NAMESPACE }}
- name: Check app health
run: |
sleep 15
APP_POD=$(kubectl get pod -n ${{ env.NAMESPACE }} -l component=app -o jsonpath='{.items[0].metadata.name}')
kubectl exec -n ${{ env.NAMESPACE }} $APP_POD -- wget -O- http://localhost:3000/api/health || echo "Health check failed (endpoint may not exist yet)"
- name: Check PocketBase health
run: |
PB_POD=$(kubectl get pod -n ${{ env.NAMESPACE }} -l component=pocketbase -o jsonpath='{.items[0].metadata.name}')
kubectl exec -n ${{ env.NAMESPACE }} $PB_POD -- wget -O- http://localhost:8090/api/health || echo "PocketBase health check completed"
+4 -2
View File
@@ -17,7 +17,9 @@ yarn.lock
/playwright-report/
/blob-report/
/playwright/.cache/
/scripts/
/_scripts/
/pb_data/
/.tanstack/
/dist/
/dist/
# lingui compile output (i18n:check); runtime uses .po via vite plugin
src/locales/*/messages.js
+23
View File
@@ -0,0 +1,23 @@
# FLXN
## i18n (Lingui v6) — applies to every user-facing string
Any change that adds, edits, or removes user-visible text must follow the
`i18n` skill (`.claude/skills/i18n/SKILL.md`): wrap with context-bound Lingui
macros, then `bun run extract`, translate new entries in every non-`en`
locale, and `bun run i18n:check`. Adding a language: use the `add-language`
skill.
Hard rules (full detail in the skill):
- Only `Trans`/`Plural`/`useLingui` from `@lingui/react/macro` and `msg` from
`@lingui/core/macro`. Never global `t`/`plural`/`select` from core/macro
(SSR locale bleed).
- `useLingui()` at top level of components/hooks only; add `t`/`i18n` to memo
dep arrays.
- Never hand-edit msgids or `src/locales/en/messages.po` (generated).
- vite.config.ts babel plugin order (lingui before react-compiler) is
load-bearing.
- `LinguiProvider` stays ABOVE `AuthProvider` in providers.tsx (localized
query hooks call useLingui), and `__root`'s `head()` must never read
`match`/context (circular route-type inference breaks beforeLoad typing).
+28
View File
@@ -0,0 +1,28 @@
FROM oven/bun:1 AS builder
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
FROM oven/bun:1-alpine AS runtime
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/server.ts ./server.ts
EXPOSE 3000
ENV NODE_ENV=production
ENV PORT=3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD bun -e "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
CMD ["bun", "run", "server.ts"]
+12 -5
View File
@@ -1,16 +1,23 @@
FROM alpine:latest
ARG PB_VERSION=0.29.2
ARG PB_VERSION=0.26.5
RUN apk add --no-cache \
unzip \
ca-certificates
# download and unzip PocketBase
ADD https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip /tmp/pb.zip
RUN unzip /tmp/pb.zip -d /pb/
RUN unzip /tmp/pb.zip -d /pb/ && \
rm /tmp/pb.zip && \
chmod +x /pb/pocketbase
RUN mkdir -p /pb/pb_data
COPY pb_migrations /pb/pb_migrations
EXPOSE 8090
# start PocketBase
CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8090"]
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8090/api/health || exit 1
CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data", "--migrationsDir=/pb/pb_migrations"]
BIN
View File
Binary file not shown.
+1357 -473
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -13,7 +13,6 @@ services:
- .env.docker
volumes:
- postgres-data:/var/lib/postgresql/data
- ./.docker-postgres-init:/docker-entrypoint-initdb.d
networks:
- app-network
+78
View File
@@ -0,0 +1,78 @@
# FLXN Replay — Design Spec (v1, locked)
**Status:** design locked, not yet built. The tournament recap feature: a **player-owned recap** and an **overall tournament recap**, viewable **retroactively** from any completed tournament. Content is shaped to render into a **shareable card layout** (the owner's friend is producing the SVG/PNG graphic template — we spec the payload, not the artwork).
**No new collection.** Everything below is derivable at read time from existing data — the same philosophy as the podium/placement derivation in `src/lib/pocketbase/util/transform-types.ts` (`transformTournament` computes `isComplete`, `first/second/third_place` purely from ended matches). Replay is a pair of server fns that compute a recap payload from data we already store.
## Availability
- A tournament has a Replay when it's **complete**: every non-bye match `status === "ended"` — reuse the exact `isComplete` derivation from `transformTournament` (`src/lib/pocketbase/util/transform-types.ts:218-219`).
- **Overall recap is public** (any authed user). **Player recap** exists for every enrolled player; the "View Replay" entry shows participants their own recap.
- Retroactive by construction: old completed tournaments get Replays for free since nothing is written at tournament end.
## Data sources (all existing)
- **Matches** — `status: "ended"`, `home_cups`/`away_cups`, `ot_count`, `start_time`/`end_time`, `home_seed`/`away_seed`, `round`, `lid`, `order`, `is_losers_bracket`, `reset`, `bye` (`src/features/matches/types.ts`, transformed at `src/lib/pocketbase/util/transform-types.ts:30`). `pbAdmin.getTournament(id)` already returns matches + teams expanded, plus `team_stats` from the `team_stats_per_tournament` view (`src/lib/pocketbase/services/tournaments.ts:21`).
- **Stats views** — `team_stats_per_tournament` gives per-team W/L, cups for/against, margins for this event (mapped onto `tournament.team_stats` in `transformTournament`). Global `player_stats` / `player_mainline_stats` / `player_regional_stats` views exist (`pb_migrations/1783926000_optimized_player_stats_views.js`) but are all-time; per-event player numbers come from the tournament's own matches (teams are 2-player, so a player's event record == their team's record).
- **Reactions** — `reactions` collection `{match, player, emoji}` (`src/lib/pocketbase/services/reactions.ts`). Currently only fetched per match; add one service method fetching all reactions for a tournament via filter `match.tournament = "<id>"`.
- **Predictions** — `computePredictionScore` (points, correct, `predictedChampionId`) in `src/features/predictions/utils.ts` and `getPredictionsLeaderboard` in `src/features/predictions/server.ts` give points / rank / called-the-champ directly.
- **Badges** — `badge_progress` (`earned` boolean, no per-tournament attribution) — see open questions.
## Player recap (content + derivation)
All from the completed tournament's ended matches involving the player's team (find the team by scanning `tournament.teams[].players` for the player id):
- **W–L + win %** — team's rows from `tournament.team_stats`, or count from matches.
- **Cups sunk / conceded** — `total_cups_made` / `total_cups_against` from `team_stats`.
- **Biggest blowout margin** — max `|home_cups - away_cups|` across their wins, with opponent.
- **Longest win streak** — sort their matches by `order` (fallback `end_time`), scan for the longest run of wins.
- **Nemesis** — the team that ended their tournament (opponent in their final loss; in double-elim that's their losers-bracket exit), or if undefeated/champion, worst H2H by aggregate cup differential.
- **Victim** — opponent with the biggest aggregate cup differential in the player's favor across H2H games.
- **OT games survived** — their ended matches with `ot_count >= 1`; show count and OT record.
- **Final placement + podium** — top 3 from the existing podium derivation; below that, label by exit round ("Eliminated in Match 12" / round name via `getMatchLabel`-style logic) rather than a fake numeric rank.
- **Partner chemistry** — teammate (the other id on the roster) + combined record; identical to team record, framed as "You and X went 4–1 together".
- **Prediction result** — points / rank / champion-call from the predictions leaderboard; omit the block if they didn't submit.
- **Most-reacted match** — their match with the most reaction rows; include the top emoji.
- **Badges earned this event** — see open questions; v1 approach below.
## Overall recap (content + derivation)
- **Champion + podium** — existing `first/second/third_place` derivation.
- **Biggest upset** — among ended matches where both `home_seed` and `away_seed` are set, the win with the largest (winner seed − loser seed) gap.
- **Highest-scoring match** — max `home_cups + away_cups`. **Closest** — min nonzero margin (tie-break: more total cups). **Longest** — max `ot_count` (tie-break: `end_time - start_time` when both set).
- **Most cups in one game (single team)** — max of `home_cups`/`away_cups` with team + match.
- **Totals** — Σ cups, count of ended matches, count with `ot_count >= 1` (+ total OT periods).
- **MVP** — best `team_stats` row by win %, then margin (cup differential); surfaced as the two players of that team (or the champion team — tunable).
- **Most-reacted moment** — match with most reactions overall + top emoji.
- **Prediction-pool winner** — top of `getPredictionsLeaderboard`; omit if nobody predicted.
- **Team count / attendance** — `tournament.teams.length` / unique players across rosters.
## Share-card layout
- The recap payload is a list of **discrete stat blocks** (`{ key, label, value, sublabel?, team?/player?, matchRef? }`) so the external SVG/PNG template can consume the same JSON the app renders — don't bake copy into components.
- In-app, render as a vertical stack of card sections (hero: placement/champion; then blocks), constrained to a portrait share-card aspect so what you see is what the graphic shows.
- v1 sharing: the friend's template consumes the payload out-of-band; an in-app "Share" (Web Share API / rendered PNG) is explicitly **not** in v1 scope — just keep the payload shape stable.
## Entry points
- `TournamentStats` (`src/features/tournaments/components/tournament-stats.tsx`) already renders the completed-tournament overview with `ListLink` rows ("View Bracket", "View Predictions" ~lines 178-196). Add **"View Replay"** there, gated on the same `isComplete` memo already computed in that file. It links to the overall recap; inside, a participant sees a "Your Replay" hero button to their own player recap.
## Reuses / dependencies
- `pbAdmin.getTournament` (one fetch covers matches, teams, team_stats), podium derivation, predictions scoring + leaderboard, reactions collection, `ListLink`, `SwipeableTabs`/sheet patterns, `useServerSuspenseQuery`.
- No dependency on any Wave 2/3 feature; ships standalone.
## Implementation notes for a fresh agent (build this cold)
Context: worktree `social` branch; changes are typically left uncommitted for review. Read `docs/flxn-replay.md` (this file) fully first, then:
- **No migration.** Replay writes nothing. Do not add a collection.
- **New feature dir:** `src/features/replay/{types.ts, server.ts, queries.ts, utils.ts, components/}`. Keep every derivation (streaks, nemesis/victim, upset, superlatives) as pure functions over `Match[]`/`TournamentTeamStats[]` in `utils.ts`, unit-testable like `src/features/predictions/utils.ts`.
- **Server fns:** two — `getTournamentRecap(tournamentId)` and `getPlayerRecap({tournamentId, playerId})` — following `createServerFn().validator(zod).middleware([...]).handler(...)` + `toServerResult`, with `.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])`; copy the shape of `getPredictionsLeaderboard` in `src/features/predictions/server.ts` (it already does getTournamentOrThrow + derive-from-matches). Resolve the caller via `pbAdmin.getPlayerByAuthId(context.userAuthId)`. Throw if the tournament isn't complete (reuse the `isComplete` logic; export it from a shared util rather than copy-pasting a third time — it already exists twice in `transform-types.ts`).
- **Reactions per tournament:** add `getReactionsForTournament(tournamentId)` to `src/lib/pocketbase/services/reactions.ts` — `getFullList({ filter: \`match.tournament="${id}"\` })` — and aggregate counts per match in `utils.ts`.
- **Prediction block:** call the existing prediction server logic (or its underlying service + `computePredictionScore`) inside the recap fn; don't re-implement scoring.
- **Badges block (v1):** fetch the player's `badge_progress` where `earned = true` and `updated` falls within `[tournament.start_time, tournament.end_time + 24h]` — attribution by timestamp. This is best-effort (see open questions); render nothing when empty.
- **Routes:** `src/app/routes/_authed/tournaments/$id.replay.tsx` (overall) and `src/app/routes/_authed/tournaments/$id.replay_.$playerId.tsx` (player), mirroring `$id.predictions.tsx` / `$id.predictions_.$playerId.tsx` exactly (same `createFileRoute` + loader header + Suspense pattern; note sub-routes use `$id`, only the profile route uses `$tournamentId`).
- **Client data:** `useServerSuspenseQuery` with a `replayKeys`/`replayQueries` module copying `src/features/predictions/queries.ts`. Data is immutable post-completion — no SSE wiring, no invalidation, long `staleTime` is fine.
- **Entry point:** in `tournament-stats.tsx`, add `{isComplete && <ListLink label="View Replay" to={\`/tournaments/${tournament.id}/replay\`} Icon={...} />}` next to the existing View Bracket/Predictions links. Skip it for `tournament.regional` (old regional data is flagged unreliable in that same file).
- **Design system:** Mantine 8 + app theme (`src/lib/mantine/mantine-provider.tsx`: `defaultRadius: "sm"`, primary = user accent, press feedback via the `flxn-press` activeClassName). The recap should feel like a highlight reel, not a table — but tasteful motion only (iOS-like easing, 120–350ms), accent color reserved for the hero stat per card, and match neighboring components rather than inventing a new look. Reuse `TeamAvatar`/`PlayerAvatar` in stat blocks.
- **Toasts:** none needed (read-only feature); errors surface via the route's error boundary.
## Tunables / open questions
- **Badge attribution** is the one shaky derivation: `badge_progress.earned` is a boolean with no event linkage, and retroactive/recomputed badges (`migrateBadgeProgress`) break timestamp inference. v1 = timestamp window (above); the clean fix is re-running badge criteria with/without this tournament's matches and diffing — decide if that's worth it before building.
- **Biggest upset without seeds:** group-stage matches (`round === -1`) and some formats lack `home_seed`/`away_seed`; v1 skips seedless matches. Fall back to group placement later?
- **Player recap visibility:** owner-only vs. public like `$id.predictions_.$playerId.tsx`? Default: viewable by any participant (it's all derivable from public match data anyway), but the entry point only advertises your own.
- **"OT games survived"** — count OT games played, or only OT wins? Default: played, with the win/loss split shown.
- **MVP definition** — best win % then margin (as specced) vs. simply the champions. Revisit after seeing real output.
- **Regional tournaments** — excluded in v1 (data quality); could be enabled per-tournament later.
- **Non-podium placement copy** — exit-round label vs. numeric rank; locked to label for v1.
+91
View File
@@ -0,0 +1,91 @@
# Free-Agent Matchmaking Board — Design Spec (v1, locked)
**Status:** design locked, not yet built. Extends the existing free-agent enrollment flow; the admin random-pairing flow stays as the fallback for anyone unmatched.
Players who sign up without a team ("free agents") can see each other — with stats and badges — and **claim a partner**. The claimed player must **confirm** (two-sided handshake, mirroring the score-report Confirm pattern). On mutual accept the app creates the team for them. The admin still arbitrates leftovers.
## What exists today (baseline)
- `free_agents` collection (`pbc_2929550049`): `{ player → players, tournament → tournaments, phone }`. Created in `pb_migrations/1758388728_created_free_agents.js`, tournament relation added in `1758402128_updated_free_agents.js`. No partner/status field, no claim concept.
- Service: `enrollFreeAgent` / `unenrollFreeAgent` / `getFreeAgents` in `src/lib/pocketbase/services/tournaments.ts` (~L189–216); `transformFreeAgent` in `src/lib/pocketbase/util/transform-types.ts` returns `{ id, phone, player }`.
- Server fns in `src/features/tournaments/server.ts`: `getFreeAgents` (L109, any authed user — note it currently returns phones for everyone), `enrollFreeAgent` (L116), `unenrollFreeAgent` (L131). All emit `{ type: "tournament", tournamentId }`.
- UI: `src/features/tournaments/components/upcoming-tournament/enroll-free-agent.tsx` (enroll sheet) and `enrolled-free-agent.tsx` (the pool view). **Regional agents can't see the pool at all** — the `isRegional` branch shows only your own card with "Partners will be randomly assigned". Non-regional agents see a plain name + phone list. No stats, no badges, no actions.
- Admin fallback: `src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx` → `useGenerateRandomTeams` / `useConfirmTeamAssignments` → `generateRandomTeams` (L146) and `confirmTeamAssignments` (L326) in `src/features/tournaments/server.ts`. Confirm finds-or-creates a `private: true` team per pair, `enrollTeam`s it, then `unenrollFreeAgent`s both players.
- The two-sided confirm precedent: `reportMatchScore` / `confirmMatchScore` in `src/features/matches/server.ts` (~L828–928). Pending state lives **on the record itself** (`reported_by_team`, `reported_home_cups`, …), the other side confirms, and a cross-report with identical values auto-finalizes.
## The board
Replaces the plain list inside `enrolled-free-agent.tsx` for **both** regional and mainline tournaments (the regional visibility gate is relaxed — see below). Each agent renders as a card:
- `PlayerAvatar` + name.
- **Stats strip:** matches, win %, avg cups/match (from `PlayerStats` — `src/features/players/types.ts` L34).
- **Badges row:** the player's earned badges, compact (icon row, overflow "+N").
- **Action:** one of `Claim` / `Claimed — waiting` (yours, with Withdraw) / `Accept · Decline` (they claimed you) / a subtle "pending with someone else" state.
- Phone: keep today's behavior for mainline (copy-to-clipboard affordance); regionals stay phone-less (`enrollFreeAgent` is already called with `""` phone via the regional path, and admin-enroll at L737 passes `""`).
Fetch stats/badges in bulk — `getAllPlayerStats` (`src/features/players/server.ts` L174) and `getAllEarnedBadges` (`src/features/badges/server.ts`, keyed in `src/features/badges/queries.ts`) — filter client-side to the agents on the board. Do **not** issue per-agent `getPlayerStats`/`getPlayerBadges` calls (N+1).
## The handshake
1. **Claim:** agent A taps Claim on agent B. A's `free_agents` row gets `claimed = <B's row id>`. A can have **one outgoing claim** at a time (withdraw to switch).
2. **B sees it** (SSE-refreshed): "A wants to partner with you" with Accept / Decline. B can hold multiple *incoming* claims; accepting one implicitly declines the rest.
3. **Accept →** server verifies A's claim still points at B, then creates the team (reusing the `confirmTeamAssignments` find-or-create logic), enrolls it in the tournament, and `unenrollFreeAgent`s both — both rows vanish from the pool, exactly like the admin flow.
4. **Decline →** clears A's `claimed` field. A is free to claim someone else. (Optionally notify A — see tunables.)
5. **Symmetry shortcut:** if B already has an outgoing claim on A when A claims B (or vice versa), that *is* mutual intent — auto-accept immediately, mirroring how `reportMatchScore` auto-finalizes on an identical cross-report.
Team naming: default `"<A first> & <B first>"`, same shape the random generator produces; rename later via the existing team-edit flow.
## Data model (decided: Option A — field on `free_agents`)
Two options were weighed:
- **Option A (chosen): a self-relation on `free_agents`.** Add one nullable field `claimed` (relation → `free_agents`, maxSelect 1) — "this agent's outgoing claim". Everything derives from it: my outgoing claim = my row's `claimed`; my incoming claims = rows where `claimed = <my row id>`; decline = clear the field; match = both rows deleted. No status enum needed for v1 (one outgoing claim, terminal states delete rows). This mirrors the score-report precedent where pending state lives on the `matches` record, and unenroll/delete automatically cleans up outgoing claims (incoming pointers to a deleted row are cleared server-side on unenroll — one filtered update).
- **Option B (rejected for v1): a `partner_claims` collection** `{ tournament, from_agent, to_agent, status: pending|accepted|declined, created }`. Buys an audit trail, decline history (anti-re-spam), and native multi-claim — at the cost of a new collection, cross-record consistency, and cleanup hooks on unenroll. Revisit if tunables land on "multiple outgoing claims" or "declines block re-claiming".
`getFreeAgents` grows to expand `claimed` and return `{ id, phone, player, claimedAgentId }`; the board derives the four card states from that plus "who points at me".
## Server fns (new, in `src/features/tournaments/server.ts`)
All player-facing: `createServerFn().validator(zod).middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware]).handler(...)` + `toServerResult`, resolving the caller via `pbAdmin.getPlayerByAuthId(context.userAuthId)` — copy `enrollFreeAgent` (L116) as the shell and the guard style of `confirmMatchScore`.
- `proposePartner({ tournamentId, targetAgentId })` — caller must be an enrolled agent with no outgoing claim; target must be a different agent in the same tournament. Sets `claimed`. If target already claims caller → run the accept path (auto-match). Emits `tournament`; push to target.
- `withdrawPartnerClaim({ tournamentId })` — clears caller's `claimed`. Emits `tournament`.
- `respondToPartnerClaim({ tournamentId, fromAgentId, accept: boolean })` — verifies `fromAgent.claimed === callerAgent.id` (claim may have been withdrawn — fail with a friendly error). Decline: clear it. Accept: **reuse the team path** — extract the find-or-create-private-team + `enrollTeam` block from `confirmTeamAssignments` (L341–381) into a shared helper (e.g. `createOrReuseTeamForPair(player1Id, player2Id, teamName, tournamentId)`), call it, then `unenrollFreeAgent` both (which drops both rows and any pointers). Emit `team` + `tournament`; push to the claimer.
Race safety: re-read both rows inside the handler before mutating (same optimistic pattern as score confirm — no locks, just verify-then-act; PB writes are serialized enough at this app's scale, per the existing flows).
## Realtime + push
- **SSE:** reuse the existing `tournament` event (`src/lib/events/emitter.ts` L25). The handler in `src/hooks/use-server-events.ts` (L51) already invalidates the `['tournaments']` prefix, which covers `tournamentKeys.free_agents(id)` (`['tournaments','free_agents',id]`, `src/features/tournaments/queries.ts` L9) — **no new event type needed**; the board updates live as claims land.
- **Push:** `sendPushToPlayer` in `src/lib/push/index.ts` — its doc comment already reserves this exact seam ("a free-agent claim involving the player"). Payloads (`PushPayload` in `src/lib/push/types.ts`):
- On claim → target: `{ title: "You've been claimed!", body: "<Name> wants to be your partner for <tournament>", url: "/", tag: "partner-claim-<agentId>" }`.
- On accept → claimer: `{ title: "It's a match", body: "<Name> accepted — you're teammates", ... }`.
- Decline: **no push** (avoid rejection stings; the cleared state shows on the board).
## Regional visibility gate (relaxed)
Delete the regional "you're enrolled, wait for assignment" dead-end in `enrolled-free-agent.tsx` (L45–74): regionals get the same board (stats, badges, claim) — that self-card + "Partners will be randomly assigned when enrollment closes" collapses into the board's header copy ("Claim a partner, or the admin pairs whoever's left"). Keep the regional enroll sheet copy in `enroll-free-agent.tsx` but update it to mention claiming. Phone stays hidden for regionals (the claim button replaces its purpose).
## Admin fallback interplay
- Accepted handshakes remove both agents from `free_agents`, so `assign-partners.tsx` and `generateRandomTeams` see only the leftovers — **zero changes to the pairing math**. The even-count and ≥2 guards (L156–162) still apply to what remains.
- Pending (unaccepted) claims are invisible to the random pairer — it may split a pending pair. Acceptable for v1: random pairing runs at deadline, and `confirmTeamAssignments`'s `unenrollFreeAgent` sweep clears all claim state. Optional nicety (cheap, do it): `generateRandomTeams` seeds mutually-irrelevant pairs randomly but **keeps any pending claim pair together** when both are still in the pool — a one-pass pre-grouping before the shuffle.
- Admin's enrolled-players count and `assign-partners` header need no changes; they read the same `useFreeAgents` query.
## Design system
- Mantine 8 + the app theme (`src/lib/mantine/mantine-provider.tsx`): `defaultRadius: "sm"`, dynamic `primaryColor`. Use `Card withBorder radius="md" p="md"` like the existing regional self-card so the board matches its neighbors.
- Accent discipline: **one** accented element per card — the `Claim` button. Waiting/pending states are `variant="subtle"`/`c="dimmed"`; Accept is filled, Decline is `variant="subtle" color="red"` (matching the enroll sheet's Cancel).
- Press feedback via the `flxn-press` theme; motion only where it earns it — a single 120–350ms iOS-eased transition when a card flips state (claimed → matched), no confetti, no bouncing.
- Badges row reuses the visual language of `src/features/badges/components/badge-showcase.tsx` at small size — don't invent a new badge chip.
## Implementation notes for a fresh agent (build this cold)
Context: worktree `social` branch; changes are typically left uncommitted for review. Read `docs/free-agents.md` (this file) fully first, then:
- **Migration:** one new `pb_migrations/*_updated_free_agents.js` adding the `claimed` self-relation (collectionId `pbc_2929550049`, maxSelect 1, required false, cascadeDelete false). Mimic `1758402128_updated_free_agents.js` (plain `migrate((app)=>{...})` with up + down). Players collection id is `pbc_3072146508`, tournaments `pbc_340646327`, teams `pbc_1568971955`. Collections have null rules; all access is server-side via `pbAdmin`.
- **Service layer:** extend `createTournamentsService` in `src/lib/pocketbase/services/tournaments.ts` — `setFreeAgentClaim(agentId, targetAgentId|null)`, `getFreeAgentByPlayer(playerId, tournamentId)` (the lookup already inlined in `unenrollFreeAgent` L198), and make `unenrollFreeAgent` also clear inbound pointers (`getFullList({ filter: 'claimed = "<rowId>"' })` → clear each). Update `transformFreeAgent` in `src/lib/pocketbase/util/transform-types.ts` to surface `claimedAgentId`.
- **Server fns:** the three fns above in `src/features/tournaments/server.ts`, next to `enrollFreeAgent`. Extract `createOrReuseTeamForPair` from `confirmTeamAssignments` (L341–381) as a module-level helper and call it from both places — do not fork the team-creation logic. Emit `emitServerEvent({ type: "tournament", tournamentId })` (and `{ type: "team" }` on match) exactly like the existing fns; fire `sendPushToPlayer` (`src/lib/push/index.ts`) after the write, non-blocking (`.catch(log)`), never failing the request.
- **Client data:** hooks in `src/features/tournaments/hooks/` following `use-enroll-free-agent.ts` (uses `useServerMutation` from `src/lib/tanstack-query/hooks`, invalidates `tournamentKeys.free_agents(id)`). Board reads `useFreeAgents` + `getAllPlayerStats` / `getAllEarnedBadges` via new bulk queries keyed like `src/features/players/queries.ts` / `src/features/badges/queries.ts`. No new SSE event type: `use-server-events.ts`'s `tournament` handler already invalidates the `['tournaments']` prefix.
- **UI:** rework `enrolled-free-agent.tsx` into a `FreeAgentBoard` (new file under the same dir; keep `unenroll-free-agent.tsx` at the bottom). Card = `PlayerAvatar` + name + stats strip + badge row + action slot. Confirm-style incoming-claim UI: filled Accept, subtle red Decline, same layout rhythm as the score-confirm affordance in the match card. Sheets via `@/components/sheet/sheet` + `useSheet`, buttons via `@/components/button`.
- **Toasts:** `import { toast } from "@/lib/sonner"` — `toast.success` / `toast.error` only. "Claim sent", "You're teammates!", and a friendly error when a claim was withdrawn/taken ("They just partnered up — pick someone else").
- **Copy updates:** `enroll-free-agent.tsx` sheet text (both branches) now mentions the board + claiming; delete the regional-only self-card branch in the pool view.
- **Design system:** Mantine 8 + the app theme; `defaultRadius: "sm"`; press feedback via `flxn-press`; one accent per card; match neighboring components, don't invent a new look.
- **Verify:** enroll two players as free agents (one regional tournament, one mainline), claim, accept — confirm a `private: true` team appears on the tournament, both agents leave the pool, and `assign-partners` shows only leftovers. Then run the admin random flow on an even leftover pool to prove the fallback is untouched.
## Tunables / open questions
- **Multiple outgoing claims?** v1: one at a time (keeps Option A trivially consistent). If demand appears, move to Option B's collection.
- **Claim expiry?** v1: none — claims live until withdrawn, declined, or the pool is swept. Candidate: auto-expire via `updated` age (e.g. 24h) checked lazily in `getFreeAgents`.
- **Does admin auto-pair leftovers at a deadline?** Today it's manual (admin opens assign-partners and generates). Keep manual for v1; a scheduled auto-pair is a separate feature.
- **Decline memory:** should a declined claimer be blocked from re-claiming the same person? v1: no (rows carry no history). Option B enables it.
- **Keep pending pairs together in random pairing?** Recommended-cheap, but genuinely optional — cut it if it complicates the shuffle.
- **Phone visibility:** mainline keeps phones on the board for now; consider hiding until matched once claiming proves out.
- **Push copy/tag collapsing:** `tag: "partner-claim-<agentId>"` collapses repeat claims from the same person; revisit if it feels spammy.
+89
View File
@@ -0,0 +1,89 @@
# Push Notifications — Ideas Board (nothing decided)
**Status:** brainstorm, NOT a spec. The push *foundation* is built; **zero product triggers are wired**. This doc is a menu of candidate notification types to pick from later — none of them are commitments, and the list is meant to be argued with, pruned, and added to.
## Guiding principle (the one non-negotiable)
**Minimal by default. Strictly opt-in. Do not annoy.**
- Every notification type ships **OFF** unless the user opts in — and even opted-in volume stays low.
- Push is a scarce resource: each notification spends trust. One annoying ping and people nuke permission at the OS level and we never get it back.
- Only a **tiny high-value default-on set** for users who flip the master toggle. Current candidates: **"you're up next"** and **"you were claimed as a partner"** — both rare, both personally about *you*, both actionable right now.
- Prefer notifications that are: about the recipient personally > rare > time-sensitive > tappable to something useful. Anything failing two of those four probably shouldn't exist.
## What already exists (the foundation)
- **`src/lib/push/index.ts`** — `sendPushToPlayer(playerId, { title, body, url?, icon?, tag? })`. Sends to every device the player subscribed, auto-prunes dead endpoints (404/410). The file itself documents the trigger seams as **deliberately unwired**.
- **`push_subscriptions` collection** — `pb_migrations/1784200000_created_push_subscriptions.js`, service helpers in `src/lib/pocketbase/services/push.ts`.
- **Opt-in settings toggle** — `src/features/settings/components/notifications-section.tsx` (per-device enable + "send test notification", via `src/hooks/use-web-push.ts`). Today it's one master switch; per-type preferences are a follow-up idea (see bottom).
- **SSE domain events** — `src/lib/events/emitter.ts` already types `match | reaction | tournament | team | player | badge` events, emitted from real product code paths (`src/features/matches/server.ts`, `src/features/tournaments/server.ts`, `src/features/badges/server.ts`, `src/features/players/server.ts`, `src/features/teams/server.ts`). **Most push triggers can hang off the exact same emit sites** — the moment we `emitServerEvent(...)`, we already know something notification-worthy happened.
Again: **no trigger below is wired.** The point of this board is to choose which few deserve to be.
## The menu
Columns: what fires it, who gets it, why it drives engagement, roughly how often it would fire, an annoyance-risk rating, and a suggested default (all of these are suggestions, not decisions). "Default ON" here means *on once the user enables notifications at all* — nothing fires for users who never flip the master switch.
### BEFORE the tournament
| Candidate | Trigger | Audience | Why it hooks | Frequency | Annoyance | Default |
|---|---|---|---|---|---|---|
| **New tournament posted / enrollment open** | Tournament created or opened for enrollment (`src/features/tournaments/server.ts` create emit, ~line 27) | All players | The starting gun — nobody wants to hear about a tournament secondhand | Rare (per tournament) | Low | Off (opt-in "announcements") |
| **Enrollment closing soon** | Timed job N hours before enrollment lock | Enrolled-nowhere players | FOMO nudge for fence-sitters | Once per tournament | **Med** — it's marketing, not information | Off |
| **You were claimed as a partner** | Free-agent partner assignment (`assignPartners` path in `src/features/tournaments/server.ts`, emits `team` + `tournament`, ~lines 385–397) | The claimed free agent(s) | Deeply personal — "someone picked YOU." Answers the question free agents are actively anxious about | Once per tournament per free agent | Low | **ON (candidate default)** |
| **Predictions are open** | Bracket generated / predictions window opens (`submitPrediction` lives in `src/features/predictions/server.ts`) | Enrolled players | Predictions only work if people fill them out before lock | Once per tournament | Low-med | Off |
| **Predictions closing soon** | Timed job before first-match lock (predictions lock on first match start) | Enrolled players who haven't submitted | Targeted (only non-submitters), genuinely useful deadline | ≤1 per tournament | Low-med | Off |
| **Side bets are open on a match** | Match hits `ready` (book opens per `docs/side-bets.md`) | Players with tokens, not in that match | Side-bets spec explicitly wants this nudge for marquee matches | Could be *every match* — needs throttling (e.g. finals only) | **High** if per-match; low if finals-only | Off |
| **Tournament starts in 1h** | Timed job vs. tournament start time | Enrolled players | Logistics — get people in the building | Once per tournament | Low | Off (but a strong opt-in) |
### DURING the tournament
| Candidate | Trigger | Audience | Why it hooks | Frequency | Annoyance | Default |
|---|---|---|---|---|---|---|
| **You're up next / your match started** | Admin starts the match — `startMatch` in `src/features/matches/server.ts` (~line 142, emits `match`) | The 2–4 players in the match | The single most valuable push in the app: personal, urgent, actionable ("get to the table") | 3–6 per player per tournament | Low — this is the notification people *install the PWA for* | **ON (candidate default)** |
| **Score reported on your match — confirm needed** | `reportMatchScore` (~line 828) when the *other* team reports | The team that must confirm | Unblocks the bracket; the confirmer often doesn't know they're the bottleneck | ≤1 per match played | Low | Off, but arguably deserves default-on — it's a to-do, not a broadcast |
| **Your match result is final** | `finalizeMatch` (~line 753, single finalization path for admin `endMatch` + player `confirmMatchScore`) | Players in the match | Closure + "what's next" tap-through | 1 per match played | Low-med (you usually *know* — you were there) | Off |
| **You advanced / you were eliminated** | Bracket propagation after finalize (same `finalizeMatch` seam) | The advancing/eliminated team | "You're in the semis" is a brag-worthy moment; elimination push is a kindness debate | ≤1 per round | Med — elimination pushes can feel like salt | Off; if built, maybe advance-only |
| **Someone reacted to your match** | `toggleMatchReaction` (~line 964, emits `reaction`) | Players in the match | Social warm-fuzzies | Potentially **very** chatty | **High** — classic notification spam shape; needs heavy batching ("5 reactions on your match") if ever built | Off, probably forever |
| **Big upset just happened** | `finalizeMatch` + an upset heuristic (seed gap / prediction consensus) | Everyone at the tournament | Shared "did you SEE that" moment; drives people to the bracket | 0–2 per tournament if threshold is strict | Med — must be genuinely rare to land | Off |
| **Side-bet settled (you won/lost)** | Settlement inside `finalizeMatch` per `docs/side-bets.md` | Bettors on that match | Win reveals are dopamine; losses close the loop | Per bet placed (self-inflicted volume) | Low-med — user opted in by betting | Off (or implicit-on for people who bet?) |
### AFTER the tournament
| Candidate | Trigger | Audience | Why it hooks | Frequency | Annoyance | Default |
|---|---|---|---|---|---|---|
| **Final results / you placed** | Tournament completed (tournament status emit in `src/features/tournaments/server.ts`) | All enrolled; podium gets a personal variant ("You took 2nd") | The recap moment; personal placement > generic results | Once per tournament | Low | Off (single post-tournament push is defensible as default though) |
| **You earned a badge** | `src/features/badges/server.ts` award (~line 44, emits `badge` with `playerId`) | The recipient | Personal, rare, purely positive — near-ideal push shape | Rare | Low | Off, but a strong candidate to promote later |
| **Prediction results — how you scored** | Final match finalizes → predictions settle | Players who submitted a prediction | Only reaches opt-ins-by-behavior; leaderboard tap-through | Once per tournament | Low | Off |
| **Side-bets final standings** | Tournament completes → side-bets leaderboard freezes | Players who placed ≥1 bet | Same self-selected audience as above | Once per tournament | Low | Off |
| **Your FLXN Replay is ready** | *(Future feature — no code yet.)* Post-tournament recap generation | All enrolled | Personalized recap = the highest-retention artifact we could push | Once per tournament | Low | Off until the feature exists; then a strong default candidate |
### AMBIENT / cross-tournament
| Candidate | Trigger | Audience | Why it hooks | Frequency | Annoyance | Default |
|---|---|---|---|---|---|---|
| **A rival is in your next match** | *(Future feature — rivalries don't exist in code yet; head-to-head data does: `getMatchesBetweenPlayers` / `getMatchesBetweenTeams` in `src/features/matches/server.ts`)* | Both sides of the rivalry | "Revenge match" framing is the best narrative hook in the app | Rare | Low-med | Off until rivalries exist |
| **New tournament posted** | Tournament create emit (also listed under BEFORE — could be the same "announcements" type) | Everyone | Re-engages lapsed players between tournaments | Rare | Low | Off (opt-in "announcements") |
| **Free agents need partners** | N unclaimed free agents as enrollment deadline nears | Enrolled players without full teams | Matchmaking pressure in both directions | ≤1 per tournament | Med | Off |
| **You've been made admin / roster changes touch you** | `player` emit sites in `src/features/players/server.ts` | Affected player | Administrative courtesy | Very rare | Low | Off — probably not worth building |
## Suggested tiny default-on set (opinion, not decision)
If a user enables notifications at all, they get exactly two things until they say otherwise:
1. **You're up next** — `startMatch` seam.
2. **You were claimed as a partner** — free-agent assignment seam.
Everything else is opt-in per type. Both defaults are rare, personal, and actionable; neither can fire more than a handful of times per tournament. This matches the seams already name-dropped in the `sendPushToPlayer` doc comment.
## How to wire one (when the picks are made)
The pattern is deliberately boring:
1. Find the emit site — e.g. `startMatch` in `src/features/matches/server.ts` already calls `emitServerEvent({ type: "match", ... })` when the admin starts a match.
2. Next to that emit, resolve the affected player IDs and call `sendPushToPlayer(playerId, { title, body, url })` from `src/lib/push/index.ts`, with `url` deep-linking to the match/tournament route. Fire-and-forget (don't block the server fn on push transport), use `tag` to collapse repeats of the same kind.
3. That's it — subscription storage, multi-device fan-out, and dead-endpoint pruning are already handled by the foundation.
## Follow-up idea: per-type preferences
Today `src/features/settings/components/notifications-section.tsx` is a single per-device switch. Once types are chosen, extend that section with **category toggles** (e.g. "My matches", "Announcements", "Social", "Results & recaps") persisted per *player* (not per device), and have each trigger check the player's category preference before calling `sendPushToPlayer`. Categories, not individual types — a settings screen with 15 switches is its own kind of annoying.
+84
View File
@@ -0,0 +1,84 @@
# Side Bets — Design Spec (v1, locked)
**Status:** design locked, not yet built. Sequenced for Wave 3 (after player score reporting + the home "Upcoming" surface; ideally after web push for bet-open nudges).
No real money, ever. This is an in-tournament engagement layer.
## Currency
- Each player gets **10 🪙 tokens fresh at the start of every tournament**. No carryover, **no seasons** (this app has no season concept).
- **Whole numbers only.**
- **No hard bust.** If you hit 0 tokens with matches remaining, you get a **1-token "comeback" bet each remaining match** so you're never fully benched. Not abusable — comeback stakes are smaller than the 10 you started with, so busting on purpose is strictly worse.
## When you can bet
- A match's book **opens when the match is *set*** (both teams populated → status `ready`).
- The book **closes the instant the admin *starts* the match** (status `started`) — same moment walkouts/announcements play. No bets after start.
- This dovetails with score reporting, which only begins once a match is `started`.
## Bet type 1 — Winner (social pool / parimutuel)
The core, social bet. You bet against your friends, not the house.
- Pick the winning team. Stake `1..(your tokens)`.
- All stakes on the match form **one pot**. On finalize, everyone who picked the actual winner **splits the whole pot proportional to their stake**; wrong picks forfeit their tokens to the pot.
- **Integer payout rule:** floor each winner's proportional share, then hand the leftover tokens out one-each to the largest fractional remainders (tie-break: larger stake, then earlier bet). Guarantees whole tokens and exact pot conservation.
- **House floor:** a correct bet always returns **at least ~1.5× the stake** even if nobody took the other side, so thin/late pools still feel worth it. (Tunable.)
- **Upset dynamics are automatic:** betting the less-popular team pays more because fewer winners split a bigger pot — no odds engine, no stats needed.
- **Edge cases:**
- Everyone picked the same team → no losers → **all stakes refunded** (no action).
- Nobody picked the winner → **everyone refunded**.
- Solo bettor → the **house floor** applies (this is exactly what it's for).
- **Live projected payout:** before close, show "if you're right you'd win ~X", ticking up via SSE as friends pile in. Final pot locks at match start.
## Bet type 2 — Overtime long shots (house-backed, fixed payout)
NOT a pool — you bet against a fixed multiplier, so including these does **not** thin the Winner pool. Cumulative "reaches at least N overtimes" tiers, keyed off the match `ot_count` field (the app tracks multiple OTs):
| Bet | Wins if | Payout |
|---|---|---|
| Reaches OT | `ot_count ≥ 1` | **2×** |
| Reaches Double OT | `ot_count ≥ 2` | **4×** |
| Reaches Triple OT | `ot_count ≥ 3` | **8×** |
Each is an independent yes/no long shot; **each additional OT doubles the payout** (memorable rule, jackpot feel for triple OT). Multipliers are tunable once real OT frequency is observed.
## Leaderboard & payoff
- **Per-tournament Side Bets leaderboard**, ranked by token count; tie-break by number of correct bets (so a hoarder who never bet ranks below active players at the same count).
- A few **badges** (global, like existing badges): e.g. "Called the Upset", "House Money", "Stone Cold Lock", "Degenerate" (most all-ins).
## Data model (proposed — finalize at build)
- New `bets` collection: `{ tournament, match, player, market: "winner"|"ot1"|"ot2"|"ot3", pick (team id for winner), stake, status: pending|won|lost|refunded, payout }`.
- **Settlement hooks into match finalization** (`finalizeMatch` / the `match` SSE): compute Winner pool splits + house-backed OT results, write payouts, update balances.
- Per-(player, tournament) balance: start 10 − stakes + payouts (materialized or derived).
## UI/UX
- Bettable matches surface on the home **Upcoming** carousel (Wave 2) + the match card/dock.
- **Bet slip sheet:** choose market, stake slider, live projected payout.
- Win/loss **reveal animation** on settle; the reaction rail already exists next to matches.
- **Side Bets leaderboard screen** — mirror the existing predictions leaderboard component.
## Reuses / dependencies
- SSE bus, the predictions leaderboard pattern, the badges system.
- Depends on: score reporting (done), home Upcoming carousel (Wave 2), web push (Wave 2) for "betting's open on the Final" nudges.
## Side Bets on the home screen (reserved slot)
The Wave 2 home redesign **leaves a labeled slot for this** — build it here later:
- A compact **"Your Side Bets" card** near the top of the **Upcoming** section, showing your 🪙 token balance, number of open (unsettled) bets, and a link to the Side Bets leaderboard. Hidden if the tournament isn't bettable (no set-but-unstarted matches) or the user has no involvement.
- On each card in the **Upcoming** carousel (matches that are `ready`, not yet `started`), a **"Bet" affordance** that opens the bet-slip sheet. This is the primary entry point — you bet on matches that are about to start.
- Once a match is `started`, its bets are locked; the card flips to showing the pool result live as it settles.
- The redesign should reserve this vertical space and (in code) leave a clear `{/* SIDE BETS SLOT — see docs/side-bets.md */}` marker where the card mounts, so wiring it in later is a drop-in.
## Implementation notes for a fresh agent (build this cold)
Context: worktree `social` branch; changes are typically left uncommitted for review. Read `docs/side-bets.md` (this file) fully first, then:
- **Server fns:** follow the `createServerFn(...).validator(zod).middleware([...]).handler(...)` + `toServerResult` pattern. Player-facing (non-admin) fns use `.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])` and resolve the caller via `getPlayerByAuthId(context.userAuthId)` — copy `toggleMatchReaction` in `src/features/matches/server.ts` as the template. All DB access is server-side via `pbAdmin`; collections have null rules.
- **Settlement:** hook into `finalizeMatch` in `src/features/matches/server.ts` (the single match-finalization path — admin `endMatch` and player `confirmMatchScore` both call it). When a match finalizes, settle its Winner pool (integer largest-remainder split + house floor) and the OT long shots. Emit an SSE event so balances/leaderboard update live.
- **Bet open/close** is derived from match `status`: bettable while `ready`, locked at `started` (identical trigger to score reporting).
- **Migration:** mimic an existing `pb_migrations/*_created_*.js` (e.g. `1784007613_created_predictions.js`) for the new `bets` collection — plain `migrate((app)=>{...})` with an up + down. Teams collection id `pbc_1568971955`, players `pbc_3072146508`, matches `pbc_2541054544`, tournaments `pbc_340646327`.
- **Client data:** use the `useServerQuery` / `useServerMutation` hooks (`src/lib/tanstack-query/hooks/*`) and the query-key convention seen in `src/features/predictions/queries.ts`. Add the new event type to `src/lib/events/emitter.ts` + handle it in `src/hooks/use-server-events.ts` (invalidate side-bets keys, mirroring how `match`/`reaction` are handled).
- **Leaderboard UI:** mirror `src/features/predictions/components/prediction-leaderboard.tsx` and its route `src/app/routes/_authed/tournaments/$id.predictions.tsx` for a `$id.side-bets.tsx` route.
- **Toasts:** `import { toast } from "@/lib/sonner"` — `toast.success` / `toast.error` only.
- **New feature dir:** `src/features/side-bets/{types.ts, server.ts, queries.ts, utils.ts, components/}` — keep pure logic (pool split, house floor, OT payout) in `utils.ts` with unit-testable functions, like `src/features/predictions/utils.ts`.
- **Design system:** Mantine 8 + the app theme; press feedback via the `flxn-press` theme; tasteful motion only (iOS-like easing, 120–350ms) — match neighboring components, don't invent a new look.
## Tunables to revisit after first live use
- House floor multiplier (default **1.5×**).
- OT multipliers (**2× / 4× / 8×**).
- Comeback token amount (**1**).
- Whether an all-agreed pool refunds or voids.
+135
View File
@@ -0,0 +1,135 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: flxn-app
labels:
app: flxn
component: app
spec:
replicas: 1 # Must stay at 1 for SSE
selector:
matchLabels:
app: flxn
component: app
template:
metadata:
labels:
app: flxn
component: app
spec:
containers:
- name: flxn-app
image: git.yohler.net/kyle/flxn-app:latest
imagePullPolicy: Always
ports:
- containerPort: 3000
name: http
protocol: TCP
env:
- name: VITE_API_DOMAIN
valueFrom:
configMapKeyRef:
name: flxn-config
key: vite_api_domain
- name: VITE_WEBSITE_DOMAIN
valueFrom:
configMapKeyRef:
name: flxn-config
key: vite_website_domain
- name: SUPERTOKENS_URI
valueFrom:
configMapKeyRef:
name: flxn-config
key: supertokens_uri
- name: POCKETBASE_URL
valueFrom:
configMapKeyRef:
name: flxn-config
key: pocketbase_url
- name: SUPERTOKENS_API_KEY
valueFrom:
secretKeyRef:
name: flxn-secrets
key: supertokens_api_key
- name: PORT
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:
secretKeyRef:
name: flxn-secrets
key: twilio_account_sid
- name: TWILIO_AUTH_TOKEN
valueFrom:
secretKeyRef:
name: flxn-secrets
key: twilio_auth_token
- name: TWILIO_SERVICE_SID
valueFrom:
secretKeyRef:
name: flxn-secrets
key: twilio_service_sid
- name: POCKETBASE_ADMIN_EMAIL
valueFrom:
secretKeyRef:
name: flxn-secrets
key: pocketbase_admin_email
- name: POCKETBASE_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: flxn-secrets
key: pocketbase_admin_password
- name: VITE_SPOTIFY_CLIENT_ID
valueFrom:
configMapKeyRef:
name: flxn-config
key: vite_spotify_client_id
- name: SPOTIFY_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: flxn-secrets
key: spotify_client_secret
- name: VITE_SPOTIFY_REDIRECT_URI
valueFrom:
configMapKeyRef:
name: flxn-config
key: vite_spotify_redirect_uri
- name: COOKIE_DOMAIN
valueFrom:
configMapKeyRef:
name: flxn-config
key: cookie_domain
resources:
requests:
memory: "768Mi"
cpu: "250m"
limits:
memory: "1536Mi"
cpu: "1000m"
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
+17
View File
@@ -0,0 +1,17 @@
apiVersion: v1
kind: Service
metadata:
name: flxn-app
labels:
app: flxn
component: app
spec:
type: ClusterIP
ports:
- port: 3000
targetPort: 3000
protocol: TCP
name: http
selector:
app: flxn
component: app
+12
View File
@@ -0,0 +1,12 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- app-deployment.yaml
- app-service.yaml
- pocketbase-deployment.yaml
- pocketbase-service.yaml
- pb-data-pvc.yaml
commonLabels:
app: flxn
+13
View File
@@ -0,0 +1,13 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: flxn-pb-data
labels:
app: flxn
component: pocketbase
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
+57
View File
@@ -0,0 +1,57 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: flxn-pocketbase
labels:
app: flxn
component: pocketbase
spec:
replicas: 1
selector:
matchLabels:
app: flxn
component: pocketbase
template:
metadata:
labels:
app: flxn
component: pocketbase
spec:
containers:
- name: pocketbase
image: git.yohler.net/kyle/flxn-pocketbase:latest
imagePullPolicy: Always
ports:
- containerPort: 8090
name: http
protocol: TCP
volumeMounts:
- name: pb-data
mountPath: /pb/pb_data
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /api/health
port: 8090
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/health
port: 8090
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
volumes:
- name: pb-data
persistentVolumeClaim:
claimName: flxn-pb-data
+18
View File
@@ -0,0 +1,18 @@
apiVersion: v1
kind: Service
metadata:
name: flxn-pocketbase
labels:
app: flxn
component: pocketbase
spec:
type: NodePort
ports:
- port: 8090
targetPort: 8090
nodePort: 30090
protocol: TCP
name: http
selector:
app: flxn
component: pocketbase
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: flxn-config
namespace: flxn-dev
data:
vite_api_domain: "https://dev.flexxon.app"
vite_website_domain: "https://dev.flexxon.app"
supertokens_uri: "http://192.168.4.43:30568"
pocketbase_url: "http://192.168.4.43:30096"
vite_spotify_client_id: "3ffde6b594e84460b3d4b329b8919277"
vite_spotify_redirect_uri: "https://dev.flexxon.app/api/spotify/callback"
s3_endpoint: "https://s3.yohler.net"
s3_bucket: "flxn-dev"
cookie_domain: "dev.flexxon.app"
+17
View File
@@ -0,0 +1,17 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: flxn-app
namespace: flxn-dev
spec:
rules:
- host: dev.flexxon.app
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: flxn-app
port:
number: 3000
+50
View File
@@ -0,0 +1,50 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: flxn-dev
resources:
- namespace.yaml
- ../../base
- configmap.yaml
- ingress.yaml
images:
- name: git.yohler.net/kyle/flxn-app
newTag: dev
- name: git.yohler.net/kyle/flxn-pocketbase
newTag: latest
commonLabels:
environment: dev
patches:
- patch: |-
- op: replace
path: /spec/template/spec/containers/0/resources/requests/memory
value: "768Mi"
- op: replace
path: /spec/template/spec/containers/0/resources/limits/memory
value: "1536Mi"
target:
kind: Deployment
name: flxn-app
- patch: |-
- op: replace
path: /spec/type
value: NodePort
- op: add
path: /spec/ports/0/nodePort
value: 30083
target:
kind: Service
name: flxn-app
- patch: |-
- op: replace
path: /spec/ports/0/nodePort
value: 30096
target:
kind: Service
name: flxn-pocketbase
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: flxn-dev
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: flxn-config
namespace: flxn-prod
data:
vite_api_domain: "https://flexxon.app"
vite_website_domain: "https://flexxon.app"
supertokens_uri: "http://192.168.4.43:30568"
pocketbase_url: "http://192.168.4.43:30097"
vite_spotify_client_id: "3ffde6b594e84460b3d4b329b8919277"
vite_spotify_redirect_uri: "https://flexxon.app/api/spotify/callback"
s3_endpoint: "https://s3.yohler.net"
s3_bucket: "flxn-prod"
cookie_domain: "flexxon.app"
+17
View File
@@ -0,0 +1,17 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: flxn-app
namespace: flxn-prod
spec:
rules:
- host: flexxon.app
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: flxn-app
port:
number: 3000
+50
View File
@@ -0,0 +1,50 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: flxn-prod
resources:
- namespace.yaml
- ../../base
- configmap.yaml
- ingress.yaml
images:
- name: git.yohler.net/kyle/flxn-app
newTag: latest
- name: git.yohler.net/kyle/flxn-pocketbase
newTag: latest
commonLabels:
environment: prod
patches:
- patch: |-
- op: replace
path: /spec/template/spec/containers/0/resources/requests/memory
value: "1536Mi"
- op: replace
path: /spec/template/spec/containers/0/resources/limits/memory
value: "3Gi"
target:
kind: Deployment
name: flxn-app
- patch: |-
- op: replace
path: /spec/type
value: NodePort
- op: add
path: /spec/ports/0/nodePort
value: 30084
target:
kind: Service
name: flxn-app
- patch: |-
- op: replace
path: /spec/ports/0/nodePort
value: 30097
target:
kind: Service
name: flxn-pocketbase
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: flxn-prod
+5
View File
@@ -0,0 +1,5 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: supertokens-config
namespace: flxn-shared
+16
View File
@@ -0,0 +1,16 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: flxn-shared
resources:
- namespace.yaml
- supertokens-deployment.yaml
- supertokens-service.yaml
- supertokens-postgres-deployment.yaml
- supertokens-postgres-service.yaml
- supertokens-db-pvc.yaml
labels:
- pairs:
environment: shared
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: flxn-shared
@@ -0,0 +1,13 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: supertokens-db-data
labels:
app: flxn
component: supertokens-db
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi
@@ -0,0 +1,71 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: supertokens
labels:
app: flxn
component: supertokens
spec:
replicas: 1
selector:
matchLabels:
app: flxn
component: supertokens
template:
metadata:
labels:
app: flxn
component: supertokens
spec:
enableServiceLinks: false
containers:
- name: supertokens
image: registry.supertokens.io/supertokens/supertokens-postgresql:latest
ports:
- containerPort: 3567
name: http
protocol: TCP
env:
- name: PORT
value: "3567"
- name: POSTGRESQL_USER
value: supertokens
- name: POSTGRESQL_PASSWORD
valueFrom:
secretKeyRef:
name: supertokens-secrets
key: postgres_password
- name: POSTGRESQL_HOST
value: supertokens-db
- name: POSTGRESQL_PORT
value: "5432"
- name: POSTGRESQL_DATABASE_NAME
value: supertokens
- name: API_KEYS
valueFrom:
secretKeyRef:
name: supertokens-secrets
key: api_keys
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /hello
port: 3567
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /hello
port: 3567
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
@@ -0,0 +1,68 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: supertokens-db
labels:
app: flxn
component: supertokens-db
spec:
replicas: 1
selector:
matchLabels:
app: flxn
component: supertokens-db
template:
metadata:
labels:
app: flxn
component: supertokens-db
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
name: postgres
protocol: TCP
env:
- name: POSTGRES_USER
value: "supertokens"
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: supertokens-secrets
key: postgres_password
- name: POSTGRES_DB
value: "supertokens"
- name: PGDATA
value: "/var/lib/postgresql/data/pgdata"
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
resources:
requests:
memory: "128Mi"
cpu: "50m"
limits:
memory: "256Mi"
cpu: "500m"
livenessProbe:
exec:
command:
- pg_isready
- -U
- supertokens
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
exec:
command:
- pg_isready
- -U
- supertokens
initialDelaySeconds: 5
periodSeconds: 5
volumes:
- name: postgres-data
persistentVolumeClaim:
claimName: supertokens-db-data
@@ -0,0 +1,17 @@
apiVersion: v1
kind: Service
metadata:
name: supertokens-db
labels:
app: flxn
component: supertokens-db
spec:
type: ClusterIP
ports:
- port: 5432
targetPort: 5432
protocol: TCP
name: postgres
selector:
app: flxn
component: supertokens-db
@@ -0,0 +1,18 @@
apiVersion: v1
kind: Service
metadata:
name: supertokens
labels:
app: flxn
component: supertokens
spec:
type: NodePort
ports:
- port: 3567
targetPort: 3567
nodePort: 30568
protocol: TCP
name: http
selector:
app: flxn
component: supertokens
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "@lingui/conf";
export default defineConfig({
sourceLocale: "en",
locales: ["en", "es", "de", "ja"],
catalogs: [
{
path: "<rootDir>/src/locales/{locale}/messages",
include: ["src"],
exclude: ["**/node_modules/**", "**/*.d.ts"],
},
],
});
+34 -30
View File
@@ -1,70 +1,72 @@
{
"name": "tanstack-start-example-basic-react-query",
"private": true,
"sideEffects": false,
"sideEffects": [
"**/*.css",
"**/themes/styles.ts",
"**/themes/theme-styles.ts"
],
"type": "module",
"scripts": {
"dev": "vite dev --host 0.0.0.0",
"build": "vite build && tsc --noEmit",
"start": "bun run .output/server/index.mjs",
"start:node": "node .output/server/index.mjs"
"extract": "lingui extract --clean",
"i18n:check": "lingui extract --clean && lingui compile --strict",
"build": "lingui extract --clean && vite build && tsc --noEmit && bun scripts/generate-sw.mjs",
"start": "bun run server.ts",
"test": "vitest run"
},
"dependencies": {
"@hello-pangea/dnd": "^18.0.1",
"@lingui/core": "^6.5.0",
"@lingui/react": "^6.5.0",
"@mantine/carousel": "^8.2.4",
"@mantine/charts": "^8.2.4",
"@mantine/core": "^8.2.4",
"@mantine/dates": "^8.2.4",
"@mantine/form": "^8.2.4",
"@mantine/hooks": "^8.2.4",
"@mantine/tiptap": "^8.2.4",
"@phosphor-icons/react": "^2.1.10",
"@svgmoji/noto": "^3.2.0",
"@tanstack/react-devtools": "^0.7.6",
"@tanstack/react-query": "^5.66.0",
"@tanstack/react-query-devtools": "^5.66.0",
"@tanstack/react-router": "^1.130.12",
"@tanstack/react-router-devtools": "^1.130.13",
"@tanstack/react-router-with-query": "^1.130.12",
"@tanstack/react-start": "^1.132.2",
"@tanstack/react-virtual": "^3.13.12",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-devtools": "^5.101.2",
"@tanstack/react-router": "^1.170.17",
"@tanstack/react-router-devtools": "^1.167.0",
"@tanstack/react-router-ssr-query": "^1.167.1",
"@tanstack/react-start": "^1.168.27",
"@tiptap/pm": "^3.4.3",
"@tiptap/react": "^3.4.3",
"@tiptap/starter-kit": "^3.4.3",
"@types/bun": "^1.2.22",
"@types/ioredis": "^4.28.10",
"browser-image-compression": "^2.0.2",
"dotenv": "^17.2.2",
"embla-carousel-react": "^8.6.0",
"facehash": "^0.0.7",
"framer-motion": "^12.23.12",
"ioredis": "^5.7.0",
"pg": "^8.16.3",
"pocketbase": "^0.26.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-imask": "^7.6.1",
"react-scan": "^0.4.3",
"react-use-draggable-scroll": "^0.4.7",
"recharts": "^3.1.2",
"redaxios": "^0.5.1",
"sonner": "^2.0.7",
"supertokens-node": "^23.0.1",
"supertokens-web-js": "^0.15.0",
"twilio": "^5.8.0",
"vaul": "^1.1.2",
"xlsx": "^0.18.5",
"zod": "^4.0.15",
"zustand": "^5.0.7"
"web-push": "^3.6.7",
"web-vitals": "^6.2.0",
"zod": "^4.0.15"
},
"devDependencies": {
"@tanstack/react-router-ssr-query": "^1.132.2",
"@tanstack/router-plugin": "^1.132.2",
"@lingui/babel-plugin-lingui-macro": "^6.5.0",
"@lingui/cli": "^6.5.0",
"@lingui/vite-plugin": "^6.5.0",
"@types/node": "^22.5.4",
"@types/pg": "^8.15.5",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/web-push": "^3.6.4",
"@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "^1.0.0",
"dotenv-cli": "^10.0.0",
"postcss": "^8.5.1",
"postcss-preset-mantine": "^1.18.0",
@@ -72,6 +74,8 @@
"tsx": "^4.20.3",
"typescript": "^5.7.2",
"vite": "^7.1.7",
"vite-tsconfig-paths": "^5.1.4"
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.1.11",
"workbox-build": "^7.4.1"
}
}
@@ -1,8 +1,12 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_4251874343");
return app.delete(collection);
try {
const collection = app.findCollectionByNameOrId("pbc_4251874343");
return app.delete(collection);
} catch (e) {
console.log("Collection pbc_4251874343 not found, skipping deletion");
return null;
}
}, (app) => {
const collection = new Collection({
"createRule": null,
+109
View File
@@ -0,0 +1,109 @@
/// <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"
},
{
"cascadeDelete": false,
"collectionId": "pbc_340646327",
"hidden": false,
"id": "relation3177167065",
"maxSelect": 1,
"minSelect": 0,
"name": "tournament",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1579384326",
"max": 0,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "number4113142680",
"max": null,
"min": null,
"name": "order",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"cascadeDelete": false,
"collectionId": "pbc_1568971955",
"hidden": false,
"id": "relation2529305176",
"maxSelect": 999,
"minSelect": 0,
"name": "teams",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"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_3346940990",
"indexes": [],
"listRule": null,
"name": "groups",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3346940990");
return app.delete(collection);
})
@@ -0,0 +1,52 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// remove field
collection.fields.removeById("select3736761055")
// add field
collection.fields.addAt(13, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text3736761055",
"max": 0,
"min": 0,
"name": "format",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// add field
collection.fields.addAt(13, new Field({
"hidden": false,
"id": "select3736761055",
"maxSelect": 1,
"name": "format",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"single_elim",
"double_elim",
"groups",
"swiss",
"swiss_bracket",
"round_robin"
]
}))
// remove field
collection.fields.removeById("text3736761055")
return app.save(collection)
})
@@ -0,0 +1,25 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// add field
collection.fields.addAt(14, new Field({
"hidden": false,
"id": "json118290348",
"maxSize": 0,
"name": "group_config",
"presentable": false,
"required": false,
"system": false,
"type": "json"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// remove field
collection.fields.removeById("json118290348")
return app.save(collection)
})
@@ -0,0 +1,29 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// add field
collection.fields.addAt(15, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text2982008523",
"max": 0,
"min": 0,
"name": "phase",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// remove field
collection.fields.removeById("text2982008523")
return app.save(collection)
})
@@ -0,0 +1,47 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_2541054544")
// add field
collection.fields.addAt(22, new Field({
"cascadeDelete": false,
"collectionId": "pbc_3346940990",
"hidden": false,
"id": "relation1841317061",
"maxSelect": 1,
"minSelect": 0,
"name": "group",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
}))
// add field
collection.fields.addAt(23, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text3987859035",
"max": 0,
"min": 0,
"name": "match_type",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_2541054544")
// remove field
collection.fields.removeById("relation1841317061")
// remove field
collection.fields.removeById("text3987859035")
return app.save(collection)
})
@@ -0,0 +1,56 @@
/// <reference path="../pb_data/types.d.ts" />
// Rewrites the player stats views from a cartesian LIKE-join over
// players x teams x matches (which forced a full re-scan per row and took
// ~1.5-2s per request) to equi-joins driven by matches, with team rosters
// expanded once via json_each. Results are byte-identical; each view now
// runs in well under 100ms. The unary "+" in the regional filter prevents
// the query planner from picking a pathological join order.
migrate((app) => {
{
const collection = app.findCollectionByNameOrId("player_stats");
unmarshal({
"viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(mt.match_id) as matches,\n COUNT(DISTINCT mt.tournament) as tournaments,\n SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) as wins,\n SUM(CASE WHEN mt.cups_for < mt.cups_against THEN 1 ELSE 0 END) as losses,\n SUM(mt.cups_for) as total_cups_made,\n SUM(mt.cups_against) as total_cups_against,\n ROUND((CAST(SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) AS REAL) / COUNT(mt.match_id)) * 100, 2) as win_percentage,\n ROUND(CAST(SUM(mt.cups_for) AS REAL) / COUNT(mt.match_id), 2) as avg_cups_per_match,\n ROUND(AVG(CASE WHEN mt.cups_for > mt.cups_against THEN mt.cups_for - mt.cups_against ELSE NULL END), 2) as margin_of_victory,\n ROUND(AVG(CASE WHEN mt.cups_for < mt.cups_against THEN mt.cups_against - mt.cups_for ELSE NULL END), 2) as margin_of_loss\n FROM (\n SELECT m.id as match_id, m.tournament as tournament, m.home as team_id, m.home_cups as cups_for, m.away_cups as cups_against\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended'\n UNION ALL\n SELECT m.id, m.tournament, m.away, m.away_cups, m.home_cups\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended'\n ) mt\n JOIN (\n SELECT teams.id AS team_id, je.value AS player_id\n FROM teams, json_each(ifnull(nullif(teams.players, ''), '[]')) je\n GROUP BY teams.id, je.value\n ) tp ON tp.team_id = mt.team_id\n JOIN players p ON p.id = tp.player_id\n GROUP BY p.id"
}, collection);
app.save(collection);
}
{
const collection = app.findCollectionByNameOrId("player_mainline_stats");
unmarshal({
"viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(mt.match_id) as matches,\n COUNT(DISTINCT mt.tournament) as tournaments,\n SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) as wins,\n SUM(CASE WHEN mt.cups_for < mt.cups_against THEN 1 ELSE 0 END) as losses,\n SUM(mt.cups_for) as total_cups_made,\n SUM(mt.cups_against) as total_cups_against,\n ROUND((CAST(SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) AS REAL) / COUNT(mt.match_id)) * 100, 2) as win_percentage,\n ROUND(CAST(SUM(mt.cups_for) AS REAL) / COUNT(mt.match_id), 2) as avg_cups_per_match,\n ROUND(AVG(CASE WHEN mt.cups_for > mt.cups_against THEN mt.cups_for - mt.cups_against ELSE NULL END), 2) as margin_of_victory,\n ROUND(AVG(CASE WHEN mt.cups_for < mt.cups_against THEN mt.cups_against - mt.cups_for ELSE NULL END), 2) as margin_of_loss\n FROM (\n SELECT m.id as match_id, m.tournament as tournament, m.home as team_id, m.home_cups as cups_for, m.away_cups as cups_against\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended' AND (tour.regional = false OR tour.regional IS NULL)\n UNION ALL\n SELECT m.id, m.tournament, m.away, m.away_cups, m.home_cups\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended' AND (tour.regional = false OR tour.regional IS NULL)\n ) mt\n JOIN (\n SELECT teams.id AS team_id, je.value AS player_id\n FROM teams, json_each(ifnull(nullif(teams.players, ''), '[]')) je\n GROUP BY teams.id, je.value\n ) tp ON tp.team_id = mt.team_id\n JOIN players p ON p.id = tp.player_id\n GROUP BY p.id"
}, collection);
app.save(collection);
}
{
const collection = app.findCollectionByNameOrId("player_regional_stats");
unmarshal({
"viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(mt.match_id) as matches,\n COUNT(DISTINCT mt.tournament) as tournaments,\n SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) as wins,\n SUM(CASE WHEN mt.cups_for < mt.cups_against THEN 1 ELSE 0 END) as losses,\n SUM(mt.cups_for) as total_cups_made,\n SUM(mt.cups_against) as total_cups_against,\n ROUND((CAST(SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) AS REAL) / COUNT(mt.match_id)) * 100, 2) as win_percentage,\n ROUND(CAST(SUM(mt.cups_for) AS REAL) / COUNT(mt.match_id), 2) as avg_cups_per_match,\n ROUND(AVG(CASE WHEN mt.cups_for > mt.cups_against THEN mt.cups_for - mt.cups_against ELSE NULL END), 2) as margin_of_victory,\n ROUND(AVG(CASE WHEN mt.cups_for < mt.cups_against THEN mt.cups_against - mt.cups_for ELSE NULL END), 2) as margin_of_loss\n FROM (\n SELECT m.id as match_id, m.tournament as tournament, m.home as team_id, m.home_cups as cups_for, m.away_cups as cups_against\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended' AND +tour.regional = true\n UNION ALL\n SELECT m.id, m.tournament, m.away, m.away_cups, m.home_cups\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended' AND +tour.regional = true\n ) mt\n JOIN (\n SELECT teams.id AS team_id, je.value AS player_id\n FROM teams, json_each(ifnull(nullif(teams.players, ''), '[]')) je\n GROUP BY teams.id, je.value\n ) tp ON tp.team_id = mt.team_id\n JOIN players p ON p.id = tp.player_id\n GROUP BY p.id"
}, collection);
app.save(collection);
}
}, (app) => {
{
const collection = app.findCollectionByNameOrId("player_stats");
unmarshal({
"viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(m.id) as matches,\n COUNT(DISTINCT m.tournament) as tournaments,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) as wins,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups < m.away_cups) OR\n (m.away = t.id AND m.away_cups < m.home_cups)\n THEN 1 ELSE 0\n END) as losses,\n SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) as total_cups_made,\n SUM(CASE\n WHEN m.home = t.id THEN m.away_cups\n WHEN m.away = t.id THEN m.home_cups\n ELSE 0\n END) as total_cups_against,\n -- Win percentage\n ROUND((CAST(SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) AS REAL) / COUNT(m.id)) * 100, 2) as win_percentage,\n -- Average cups per match\n ROUND(CAST(SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) AS REAL) / COUNT(m.id), 2) as avg_cups_per_match,\n -- Margin of Victory\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups > m.away_cups\n THEN m.home_cups - m.away_cups\n WHEN m.away = t.id AND m.away_cups > m.home_cups\n THEN m.away_cups - m.home_cups\n ELSE NULL\n END), 2) as margin_of_victory,\n -- Margin of Loss\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups < m.away_cups\n THEN m.away_cups - m.home_cups\n WHEN m.away = t.id AND m.away_cups < m.home_cups\n THEN m.home_cups - m.away_cups\n ELSE NULL\n END), 2) as margin_of_loss\n FROM players p, teams t, matches m, tournaments tour\n WHERE\n t.players LIKE '%\"' || p.id || '\"%' AND\n (m.home = t.id OR m.away = t.id) AND\n m.tournament = tour.id AND\n m.status = 'ended'\n GROUP BY p.id"
}, collection);
app.save(collection);
}
{
const collection = app.findCollectionByNameOrId("player_mainline_stats");
unmarshal({
"viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(m.id) as matches,\n COUNT(DISTINCT m.tournament) as tournaments,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) as wins,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups < m.away_cups) OR\n (m.away = t.id AND m.away_cups < m.home_cups)\n THEN 1 ELSE 0\n END) as losses,\n SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) as total_cups_made,\n SUM(CASE\n WHEN m.home = t.id THEN m.away_cups\n WHEN m.away = t.id THEN m.home_cups\n ELSE 0\n END) as total_cups_against,\n -- Win percentage\n ROUND((CAST(SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) AS REAL) / COUNT(m.id)) * 100, 2) as win_percentage,\n -- Average cups per match\n ROUND(CAST(SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) AS REAL) / COUNT(m.id), 2) as avg_cups_per_match,\n -- Margin of Victory\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups > m.away_cups\n THEN m.home_cups - m.away_cups\n WHEN m.away = t.id AND m.away_cups > m.home_cups\n THEN m.away_cups - m.home_cups\n ELSE NULL\n END), 2) as margin_of_victory,\n -- Margin of Loss\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups < m.away_cups\n THEN m.away_cups - m.home_cups\n WHEN m.away = t.id AND m.away_cups < m.home_cups\n THEN m.home_cups - m.away_cups\n ELSE NULL\n END), 2) as margin_of_loss\n FROM players p, teams t, matches m, tournaments tour\n WHERE\n t.players LIKE '%\"' || p.id || '\"%' AND\n (m.home = t.id OR m.away = t.id) AND\n m.tournament = tour.id AND\n m.status = 'ended' AND\n (tour.regional = false OR tour.regional IS NULL)\n GROUP BY p.id"
}, collection);
app.save(collection);
}
{
const collection = app.findCollectionByNameOrId("player_regional_stats");
unmarshal({
"viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(m.id) as matches,\n COUNT(DISTINCT m.tournament) as tournaments,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) as wins,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups < m.away_cups) OR\n (m.away = t.id AND m.away_cups < m.home_cups)\n THEN 1 ELSE 0\n END) as losses,\n SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) as total_cups_made,\n SUM(CASE\n WHEN m.home = t.id THEN m.away_cups\n WHEN m.away = t.id THEN m.home_cups\n ELSE 0\n END) as total_cups_against,\n -- Win percentage\n ROUND((CAST(SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) AS REAL) / COUNT(m.id)) * 100, 2) as win_percentage,\n -- Average cups per match\n ROUND(CAST(SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) AS REAL) / COUNT(m.id), 2) as avg_cups_per_match,\n -- Margin of Victory\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups > m.away_cups\n THEN m.home_cups - m.away_cups\n WHEN m.away = t.id AND m.away_cups > m.home_cups\n THEN m.away_cups - m.home_cups\n ELSE NULL\n END), 2) as margin_of_victory,\n -- Margin of Loss\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups < m.away_cups\n THEN m.away_cups - m.home_cups\n WHEN m.away = t.id AND m.away_cups < m.home_cups\n THEN m.home_cups - m.away_cups\n ELSE NULL\n END), 2) as margin_of_loss\n FROM players p, teams t, matches m, tournaments tour\n WHERE\n t.players LIKE '%\"' || p.id || '\"%' AND\n (m.home = t.id OR m.away = t.id) AND\n m.tournament = tour.id AND\n m.status = 'ended' AND\n tour.regional = true\n GROUP BY p.id"
}, collection);
app.save(collection);
}
})
@@ -0,0 +1,95 @@
/// <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"
},
{
"cascadeDelete": true,
"collectionId": "pbc_340646327",
"hidden": false,
"id": "relation3177167065",
"maxSelect": 1,
"minSelect": 0,
"name": "tournament",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"cascadeDelete": true,
"collectionId": "pbc_3072146508",
"hidden": false,
"id": "relation2551806565",
"maxSelect": 1,
"minSelect": 0,
"name": "player",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "json2153001328",
"maxSize": 0,
"name": "picks",
"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_1784007613",
"indexes": [
"CREATE UNIQUE INDEX `idx_predictions_tournament_player` ON `predictions` (`tournament`, `player`)"
],
"listRule": null,
"name": "predictions",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1784007613");
return app.delete(collection);
})
@@ -0,0 +1,97 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_2541054544")
// add field
collection.fields.addAt(24, new Field({
"hidden": false,
"id": "number9101010101",
"max": null,
"min": null,
"name": "reported_home_cups",
"onlyInt": true,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}))
// add field
collection.fields.addAt(25, new Field({
"hidden": false,
"id": "number9202020202",
"max": null,
"min": null,
"name": "reported_away_cups",
"onlyInt": true,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}))
// add field
collection.fields.addAt(26, new Field({
"hidden": false,
"id": "number9303030303",
"max": null,
"min": null,
"name": "reported_ot_count",
"onlyInt": true,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}))
// add field
collection.fields.addAt(27, new Field({
"cascadeDelete": false,
"collectionId": "pbc_1568971955",
"hidden": false,
"id": "relation9404040404",
"maxSelect": 1,
"minSelect": 0,
"name": "reported_by_team",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
}))
// add field
collection.fields.addAt(28, new Field({
"cascadeDelete": false,
"collectionId": "pbc_3072146508",
"hidden": false,
"id": "relation9505050505",
"maxSelect": 1,
"minSelect": 0,
"name": "reported_by_player",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_2541054544")
// remove field
collection.fields.removeById("number9101010101")
// remove field
collection.fields.removeById("number9202020202")
// remove field
collection.fields.removeById("number9303030303")
// remove field
collection.fields.removeById("relation9404040404")
// remove field
collection.fields.removeById("relation9505050505")
return app.save(collection)
})
@@ -0,0 +1,128 @@
/// <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"
},
{
"cascadeDelete": true,
"collectionId": "pbc_3072146508",
"hidden": false,
"id": "relation_player_push",
"maxSelect": 1,
"minSelect": 0,
"name": "player",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text_endpoint_push",
"max": 0,
"min": 0,
"name": "endpoint",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text_p256dh_push",
"max": 0,
"min": 0,
"name": "p256dh",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text_auth_push",
"max": 0,
"min": 0,
"name": "auth",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text_user_agent_push",
"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_push_subscriptions",
"indexes": [
"CREATE UNIQUE INDEX `idx_push_subscriptions_endpoint` ON `push_subscriptions` (`endpoint`)"
],
"listRule": null,
"name": "push_subscriptions",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_push_subscriptions");
return app.delete(collection);
})
@@ -0,0 +1,36 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
// Dedupe before adding the unique index: keep the best row per
// (player, badge) — earned first, then highest progress, then newest.
app.db().newQuery(`
DELETE FROM badge_progress
WHERE id NOT IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY player, badge
ORDER BY earned DESC, progress DESC, updated DESC
) AS rn
FROM badge_progress
)
WHERE rn = 1
)
`).execute()
const collection = app.findCollectionByNameOrId("pbc_1063824264")
unmarshal({
"indexes": [
"CREATE UNIQUE INDEX `idx_badge_progress_player_badge` ON `badge_progress` (`player`, `badge`)"
]
}, collection)
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1063824264")
unmarshal({
"indexes": []
}, collection)
return app.save(collection)
})
@@ -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"));
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+58
View File
@@ -0,0 +1,58 @@
/* eslint-disable no-undef */
// Hand-written Web Push handlers, imported into the Workbox-generated SW via
// `importScripts` (see scripts/generate-sw.mjs). Workbox never overwrites this
// file — it's copied verbatim from /public and pulled in at SW startup.
self.addEventListener('push', (event) => {
let data = {};
try {
data = event.data ? event.data.json() : {};
} catch (e) {
data = { title: 'Notification', body: event.data ? event.data.text() : '' };
}
const title = data.title || 'Flexxon';
const options = {
body: data.body || '',
icon: data.icon || '/icon-192x192.png',
badge: '/icon-192x192.png',
tag: data.tag || undefined,
data: { url: data.url || '/' },
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const targetUrl = (event.notification.data && event.notification.data.url) || '/';
event.waitUntil(
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
// Focus an existing tab if one is already open, else open a new one.
for (const client of clientList) {
try {
const clientUrl = new URL(client.url);
const target = new URL(targetUrl, self.location.origin);
if (clientUrl.pathname === target.pathname && 'focus' in client) {
return client.focus();
}
} catch (e) {
// ignore malformed URLs
}
}
for (const client of clientList) {
if ('focus' in client) {
client.navigate(targetUrl);
return client.focus();
}
}
if (self.clients.openWindow) {
return self.clients.openWindow(targetUrl);
}
})
);
});
+17 -6
View File
@@ -1,17 +1,28 @@
{
"name": "FLXN IX",
"short_name": "FLXN",
"name": "FLXN",
"description": "Amicus meus madidus",
"icons": [
{
"src": "/favicon.png",
"src": "/icon-192x192.png",
"type": "image/png",
"sizes": "192x192",
"type": "image/png"
"purpose": "any maskable"
},
{
"src": "/favicon.png",
"src": "/icon-512x512.png",
"type": "image/png",
"sizes": "512x512",
"type": "image/png"
"purpose": "any maskable"
}
],
"display": "standalone"
"start_url": "/",
"display": "standalone",
"theme_color": "#242424",
"background_color": "#242424",
"orientation": "portrait-primary",
"scope": "/",
"categories": ["games", "social", "beer pong"],
"prefer_related_applications": false,
"shortcuts": []
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 273 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 31 KiB

+44
View File
@@ -2,6 +2,26 @@
overscroll-behavior: none;
}
/* Keep app chrome non-selectable so iOS long-press doesn't open the
edit menu (AutoFill/Copy/Siri). Inputs and opted-in regions stay
selectable. */
body {
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
input,
textarea,
[contenteditable="true"],
[contenteditable=""],
[data-selectable],
[data-selectable] * {
-webkit-touch-callout: default;
-webkit-user-select: text;
user-select: text;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
@@ -28,4 +48,28 @@
[data-drawer-level="3"].drawer-scaling {
transform: scale(0.90) translateY(-4px);
}
@media (prefers-reduced-motion: reduce) {
.app,
[data-drawer-level] {
transition: none;
}
.app.drawer-scaling,
[data-drawer-level].drawer-scaling {
transform: none;
border-radius: 0;
}
[data-vaul-drawer],
[data-vaul-overlay] {
animation: none !important;
transition: none !important;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(0deg); }
}
}
+79
View File
@@ -0,0 +1,79 @@
import { generateSW } from 'workbox-build'
const ONE_YEAR = 60 * 60 * 24 * 365
const THIRTY_DAYS = 60 * 60 * 24 * 30
const { count, size, warnings } = await generateSW({
swDest: 'dist/client/sw.js',
globDirectory: 'dist/client',
globPatterns: [
'assets/**/*.{js,css}',
'favicon*.{ico,png}',
'apple-touch-icon.png',
'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
// the generated SW at startup. importScripts runs them in the SW global
// scope, so their `push` / `notificationclick` listeners compose with
// Workbox's precache + runtime caching setup below.
importScripts: ['/push-handlers.js'],
navigateFallback: null,
skipWaiting: true,
clientsClaim: true,
cleanupOutdatedCaches: true,
sourcemap: false,
runtimeCaching: [
{
urlPattern: ({ url, request }) =>
url.origin === self.location.origin && request.destination === 'image',
handler: 'CacheFirst',
options: {
cacheName: 'images-cache',
expiration: {
maxEntries: 60,
maxAgeSeconds: THIRTY_DAYS,
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'google-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: ONE_YEAR,
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
{
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'gstatic-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: ONE_YEAR,
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
],
})
for (const warning of warnings) console.warn(warning)
console.log(
`sw.js generated: precached ${count} files, ${(size / 1024).toFixed(1)} KiB`,
)
+81
View File
@@ -0,0 +1,81 @@
// Grant or revoke the "Admin" role for a user by phone number (US +1 assumed).
// Usage: bun run scripts/make-admin.ts <phone> [--revoke]
import "dotenv/config";
import SuperTokens from "supertokens-node";
import Session from "supertokens-node/recipe/session";
import Passwordless from "supertokens-node/recipe/passwordless";
import UserRoles from "supertokens-node/recipe/userroles";
SuperTokens.init({
framework: "custom",
supertokens: {
connectionURI: process.env.SUPERTOKENS_URI || "http://localhost:3567",
apiKey: process.env.SUPERTOKENS_API_KEY || undefined,
},
appInfo: {
appName: "FLXN",
apiDomain: "http://localhost:3000",
websiteDomain: "http://localhost:3000",
apiBasePath: "/api/auth",
websiteBasePath: "/auth",
},
recipeList: [
Passwordless.init({ contactMethod: "PHONE", flowType: "USER_INPUT_CODE" }),
Session.init(),
UserRoles.init(),
],
});
const raw = process.argv[2];
const revoke = process.argv.includes("--revoke");
if (!raw) {
console.error("Usage: bun run scripts/make-admin.ts <phone> [--revoke]");
process.exit(1);
}
const digits = raw.replace(/[^\d]/g, "");
const candidates = Array.from(
new Set([
raw.startsWith("+") ? raw : null,
digits.length === 10 ? `+1${digits}` : null,
`+${digits}`,
digits,
].filter(Boolean) as string[])
);
let user: { id: string } | undefined;
let matched = "";
for (const phoneNumber of candidates) {
const users = await SuperTokens.listUsersByAccountInfo("public", { phoneNumber });
if (users.length) {
user = users[0];
matched = phoneNumber;
break;
}
}
if (!user) {
console.error(`No SuperTokens user found for phone (tried: ${candidates.join(", ")}).`);
console.error("The user must have logged in at least once so their account exists.");
process.exit(1);
}
await UserRoles.createNewRoleOrAddPermissions("Admin", []);
if (revoke) {
const res = await UserRoles.removeUserRole("public", user.id, "Admin");
console.log(`Removed Admin from ${matched} (user ${user.id}):`, res.status);
} else {
const res = await UserRoles.addRoleToUser("public", user.id, "Admin");
console.log(
`Granted Admin to ${matched} (user ${user.id}):`,
res.status === "OK"
? res.didUserAlreadyHaveRole
? "already had it"
: "added"
: res.status
);
}
console.log("Note: sign out and back in (or refresh the session) for the role to take effect.");
process.exit(0);
+300 -105
View File
@@ -16,73 +16,133 @@
* - Server port number
* - Default: 3000
*
* STATIC_PRELOAD_MAX_BYTES (number)
* ASSET_PRELOAD_MAX_SIZE (number)
* - Maximum file size in bytes to preload into memory
* - Files larger than this will be served on-demand from disk
* - Default: 5242880 (5MB)
* - Example: STATIC_PRELOAD_MAX_BYTES=5242880 (5MB)
* - Example: ASSET_PRELOAD_MAX_SIZE=5242880 (5MB)
*
* STATIC_PRELOAD_INCLUDE (string)
* ASSET_PRELOAD_INCLUDE_PATTERNS (string)
* - Comma-separated list of glob patterns for files to include
* - If specified, only matching files are eligible for preloading
* - Patterns are matched against filenames only, not full paths
* - Example: STATIC_PRELOAD_INCLUDE="*.js,*.css,*.woff2"
* - Example: ASSET_PRELOAD_INCLUDE_PATTERNS="*.js,*.css,*.woff2"
*
* STATIC_PRELOAD_EXCLUDE (string)
* ASSET_PRELOAD_EXCLUDE_PATTERNS (string)
* - Comma-separated list of glob patterns for files to exclude
* - Applied after include patterns
* - Patterns are matched against filenames only, not full paths
* - Example: STATIC_PRELOAD_EXCLUDE="*.map,*.txt"
* - Example: ASSET_PRELOAD_EXCLUDE_PATTERNS="*.map,*.txt"
*
* STATIC_PRELOAD_VERBOSE (boolean)
* ASSET_PRELOAD_VERBOSE_LOGGING (boolean)
* - Enable detailed logging of loaded and skipped files
* - Default: false
* - Set to "true" to enable verbose output
*
* ASSET_PRELOAD_ENABLE_ETAG (boolean)
* - Enable ETag generation for preloaded assets
* - Default: true
* - Set to "false" to disable ETag support
*
* ASSET_PRELOAD_ENABLE_GZIP (boolean)
* - Enable Gzip compression for eligible assets
* - Default: true
* - Set to "false" to disable Gzip compression
*
* ASSET_PRELOAD_GZIP_MIN_SIZE (number)
* - Minimum file size in bytes required for Gzip compression
* - Files smaller than this will not be compressed
* - Default: 1024 (1KB)
*
* ASSET_PRELOAD_GZIP_MIME_TYPES (string)
* - Comma-separated list of MIME types eligible for Gzip compression
* - Supports partial matching for types ending with "/"
* - Default: text/,application/javascript,application/json,application/xml,image/svg+xml
*
* Usage:
* bun run server.ts
*/
import { readdir } from 'node:fs/promises'
import { join } from 'node:path'
import path from 'node:path'
// Configuration
const PORT = Number(process.env.PORT ?? 3000)
const CLIENT_DIR = './dist/client'
const SERVER_ENTRY = './dist/server/server.js'
const SERVER_PORT = Number(process.env.PORT ?? 3000)
const CLIENT_DIRECTORY = './dist/client'
const SERVER_ENTRY_POINT = './dist/server/server.js'
// Logging utilities for professional output
const log = {
info: (message: string) => {
console.log(`[INFO] ${message}`)
},
success: (message: string) => {
console.log(`[SUCCESS] ${message}`)
},
warning: (message: string) => {
console.log(`[WARNING] ${message}`)
},
error: (message: string) => {
console.log(`[ERROR] ${message}`)
},
header: (message: string) => {
console.log(`\n${message}\n`)
},
}
// Preloading configuration from environment variables
const MAX_PRELOAD_BYTES = Number(
process.env.STATIC_PRELOAD_MAX_BYTES ?? 5 * 1024 * 1024, // 5MB default
process.env.ASSET_PRELOAD_MAX_SIZE ?? 5 * 1024 * 1024, // 5MB default
)
// Parse comma-separated include patterns (no defaults)
const INCLUDE_PATTERNS = (process.env.STATIC_PRELOAD_INCLUDE ?? '')
const INCLUDE_PATTERNS = (process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map(globToRegExp)
.map((pattern: string) => convertGlobToRegExp(pattern))
// Parse comma-separated exclude patterns (no defaults)
const EXCLUDE_PATTERNS = (process.env.STATIC_PRELOAD_EXCLUDE ?? '')
const EXCLUDE_PATTERNS = (process.env.ASSET_PRELOAD_EXCLUDE_PATTERNS ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map(globToRegExp)
.map((pattern: string) => convertGlobToRegExp(pattern))
// Verbose logging flag
const VERBOSE = process.env.STATIC_PRELOAD_VERBOSE === 'true'
const VERBOSE = process.env.ASSET_PRELOAD_VERBOSE_LOGGING === 'true'
// Optional ETag feature
const ENABLE_ETAG = (process.env.ASSET_PRELOAD_ENABLE_ETAG ?? 'true') === 'true'
// Optional Gzip feature
const ENABLE_GZIP = (process.env.ASSET_PRELOAD_ENABLE_GZIP ?? 'true') === 'true'
const GZIP_MIN_BYTES = Number(process.env.ASSET_PRELOAD_GZIP_MIN_SIZE ?? 1024) // 1KB
const GZIP_TYPES = (
process.env.ASSET_PRELOAD_GZIP_MIME_TYPES ??
'text/,application/javascript,application/json,application/xml,image/svg+xml'
)
.split(',')
.map((v) => v.trim())
.filter(Boolean)
/**
* Convert a simple glob pattern to a regular expression
* Supports * wildcard for matching any characters
*/
function globToRegExp(glob: string): RegExp {
function convertGlobToRegExp(globPattern: string): RegExp {
// Escape regex special chars except *, then replace * with .*
const escaped = glob
const escapedPattern = globPattern
.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&')
.replace(/\*/g, '.*')
return new RegExp(`^${escaped}$`, 'i')
return new RegExp(`^${escapedPattern}$`, 'i')
}
/**
* Compute ETag for a given data buffer
*/
function computeEtag(data: Uint8Array): string {
const hash = Bun.hash(data)
return `W/"${hash.toString(16)}-${data.byteLength.toString()}"`
}
/**
@@ -95,18 +155,30 @@ interface AssetMetadata {
}
/**
* Result of static asset preloading process
* In-memory asset with ETag and Gzip support
*/
interface PreloadResult {
routes: Record<string, () => Response>
loaded: Array<AssetMetadata>
skipped: Array<AssetMetadata>
interface InMemoryAsset {
raw: Uint8Array
gz?: Uint8Array
etag?: string
type: string
immutable: boolean
size: number
}
/**
* Check if a file should be included based on configured patterns
* Result of static asset preloading process
*/
function shouldInclude(relativePath: string): boolean {
interface PreloadResult {
routes: Record<string, (req: Request) => Response | Promise<Response>>
loaded: AssetMetadata[]
skipped: AssetMetadata[]
}
/**
* Check if a file is eligible for preloading based on configured patterns
*/
function isFileEligibleForPreloading(relativePath: string): boolean {
const fileName = relativePath.split(/[/\\]/).pop() ?? relativePath
// If include patterns are specified, file must match at least one
@@ -125,38 +197,122 @@ function shouldInclude(relativePath: string): boolean {
}
/**
* Build static routes with intelligent preloading strategy
* Check if a MIME type is compressible
*/
function isMimeTypeCompressible(mimeType: string): boolean {
return GZIP_TYPES.some((type) =>
type.endsWith('/') ? mimeType.startsWith(type) : mimeType === type,
)
}
/**
* Conditionally compress data based on size and MIME type
*/
function compressDataIfAppropriate(
data: Uint8Array,
mimeType: string,
): Uint8Array | undefined {
if (!ENABLE_GZIP) return undefined
if (data.byteLength < GZIP_MIN_BYTES) return undefined
if (!isMimeTypeCompressible(mimeType)) return undefined
try {
return Bun.gzipSync(data.buffer as ArrayBuffer)
} catch {
return undefined
}
}
/**
* Create response handler function with ETag and Gzip support
*/
function createResponseHandler(
asset: InMemoryAsset,
): (req: Request) => Response {
return (req: Request) => {
const headers: Record<string, string> = {
'Content-Type': asset.type,
'Cache-Control': asset.immutable
? 'public, max-age=31536000, immutable'
: 'public, max-age=3600',
}
if (ENABLE_ETAG && asset.etag) {
const ifNone = req.headers.get('if-none-match')
if (ifNone && ifNone === asset.etag) {
return new Response(null, {
status: 304,
headers: { ETag: asset.etag },
})
}
headers.ETag = asset.etag
}
if (
ENABLE_GZIP &&
asset.gz &&
req.headers.get('accept-encoding')?.includes('gzip')
) {
headers['Content-Encoding'] = 'gzip'
headers['Content-Length'] = String(asset.gz.byteLength)
const gzCopy = new Uint8Array(asset.gz)
return new Response(gzCopy, { status: 200, headers })
}
headers['Content-Length'] = String(asset.raw.byteLength)
const rawCopy = new Uint8Array(asset.raw)
return new Response(rawCopy, { status: 200, headers })
}
}
/**
* Create composite glob pattern from include patterns
*/
function createCompositeGlobPattern(): Bun.Glob {
const raw = (process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
if (raw.length === 0) return new Bun.Glob('**/*')
if (raw.length === 1) return new Bun.Glob(raw[0])
return new Bun.Glob(`{${raw.join(',')}}`)
}
/**
* Initialize static routes with intelligent preloading strategy
* Small files are loaded into memory, large files are served on-demand
*/
async function buildStaticRoutes(clientDir: string): Promise<PreloadResult> {
const routes: Record<string, () => Response> = {}
const loaded: Array<AssetMetadata> = []
const skipped: Array<AssetMetadata> = []
async function initializeStaticRoutes(
clientDirectory: string,
): Promise<PreloadResult> {
const routes: Record<string, (req: Request) => Response | Promise<Response>> =
{}
const loaded: AssetMetadata[] = []
const skipped: AssetMetadata[] = []
console.log(`📦 Loading static assets from ${clientDir}...`)
console.log(
` Max preload size: ${(MAX_PRELOAD_BYTES / 1024 / 1024).toFixed(2)} MB`,
)
if (INCLUDE_PATTERNS.length > 0) {
log.info(`Loading static assets from ${clientDirectory}...`)
if (VERBOSE) {
console.log(
` Include patterns: ${process.env.STATIC_PRELOAD_INCLUDE ?? ''}`,
)
}
if (EXCLUDE_PATTERNS.length > 0) {
console.log(
` Exclude patterns: ${process.env.STATIC_PRELOAD_EXCLUDE ?? ''}`,
`Max preload size: ${(MAX_PRELOAD_BYTES / 1024 / 1024).toFixed(2)} MB`,
)
if (INCLUDE_PATTERNS.length > 0) {
console.log(
`Include patterns: ${process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? ''}`,
)
}
if (EXCLUDE_PATTERNS.length > 0) {
console.log(
`Exclude patterns: ${process.env.ASSET_PRELOAD_EXCLUDE_PATTERNS ?? ''}`,
)
}
}
let totalPreloadedBytes = 0
try {
// Read all files recursively
const files = await readdir(clientDir, { recursive: true })
for (const relativePath of files) {
const filepath = join(clientDir, relativePath)
const route = '/' + relativePath.replace(/\\/g, '/') // Handle Windows paths
const glob = createCompositeGlobPattern()
for await (const relativePath of glob.scan({ cwd: clientDirectory })) {
const filepath = path.join(clientDirectory, relativePath)
const route = `/${relativePath.split(path.sep).join(path.posix.sep)}`
try {
// Get file metadata
@@ -174,20 +330,26 @@ async function buildStaticRoutes(clientDir: string): Promise<PreloadResult> {
}
// Determine if file should be preloaded
const matchesPattern = shouldInclude(relativePath)
const matchesPattern = isFileEligibleForPreloading(relativePath)
const withinSizeLimit = file.size <= MAX_PRELOAD_BYTES
if (matchesPattern && withinSizeLimit) {
// Preload small files into memory
const bytes = await file.bytes()
routes[route] = () =>
new Response(bytes, {
headers: {
'Content-Type': metadata.type,
'Cache-Control': 'public, max-age=31536000, immutable',
},
})
// Preload small files into memory with ETag and Gzip support
const bytes = new Uint8Array(await file.arrayBuffer())
const gz = compressDataIfAppropriate(bytes, metadata.type)
const etag = ENABLE_ETAG ? computeEtag(bytes) : undefined
const asset: InMemoryAsset = {
raw: bytes,
gz,
etag,
type: metadata.type,
// Only Vite's content-hashed output under /assets/ is safe to
// cache immutably; unhashed files (favicon, manifest, images)
// can change between deploys and must revalidate.
immutable: route.startsWith('/assets/'),
size: bytes.byteLength,
}
routes[route] = createResponseHandler(asset)
loaded.push({ ...metadata, size: bytes.byteLength })
totalPreloadedBytes += bytes.byteLength
@@ -207,13 +369,13 @@ async function buildStaticRoutes(clientDir: string): Promise<PreloadResult> {
}
} catch (error: unknown) {
if (error instanceof Error && error.name !== 'EISDIR') {
console.error(`❌ Failed to load ${filepath}:`, error)
log.error(`Failed to load ${filepath}: ${error.message}`)
}
}
}
// Always show file overview in Vite-like format first
if (loaded.length > 0 || skipped.length > 0) {
// Show detailed file overview only when verbose mode is enabled
if (VERBOSE && (loaded.length > 0 || skipped.length > 0)) {
const allFiles = [...loaded, ...skipped].sort((a, b) =>
a.route.localeCompare(b.route),
)
@@ -224,124 +386,157 @@ async function buildStaticRoutes(clientDir: string): Promise<PreloadResult> {
60,
)
// Format file size with KB and gzip estimation
const formatFileSize = (bytes: number) => {
// Format file size with KB and actual gzip size
const formatFileSize = (bytes: number, gzBytes?: number) => {
const kb = bytes / 1024
// Rough gzip estimation (typically 30-70% compression)
const sizeStr = kb < 100 ? kb.toFixed(2) : kb.toFixed(1)
if (gzBytes !== undefined) {
const gzKb = gzBytes / 1024
const gzStr = gzKb < 100 ? gzKb.toFixed(2) : gzKb.toFixed(1)
return {
size: sizeStr,
gzip: gzStr,
}
}
// Rough gzip estimation (typically 30-70% compression) if no actual gzip data
const gzipKb = kb * 0.35
return {
size: kb < 100 ? kb.toFixed(2) : kb.toFixed(1),
size: sizeStr,
gzip: gzipKb < 100 ? gzipKb.toFixed(2) : gzipKb.toFixed(1),
}
}
if (loaded.length > 0) {
console.log('\n📁 Preloaded into memory:')
console.log(
'Path │ Size │ Gzip Size',
)
loaded
.sort((a, b) => a.route.localeCompare(b.route))
.forEach((file) => {
const { size, gzip } = formatFileSize(file.size)
const paddedPath = file.route.padEnd(maxPathLength)
const sizeStr = `${size.padStart(7)} kB`
const gzipStr = `gzip: ${gzip.padStart(6)} kB`
console.log(` ${paddedPath} ${sizeStr} │ ${gzipStr}`)
const gzipStr = `${gzip.padStart(7)} kB`
console.log(`${paddedPath} │ ${sizeStr} │ ${gzipStr}`)
})
}
if (skipped.length > 0) {
console.log('\n💾 Served on-demand:')
console.log(
'Path │ Size │ Gzip Size',
)
skipped
.sort((a, b) => a.route.localeCompare(b.route))
.forEach((file) => {
const { size, gzip } = formatFileSize(file.size)
const paddedPath = file.route.padEnd(maxPathLength)
const sizeStr = `${size.padStart(7)} kB`
const gzipStr = `gzip: ${gzip.padStart(6)} kB`
console.log(` ${paddedPath} ${sizeStr} │ ${gzipStr}`)
const gzipStr = `${gzip.padStart(7)} kB`
console.log(`${paddedPath} │ ${sizeStr} │ ${gzipStr}`)
})
}
}
// Show detailed verbose info if enabled
if (VERBOSE) {
// Show detailed verbose info if enabled
if (VERBOSE) {
if (loaded.length > 0 || skipped.length > 0) {
const allFiles = [...loaded, ...skipped].sort((a, b) =>
a.route.localeCompare(b.route),
)
console.log('\n📊 Detailed file information:')
console.log(
'Status │ Path │ MIME Type │ Reason',
)
allFiles.forEach((file) => {
const isPreloaded = loaded.includes(file)
const status = isPreloaded ? '[MEMORY]' : '[ON-DEMAND]'
const status = isPreloaded ? 'MEMORY' : 'ON-DEMAND'
const reason =
!isPreloaded && file.size > MAX_PRELOAD_BYTES
? ' (too large)'
? 'too large'
: !isPreloaded
? ' (filtered)'
: ''
? 'filtered'
: 'preloaded'
const route =
file.route.length > 30
? file.route.substring(0, 27) + '...'
: file.route
console.log(
` ${status.padEnd(12)} ${file.route} - ${file.type}${reason}`,
`${status.padEnd(12)} │ ${route.padEnd(30)} │ ${file.type.padEnd(28)} │ ${reason.padEnd(10)}`,
)
})
} else {
console.log('\n📊 No files found to display')
}
}
// Log summary after the file list
console.log() // Empty line for separation
if (loaded.length > 0) {
console.log(
`✅ Preloaded ${String(loaded.length)} files (${(totalPreloadedBytes / 1024 / 1024).toFixed(2)} MB) into memory`,
log.success(
`Preloaded ${String(loaded.length)} files (${(totalPreloadedBytes / 1024 / 1024).toFixed(2)} MB) into memory`,
)
} else {
console.log('ℹ️ No files preloaded into memory')
log.info('No files preloaded into memory')
}
if (skipped.length > 0) {
const tooLarge = skipped.filter((f) => f.size > MAX_PRELOAD_BYTES).length
const filtered = skipped.length - tooLarge
console.log(
`ℹ️ ${String(skipped.length)} files will be served on-demand (${String(tooLarge)} too large, ${String(filtered)} filtered)`,
log.info(
`${String(skipped.length)} files will be served on-demand (${String(tooLarge)} too large, ${String(filtered)} filtered)`,
)
}
} catch (error) {
console.error(`❌ Failed to load static files from ${clientDir}:`, error)
log.error(
`Failed to load static files from ${clientDirectory}: ${String(error)}`,
)
}
return { routes, loaded, skipped }
}
/**
* Start the production server
* Initialize the server
*/
async function startServer() {
console.log('🚀 Starting production server...')
async function initializeServer() {
log.header('Starting Production Server')
// Load TanStack Start server handler
let handler: { fetch: (request: Request) => Response | Promise<Response> }
try {
const serverModule = (await import(SERVER_ENTRY)) as {
const serverModule = (await import(SERVER_ENTRY_POINT)) as {
default: { fetch: (request: Request) => Response | Promise<Response> }
}
handler = serverModule.default
console.log('✅ TanStack Start handler loaded')
log.success('TanStack Start application handler initialized')
} catch (error) {
console.error('❌ Failed to load server handler:', error)
log.error(`Failed to load server handler: ${String(error)}`)
process.exit(1)
}
// Build static routes with intelligent preloading
const { routes } = await buildStaticRoutes(CLIENT_DIR)
const { routes } = await initializeStaticRoutes(CLIENT_DIRECTORY)
// Create Bun server
const server = Bun.serve({
port: PORT,
idleTimeout: 255,
port: SERVER_PORT,
routes: {
// Serve static assets (preloaded or on-demand)
...routes,
// Fallback to TanStack Start handler for all other routes
'/*': (request) => {
'/*': async (req: Request) => {
try {
return handler.fetch(request)
// Return the handler's Response as-is so streaming bodies
// (SSR streaming, SSE event streams) pass through unbuffered.
return await handler.fetch(req)
} catch (error) {
console.error('Server handler error:', error)
log.error(`Server handler error: ${String(error)}`)
return new Response('Internal Server Error', { status: 500 })
}
},
@@ -349,18 +544,18 @@ async function startServer() {
// Global error handler
error(error) {
console.error('Uncaught server error:', error)
log.error(
`Uncaught server error: ${error instanceof Error ? error.message : String(error)}`,
)
return new Response('Internal Server Error', { status: 500 })
},
})
console.log(
`\n🚀 Server running at http://localhost:${String(server.port)}\n`,
)
log.success(`Server listening on http://localhost:${String(server.port)}`)
}
// Start the server
startServer().catch((error: unknown) => {
console.error('Failed to start server:', error)
// Initialize the server
initializeServer().catch((error: unknown) => {
log.error(`Failed to start server: ${String(error)}`)
process.exit(1)
})
+415 -13
View File
@@ -14,6 +14,7 @@ import { Route as LogoutRouteImport } from './routes/logout'
import { Route as LoginRouteImport } from './routes/login'
import { Route as AuthedRouteImport } from './routes/_authed'
import { Route as AuthedIndexRouteImport } from './routes/_authed/index'
import { Route as ApiHealthRouteImport } from './routes/api/health'
import { Route as AuthedStatsRouteImport } from './routes/_authed/stats'
import { Route as AuthedSettingsRouteImport } from './routes/_authed/settings'
import { Route as AuthedBadgesRouteImport } from './routes/_authed/badges'
@@ -21,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'
@@ -28,20 +30,36 @@ import { Route as ApiSpotifyResumeRouteImport } from './routes/api/spotify/resum
import { Route as ApiSpotifyPlaybackRouteImport } from './routes/api/spotify/playback'
import { Route as ApiSpotifyCaptureRouteImport } from './routes/api/spotify/capture'
import { Route as ApiSpotifyCallbackRouteImport } from './routes/api/spotify/callback'
import { Route as ApiPushUnsubscribeRouteImport } from './routes/api/push/unsubscribe'
import { Route as ApiPushTestRouteImport } from './routes/api/push/test'
import { Route as ApiPushSubscribeRouteImport } from './routes/api/push/subscribe'
import { Route as ApiEventsSplatRouteImport } from './routes/api/events.$'
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({
id: '/refresh-session',
@@ -67,6 +85,11 @@ const AuthedIndexRoute = AuthedIndexRouteImport.update({
path: '/',
getParentRoute: () => AuthedRoute,
} as any)
const ApiHealthRoute = ApiHealthRouteImport.update({
id: '/api/health',
path: '/api/health',
getParentRoute: () => rootRouteImport,
} as any)
const AuthedStatsRoute = AuthedStatsRouteImport.update({
id: '/stats',
path: '/stats',
@@ -103,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',
@@ -138,6 +166,21 @@ const ApiSpotifyCallbackRoute = ApiSpotifyCallbackRouteImport.update({
path: '/api/spotify/callback',
getParentRoute: () => rootRouteImport,
} as any)
const ApiPushUnsubscribeRoute = ApiPushUnsubscribeRouteImport.update({
id: '/api/push/unsubscribe',
path: '/api/push/unsubscribe',
getParentRoute: () => rootRouteImport,
} as any)
const ApiPushTestRoute = ApiPushTestRouteImport.update({
id: '/api/push/test',
path: '/api/push/test',
getParentRoute: () => rootRouteImport,
} as any)
const ApiPushSubscribeRoute = ApiPushSubscribeRouteImport.update({
id: '/api/push/subscribe',
path: '/api/push/subscribe',
getParentRoute: () => rootRouteImport,
} as any)
const ApiEventsSplatRoute = ApiEventsSplatRouteImport.update({
id: '/api/events/$',
path: '/api/events/$',
@@ -164,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',
@@ -185,12 +233,60 @@ 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',
path: '/tournaments/$id/predictions',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedTournamentsIdGroupsRoute =
AuthedTournamentsIdGroupsRouteImport.update({
id: '/tournaments/$id/groups',
path: '/tournaments/$id/groups',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedTournamentsIdBracketRoute =
AuthedTournamentsIdBracketRouteImport.update({
id: '/tournaments/$id/bracket',
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/',
@@ -203,6 +299,18 @@ const ApiFilesCollectionRecordIdFileRoute =
path: '/api/files/$collection/$recordId/$file',
getParentRoute: () => rootRouteImport,
} as any)
const AuthedTournamentsIdPredictionsMakeRoute =
AuthedTournamentsIdPredictionsMakeRouteImport.update({
id: '/tournaments/$id/predictions_/make',
path: '/tournaments/$id/predictions/make',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedTournamentsIdPredictionsPlayerIdRoute =
AuthedTournamentsIdPredictionsPlayerIdRouteImport.update({
id: '/tournaments/$id/predictions_/$playerId',
path: '/tournaments/$id/predictions/$playerId',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedAdminTournamentsRunIdRoute =
AuthedAdminTournamentsRunIdRouteImport.update({
id: '/tournaments/run/$id',
@@ -215,8 +323,21 @@ 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',
path: '/tournaments/$id/assign-partners',
getParentRoute: () => AuthedAdminRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof AuthedIndexRoute
'/login': typeof LoginRoute
'/logout': typeof LogoutRoute
'/refresh-session': typeof RefreshSessionRoute
@@ -224,15 +345,19 @@ export interface FileRoutesByFullPath {
'/badges': typeof AuthedBadgesRoute
'/settings': typeof AuthedSettingsRoute
'/stats': typeof AuthedStatsRoute
'/': typeof AuthedIndexRoute
'/api/health': typeof ApiHealthRoute
'/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
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/events/$': typeof ApiEventsSplatRoute
'/api/push/subscribe': typeof ApiPushSubscribeRoute
'/api/push/test': typeof ApiPushTestRoute
'/api/push/unsubscribe': typeof ApiPushUnsubscribeRoute
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
@@ -240,15 +365,28 @@ 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
'/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
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
'/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
'/tournaments/$id/predictions/make': typeof AuthedTournamentsIdPredictionsMakeRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
'/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
@@ -257,6 +395,7 @@ export interface FileRoutesByTo {
'/badges': typeof AuthedBadgesRoute
'/settings': typeof AuthedSettingsRoute
'/stats': typeof AuthedStatsRoute
'/api/health': typeof ApiHealthRoute
'/': typeof AuthedIndexRoute
'/admin/activities': typeof AuthedAdminActivitiesRoute
'/admin/badges': typeof AuthedAdminBadgesRoute
@@ -266,6 +405,9 @@ export interface FileRoutesByTo {
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/events/$': typeof ApiEventsSplatRoute
'/api/push/subscribe': typeof ApiPushSubscribeRoute
'/api/push/test': typeof ApiPushTestRoute
'/api/push/unsubscribe': typeof ApiPushUnsubscribeRoute
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
@@ -273,13 +415,26 @@ 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
'/tournaments/$id/predictions/make': typeof AuthedTournamentsIdPredictionsMakeRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
}
@@ -293,15 +448,20 @@ export interface FileRoutesById {
'/_authed/badges': typeof AuthedBadgesRoute
'/_authed/settings': typeof AuthedSettingsRoute
'/_authed/stats': typeof AuthedStatsRoute
'/api/health': typeof ApiHealthRoute
'/_authed/': typeof AuthedIndexRoute
'/_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
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/events/$': typeof ApiEventsSplatRoute
'/api/push/subscribe': typeof ApiPushSubscribeRoute
'/api/push/test': typeof ApiPushTestRoute
'/api/push/unsubscribe': typeof ApiPushUnsubscribeRoute
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
@@ -309,19 +469,33 @@ 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
'/_authed/tournaments/$id/predictions_/make': typeof AuthedTournamentsIdPredictionsMakeRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/_authed/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/login'
| '/logout'
| '/refresh-session'
@@ -329,15 +503,19 @@ export interface FileRouteTypes {
| '/badges'
| '/settings'
| '/stats'
| '/'
| '/api/health'
| '/admin/activities'
| '/admin/badges'
| '/admin/preview'
| '/admin/telemetry'
| '/profile/$playerId'
| '/teams/$teamId'
| '/tournaments/$tournamentId'
| '/api/auth/$'
| '/api/events/$'
| '/api/push/subscribe'
| '/api/push/test'
| '/api/push/unsubscribe'
| '/api/spotify/callback'
| '/api/spotify/capture'
| '/api/spotify/playback'
@@ -345,15 +523,28 @@ export interface FileRouteTypes {
| '/api/spotify/search'
| '/api/spotify/token'
| '/api/teams/upload-logo'
| '/api/telemetry/beacon'
| '/api/tournaments/upload-logo'
| '/admin/'
| '/tournaments'
| '/tournaments/'
| '/admin/telemetry/errors'
| '/admin/telemetry/functions'
| '/admin/telemetry/players'
| '/admin/telemetry/sessions'
| '/admin/telemetry/usage'
| '/tournaments/$id/bracket'
| '/admin/tournaments'
| '/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'
| '/tournaments/$id/predictions/make'
| '/api/files/$collection/$recordId/$file'
| '/admin/tournaments/$id'
| '/admin/tournaments/$id/'
fileRoutesByTo: FileRoutesByTo
to:
| '/login'
@@ -362,6 +553,7 @@ export interface FileRouteTypes {
| '/badges'
| '/settings'
| '/stats'
| '/api/health'
| '/'
| '/admin/activities'
| '/admin/badges'
@@ -371,6 +563,9 @@ export interface FileRouteTypes {
| '/tournaments/$tournamentId'
| '/api/auth/$'
| '/api/events/$'
| '/api/push/subscribe'
| '/api/push/test'
| '/api/push/unsubscribe'
| '/api/spotify/callback'
| '/api/spotify/capture'
| '/api/spotify/playback'
@@ -378,13 +573,26 @@ 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'
| '/tournaments/$id/predictions/make'
| '/api/files/$collection/$recordId/$file'
| '/admin/tournaments/$id'
id:
@@ -397,15 +605,20 @@ export interface FileRouteTypes {
| '/_authed/badges'
| '/_authed/settings'
| '/_authed/stats'
| '/api/health'
| '/_authed/'
| '/_authed/admin/activities'
| '/_authed/admin/badges'
| '/_authed/admin/preview'
| '/_authed/admin/telemetry'
| '/_authed/profile/$playerId'
| '/_authed/teams/$teamId'
| '/_authed/tournaments/$tournamentId'
| '/api/auth/$'
| '/api/events/$'
| '/api/push/subscribe'
| '/api/push/test'
| '/api/push/unsubscribe'
| '/api/spotify/callback'
| '/api/spotify/capture'
| '/api/spotify/playback'
@@ -413,13 +626,26 @@ 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'
| '/_authed/tournaments/$id/predictions_/make'
| '/api/files/$collection/$recordId/$file'
| '/_authed/admin/tournaments/$id/'
fileRoutesById: FileRoutesById
@@ -429,8 +655,12 @@ export interface RootRouteChildren {
LoginRoute: typeof LoginRoute
LogoutRoute: typeof LogoutRoute
RefreshSessionRoute: typeof RefreshSessionRoute
ApiHealthRoute: typeof ApiHealthRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiEventsSplatRoute: typeof ApiEventsSplatRoute
ApiPushSubscribeRoute: typeof ApiPushSubscribeRoute
ApiPushTestRoute: typeof ApiPushTestRoute
ApiPushUnsubscribeRoute: typeof ApiPushUnsubscribeRoute
ApiSpotifyCallbackRoute: typeof ApiSpotifyCallbackRoute
ApiSpotifyCaptureRoute: typeof ApiSpotifyCaptureRoute
ApiSpotifyPlaybackRoute: typeof ApiSpotifyPlaybackRoute
@@ -438,6 +668,7 @@ export interface RootRouteChildren {
ApiSpotifySearchRoute: typeof ApiSpotifySearchRoute
ApiSpotifyTokenRoute: typeof ApiSpotifyTokenRoute
ApiTeamsUploadLogoRoute: typeof ApiTeamsUploadLogoRoute
ApiTelemetryBeaconRoute: typeof ApiTelemetryBeaconRoute
ApiTournamentsUploadLogoRoute: typeof ApiTournamentsUploadLogoRoute
ApiFilesCollectionRecordIdFileRoute: typeof ApiFilesCollectionRecordIdFileRoute
}
@@ -468,7 +699,7 @@ declare module '@tanstack/react-router' {
'/_authed': {
id: '/_authed'
path: ''
fullPath: ''
fullPath: '/'
preLoaderRoute: typeof AuthedRouteImport
parentRoute: typeof rootRouteImport
}
@@ -479,6 +710,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedIndexRouteImport
parentRoute: typeof AuthedRoute
}
'/api/health': {
id: '/api/health'
path: '/api/health'
fullPath: '/api/health'
preLoaderRoute: typeof ApiHealthRouteImport
parentRoute: typeof rootRouteImport
}
'/_authed/stats': {
id: '/_authed/stats'
path: '/stats'
@@ -510,7 +748,7 @@ declare module '@tanstack/react-router' {
'/_authed/tournaments/': {
id: '/_authed/tournaments/'
path: '/tournaments'
fullPath: '/tournaments'
fullPath: '/tournaments/'
preLoaderRoute: typeof AuthedTournamentsIndexRouteImport
parentRoute: typeof AuthedRoute
}
@@ -528,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'
@@ -577,6 +822,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiSpotifyCallbackRouteImport
parentRoute: typeof rootRouteImport
}
'/api/push/unsubscribe': {
id: '/api/push/unsubscribe'
path: '/api/push/unsubscribe'
fullPath: '/api/push/unsubscribe'
preLoaderRoute: typeof ApiPushUnsubscribeRouteImport
parentRoute: typeof rootRouteImport
}
'/api/push/test': {
id: '/api/push/test'
path: '/api/push/test'
fullPath: '/api/push/test'
preLoaderRoute: typeof ApiPushTestRouteImport
parentRoute: typeof rootRouteImport
}
'/api/push/subscribe': {
id: '/api/push/subscribe'
path: '/api/push/subscribe'
fullPath: '/api/push/subscribe'
preLoaderRoute: typeof ApiPushSubscribeRouteImport
parentRoute: typeof rootRouteImport
}
'/api/events/$': {
id: '/api/events/$'
path: '/api/events/$'
@@ -612,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'
@@ -636,10 +909,31 @@ declare module '@tanstack/react-router' {
'/_authed/admin/tournaments/': {
id: '/_authed/admin/tournaments/'
path: '/tournaments'
fullPath: '/admin/tournaments'
fullPath: '/admin/tournaments/'
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'
fullPath: '/tournaments/$id/predictions'
preLoaderRoute: typeof AuthedTournamentsIdPredictionsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/tournaments/$id/groups': {
id: '/_authed/tournaments/$id/groups'
path: '/tournaments/$id/groups'
fullPath: '/tournaments/$id/groups'
preLoaderRoute: typeof AuthedTournamentsIdGroupsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/tournaments/$id/bracket': {
id: '/_authed/tournaments/$id/bracket'
path: '/tournaments/$id/bracket'
@@ -647,10 +941,45 @@ 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'
fullPath: '/admin/tournaments/$id'
fullPath: '/admin/tournaments/$id/'
preLoaderRoute: typeof AuthedAdminTournamentsIdIndexRouteImport
parentRoute: typeof AuthedAdminRoute
}
@@ -661,6 +990,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiFilesCollectionRecordIdFileRouteImport
parentRoute: typeof rootRouteImport
}
'/_authed/tournaments/$id/predictions_/make': {
id: '/_authed/tournaments/$id/predictions_/make'
path: '/tournaments/$id/predictions/make'
fullPath: '/tournaments/$id/predictions/make'
preLoaderRoute: typeof AuthedTournamentsIdPredictionsMakeRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/tournaments/$id/predictions_/$playerId': {
id: '/_authed/tournaments/$id/predictions_/$playerId'
path: '/tournaments/$id/predictions/$playerId'
fullPath: '/tournaments/$id/predictions/$playerId'
preLoaderRoute: typeof AuthedTournamentsIdPredictionsPlayerIdRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/admin/tournaments/run/$id': {
id: '/_authed/admin/tournaments/run/$id'
path: '/tournaments/run/$id'
@@ -675,15 +1018,53 @@ 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'
fullPath: '/admin/tournaments/$id/assign-partners'
preLoaderRoute: typeof AuthedAdminTournamentsIdAssignPartnersRouteImport
parentRoute: typeof AuthedAdminRoute
}
}
}
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
@@ -693,8 +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,
@@ -715,6 +1101,10 @@ interface AuthedRouteChildren {
AuthedTournamentsTournamentIdRoute: typeof AuthedTournamentsTournamentIdRoute
AuthedTournamentsIndexRoute: typeof AuthedTournamentsIndexRoute
AuthedTournamentsIdBracketRoute: typeof AuthedTournamentsIdBracketRoute
AuthedTournamentsIdGroupsRoute: typeof AuthedTournamentsIdGroupsRoute
AuthedTournamentsIdPredictionsRoute: typeof AuthedTournamentsIdPredictionsRoute
AuthedTournamentsIdPredictionsPlayerIdRoute: typeof AuthedTournamentsIdPredictionsPlayerIdRoute
AuthedTournamentsIdPredictionsMakeRoute: typeof AuthedTournamentsIdPredictionsMakeRoute
}
const AuthedRouteChildren: AuthedRouteChildren = {
@@ -728,6 +1118,12 @@ const AuthedRouteChildren: AuthedRouteChildren = {
AuthedTournamentsTournamentIdRoute: AuthedTournamentsTournamentIdRoute,
AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute,
AuthedTournamentsIdBracketRoute: AuthedTournamentsIdBracketRoute,
AuthedTournamentsIdGroupsRoute: AuthedTournamentsIdGroupsRoute,
AuthedTournamentsIdPredictionsRoute: AuthedTournamentsIdPredictionsRoute,
AuthedTournamentsIdPredictionsPlayerIdRoute:
AuthedTournamentsIdPredictionsPlayerIdRoute,
AuthedTournamentsIdPredictionsMakeRoute:
AuthedTournamentsIdPredictionsMakeRoute,
}
const AuthedRouteWithChildren =
@@ -738,8 +1134,12 @@ const rootRouteChildren: RootRouteChildren = {
LoginRoute: LoginRoute,
LogoutRoute: LogoutRoute,
RefreshSessionRoute: RefreshSessionRoute,
ApiHealthRoute: ApiHealthRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiEventsSplatRoute: ApiEventsSplatRoute,
ApiPushSubscribeRoute: ApiPushSubscribeRoute,
ApiPushTestRoute: ApiPushTestRoute,
ApiPushUnsubscribeRoute: ApiPushUnsubscribeRoute,
ApiSpotifyCallbackRoute: ApiSpotifyCallbackRoute,
ApiSpotifyCaptureRoute: ApiSpotifyCaptureRoute,
ApiSpotifyPlaybackRoute: ApiSpotifyPlaybackRoute,
@@ -747,6 +1147,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiSpotifySearchRoute: ApiSpotifySearchRoute,
ApiSpotifyTokenRoute: ApiSpotifyTokenRoute,
ApiTeamsUploadLogoRoute: ApiTeamsUploadLogoRoute,
ApiTelemetryBeaconRoute: ApiTelemetryBeaconRoute,
ApiTournamentsUploadLogoRoute: ApiTournamentsUploadLogoRoute,
ApiFilesCollectionRecordIdFileRoute: ApiFilesCollectionRecordIdFileRoute,
}
@@ -755,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>>
}
}
+1 -2
View File
@@ -29,9 +29,8 @@ export function getRouter() {
fullWidth: false,
},
defaultPreload: "intent",
defaultPreloadStaleTime: 60_000,
defaultErrorComponent: DefaultCatchBoundary,
scrollRestoration: true,
defaultViewTransition: false,
});
setupRouterSsrQueryIntegration({

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