Files
flxn-app/docs/push-notifications.md
T
yohlo 22c282d8fa
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m49s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 30s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 9m40s
more for the overhaul
2026-07-22 16:41:44 -07:00

11 KiB
Raw Blame History

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.tssendPushToPlayer(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 collectionpb_migrations/1784200000_created_push_subscriptions.js, service helpers in src/lib/pocketbase/services/push.ts.
  • Opt-in settings togglesrc/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 eventssrc/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 385397) 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 24 players in the match The single most valuable push in the app: personal, urgent, actionable ("get to the table") 36 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 02 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 nextstartMatch 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.