Notifications and categories
An inbox every app shows (notifications rows), and a push to the player's devices that says something landed in it. src/lib/notifications.ts (inbox, devices, preferences), src/lib/push.ts (the senders), src/lib/digest.ts (the daily ones).
| Kind | When | Tapping it opens | data |
|---|---|---|---|
announcement | A game's author sends one in its channel: every other member, at once | The game's channel | {gameId, messageId} |
changes | Once a day at most: new versions landed, by someone else, on games the player made or helped build (a change they asked for, or a draft of their own) | "What changed" (GET /api/app/changes?since=) | {since} |
new_games | Once a day at most, for players with a device: games whose first version landed | The games feed | {since} |
The daily two are sent by an hourly cron (vercel.ts → GET /api/cron/notifications, CRON_SECRET) to players who are due and for whom it is between 9:00 and 20:59 (their device's time zone, users.timezone; UTC when unknown). A player can turn each kind off; an off kind makes no row and no push.
API
| Route | What |
|---|---|
GET /api/app/notifications | {notifications: [{id, kind, title, body, data, read, createdAt}], unread, prefs: {announcements, changes, newGames}}, newest first |
POST /api/app/notifications/read | {ids: […]}, or no body for all → {unread} |
PUT /api/app/notifications/prefs | Any of {announcements, changes, newGames} (booleans) → {prefs} |
POST /api/app/devices | Registers this device (the platform is X-Crescent-Client). iOS {token: <APNs hex>, environment: "production" | "sandbox", timezone}; Android {token: <FCM>, timezone}; web {token: <subscription endpoint>, keys: {p256dh, auth}, timezone}. timezone is an IANA name. Call it on every launch once notifications are allowed. |
DELETE /api/app/devices | {token}: notifications were turned off on this device |
GET /api/app/changes[?since=<ISO>] | {since, total, games: [{game: PublicGame, yours, changes: [{buildId, version, by, summary, features, createdAt}]}]}: at most a week back, a day back without since |
GET /api/app/config | now also push: {vapidPublicKey} (null when Web Push is off) |
What a push carries
Flat strings: kind, id (the inbox row) and the row's data fields.
- iOS (APNs): at the top level of the payload, next to
aps(aps.alert.title/body,thread-id= the kind). - Android (FCM): in
data; the title and body are innotification, on the Android channel named after the kind. - Web (Web Push): the payload is
{title, body, data}as JSON; the service worker (public/sw.js) shows it and opens the app on click.
Keys
Each sender is on only when its keys are set (pushConfigured()); without any, the inbox still fills.
| Platform | Env | Where it comes from |
|---|---|---|
| iOS | APNS_KEY_ID, APNS_TEAM_ID, APNS_PRIVATE_KEY (the .p8's text), APNS_TOPIC (defaults to the app's bundle id) | An APNs auth key from the Apple developer account; the App ID needs the Push Notifications capability and the app the aps-environment entitlement |
| Android | FCM_SERVICE_ACCOUNT (the service account JSON) | The Firebase project of the Android app; the key is a service account key from its Service accounts page |
| Web | VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY | npx web-push generate-vapid-keys |
Categories
Every game lands in one category (src/lib/categories.ts: Arcade, Runner, Puzzle, Racing, Action, Shooter, Platformer, Adventure, Strategy, Builder, Board & cards, Sports). The categorizer (src/lib/categorizer.ts, Claude Haiku 5.5 with a fixed answer format) reads the game's title, first request, latest notes and design document and picks one when the game's first public version lands (sortGame in the run workflow); a game keeps its category after that. npm run categories:backfill -- [--all] [--game <id>] [--dry-run] sorts older games.
Every game the API hands out carries category: {id, name} | null, and GET /api/app/feed adds categories: [{id, name, games}], the ones that have games, in the list's order. The apps filter the feed on the device.