Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22c282d8fa | ||
|
|
86cda294f9 | ||
|
|
9db3fa697d | ||
|
|
6565714ee4 | ||
|
|
1783f0e6bc | ||
|
|
0a7f4de321 | ||
|
|
e8b7647f3d | ||
|
|
0284b33e50 | ||
|
|
d3809b5805 | ||
|
|
a50f9b6644 | ||
|
|
f77b3a5c41 | ||
|
|
3b087850e7 | ||
|
|
19f61ac454 | ||
|
|
e6c72a5789 | ||
|
|
e9040bb02f | ||
|
|
552525d272 | ||
|
|
4420e4d0fe | ||
|
|
3cc16bdeb2 | ||
|
|
48eb7d71b2 | ||
|
|
4f81f3c0ca | ||
|
|
778f0f7994 | ||
|
|
b6085ff329 | ||
|
|
ec334bbed4 | ||
|
|
e67f6b073c | ||
|
|
569ea8833b | ||
|
|
74d83da466 | ||
|
|
aff5fa2ea4 | ||
|
|
299f32a2b7 | ||
|
|
3f125e5761 | ||
|
|
fb4f50b851 | ||
|
|
5305dc37e7 | ||
|
|
3e5ef04c9f | ||
|
|
e51ff24944 | ||
|
|
c138442530 | ||
|
|
3baec5ac0f | ||
|
|
8f84dddc64 | ||
|
|
9571980898 | ||
|
|
1ce8a6a982 | ||
|
|
f0d8297f34 | ||
|
|
f49234702c | ||
|
|
f6da7be404 | ||
|
|
c7487e37f2 | ||
|
|
873ca3e4c4 | ||
|
|
c5b3ad50ca | ||
|
|
938418cec5 | ||
|
|
5d6136a445 | ||
|
|
9a64f316b9 | ||
|
|
6199afc687 | ||
|
|
f83a7d69c8 | ||
|
|
2dd3e5b170 | ||
|
|
b9e16e2b64 | ||
|
|
7f60b4d200 | ||
|
|
6e9e014fcc | ||
|
|
a54a74d7de | ||
|
|
236fcda671 | ||
|
|
9ed054e5d0 | ||
|
|
63853f22de | ||
|
|
5dd41d8022 | ||
|
|
937758bd49 | ||
|
|
7b95998b05 | ||
|
|
f069ba3827 | ||
|
|
70a032db20 | ||
|
|
243fb094e1 | ||
|
|
84202cdbe9 | ||
|
|
6370ebe48a | ||
|
|
8b271f93ac | ||
|
|
2326693bfb | ||
|
|
b209bbf4ef | ||
|
|
c4bf13744c | ||
|
|
1e3eaf0c35 | ||
|
|
d4b52e762b | ||
|
|
a4e618f327 | ||
|
|
d1951afb3c | ||
|
|
dce31905fc | ||
|
|
e58ed86d3b | ||
|
|
d833e5f1a1 | ||
|
|
15ed78a9c5 | ||
|
|
4b66a4bd13 | ||
|
|
a04a78fbf8 | ||
|
|
7fa3b06673 | ||
|
|
694c58e449 | ||
|
|
7afe40775e | ||
|
|
4f88fdd43a | ||
|
|
fa98634402 | ||
|
|
470b4ef99c | ||
|
|
612f1f28bf | ||
|
|
168ef1b05d | ||
|
|
939d1cee90 | ||
|
|
b59c7cd7b6 | ||
|
|
2ed5ab6026 | ||
|
|
d3379e54a4 | ||
|
|
43972b6a06 | ||
|
|
14c2eb2c02 | ||
|
|
46943b6971 | ||
|
|
f74d2daf9c | ||
|
|
26c6343a89 | ||
|
|
127709bb6c | ||
|
|
6a7d119d3e | ||
|
|
4b534c86cd | ||
|
|
f96f92c7c9 | ||
|
|
97427718e8 | ||
|
|
15bbca8b90 | ||
|
|
49bbd1611c | ||
|
|
45db283bc5 | ||
|
|
b673f9e072 | ||
|
|
34d896947d | ||
|
|
42bd2542f3 | ||
|
|
b2df869111 | ||
|
|
375e0bbfc8 | ||
|
|
147dc4e744 | ||
|
|
b458872ac1 | ||
|
|
afd0b692fa | ||
|
|
af0ec85811 | ||
|
|
d18d148d32 | ||
|
|
95a50ee7a7 | ||
|
|
1ef786ea79 | ||
|
|
47962a8681 | ||
|
|
2e6d3366e4 | ||
|
|
fafe5ca3ec | ||
|
|
b52c79772f | ||
|
|
8579ec36ca | ||
|
|
2dfb7c63d3 | ||
|
|
03b2b54c1f | ||
|
|
0910f11228 | ||
|
|
a376f98fe7 | ||
|
|
1f4f66f8c5 | ||
|
|
5729dab35f | ||
|
|
c05fd5dc6d | ||
|
|
b9a42b4743 | ||
|
|
74e28cc2ac | ||
|
|
adf304b1e0 | ||
|
|
d18cdce15f | ||
|
|
aa87a9da5b | ||
|
|
6224404aa9 | ||
|
|
654041b6b6 | ||
|
|
ce29c41bf3 | ||
|
|
63ea515a31 | ||
|
|
8b1bbe213d | ||
|
|
ed538b7373 | ||
|
|
03e3bbcbc0 | ||
|
|
baf75eddba | ||
|
|
5094933302 | ||
|
|
9564b46d45 | ||
|
|
ece5094f13 | ||
|
|
cfe1ee7171 | ||
|
|
3a41609a91 | ||
|
|
732afaf623 | ||
|
|
48aeaabeea | ||
|
|
a4b9fe9065 | ||
|
|
31e50af593 | ||
|
|
39053cadaa | ||
|
|
ea6656aa33 | ||
|
|
92c4987372 | ||
|
|
b3ebf46afa | ||
|
|
c0ef535001 | ||
|
|
81329e4354 | ||
|
|
36f3bb77d4 | ||
|
|
6760ea46f9 | ||
|
|
e4164cbc71 | ||
|
|
94ea44c66e |
@@ -0,0 +1,146 @@
|
||||
name: CI/CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- development
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build-app:
|
||||
name: Build and Push App Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
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"
|
||||
@@ -17,6 +17,7 @@ yarn.lock
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/scripts/
|
||||
/_scripts/
|
||||
/pb_data/
|
||||
/.tanstack/
|
||||
/dist/
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
|
||||
@@ -13,7 +13,6 @@ services:
|
||||
- .env.docker
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
- ./.docker-postgres-init:/docker-entrypoint-initdb.d
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,129 @@
|
||||
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: 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: flxn-dev
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: flxn-prod
|
||||
@@ -0,0 +1,5 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: supertokens-config
|
||||
namespace: flxn-shared
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -5,65 +5,63 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --host 0.0.0.0",
|
||||
"build": "vite build && tsc --noEmit",
|
||||
"start": "vite start"
|
||||
"build": "vite build && tsc --noEmit && bun scripts/generate-sw.mjs",
|
||||
"start": "bun run server.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@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-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.130.15",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tanstack/react-devtools": "^0.7.6",
|
||||
"@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/ioredis": "^4.28.10",
|
||||
"@types/bun": "^1.2.22",
|
||||
"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",
|
||||
"zod": "^4.0.15",
|
||||
"zustand": "^5.0.7"
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^4.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.3.5",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
"vite": "^7.1.7",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"workbox-build": "^7.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/// <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": "text1579384326",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "name",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "json4225120046",
|
||||
"maxSize": 0,
|
||||
"name": "arguments",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "pbc_3072146508",
|
||||
"hidden": false,
|
||||
"id": "relation2551806565",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "player",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text3293145029",
|
||||
"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_1262591861",
|
||||
"indexes": [],
|
||||
"listRule": null,
|
||||
"name": "activities",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": null,
|
||||
"viewRule": null
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_1262591861");
|
||||
|
||||
return app.delete(collection);
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_1262591861")
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(5, new Field({
|
||||
"hidden": false,
|
||||
"id": "number2254405824",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "duration",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_1262591861")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("number2254405824")
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_1262591861")
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(6, new Field({
|
||||
"hidden": false,
|
||||
"id": "bool1862328242",
|
||||
"name": "success",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
}))
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(7, new Field({
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text1574812785",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "error",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_1262591861")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("bool1862328242")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("text1574812785")
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_1340419796")
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(7, new Field({
|
||||
"hidden": false,
|
||||
"id": "number4113142680",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "order",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_1340419796")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("number4113142680")
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_3072146508")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("relation2029409178")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("relation2813965191")
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_3072146508")
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(5, new Field({
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "pbc_1340419796",
|
||||
"hidden": false,
|
||||
"id": "relation2029409178",
|
||||
"maxSelect": 999,
|
||||
"minSelect": 0,
|
||||
"name": "badges",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
}))
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(6, new Field({
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "pbc_1340419796",
|
||||
"hidden": false,
|
||||
"id": "relation2813965191",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "featured_badge",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
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,
|
||||
"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": "relation2847519201",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 1,
|
||||
"name": "player_id",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "pbc_1340419796",
|
||||
"hidden": false,
|
||||
"id": "relation3948571039",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 1,
|
||||
"name": "badge_id",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "number1847293057",
|
||||
"max": null,
|
||||
"min": 0,
|
||||
"name": "current_progress",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "number2948571040",
|
||||
"max": null,
|
||||
"min": 1,
|
||||
"name": "target_progress",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "bool3049672141",
|
||||
"name": "is_earned",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "date1150773242",
|
||||
"max": "",
|
||||
"min": "",
|
||||
"name": "earned_at",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"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_4251874343",
|
||||
"indexes": [
|
||||
"CREATE UNIQUE INDEX `idx_unique_player_badge` ON `player_badge_progress` (`player_id`, `badge_id`)"
|
||||
],
|
||||
"listRule": null,
|
||||
"name": "player_badge_progress",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": null,
|
||||
"viewRule": null
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_1340419796");
|
||||
|
||||
return app.delete(collection);
|
||||
}, (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": "text1579384326",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "name",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text1843675174",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "description",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "select4029814376",
|
||||
"maxSelect": 1,
|
||||
"name": "type",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"tournament_participation",
|
||||
"tournament_placement",
|
||||
"performance",
|
||||
"overtime",
|
||||
"match_milestone"
|
||||
]
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "json1578432567",
|
||||
"maxSize": 2000000,
|
||||
"name": "criteria",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text3928475610",
|
||||
"max": 50,
|
||||
"min": 0,
|
||||
"name": "icon",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text1847293056",
|
||||
"max": 50,
|
||||
"min": 0,
|
||||
"name": "color",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "bool2847519203",
|
||||
"name": "is_progressive",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "number2948571038",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "progress_target",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"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_1340419796",
|
||||
"indexes": [],
|
||||
"listRule": null,
|
||||
"name": "badges",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": null,
|
||||
"viewRule": null
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
/// <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": "text1579384326",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "name",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text2324736937",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "key",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text1843675174",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "description",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "json3055524737",
|
||||
"maxSize": 0,
|
||||
"name": "criteria",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "select2363381545",
|
||||
"maxSelect": 1,
|
||||
"name": "type",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"manual",
|
||||
"match",
|
||||
"tournament"
|
||||
]
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "bool3646955747",
|
||||
"name": "progressive",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "number4113142680",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "order",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"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_1340419796",
|
||||
"indexes": [],
|
||||
"listRule": null,
|
||||
"name": "badges",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": null,
|
||||
"viewRule": null
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_1340419796");
|
||||
|
||||
return app.delete(collection);
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
/// <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_1340419796",
|
||||
"hidden": false,
|
||||
"id": "relation4277159965",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "badge",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "pbc_3072146508",
|
||||
"hidden": false,
|
||||
"id": "relation2551806565",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "player",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "number570552902",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "progress",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "bool2625885481",
|
||||
"name": "earned",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"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_1063824264",
|
||||
"indexes": [],
|
||||
"listRule": null,
|
||||
"name": "player_badge_progress",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": null,
|
||||
"viewRule": null
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_1063824264");
|
||||
|
||||
return app.delete(collection);
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_1063824264")
|
||||
|
||||
// update collection data
|
||||
unmarshal({
|
||||
"name": "badge_progress"
|
||||
}, collection)
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_1063824264")
|
||||
|
||||
// update collection data
|
||||
unmarshal({
|
||||
"name": "player_badge_progress"
|
||||
}, collection)
|
||||
|
||||
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(10, new Field({
|
||||
"hidden": false,
|
||||
"id": "file538556518",
|
||||
"maxSelect": 1,
|
||||
"maxSize": 0,
|
||||
"mimeTypes": [],
|
||||
"name": "glitch_logo",
|
||||
"presentable": false,
|
||||
"protected": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"thumbs": [],
|
||||
"type": "file"
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("file538556518")
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||
|
||||
// update field
|
||||
collection.fields.addAt(10, new Field({
|
||||
"hidden": false,
|
||||
"id": "file538556518",
|
||||
"maxSelect": 1,
|
||||
"maxSize": 6000000,
|
||||
"mimeTypes": [],
|
||||
"name": "glitch_logo",
|
||||
"presentable": false,
|
||||
"protected": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"thumbs": [],
|
||||
"type": "file"
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||
|
||||
// update field
|
||||
collection.fields.addAt(10, new Field({
|
||||
"hidden": false,
|
||||
"id": "file538556518",
|
||||
"maxSelect": 1,
|
||||
"maxSize": 0,
|
||||
"mimeTypes": [],
|
||||
"name": "glitch_logo",
|
||||
"presentable": false,
|
||||
"protected": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"thumbs": [],
|
||||
"type": "file"
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_3072146508")
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(5, new Field({
|
||||
"hidden": false,
|
||||
"id": "date3558165700",
|
||||
"max": "",
|
||||
"min": "",
|
||||
"name": "last_activity",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "date"
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_3072146508")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("date3558165700")
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_1568971955")
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(14, new Field({
|
||||
"hidden": false,
|
||||
"id": "bool3523658193",
|
||||
"name": "private",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_1568971955")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("bool3523658193")
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(12, new Field({
|
||||
"hidden": false,
|
||||
"id": "bool3403970290",
|
||||
"name": "regional",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("bool3403970290")
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((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"
|
||||
]
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("select3736761055")
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = new Collection({
|
||||
"createRule": null,
|
||||
"deleteRule": null,
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "pbc_3072146508",
|
||||
"hidden": false,
|
||||
"id": "relation2582050271",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "player_id",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "json4231605813",
|
||||
"maxSize": 1,
|
||||
"name": "player_name",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "number103159226",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "matches",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "number3837590211",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "tournaments",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "json2732118329",
|
||||
"maxSize": 1,
|
||||
"name": "wins",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "json724428801",
|
||||
"maxSize": 1,
|
||||
"name": "losses",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "json3154249934",
|
||||
"maxSize": 1,
|
||||
"name": "total_cups_made",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "json3227208027",
|
||||
"maxSize": 1,
|
||||
"name": "total_cups_against",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "json2379943496",
|
||||
"maxSize": 1,
|
||||
"name": "win_percentage",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "json3165107022",
|
||||
"maxSize": 1,
|
||||
"name": "avg_cups_per_match",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "json3041953980",
|
||||
"maxSize": 1,
|
||||
"name": "margin_of_victory",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "json1531431708",
|
||||
"maxSize": 1,
|
||||
"name": "margin_of_loss",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
}
|
||||
],
|
||||
"id": "pbc_4086490894",
|
||||
"indexes": [],
|
||||
"listRule": null,
|
||||
"name": "player_regional_stats",
|
||||
"system": false,
|
||||
"type": "view",
|
||||
"updateRule": null,
|
||||
"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",
|
||||
"viewRule": null
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_4086490894");
|
||||
|
||||
return app.delete(collection);
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = new Collection({
|
||||
"createRule": null,
|
||||
"deleteRule": null,
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "pbc_3072146508",
|
||||
"hidden": false,
|
||||
"id": "relation2582050271",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "player_id",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "json4231605813",
|
||||
"maxSize": 1,
|
||||
"name": "player_name",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "number103159226",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "matches",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "number3837590211",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "tournaments",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "json2732118329",
|
||||
"maxSize": 1,
|
||||
"name": "wins",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "json724428801",
|
||||
"maxSize": 1,
|
||||
"name": "losses",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "json3154249934",
|
||||
"maxSize": 1,
|
||||
"name": "total_cups_made",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "json3227208027",
|
||||
"maxSize": 1,
|
||||
"name": "total_cups_against",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "json2379943496",
|
||||
"maxSize": 1,
|
||||
"name": "win_percentage",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "json3165107022",
|
||||
"maxSize": 1,
|
||||
"name": "avg_cups_per_match",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "json3041953980",
|
||||
"maxSize": 1,
|
||||
"name": "margin_of_victory",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "json1531431708",
|
||||
"maxSize": 1,
|
||||
"name": "margin_of_loss",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
}
|
||||
],
|
||||
"id": "pbc_15286826",
|
||||
"indexes": [],
|
||||
"listRule": null,
|
||||
"name": "player_mainline_stats",
|
||||
"system": false,
|
||||
"type": "view",
|
||||
"updateRule": null,
|
||||
"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",
|
||||
"viewRule": null
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_15286826");
|
||||
|
||||
return app.delete(collection);
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||
|
||||
// update 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"
|
||||
]
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||
|
||||
// update 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"
|
||||
]
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
@@ -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);
|
||||
})
|
||||
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 832 B After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 64 KiB |
@@ -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);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -1,17 +1,28 @@
|
||||
{
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"short_name": "FLXN",
|
||||
"name": "FLXN",
|
||||
"description": "Amicus meus madidus",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"src": "/icon-192x192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.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": []
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 747 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 45 KiB |