From 895d2d8b08a9c0f28a4463837151e5b9a833e1e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 04:13:24 +0000 Subject: [PATCH] Weekly leagues, friends and push notifications League: every stored run earns XP (about one per ten correct characters, less when sloppy, 1.5x when Ranked verifies it; capped per run and per day, and counted once per run). The first XP of a week puts you in a group of up to 30 at your division; when the week ends the top 5 move up and the bottom 5 move down (Bronze, Silver, Gold, Diamond), settled the next time you are seen. /league shows your group with promotion and demotion zones, a countdown, last week's result and Kap wearing your division medal; the results screen shows the XP a run earned. Friends: follow anyone from their profile or by searching GitHub usernames; a friends board ranks you and the people you follow by weekly XP and best speed, and a feed shows their recent runs. Push: Settings turns on Web Push per device. A daily Vercel Cron (12:00 UTC, 19:00 WIB) reminds players whose streak breaks tonight, in their time zone, and being overtaken in your league sends a nudge. The tab-only reminder steps aside when push covers it. New serverless functions: api/social.ts and api/push.ts (10 of 12). Needs npm run setup:social and the VAPID/CRON env vars (npm run setup:push-keys). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018xNjY7412Rp5bFuw5h69yu --- api/_lib/fake-db.ts | 88 ++++++ api/_lib/league.test.ts | 101 ++++++ api/_lib/league.ts | 274 ++++++++++++++++ api/_lib/push.test.ts | 56 ++++ api/_lib/push.ts | 167 ++++++++++ api/_lib/route.ts | 44 +++ api/_lib/routes/push.ts | 37 +++ api/_lib/routes/social.ts | 38 +++ api/_lib/social.test.ts | 63 ++++ api/_lib/social.ts | 198 ++++++++++++ api/push.ts | 5 + api/social.ts | 14 + appwrite/schema.md | 47 +++ package-lock.json | 147 ++++++++- package.json | 6 +- public/push-sw.js | 33 ++ scripts/generate-vapid-keys.mjs | 18 ++ scripts/setup-appwrite-social.mjs | 122 +++++++ src/App.tsx | 12 +- src/components/AuthProvider.tsx | 2 + src/components/Header.tsx | 2 + src/components/ResultsScreen.tsx | 3 + src/components/social/FollowButton.tsx | 32 ++ src/components/social/LeagueXpChip.tsx | 29 ++ src/components/social/ProfileFollow.tsx | 41 +++ src/components/social/PushCard.tsx | 84 +++++ src/components/streak/KapScene.ts | 19 +- src/components/streak/StreakReminder.tsx | 4 + src/lib/cloud.ts | 5 + src/lib/push.ts | 93 ++++++ src/lib/ranked.ts | 27 +- src/lib/social.ts | 128 ++++++++ src/main.tsx | 3 +- src/routes/League.tsx | 387 +++++++++++++++++++++++ src/routes/Profile.tsx | 2 + src/routes/Settings.tsx | 3 + src/routes/lazy.ts | 3 +- src/utils/kap-art.ts | 11 + vercel.json | 6 + vite.config.ts | 2 + 40 files changed, 2338 insertions(+), 18 deletions(-) create mode 100644 api/_lib/fake-db.ts create mode 100644 api/_lib/league.test.ts create mode 100644 api/_lib/league.ts create mode 100644 api/_lib/push.test.ts create mode 100644 api/_lib/push.ts create mode 100644 api/_lib/route.ts create mode 100644 api/_lib/routes/push.ts create mode 100644 api/_lib/routes/social.ts create mode 100644 api/_lib/social.test.ts create mode 100644 api/_lib/social.ts create mode 100644 api/push.ts create mode 100644 api/social.ts create mode 100644 public/push-sw.js create mode 100644 scripts/generate-vapid-keys.mjs create mode 100644 scripts/setup-appwrite-social.mjs create mode 100644 src/components/social/FollowButton.tsx create mode 100644 src/components/social/LeagueXpChip.tsx create mode 100644 src/components/social/ProfileFollow.tsx create mode 100644 src/components/social/PushCard.tsx create mode 100644 src/lib/push.ts create mode 100644 src/lib/social.ts create mode 100644 src/routes/League.tsx diff --git a/api/_lib/fake-db.ts b/api/_lib/fake-db.ts new file mode 100644 index 0000000..9a6810c --- /dev/null +++ b/api/_lib/fake-db.ts @@ -0,0 +1,88 @@ +/** + * An in-memory stand-in for node-appwrite's Databases, for tests: documents + * per collection, with the handful of query methods the API uses. + */ + +type Doc = Record & { $id: string }; +interface ParsedQuery { method: string; attribute?: string; values?: unknown[] } + +const appwriteError = (code: number, message: string) => Object.assign(new Error(message), { code }); + +export class FakeDb { + collections = new Map>(); + private clock = 0; + + private table(collectionId: string) { + let table = this.collections.get(collectionId); + if (!table) this.collections.set(collectionId, (table = new Map())); + return table; + } + + /** Seeds a document directly, e.g. a run with a given $createdAt. */ + put(collectionId: string, doc: Doc) { + this.table(collectionId).set(doc.$id, { $createdAt: new Date().toISOString(), $updatedAt: this.stamp(), ...doc }); + } + + all(collectionId: string): Doc[] { + return [...this.table(collectionId).values()]; + } + + private stamp() { + this.clock += 1; + return new Date(Date.UTC(2026, 0, 1, 0, 0, this.clock)).toISOString(); + } + + async getDocument({ collectionId, documentId }: { collectionId: string; documentId: string }) { + const doc = this.table(collectionId).get(documentId); + if (!doc) throw appwriteError(404, "Document not found"); + return { ...doc }; + } + + async createDocument({ collectionId, documentId, data }: { collectionId: string; documentId: string; data: Record }) { + const table = this.table(collectionId); + if (table.has(documentId)) throw appwriteError(409, "Document already exists"); + const now = new Date().toISOString(); + const doc = { ...data, $id: documentId, $createdAt: now, $updatedAt: this.stamp() }; + table.set(documentId, doc); + return { ...doc }; + } + + async updateDocument({ collectionId, documentId, data }: { collectionId: string; documentId: string; data: Record }) { + const table = this.table(collectionId); + const doc = table.get(documentId); + if (!doc) throw appwriteError(404, "Document not found"); + const next = { ...doc, ...data, $updatedAt: this.stamp() }; + table.set(documentId, next); + return { ...next }; + } + + async deleteDocument({ collectionId, documentId }: { collectionId: string; documentId: string }) { + if (!this.table(collectionId).delete(documentId)) throw appwriteError(404, "Document not found"); + return {}; + } + + async listDocuments({ collectionId, queries = [] }: { collectionId: string; queries?: string[] }) { + let docs = this.all(collectionId); + const parsed = queries.map((query) => JSON.parse(query) as ParsedQuery); + let limit = 25; + for (const query of parsed) { + const key = query.attribute ?? ""; + const values = query.values ?? []; + const get = (doc: Doc) => doc[key] as never; + if (query.method === "equal") docs = docs.filter((doc) => values.includes(get(doc))); + else if (query.method === "notEqual") docs = docs.filter((doc) => !values.includes(get(doc))); + else if (query.method === "lessThan") docs = docs.filter((doc) => get(doc) < (values[0] as never)); + else if (query.method === "greaterThanEqual") docs = docs.filter((doc) => get(doc) >= (values[0] as never)); + else if (query.method === "greaterThan") docs = docs.filter((doc) => get(doc) > (values[0] as never)); + else if (query.method === "startsWith") docs = docs.filter((doc) => String(get(doc) ?? "").toLowerCase().startsWith(String(values[0]).toLowerCase())); + else if (query.method === "limit") limit = Number(values[0]); + } + const total = docs.length; + for (const query of parsed.filter((item) => item.method === "orderAsc" || item.method === "orderDesc").reverse()) { + const key = query.attribute ?? ""; + const direction = query.method === "orderAsc" ? 1 : -1; + docs = [...docs].sort((a, b) => (a[key] as never) > (b[key] as never) ? direction : (a[key] as never) < (b[key] as never) ? -direction : 0); + } + return { documents: docs.slice(0, limit).map((doc) => ({ ...doc })), total }; + } +} diff --git a/api/_lib/league.test.ts b/api/_lib/league.test.ts new file mode 100644 index 0000000..fa37693 --- /dev/null +++ b/api/_lib/league.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import { FakeDb } from './fake-db'; +import { awardRun, LEAGUE, loadPlayer, MAX_XP_PER_DAY, memberId, settle, weekEndsAt, weekKey, xpForRun, type LeagueDb } from './league'; + +const profile = (name: string) => ({ name, username: null, avatarUrl: null }); +const NOW = new Date('2026-09-30T10:00:00Z'); + +function run(db: FakeDb, id: string, userId: string, correctChars = 300, accuracy = 97, createdAt = NOW) { + db.put(LEAGUE.runs, { $id: id, userId, correctChars, accuracy, verified: false, $createdAt: createdAt.toISOString() }); +} + +describe('league rules', () => { + it('names weeks by their Monday', () => { + expect(weekKey(new Date('2026-09-30T10:00:00Z'))).toBe('2026-09-28'); + expect(weekKey(new Date('2026-09-28T00:00:00Z'))).toBe('2026-09-28'); + expect(weekKey(new Date('2026-10-04T23:59:59Z'))).toBe('2026-09-28'); + expect(weekEndsAt('2026-09-28')).toBe('2026-10-05T00:00:00.000Z'); + }); + + it('pays for careful characters', () => { + expect(xpForRun({ correctChars: 10, accuracy: 100 })).toBe(0); + expect(xpForRun({ correctChars: 300, accuracy: 97 })).toBe(30); + expect(xpForRun({ correctChars: 300, accuracy: 85 })).toBe(15); + expect(xpForRun({ correctChars: 300, accuracy: 97, verified: true })).toBe(45); + expect(xpForRun({ correctChars: 5000, accuracy: 99 })).toBe(60); + }); + + it('promotes the top five and demotes the bottom five', () => { + expect(settle(0, 1, 30)).toEqual({ division: 1, result: 'up' }); + expect(settle(3, 1, 30)).toEqual({ division: 3, result: 'stay' }); + expect(settle(2, 27, 30)).toEqual({ division: 1, result: 'down' }); + expect(settle(0, 30, 30)).toEqual({ division: 0, result: 'stay' }); + // Small groups: nobody drops. + expect(settle(1, 8, 8)).toEqual({ division: 1, result: 'stay' }); + }); +}); + +describe('earning XP', () => { + it('joins a group on the first XP and counts each run once', async () => { + const db = new FakeDb(); + run(db, 'r1', 'ana'); + const first = await awardRun(db as unknown as LeagueDb, 'ana', 'r1', profile('Ana'), NOW); + expect(first).toMatchObject({ gained: 30, weekXp: 30, division: 0, groupId: '2026-09-28-0-1' }); + expect(await awardRun(db as unknown as LeagueDb, 'ana', 'r1', profile('Ana'), NOW)).toBeNull(); + expect(db.all(LEAGUE.members)).toHaveLength(1); + }); + + it('ignores other people\'s runs and old runs', async () => { + const db = new FakeDb(); + run(db, 'r1', 'ana'); + run(db, 'r2', 'ana', 300, 97, new Date('2026-09-27T00:00:00Z')); + expect(await awardRun(db as unknown as LeagueDb, 'bob', 'r1', profile('Bob'), NOW)).toBeNull(); + expect(await awardRun(db as unknown as LeagueDb, 'ana', 'r2', profile('Ana'), NOW)).toBeNull(); + }); + + it('caps XP per day', async () => { + const db = new FakeDb(); + for (let i = 0; i < 20; i += 1) run(db, `r${i}`, 'ana', 5000); + let total = 0; + for (let i = 0; i < 20; i += 1) total = (await awardRun(db as unknown as LeagueDb, 'ana', `r${i}`, profile('Ana'), NOW))?.weekXp ?? total; + expect(total).toBe(MAX_XP_PER_DAY); + }); + + it('reports who was overtaken', async () => { + const db = new FakeDb(); + run(db, 'a1', 'ana', 300); + run(db, 'b1', 'bob', 100); + run(db, 'b2', 'bob', 400); + await awardRun(db as unknown as LeagueDb, 'ana', 'a1', profile('Ana'), NOW); + await awardRun(db as unknown as LeagueDb, 'bob', 'b1', profile('Bob'), NOW); + const pass = await awardRun(db as unknown as LeagueDb, 'bob', 'b2', profile('Bob'), NOW); + expect(pass?.overtaken).toEqual(['ana']); + }); + + it('fills groups of 30 before opening another', async () => { + const db = new FakeDb(); + for (let i = 0; i < 31; i += 1) { + run(db, `r${i}`, `user${i}`); + await awardRun(db as unknown as LeagueDb, `user${i}`, `r${i}`, profile(`U${i}`), NOW); + } + const groups = db.all(LEAGUE.groups).map((group) => [group.$id, group.size]); + expect(groups).toEqual([['2026-09-28-0-1', 30], ['2026-09-28-0-2', 1]]); + }); +}); + +describe('settling a finished week', () => { + it('moves players by their final rank the next time they are seen', async () => { + const db = new FakeDb(); + const lastWeek = new Date('2026-09-23T10:00:00Z'); + for (let i = 0; i < 12; i += 1) { + run(db, `r${i}`, `user${i}`, 100 + i * 40, 97, lastWeek); + await awardRun(db as unknown as LeagueDb, `user${i}`, `r${i}`, profile(`U${i}`), lastWeek); + } + // user11 had the most XP; user0 the least. + const top = await loadPlayer(db as unknown as LeagueDb, 'user11', '2026-09-28'); + expect(top).toMatchObject({ division: 1, lastResult: 'up', lastRank: 1, week: '', lastWeek: '2026-09-21' }); + const bottom = await loadPlayer(db as unknown as LeagueDb, 'user0', '2026-09-28'); + expect(bottom).toMatchObject({ division: 0, lastResult: 'stay', lastRank: 12 }); + expect(db.all(LEAGUE.members).find((doc) => doc.$id === memberId('2026-09-21', 'user11'))?.xp).toBe(54); + }); +}); diff --git a/api/_lib/league.ts b/api/_lib/league.ts new file mode 100644 index 0000000..adafdd8 --- /dev/null +++ b/api/_lib/league.ts @@ -0,0 +1,274 @@ +import { Query } from 'node-appwrite'; +import { APPWRITE } from './appwrite-admin.js'; + +/** + * Weekly leagues, Duolingo style. Practice earns XP; the first XP of a week + * puts the player in a group of up to 30 at their division. When a new week + * starts, each player's previous week is settled the first time we see them: + * the top 5 move up a division, the bottom 5 move down. + * + * Collections (scripts/setup-appwrite-league.mjs): + * league_players one per player: division, current week and group, last result + * league_groups one per group: week, division, size + * league_members one per player per week: the XP and display data shown in the group + * league_xp one per counted run (id = run id), so a run never counts twice + */ + +export const DIVISIONS = ['Bronze', 'Silver', 'Gold', 'Diamond'] as const; +export const TOP_DIVISION = DIVISIONS.length - 1; +export const GROUP_SIZE = 30; +export const PROMOTE = 5; +export const DEMOTE = 5; +/** Demotion needs a group big enough that the bottom five are not most of it. */ +export const MIN_SIZE_TO_DEMOTE = 10; +export const MAX_XP_PER_RUN = 60; +export const MAX_XP_PER_DAY = 800; +/** Runs older than this no longer earn XP (e.g. an old history upload). */ +export const XP_WINDOW_MS = 36 * 60 * 60 * 1000; + +export const LEAGUE = { + players: process.env.VITE_APPWRITE_LEAGUE_PLAYERS_COLLECTION_ID || 'league_players', + groups: process.env.VITE_APPWRITE_LEAGUE_GROUPS_COLLECTION_ID || 'league_groups', + members: process.env.VITE_APPWRITE_LEAGUE_MEMBERS_COLLECTION_ID || 'league_members', + xp: process.env.VITE_APPWRITE_LEAGUE_XP_COLLECTION_ID || 'league_xp', + runs: process.env.VITE_APPWRITE_RUNS_COLLECTION_ID || 'runs', +}; + +type Doc = Record & { $id: string }; + +export interface LeagueDb { + getDocument(params: { databaseId: string; collectionId: string; documentId: string }): Promise; + createDocument(params: { databaseId: string; collectionId: string; documentId: string; data: Record; permissions?: string[] }): Promise; + updateDocument(params: { databaseId: string; collectionId: string; documentId: string; data: Record }): Promise; + listDocuments(params: { databaseId: string; collectionId: string; queries: string[] }): Promise<{ documents: Doc[]; total?: number }>; +} + +const db = (collectionId: string) => ({ databaseId: APPWRITE.databaseId, collectionId }); +const isCode = (error: unknown, code: number) => (error as { code?: number } | null)?.code === code; + +/* ---- Pure rules ---- */ + +/** The week a moment falls in, named by its Monday (UTC), e.g. "2026-09-28". */ +export function weekKey(date = new Date()): string { + const day = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + day.setUTCDate(day.getUTCDate() - ((day.getUTCDay() + 6) % 7)); + return day.toISOString().slice(0, 10); +} + +export function weekEndsAt(week: string): string { + const end = new Date(`${week}T00:00:00.000Z`); + end.setUTCDate(end.getUTCDate() + 7); + return end.toISOString(); +} + +/** XP for one run: about one point per ten correct characters, less when sloppy, more when verified. */ +export function xpForRun(run: { correctChars: number; accuracy: number; verified?: boolean }): number { + const chars = Math.max(0, Number(run.correctChars) || 0); + if (chars < 20) return 0; + const accuracy = Number(run.accuracy) || 0; + const care = accuracy >= 95 ? 1 : accuracy >= 90 ? 0.8 : accuracy >= 80 ? 0.5 : 0.25; + const bonus = run.verified ? 1.5 : 1; + return Math.max(1, Math.min(MAX_XP_PER_RUN, Math.round((chars / 10) * care * bonus))); +} + +export type WeekResult = 'up' | 'down' | 'stay'; + +/** Where a finished week leaves a player. `rank` is 1-based. */ +export function settle(division: number, rank: number, size: number): { division: number; result: WeekResult } { + if (rank <= PROMOTE && division < TOP_DIVISION) return { division: division + 1, result: 'up' }; + if (division > 0 && size >= MIN_SIZE_TO_DEMOTE && rank > size - DEMOTE) return { division: division - 1, result: 'down' }; + return { division, result: 'stay' }; +} + +/** Standings order: most XP first, ties to whoever got there first. */ +export function rankMembers(members: T[]): T[] { + return [...members].sort((a, b) => b.xp - a.xp || String(a.updatedAt ?? '').localeCompare(String(b.updatedAt ?? ''))); +} + +/* ---- Players and groups ---- */ + +export interface LeaguePlayer { + division: number; + week: string; + groupId: string; + lastWeek: string; + lastResult: string; + lastRank: number; + lastDivision: number; + bestDivision: number; + xpDate: string; + xpToday: number; +} + +function toPlayer(doc: Doc | null): LeaguePlayer { + return { + division: Math.max(0, Math.min(TOP_DIVISION, Number(doc?.division) || 0)), + week: String(doc?.week ?? ''), + groupId: String(doc?.groupId ?? ''), + lastWeek: String(doc?.lastWeek ?? ''), + lastResult: String(doc?.lastResult ?? ''), + lastRank: Number(doc?.lastRank) || 0, + lastDivision: Number(doc?.lastDivision) || 0, + bestDivision: Number(doc?.bestDivision) || 0, + xpDate: String(doc?.xpDate ?? ''), + xpToday: Number(doc?.xpToday) || 0, + }; +} + +export const memberId = (week: string, userId: string) => `${week}_${userId}`.slice(0, 36); + +export async function listGroup(database: LeagueDb, groupId: string): Promise { + if (!groupId) return []; + const { documents } = await database.listDocuments({ ...db(LEAGUE.members), queries: [Query.equal('groupId', groupId), Query.orderDesc('xp'), Query.limit(GROUP_SIZE + 10)] }); + return rankMembers(documents.map((doc) => ({ ...doc, xp: Number(doc.xp) || 0, updatedAt: String(doc.$updatedAt ?? '') }))); +} + +/** + * The player's record, with any finished week settled: a player last seen in + * an older week moves up, down or stays by their final rank there. + */ +export async function loadPlayer(database: LeagueDb, userId: string, week = weekKey()): Promise { + let doc: Doc | null = null; + try { + doc = await database.getDocument({ ...db(LEAGUE.players), documentId: userId }); + } catch (error) { + if (!isCode(error, 404)) throw error; + } + const player = toPlayer(doc); + if (!doc) { + await database.createDocument({ ...db(LEAGUE.players), documentId: userId, data: { ...player } }); + return player; + } + if (!player.week || player.week === week) return player; + + const members = await listGroup(database, player.groupId); + const rank = members.findIndex((member) => member.userId === userId) + 1; + const outcome = rank > 0 ? settle(player.division, rank, members.length) : { division: player.division, result: 'stay' as const }; + const settled: LeaguePlayer = { + ...player, + division: outcome.division, + bestDivision: Math.max(player.bestDivision, outcome.division), + lastWeek: player.week, + lastResult: outcome.result, + lastRank: rank, + lastDivision: player.division, + week: '', + groupId: '', + }; + await database.updateDocument({ ...db(LEAGUE.players), documentId: userId, data: { ...settled } }); + return settled; +} + +/** A group at this division with room, or a new one. */ +async function openGroup(database: LeagueDb, week: string, division: number): Promise { + const { documents } = await database.listDocuments({ + ...db(LEAGUE.groups), + queries: [Query.equal('week', week), Query.equal('division', division), Query.lessThan('size', GROUP_SIZE), Query.orderAsc('number'), Query.limit(1)], + }); + const open = documents[0]; + if (open) { + await database.updateDocument({ ...db(LEAGUE.groups), documentId: open.$id, data: { size: (Number(open.size) || 0) + 1 } }); + return open.$id; + } + const { total = 0 } = await database.listDocuments({ ...db(LEAGUE.groups), queries: [Query.equal('week', week), Query.equal('division', division), Query.limit(1)] }); + const number = total + 1; + const id = `${week}-${division}-${number}`; + try { + await database.createDocument({ ...db(LEAGUE.groups), documentId: id, data: { week, division, number, size: 1 } }); + } catch (error) { + // Someone opened the same group a moment ago; join it. + if (!isCode(error, 409)) throw error; + const group = await database.getDocument({ ...db(LEAGUE.groups), documentId: id }); + await database.updateDocument({ ...db(LEAGUE.groups), documentId: id, data: { size: (Number(group.size) || 0) + 1 } }); + } + return id; +} + +export interface MemberProfile { + name: string; + username: string | null; + avatarUrl: string | null; +} + +/** Joins the current week (first XP of the week) and returns the membership id. */ +export async function joinWeek(database: LeagueDb, userId: string, player: LeaguePlayer, profile: MemberProfile, week = weekKey()): Promise { + if (player.week === week && player.groupId) return player; + const groupId = await openGroup(database, week, player.division); + try { + await database.createDocument({ + ...db(LEAGUE.members), + documentId: memberId(week, userId), + data: { week, groupId, userId, division: player.division, xp: 0, name: profile.name.slice(0, 64), username: profile.username ?? '', avatarUrl: profile.avatarUrl ?? '' }, + }); + } catch (error) { + if (!isCode(error, 409)) throw error; + } + const joined = { ...player, week, groupId }; + await database.updateDocument({ ...db(LEAGUE.players), documentId: userId, data: { week, groupId } }); + return joined; +} + +/* ---- Earning XP ---- */ + +export interface XpAward { + gained: number; + weekXp: number; + /** Players this run pushed past, for "you were overtaken" notifications. */ + overtaken: string[]; + groupId: string; + division: number; +} + +/** + * Counts a stored run towards this week's league, once. The run must belong to + * the player and be recent; XP is capped per run and per day. + */ +export async function awardRun(database: LeagueDb, userId: string, runId: string, profile: MemberProfile, now = new Date()): Promise { + let run: Doc; + try { + run = await database.getDocument({ ...db(LEAGUE.runs), documentId: runId }); + } catch (error) { + if (isCode(error, 404)) return null; + throw error; + } + if (run.userId !== userId) return null; + const createdAt = new Date(String(run.$createdAt ?? '')).getTime(); + if (!createdAt || now.getTime() - createdAt > XP_WINDOW_MS) return null; + const earned = xpForRun({ correctChars: Number(run.correctChars) || 0, accuracy: Number(run.accuracy) || 0, verified: run.verified === true }); + if (!earned) return null; + + const week = weekKey(now); + const today = now.toISOString().slice(0, 10); + let player = await loadPlayer(database, userId, week); + const usedToday = player.xpDate === today ? player.xpToday : 0; + const gained = Math.min(earned, Math.max(0, MAX_XP_PER_DAY - usedToday)); + + try { + await database.createDocument({ ...db(LEAGUE.xp), documentId: runId, data: { userId, week, xp: gained } }); + } catch (error) { + if (isCode(error, 409)) return null; // Already counted. + throw error; + } + + player = await joinWeek(database, userId, player, profile, week); + const id = memberId(week, userId); + const member = await database.getDocument({ ...db(LEAGUE.members), documentId: id }); + const before = Number(member.xp) || 0; + const after = before + gained; + if (gained > 0) { + await database.updateDocument({ ...db(LEAGUE.members), documentId: id, data: { xp: after, name: profile.name.slice(0, 64), avatarUrl: profile.avatarUrl ?? '' } }); + await database.updateDocument({ ...db(LEAGUE.players), documentId: userId, data: { xpDate: today, xpToday: usedToday + gained } }); + } + + let overtaken: string[] = []; + if (gained > 0) { + const { documents } = await database.listDocuments({ + ...db(LEAGUE.members), + queries: [Query.equal('groupId', player.groupId), Query.greaterThanEqual('xp', before), Query.lessThan('xp', after), Query.limit(GROUP_SIZE)], + }); + // Only people who were ahead or level and had some XP of their own. + overtaken = documents.filter((doc) => doc.userId !== userId && (Number(doc.xp) || 0) > 0).map((doc) => String(doc.userId)); + } + return { gained, weekXp: after, overtaken, groupId: player.groupId, division: player.division }; +} + diff --git a/api/_lib/push.test.ts b/api/_lib/push.test.ts new file mode 100644 index 0000000..7e3b988 --- /dev/null +++ b/api/_lib/push.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest'; +import { FakeDb } from './fake-db'; +import { APPWRITE } from './appwrite-admin'; +import { notifyUsers, PUSH_SUBSCRIPTIONS, saveSubscription, sendStreakReminders, streakAtRisk, type PushDb, type Sender } from './push'; + +const as = (db: FakeDb) => db as unknown as PushDb; +const sub = (n: number) => ({ endpoint: `https://push.example/${n}`, keys: { p256dh: 'p', auth: 'a' } }); + +describe('push subscriptions', () => { + it('stores one subscription per device and validates it', async () => { + const db = new FakeDb(); + await saveSubscription(as(db), 'ana', { ...sub(1), tzOffset: 420 }); + await saveSubscription(as(db), 'ana', { ...sub(1), league: false }); + expect(db.all(PUSH_SUBSCRIPTIONS)).toHaveLength(1); + expect(db.all(PUSH_SUBSCRIPTIONS)[0]).toMatchObject({ league: false, streak: true, tzOffset: 0 }); + await expect(saveSubscription(as(db), 'ana', { endpoint: 'http://nope', keys: { p256dh: 'p', auth: 'a' } })).rejects.toMatchObject({ code: 400 }); + }); + + it('only notifies devices that opted in, and forgets dead ones', async () => { + const db = new FakeDb(); + await saveSubscription(as(db), 'ana', { ...sub(1) }); + await saveSubscription(as(db), 'ana', { ...sub(2), league: false }); + await saveSubscription(as(db), 'bob', { ...sub(3) }); + const send = vi.fn(async (subscription) => { + if (subscription.endpoint.endsWith('/3')) throw Object.assign(new Error('gone'), { statusCode: 410 }); + }); + const sent = await notifyUsers(as(db), ['ana', 'bob'], 'league', { title: 't', body: 'b', url: '/', tag: 'x' }, send); + expect(sent).toBe(1); + expect(send).toHaveBeenCalledTimes(2); + expect(db.all(PUSH_SUBSCRIPTIONS).map((doc) => doc.userId)).toEqual(['ana', 'ana']); + }); +}); + +describe('streak reminders', () => { + const now = new Date('2026-09-30T12:00:00Z'); + + it('reminds only streaks that break tonight, in the player\'s time zone', () => { + expect(streakAtRisk({ currentStreak: 5, lastActiveDate: '2026-09-29T15:00:00Z' }, 0, now)).toBe(5); + expect(streakAtRisk({ currentStreak: 5, lastActiveDate: '2026-09-30T01:00:00Z' }, 0, now)).toBe(0); + expect(streakAtRisk({ currentStreak: 5, lastActiveDate: '2026-09-27T15:00:00Z' }, 0, now)).toBe(0); + // 19:00 in Jakarta on the 30th; they last practised at 06:00 there on the 30th. + expect(streakAtRisk({ currentStreak: 5, lastActiveDate: '2026-09-29T23:00:00Z' }, 420, now)).toBe(0); + expect(streakAtRisk({ currentStreak: 0, lastActiveDate: '2026-09-29T15:00:00Z' }, 0, now)).toBe(0); + }); + + it('sends the cron reminder to players at risk', async () => { + const db = new FakeDb(); + db.put(APPWRITE.collections.profiles, { $id: 'ana', currentStreak: 9, lastActiveDate: '2026-09-29T10:00:00Z' }); + db.put(APPWRITE.collections.profiles, { $id: 'bob', currentStreak: 3, lastActiveDate: '2026-09-30T08:00:00Z' }); + await saveSubscription(as(db), 'ana', { ...sub(1) }); + await saveSubscription(as(db), 'bob', { ...sub(2) }); + const send = vi.fn(async () => undefined); + expect(await sendStreakReminders(as(db), as(db), now, send)).toBe(1); + expect(JSON.parse(send.mock.calls[0][1])).toMatchObject({ title: "Kap's 9-day flame is fading", url: '/' }); + }); +}); diff --git a/api/_lib/push.ts b/api/_lib/push.ts new file mode 100644 index 0000000..1d4533d --- /dev/null +++ b/api/_lib/push.ts @@ -0,0 +1,167 @@ +import crypto from 'node:crypto'; +import webpush from 'web-push'; +import { Query } from 'node-appwrite'; +import { APPWRITE } from './appwrite-admin.js'; +import { DIVISIONS } from './league.js'; + +/** + * Web Push: subscriptions per device, a daily streak reminder (Vercel cron) + * and a nudge when someone overtakes you in your league. Needs VAPID keys: + * VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY on the server, VITE_VAPID_PUBLIC_KEY + * for the app, and VAPID_SUBJECT (a mailto: or https: contact). + */ + +export const PUSH_SUBSCRIPTIONS = process.env.VITE_APPWRITE_PUSH_COLLECTION_ID || 'push_subscriptions'; + +type Doc = Record & { $id: string }; +export interface PushDb { + getDocument(params: { databaseId: string; collectionId: string; documentId: string }): Promise; + createDocument(params: { databaseId: string; collectionId: string; documentId: string; data: Record }): Promise; + updateDocument(params: { databaseId: string; collectionId: string; documentId: string; data: Record }): Promise; + deleteDocument(params: { databaseId: string; collectionId: string; documentId: string }): Promise; + listDocuments(params: { databaseId: string; collectionId: string; queries: string[] }): Promise<{ documents: Doc[]; total?: number }>; +} + +export interface PushMessage { + title: string; + body: string; + url: string; + tag: string; +} + +export type Sender = (subscription: { endpoint: string; keys: { p256dh: string; auth: string } }, payload: string) => Promise; + +const db = () => ({ databaseId: APPWRITE.databaseId, collectionId: PUSH_SUBSCRIPTIONS }); +const isCode = (error: unknown, code: number) => (error as { code?: number } | null)?.code === code; + +export function pushConfigured() { + return Boolean(process.env.VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY); +} + +let configured = false; +/** Sends through web-push with our VAPID keys. */ +export const webPushSender: Sender = (subscription, payload) => { + if (!configured) { + webpush.setVapidDetails(process.env.VAPID_SUBJECT || 'mailto:hello@codey.app', process.env.VAPID_PUBLIC_KEY!, process.env.VAPID_PRIVATE_KEY!); + configured = true; + } + return webpush.sendNotification(subscription, payload, { TTL: 60 * 60 * 12 }); +}; + +export const subscriptionId = (endpoint: string) => crypto.createHash('sha256').update(endpoint).digest('hex').slice(0, 32); + +export interface SubscribeInput { + endpoint?: unknown; + keys?: { p256dh?: unknown; auth?: unknown }; + streak?: unknown; + league?: unknown; + tzOffset?: unknown; +} + +/** Stores (or updates) this device's subscription and what it wants to hear about. */ +export async function saveSubscription(database: PushDb, userId: string, input: SubscribeInput) { + const endpoint = typeof input.endpoint === 'string' ? input.endpoint : ''; + const p256dh = typeof input.keys?.p256dh === 'string' ? input.keys.p256dh : ''; + const auth = typeof input.keys?.auth === 'string' ? input.keys.auth : ''; + if (!/^https:\/\//.test(endpoint) || endpoint.length > 1000 || !p256dh || p256dh.length > 200 || !auth || auth.length > 100) { + throw Object.assign(new Error('That push subscription is not valid.'), { code: 400 }); + } + const tz = Math.max(-840, Math.min(840, Math.round(Number(input.tzOffset) || 0))); + const data = { userId, endpoint, p256dh, auth, streak: input.streak !== false, league: input.league !== false, tzOffset: tz }; + const id = subscriptionId(endpoint); + try { + await database.createDocument({ ...db(), documentId: id, data }); + } catch (error) { + if (!isCode(error, 409)) throw error; + await database.updateDocument({ ...db(), documentId: id, data }); + } + return { ok: true, streak: data.streak, league: data.league }; +} + +export async function removeSubscription(database: PushDb, userId: string, endpoint: string) { + const id = subscriptionId(endpoint); + try { + const doc = await database.getDocument({ ...db(), documentId: id }); + if (doc.userId === userId) await database.deleteDocument({ ...db(), documentId: id }); + } catch (error) { + if (!isCode(error, 404)) throw error; + } + return { ok: true }; +} + +async function deliver(database: PushDb, subscription: Doc, message: PushMessage, send: Sender) { + try { + await send({ endpoint: String(subscription.endpoint), keys: { p256dh: String(subscription.p256dh), auth: String(subscription.auth) } }, JSON.stringify(message)); + return true; + } catch (error) { + // The browser dropped the subscription: forget it. + const status = (error as { statusCode?: number }).statusCode; + if (status === 404 || status === 410) await database.deleteDocument({ ...db(), documentId: subscription.$id }).catch(() => undefined); + return false; + } +} + +/** Pushes to every device of these players that opted in to this kind of message. */ +export async function notifyUsers(database: PushDb, userIds: string[], kind: 'streak' | 'league', message: PushMessage, send: Sender = webPushSender) { + if (!userIds.length || (send === webPushSender && !pushConfigured())) return 0; + const { documents } = await database.listDocuments({ ...db(), queries: [Query.equal('userId', userIds.slice(0, 100)), Query.equal(kind, true), Query.limit(200)] }); + const sent = await Promise.all(documents.map((subscription) => deliver(database, subscription, message, send))); + return sent.filter(Boolean).length; +} + +export function overtakeMessage(name: string, division: number): PushMessage { + return { + title: `${name} just passed you`, + body: `You slipped a place in the ${DIVISIONS[division] ?? 'Bronze'} league. One run gets it back.`, + url: '/league', + tag: 'codey-league', + }; +} + +/* ---- Daily streak reminder ---- */ + +const localDate = (at: Date, offsetMinutes: number) => new Date(at.getTime() + offsetMinutes * 60_000).toISOString().slice(0, 10); + +/** Whether a player's streak is still open today (practiced yesterday, not yet today) in their time zone. */ +export function streakAtRisk(profile: Record, tzOffset: number, now = new Date()): number { + const streak = Number(profile.currentStreak) || 0; + const last = typeof profile.lastActiveDate === 'string' ? profile.lastActiveDate : ''; + if (streak < 1 || !last) return 0; + const lastDay = localDate(new Date(last), tzOffset); + const today = localDate(now, tzOffset); + const yesterday = localDate(new Date(now.getTime() - 86_400_000), tzOffset); + if (lastDay === today) return 0; + return lastDay === yesterday ? streak : 0; +} + +export function streakMessage(days: number): PushMessage { + return { + title: days >= 7 ? `Kap's ${days}-day flame is fading` : `Keep your ${days}-day streak`, + body: 'One quick run keeps it alive. Kap is waiting.', + url: '/', + tag: 'codey-streak', + }; +} + +/** The daily cron: remind every opted-in player whose streak would break at midnight. */ +export async function sendStreakReminders(database: PushDb, profilesDb: PushDb, now = new Date(), send: Sender = webPushSender) { + let cursor = ''; + let sent = 0; + for (let page = 0; page < 20; page += 1) { + const queries = [Query.equal('streak', true), Query.orderAsc('$id'), Query.limit(100)]; + if (cursor) queries.push(Query.cursorAfter(cursor)); + const { documents } = await database.listDocuments({ ...db(), queries }); + if (!documents.length) break; + cursor = documents[documents.length - 1].$id; + const ids = [...new Set(documents.map((doc) => String(doc.userId)))]; + const { documents: profiles } = await profilesDb.listDocuments({ databaseId: APPWRITE.databaseId, collectionId: APPWRITE.collections.profiles, queries: [Query.equal('$id', ids), Query.limit(ids.length)] }); + const byId = new Map(profiles.map((profile) => [profile.$id, profile])); + for (const subscription of documents) { + const profile = byId.get(String(subscription.userId)); + const days = profile ? streakAtRisk(profile, Number(subscription.tzOffset) || 0, now) : 0; + if (days && (await deliver(database, subscription, streakMessage(days), send))) sent += 1; + } + if (documents.length < 100) break; + } + return sent; +} diff --git a/api/_lib/route.ts b/api/_lib/route.ts new file mode 100644 index 0000000..91cda3b --- /dev/null +++ b/api/_lib/route.ts @@ -0,0 +1,44 @@ +import { adminDatabases, applyCors, authenticateUser, isConfigured, readBody, type ApiRequest, type ApiResponse } from './appwrite-admin.js'; + +/** + * Boilerplate for small JSON routes: CORS, method check, configuration and + * (optionally) the signed-in player. Errors with a 4xx `code` are shown to the + * player; anything else is logged and reported as unavailable. + */ + +export interface RouteContext { + req: ApiRequest; + user: { id: string; name: string } | null; + body: Partial; + query: (key: string) => string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + db: any; +} + +export function route>( + method: 'GET' | 'POST', + options: { auth: 'required' | 'optional' | 'none'; label: string }, + handle: (context: RouteContext) => Promise, +) { + return async (req: ApiRequest, res: ApiResponse) => { + applyCors(res, method); + if (req.method === 'OPTIONS') return void res.status(200).json({ ok: true }); + if (req.method && req.method !== method) return void res.status(405).json({ error: 'Method not allowed.' }); + if (!isConfigured()) return void res.status(503).json({ error: `${options.label} is not configured.` }); + const user = options.auth === 'none' ? null : await authenticateUser(req.headers); + if (options.auth === 'required' && !user) return void res.status(401).json({ error: 'Sign in with GitHub first.' }); + const query = (key: string) => { + const value = req.query[key]; + return (Array.isArray(value) ? value[0] : value) ?? ''; + }; + try { + res.setHeader('Cache-Control', 'no-store'); + res.status(200).json(await handle({ req, user, body: readBody(req), query, db: adminDatabases() })); + } catch (error) { + const code = (error as { code?: number }).code; + if (code && code >= 400 && code < 500 && error instanceof Error) return void res.status(code).json({ error: error.message }); + console.error(`${options.label} failed:`, error); + res.status(503).json({ error: `${options.label} is unavailable right now. Try again shortly.` }); + } + }; +} diff --git a/api/_lib/routes/push.ts b/api/_lib/routes/push.ts new file mode 100644 index 0000000..16568e7 --- /dev/null +++ b/api/_lib/routes/push.ts @@ -0,0 +1,37 @@ +import { APPWRITE, adminDatabases, applyCors, type ApiRequest, type ApiResponse } from '../appwrite-admin.js'; +import { route } from '../route.js'; +import { pushConfigured, removeSubscription, saveSubscription, sendStreakReminders, type SubscribeInput } from '../push.js'; + +const notReady = () => Object.assign(new Error('Push notifications are not set up on this server yet.'), { code: 503 }); + +/** GET /api/push/config: whether push works here, and the key browsers subscribe with. */ +export const config = route('GET', { auth: 'none', label: 'Push' }, async () => ({ enabled: pushConfigured(), publicKey: process.env.VAPID_PUBLIC_KEY ?? null })); + +/** POST /api/push/subscribe { endpoint, keys, streak, league, tzOffset } */ +export const subscribe = route('POST', { auth: 'required', label: 'Push' }, ({ db, user, body }) => { + if (!pushConfigured()) throw notReady(); + return saveSubscription(db, user!.id, body); +}); + +/** POST /api/push/unsubscribe { endpoint } */ +export const unsubscribe = route<{ endpoint: string }>('POST', { auth: 'required', label: 'Push' }, ({ db, user, body }) => removeSubscription(db, user!.id, String(body.endpoint ?? ''))); + +/** + * GET /api/push/cron: the daily streak reminder, run by Vercel Cron. With + * CRON_SECRET set, Vercel sends it as a bearer token and nobody else can run it. + */ +export async function cron(req: ApiRequest, res: ApiResponse) { + applyCors(res, 'GET'); + const secret = process.env.CRON_SECRET; + const header = req.headers.authorization; + if (!secret || (Array.isArray(header) ? header[0] : header) !== `Bearer ${secret}`) return void res.status(401).json({ error: 'Unauthorized.' }); + if (!pushConfigured() || !APPWRITE.apiKey) return void res.status(200).json({ sent: 0, skipped: 'push is not configured' }); + try { + const db = adminDatabases(); + const sent = await sendStreakReminders(db as never, db as never); + res.status(200).json({ sent }); + } catch (error) { + console.error('Streak reminders failed:', error); + res.status(500).json({ error: 'Streak reminders failed.' }); + } +} diff --git a/api/_lib/routes/social.ts b/api/_lib/routes/social.ts new file mode 100644 index 0000000..4b14abf --- /dev/null +++ b/api/_lib/routes/social.ts @@ -0,0 +1,38 @@ +import { route } from '../route.js'; +import { awardRun } from '../league.js'; +import { feed, follow, friendsBoard, leagueView, memberProfile, relation, search, unfollow, USER_ID } from '../social.js'; +import { notifyUsers, overtakeMessage } from '../push.js'; + +const RUN_ID = /^[A-Za-z0-9._-]{1,36}$/; +const badRequest = (message: string) => Object.assign(new Error(message), { code: 400 }); + +/** GET /api/social/league: your division, this week's group and last week's result. */ +export const league = route('GET', { auth: 'required', label: 'The league' }, ({ db, user }) => leagueView(db, user!.id)); + +/** POST /api/social/xp { runId }: counts a stored run towards this week's league. */ +export const xp = route<{ runId: string }>('POST', { auth: 'required', label: 'League XP' }, async ({ db, user, body }) => { + const runId = String(body.runId ?? ''); + if (!RUN_ID.test(runId)) throw badRequest('Unknown run.'); + const profile = await memberProfile(db, user!.id, user!.name); + const award = await awardRun(db, user!.id, runId, profile); + if (!award) return { gained: 0 }; + if (award.overtaken.length) { + // Best effort: a failed push never costs the player their XP. + await notifyUsers(db, award.overtaken, 'league', overtakeMessage(profile.name, award.division)).catch((error) => console.warn('League push failed:', error)); + } + return { gained: award.gained, weekXp: award.weekXp, division: award.division }; +}); + +const target = (value: unknown) => { + const id = String(value ?? ''); + if (!USER_ID.test(id)) throw badRequest('Unknown player.'); + return id; +}; + +export const followRoute = route<{ userId: string }>('POST', { auth: 'required', label: 'Following' }, ({ db, user, body }) => follow(db, user!.id, target(body.userId))); +export const unfollowRoute = route<{ userId: string }>('POST', { auth: 'required', label: 'Following' }, ({ db, user, body }) => unfollow(db, user!.id, target(body.userId))); +/** GET /api/social/relation?userId=: follower counts, and whether you follow them. */ +export const relationRoute = route('GET', { auth: 'optional', label: 'Profiles' }, ({ db, user, query }) => relation(db, user?.id ?? null, target(query('userId')))); +export const friends = route('GET', { auth: 'required', label: 'Friends' }, ({ db, user }) => friendsBoard(db, user!.id)); +export const feedRoute = route('GET', { auth: 'required', label: 'The feed' }, ({ db, user }) => feed(db, user!.id)); +export const searchRoute = route('GET', { auth: 'optional', label: 'Search' }, ({ db, user, query }) => search(db, user?.id ?? null, query('q'))); diff --git a/api/_lib/social.test.ts b/api/_lib/social.test.ts new file mode 100644 index 0000000..ef4d1ee --- /dev/null +++ b/api/_lib/social.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { FakeDb } from './fake-db'; +import { APPWRITE } from './appwrite-admin'; +import { awardRun, LEAGUE } from './league'; +import { feed, follow, followingIds, friendsBoard, leagueView, relation, search, unfollow, type SocialDb } from './social'; + +const NOW = new Date('2026-09-30T10:00:00Z'); +const as = (db: FakeDb) => db as unknown as SocialDb; + +function world() { + const db = new FakeDb(); + for (const [id, name, username] of [['ana', 'Ana', 'ana-dev'], ['bob', 'Bob', 'bobby'], ['cy', 'Cy', 'cyrus']]) { + db.put(APPWRITE.collections.profiles, { $id: id, displayName: name, githubUsername: username }); + } + return db; +} + +describe('follows', () => { + it('follows once, unfollows, and never follows yourself', async () => { + const db = world(); + await follow(as(db), 'ana', 'bob'); + await follow(as(db), 'ana', 'bob'); + await follow(as(db), 'ana', 'ana'); + expect(await followingIds(as(db), 'ana')).toEqual(['bob']); + expect(await relation(as(db), 'ana', 'bob')).toEqual({ followers: 1, following: 0, isFollowing: true }); + await unfollow(as(db), 'ana', 'bob'); + expect(await relation(as(db), 'ana', 'bob')).toEqual({ followers: 0, following: 0, isFollowing: false }); + }); + + it('finds players by GitHub username or name', async () => { + const db = world(); + await follow(as(db), 'ana', 'bob'); + const { results } = await search(as(db), 'ana', '@bo'); + expect(results.map((r) => [r.userId, r.isFollowing])).toEqual([['bob', true]]); + expect((await search(as(db), 'ana', 'a')).results).toEqual([]); + }); +}); + +describe('friends board and feed', () => { + it('ranks you and the people you follow by weekly XP, with best speed', async () => { + const db = world(); + await follow(as(db), 'ana', 'bob'); + db.put(LEAGUE.runs, { $id: 'b1', userId: 'bob', correctChars: 500, accuracy: 98, wpm: 91, language: 'Go', $createdAt: NOW.toISOString() }); + db.put(LEAGUE.runs, { $id: 'a1', userId: 'ana', correctChars: 200, accuracy: 98, wpm: 70, language: 'TypeScript', $createdAt: NOW.toISOString() }); + await awardRun(as(db), 'bob', 'b1', { name: 'Bob', username: 'bobby', avatarUrl: null }, NOW); + await awardRun(as(db), 'ana', 'a1', { name: 'Ana', username: 'ana-dev', avatarUrl: null }, NOW); + const board = await friendsBoard(as(db), 'ana', NOW); + expect(board.rows.map((row) => [row.userId, row.xp, row.bestWpm, row.you])).toEqual([['bob', 50, 91, false], ['ana', 20, 70, true]]); + + const { items } = await feed(as(db), 'ana'); + expect(items.map((item) => [item.player.name, item.wpm, item.language])).toEqual([['Bob', 91, 'Go']]); + }); + + it('shows the league once you have XP this week', async () => { + const db = world(); + expect((await leagueView(as(db), 'ana', NOW)).joined).toBe(false); + db.put(LEAGUE.runs, { $id: 'a1', userId: 'ana', correctChars: 200, accuracy: 98, wpm: 70, $createdAt: NOW.toISOString() }); + await awardRun(as(db), 'ana', 'a1', { name: 'Ana', username: null, avatarUrl: null }, NOW); + const view = await leagueView(as(db), 'ana', NOW); + expect(view).toMatchObject({ joined: true, divisionName: 'Bronze', week: '2026-09-28', rules: { promote: 5, demote: 0 } }); + expect(view.standings).toEqual([{ rank: 1, userId: 'ana', name: 'Ana', username: null, avatarUrl: null, xp: 20 }]); + }); +}); diff --git a/api/_lib/social.ts b/api/_lib/social.ts new file mode 100644 index 0000000..032a154 --- /dev/null +++ b/api/_lib/social.ts @@ -0,0 +1,198 @@ +import crypto from 'node:crypto'; +import { Query } from 'node-appwrite'; +import { APPWRITE } from './appwrite-admin.js'; +import { DEMOTE, DIVISIONS, GROUP_SIZE, LEAGUE, listGroup, loadPlayer, memberId, MIN_SIZE_TO_DEMOTE, PROMOTE, TOP_DIVISION, weekEndsAt, weekKey, type LeagueDb, type MemberProfile } from './league.js'; + +/** + * Friends: one-way follows (like GitHub), a board of the people you follow by + * this week's league XP and best speed, and a feed of their recent runs. + */ + +export const FOLLOWS = process.env.VITE_APPWRITE_FOLLOWS_COLLECTION_ID || 'follows'; +const MAX_FOLLOWING = 200; + +type Doc = Record & { $id: string }; +export interface SocialDb extends LeagueDb { + deleteDocument(params: { databaseId: string; collectionId: string; documentId: string }): Promise; +} + +const db = (collectionId: string) => ({ databaseId: APPWRITE.databaseId, collectionId }); +const isCode = (error: unknown, code: number) => (error as { code?: number } | null)?.code === code; +const text = (value: unknown, max: number) => String(value ?? '').replace(/[\u0000-\u001f]/g, '').slice(0, max); +export const USER_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/; + +export const followId = (follower: string, followee: string) => crypto.createHash('sha256').update(`${follower}:${followee}`).digest('hex').slice(0, 32); + +export interface PublicProfile { + userId: string; + name: string; + username: string | null; + avatarUrl: string | null; +} + +export function toPublicProfile(doc: Doc | undefined, userId: string): PublicProfile { + const username = text(doc?.githubUsername, 100) || null; + const avatar = typeof doc?.avatarUrl === 'string' && doc.avatarUrl.startsWith('https://') ? doc.avatarUrl : username ? `https://avatars.githubusercontent.com/${encodeURIComponent(username)}?s=96` : null; + return { userId, name: text(doc?.displayName, 60) || username || 'Codey typist', username, avatarUrl: avatar }; +} + +export async function profilesFor(database: SocialDb, ids: string[]): Promise> { + const out = new Map(); + for (let index = 0; index < ids.length; index += 100) { + const chunk = ids.slice(index, index + 100); + if (!chunk.length) continue; + try { + const { documents } = await database.listDocuments({ ...db(APPWRITE.collections.profiles), queries: [Query.equal('$id', chunk), Query.limit(chunk.length)] }); + for (const doc of documents) out.set(doc.$id, toPublicProfile(doc, doc.$id)); + } catch { + // Names fall back below. + } + } + for (const id of ids) if (!out.has(id)) out.set(id, toPublicProfile(undefined, id)); + return out; +} + +export async function memberProfile(database: SocialDb, userId: string, fallbackName: string): Promise { + const profile = (await profilesFor(database, [userId])).get(userId)!; + return { name: profile.name === 'Codey typist' && fallbackName ? fallbackName : profile.name, username: profile.username, avatarUrl: profile.avatarUrl }; +} + +/* ---- League view ---- */ + +export async function leagueView(database: SocialDb, userId: string, now = new Date()) { + const week = weekKey(now); + const player = await loadPlayer(database, userId, week); + const joined = player.week === week && player.groupId; + const members = joined ? await listGroup(database, player.groupId) : []; + const standings = members.map((doc, index) => ({ + rank: index + 1, + userId: String(doc.userId), + name: text(doc.name, 64) || 'Codey typist', + username: text(doc.username, 100) || null, + avatarUrl: text(doc.avatarUrl, 500) || null, + xp: Number(doc.xp) || 0, + })); + return { + week, + endsAt: weekEndsAt(week), + division: player.division, + divisionName: DIVISIONS[player.division], + bestDivision: player.bestDivision, + joined: Boolean(joined), + standings, + rules: { groupSize: GROUP_SIZE, promote: player.division < TOP_DIVISION ? PROMOTE : 0, demote: player.division > 0 && standings.length >= MIN_SIZE_TO_DEMOTE ? DEMOTE : 0 }, + lastWeek: player.lastWeek + ? { week: player.lastWeek, result: player.lastResult, rank: player.lastRank, from: player.lastDivision, to: player.division } + : null, + }; +} + +/* ---- Follows ---- */ + +export async function follow(database: SocialDb, follower: string, followee: string) { + if (follower === followee || !USER_ID.test(followee)) return { following: false }; + const { total = 0 } = await database.listDocuments({ ...db(FOLLOWS), queries: [Query.equal('followerId', follower), Query.limit(1)] }); + if (total >= MAX_FOLLOWING) throw Object.assign(new Error(`You can follow up to ${MAX_FOLLOWING} people.`), { code: 400 }); + try { + await database.createDocument({ ...db(FOLLOWS), documentId: followId(follower, followee), data: { followerId: follower, followeeId: followee } }); + } catch (error) { + if (!isCode(error, 409)) throw error; + } + return { following: true }; +} + +export async function unfollow(database: SocialDb, follower: string, followee: string) { + try { + await database.deleteDocument({ ...db(FOLLOWS), documentId: followId(follower, followee) }); + } catch (error) { + if (!isCode(error, 404)) throw error; + } + return { following: false }; +} + +export async function followingIds(database: SocialDb, userId: string): Promise { + const { documents } = await database.listDocuments({ ...db(FOLLOWS), queries: [Query.equal('followerId', userId), Query.limit(MAX_FOLLOWING)] }); + return documents.map((doc) => String(doc.followeeId)); +} + +export async function relation(database: SocialDb, viewer: string | null, userId: string) { + const [followers, following, mine] = await Promise.all([ + database.listDocuments({ ...db(FOLLOWS), queries: [Query.equal('followeeId', userId), Query.limit(1)] }), + database.listDocuments({ ...db(FOLLOWS), queries: [Query.equal('followerId', userId), Query.limit(1)] }), + viewer && viewer !== userId + ? database.getDocument({ ...db(FOLLOWS), documentId: followId(viewer, userId) }).then(() => true, () => false) + : Promise.resolve(false), + ]); + return { followers: followers.total ?? 0, following: following.total ?? 0, isFollowing: mine }; +} + +/* ---- Friends board and feed ---- */ + +async function runsOf(database: SocialDb, ids: string[], queries: string[]): Promise { + if (!ids.length) return []; + const { documents } = await database.listDocuments({ ...db(LEAGUE.runs), queries: [Query.equal('userId', ids.slice(0, 100)), ...queries] }); + return documents; +} + +/** You and everyone you follow, by this week's XP, with their best speed this week. */ +export async function friendsBoard(database: SocialDb, userId: string, now = new Date()) { + const week = weekKey(now); + const ids = [userId, ...(await followingIds(database, userId))]; + const [profiles, members, runs] = await Promise.all([ + profilesFor(database, ids), + database.listDocuments({ ...db(LEAGUE.members), queries: [Query.equal('$id', ids.map((id) => memberId(week, id)).slice(0, 100)), Query.limit(100)] }).then((result) => result.documents, () => [] as Doc[]), + // Ordered by the (userId, $createdAt) index; the best speed is picked below. + runsOf(database, ids, [Query.greaterThanEqual('$createdAt', `${week}T00:00:00.000Z`), Query.orderDesc('$createdAt'), Query.limit(500)]).catch(() => [] as Doc[]), + ]); + const xp = new Map(members.map((doc) => [String(doc.userId), { xp: Number(doc.xp) || 0, division: Number(doc.division) || 0 }])); + const best = new Map(); + for (const run of runs) { + const id = String(run.userId); + const wpm = Number(run.wpm) || 0; + if (wpm > (best.get(id)?.wpm ?? -1)) best.set(id, { wpm, language: text(run.language, 40) }); + } + const rows = ids.map((id) => ({ + ...profiles.get(id)!, + you: id === userId, + xp: xp.get(id)?.xp ?? 0, + division: xp.get(id)?.division ?? null, + bestWpm: best.get(id)?.wpm ?? null, + bestLanguage: best.get(id)?.language ?? null, + })); + rows.sort((a, b) => b.xp - a.xp || (b.bestWpm ?? 0) - (a.bestWpm ?? 0)); + return { week, rows, following: ids.length - 1 }; +} + +/** Recent runs by people you follow, newest first. */ +export async function feed(database: SocialDb, userId: string) { + const ids = await followingIds(database, userId); + const runs = await runsOf(database, ids, [Query.orderDesc('$createdAt'), Query.limit(40)]).catch(() => [] as Doc[]); + const profiles = await profilesFor(database, [...new Set(runs.map((run) => String(run.userId)))]); + return { + items: runs + .filter((run) => (Number(run.wpm) || 0) > 0) + .map((run) => ({ + id: run.$id, + at: text(run.$createdAt, 40), + wpm: Number(run.wpm) || 0, + accuracy: Number(run.accuracy) || 0, + language: text(run.language, 40), + mode: text(run.mode, 10), + verified: run.verified === true, + player: profiles.get(String(run.userId))!, + })), + }; +} + +/** Players whose GitHub username or display name starts with the query. */ +export async function search(database: SocialDb, viewer: string | null, query: string) { + const q = query.trim().replace(/^@/, '').slice(0, 40); + if (q.length < 2) return { results: [] }; + const lists = await Promise.all(['githubUsername', 'displayName'].map((key) => + database.listDocuments({ ...db(APPWRITE.collections.profiles), queries: [Query.startsWith(key, q), Query.limit(10)] }).then((result) => result.documents, () => [] as Doc[]), + )); + const seen = new Set(); + const docs = lists.flat().filter((doc) => doc.$id !== viewer && !seen.has(doc.$id) && seen.add(doc.$id)).slice(0, 12); + const followed = viewer ? new Set(await followingIds(database, viewer)) : new Set(); + return { results: docs.map((doc) => ({ ...toPublicProfile(doc, doc.$id), isFollowing: followed.has(doc.$id) })) }; +} diff --git a/api/push.ts b/api/push.ts new file mode 100644 index 0000000..21db993 --- /dev/null +++ b/api/push.ts @@ -0,0 +1,5 @@ +import { dispatch } from './_lib/dispatch.js'; +import { config, cron, subscribe, unsubscribe } from './_lib/routes/push.js'; + +/** Web Push: /api/push/. */ +export default dispatch('push', { config, subscribe, unsubscribe, cron }); diff --git a/api/social.ts b/api/social.ts new file mode 100644 index 0000000..5f4218f --- /dev/null +++ b/api/social.ts @@ -0,0 +1,14 @@ +import { dispatch } from './_lib/dispatch.js'; +import { feedRoute, followRoute, friends, league, relationRoute, searchRoute, unfollowRoute, xp } from './_lib/routes/social.js'; + +/** Weekly leagues and friends: /api/social/. */ +export default dispatch('social', { + league, + xp, + follow: followRoute, + unfollow: unfollowRoute, + relation: relationRoute, + friends, + feed: feedRoute, + search: searchRoute, +}); diff --git a/appwrite/schema.md b/appwrite/schema.md index 790c8e3..bde3595 100644 --- a/appwrite/schema.md +++ b/appwrite/schema.md @@ -229,3 +229,50 @@ nobody can edit or delete a challenge once it exists. | `accuracy` | float | yes | Index: `by_user` on `userId`. + +## Weekly leagues, friends and push + +Created with `npm run setup:social`. Every collection below is server-only +(no client permissions); the app reaches them through `/api/social/*` and +`/api/push/*`, which use the server API key. + +### `league_players` + +One per player (document id = user id): `division` (0 Bronze … 3 Diamond), +the `week` and `groupId` they are playing this week, last week's settlement +(`lastWeek`, `lastResult` up/down/stay, `lastRank`, `lastDivision`), +`bestDivision`, and the daily XP cap counter (`xpDate`, `xpToday`). + +A finished week is settled the first time the player is seen in a new week: +top 5 of the group move up, bottom 5 move down (groups of 10 or more). + +### `league_groups` + +`week`, `division`, `number`, `size`. Groups hold up to 30 players; id +`--`. Index: `week`, `division`, `size`, `number`. + +### `league_members` + +One per player per week (id `_`): `week`, `groupId`, `userId`, +`division`, `xp`, and the display `name`, `username`, `avatarUrl`. +Indexes: `groupId` + `xp` desc, `userId`. + +### `league_xp` + +One per run counted towards a league (document id = the run's id), so a run +never earns XP twice: `userId`, `week`, `xp`. + +### `follows` + +One-way follows, id = hash of follower and followee: `followerId`, +`followeeId`. Indexes on each. + +### `push_subscriptions` + +One per browser (id = hash of the endpoint): `userId`, `endpoint`, `p256dh`, +`auth`, `streak` and `league` opt-ins, and `tzOffset` (minutes east of UTC). +Indexes: `userId`, `streak`. + +Push needs these Vercel environment variables (`npm run setup:push-keys` +prints a fresh set): `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT` +and `CRON_SECRET`. The daily streak reminder runs from Vercel Cron at 12:00 UTC. diff --git a/package-lock.json b/package-lock.json index 8311bb1..bceb1c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "simple-icons": "^16.28.0", "tailwind-merge": "^3.6.0", "three": "^0.180.0", + "web-push": "^3.6.7", "workbox-window": "^7.4.1" }, "devDependencies": { @@ -34,6 +35,7 @@ "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@types/three": "^0.180.0", + "@types/web-push": "^3.6.4", "@vitejs/plugin-react": "^6.0.5", "happy-dom": "^20.14.5", "lightningcss": "^1.33.0", @@ -4358,6 +4360,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/webxr": { "version": "0.5.24", "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", @@ -4545,6 +4557,15 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -4683,6 +4704,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -4842,6 +4875,12 @@ "node": "*" } }, + "node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -4941,6 +4980,12 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -5542,7 +5587,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -5753,6 +5797,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -6930,6 +6983,15 @@ "node": ">=16.9.0" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -6951,6 +7013,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", @@ -7016,7 +7091,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/internal-slot": { @@ -7802,6 +7876,27 @@ "node": ">=0.10.0" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -8329,6 +8424,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -8349,7 +8450,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -8369,7 +8469,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -9605,6 +9704,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -9644,7 +9763,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/satori": { @@ -11093,6 +11211,25 @@ "@jridgewell/sourcemap-codec": "^1.6.0" } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/webrtc-adapter": { "version": "9.0.6", "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz", diff --git a/package.json b/package.json index 83c88f4..346f032 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "setup:daily": "node --env-file=.env scripts/setup-appwrite-daily.mjs", "setup:duel": "node --env-file=.env scripts/setup-appwrite-duel.mjs", "setup:share": "node --env-file=.env scripts/setup-appwrite-share.mjs", - "setup:challenges": "node --env-file=.env scripts/setup-appwrite-challenges.mjs" + "setup:challenges": "node --env-file=.env scripts/setup-appwrite-challenges.mjs", + "setup:social": "node --env-file=.env scripts/setup-appwrite-social.mjs", + "setup:push-keys": "node scripts/generate-vapid-keys.mjs" }, "dependencies": { "@radix-ui/react-slot": "^1.3.3", @@ -32,6 +34,7 @@ "simple-icons": "^16.28.0", "tailwind-merge": "^3.6.0", "three": "^0.180.0", + "web-push": "^3.6.7", "workbox-window": "^7.4.1" }, "devDependencies": { @@ -41,6 +44,7 @@ "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@types/three": "^0.180.0", + "@types/web-push": "^3.6.4", "@vitejs/plugin-react": "^6.0.5", "happy-dom": "^20.14.5", "lightningcss": "^1.33.0", diff --git a/public/push-sw.js b/public/push-sw.js new file mode 100644 index 0000000..635f5ed --- /dev/null +++ b/public/push-sw.js @@ -0,0 +1,33 @@ +/* Web Push for Codey, imported by the generated service worker. */ +self.addEventListener("push", (event) => { + let data = {}; + try { + data = event.data ? event.data.json() : {}; + } catch { + data = { title: "Codey", body: event.data ? event.data.text() : "" }; + } + const title = data.title || "Codey"; + event.waitUntil(self.registration.showNotification(title, { + body: data.body || "", + icon: "/app-icon-192.png", + badge: "/app-icon-192.png", + tag: data.tag || "codey", + data: { url: data.url || "/" }, + })); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const url = new URL((event.notification.data && event.notification.data.url) || "/", self.location.origin).href; + event.waitUntil((async () => { + const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + for (const client of windows) { + if (new URL(client.url).origin === self.location.origin && "focus" in client) { + await client.focus(); + if ("navigate" in client) await client.navigate(url).catch(() => undefined); + return; + } + } + await self.clients.openWindow(url); + })()); +}); diff --git a/scripts/generate-vapid-keys.mjs b/scripts/generate-vapid-keys.mjs new file mode 100644 index 0000000..2241c61 --- /dev/null +++ b/scripts/generate-vapid-keys.mjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node +/** + * Prints a fresh VAPID key pair for Web Push. Put the values in Vercel + * (Project → Settings → Environment Variables) and redeploy: + * + * VAPID_PUBLIC_KEY the public key (the app reads it from /api/push/config) + * VAPID_PRIVATE_KEY the private key: keep it secret, never commit it + * VAPID_SUBJECT mailto:you@example.com (who push services can contact) + * CRON_SECRET any long random string, so only Vercel Cron can run reminders + */ +import crypto from "node:crypto"; +import webpush from "web-push"; + +const { publicKey, privateKey } = webpush.generateVAPIDKeys(); +console.log(`VAPID_PUBLIC_KEY=${publicKey}`); +console.log(`VAPID_PRIVATE_KEY=${privateKey}`); +console.log("VAPID_SUBJECT=mailto:you@example.com"); +console.log(`CRON_SECRET=${crypto.randomBytes(24).toString("hex")}`); diff --git a/scripts/setup-appwrite-social.mjs b/scripts/setup-appwrite-social.mjs new file mode 100644 index 0000000..912572a --- /dev/null +++ b/scripts/setup-appwrite-social.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node +/** + * Collections behind weekly leagues, friends and push notifications + * (see appwrite/schema.md). Only the server API key reads or writes them; + * the app goes through /api/social and /api/push. Safe to re-run. + * + * node --env-file=.env scripts/setup-appwrite-social.mjs + * + * Needs VITE_APPWRITE_ENDPOINT, VITE_APPWRITE_PROJECT_ID and APPWRITE_API_KEY + * (a server key with databases, collections, attributes and indexes scopes). + * The API key used by Vercel also needs the users.read scope, for share cards. + */ +import { OrderBy } from "node-appwrite"; +import { applySchema, connect, fail } from "./lib/appwrite-setup.mjs"; + +const env = process.env; +const serverOnly = { permissions: [], documentSecurity: false }; + +const SCHEMA = [ + { + id: env.VITE_APPWRITE_LEAGUE_PLAYERS_COLLECTION_ID || "league_players", + name: "League players", + ...serverOnly, + attributes: [ + { type: "integer", key: "division", required: false }, + { type: "string", key: "week", size: 10, required: false }, + { type: "string", key: "groupId", size: 36, required: false }, + { type: "string", key: "lastWeek", size: 10, required: false }, + { type: "string", key: "lastResult", size: 8, required: false }, + { type: "integer", key: "lastRank", required: false }, + { type: "integer", key: "lastDivision", required: false }, + { type: "integer", key: "bestDivision", required: false }, + { type: "string", key: "xpDate", size: 10, required: false }, + { type: "integer", key: "xpToday", required: false }, + ], + indexes: [], + }, + { + id: env.VITE_APPWRITE_LEAGUE_GROUPS_COLLECTION_ID || "league_groups", + name: "League groups", + ...serverOnly, + attributes: [ + { type: "string", key: "week", size: 10, required: true }, + { type: "integer", key: "division", required: true }, + { type: "integer", key: "number", required: true }, + { type: "integer", key: "size", required: true }, + ], + indexes: [{ key: "by_week_division", attributes: ["week", "division", "size", "number"], orders: [OrderBy.Asc, OrderBy.Asc, OrderBy.Asc, OrderBy.Asc] }], + }, + { + id: env.VITE_APPWRITE_LEAGUE_MEMBERS_COLLECTION_ID || "league_members", + name: "League members", + ...serverOnly, + attributes: [ + { type: "string", key: "week", size: 10, required: true }, + { type: "string", key: "groupId", size: 36, required: true }, + { type: "string", key: "userId", size: 36, required: true }, + { type: "integer", key: "division", required: true }, + { type: "integer", key: "xp", required: true }, + { type: "string", key: "name", size: 64, required: false }, + { type: "string", key: "username", size: 100, required: false }, + { type: "string", key: "avatarUrl", size: 500, required: false }, + ], + indexes: [ + { key: "by_group_xp", attributes: ["groupId", "xp"], orders: [OrderBy.Asc, OrderBy.Desc] }, + { key: "by_user", attributes: ["userId"], orders: [OrderBy.Asc] }, + ], + }, + { + id: env.VITE_APPWRITE_LEAGUE_XP_COLLECTION_ID || "league_xp", + name: "League XP (one per counted run)", + ...serverOnly, + attributes: [ + { type: "string", key: "userId", size: 36, required: true }, + { type: "string", key: "week", size: 10, required: true }, + { type: "integer", key: "xp", required: true }, + ], + indexes: [], + }, + { + id: env.VITE_APPWRITE_FOLLOWS_COLLECTION_ID || "follows", + name: "Follows", + ...serverOnly, + attributes: [ + { type: "string", key: "followerId", size: 36, required: true }, + { type: "string", key: "followeeId", size: 36, required: true }, + ], + indexes: [ + { key: "by_follower", attributes: ["followerId"], orders: [OrderBy.Asc] }, + { key: "by_followee", attributes: ["followeeId"], orders: [OrderBy.Asc] }, + ], + }, + { + id: env.VITE_APPWRITE_PUSH_COLLECTION_ID || "push_subscriptions", + name: "Push subscriptions", + ...serverOnly, + attributes: [ + { type: "string", key: "userId", size: 36, required: true }, + { type: "string", key: "endpoint", size: 1000, required: true }, + { type: "string", key: "p256dh", size: 200, required: true }, + { type: "string", key: "auth", size: 100, required: true }, + { type: "boolean", key: "streak", required: false }, + { type: "boolean", key: "league", required: false }, + { type: "integer", key: "tzOffset", required: false }, + ], + indexes: [ + { key: "by_user", attributes: ["userId"], orders: [OrderBy.Asc] }, + { key: "by_streak", attributes: ["streak"], orders: [OrderBy.Asc] }, + ], + }, + // Friend search looks players up by the start of their name. + { + id: env.VITE_APPWRITE_PROFILES_COLLECTION_ID || "profiles", + name: "Profiles", + attributes: [{ type: "string", key: "displayName", size: 100, required: false }], + indexes: [{ key: "by_display_name", attributes: ["displayName"], orders: [OrderBy.Asc] }], + }, +]; + +applySchema(connect("scripts/setup-appwrite-social.mjs"), SCHEMA) + .then(() => console.log("\nDone. Leagues, friends and push notifications are ready.")) + .catch(fail); diff --git a/src/App.tsx b/src/App.tsx index 87d2a30..8246288 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,11 +17,12 @@ import { usePreferences } from "@/components/PreferencesProvider"; import { Header } from "@/components/Header"; import { Footer } from "@/components/Footer"; import { useAuth } from "@/components/AuthProvider"; -import { uploadRun } from "@/lib/cloud"; +import { cloudRunId, uploadRun } from "@/lib/cloud"; import { canChallenge, challengeSnippet, getChallenge, type Challenge } from "@/lib/challenges"; import { isProse, loadTextCorpus, TEXT_LANGUAGE_NAMES, passageSnippet, pickPassage, wordsSnippet, type TextCorpus, type TextLanguage } from "@/lib/text-practice"; import { TextLanguagePicker } from "@/components/TextLanguagePicker"; import { emitKap } from "@/lib/kap-events"; +import { awardLeagueXp } from "@/lib/social"; import { KapBuddy } from "@/components/streak/KapBuddy"; import { markOnboarded, OnboardingDialog, shouldOnboard, type OnboardingChoice } from "@/components/OnboardingDialog"; import { ChallengeModeBanner, ChallengeResultBanner } from "@/components/ChallengeBanners"; @@ -285,8 +286,12 @@ export default function App() { setGoalRefreshKey((key) => key + 1); // Every run syncs to the account; Ranked runs are stored by the server when verified. if (!isRanked && userIdRef.current) { - void uploadRun(userIdRef.current, saved) - .then(() => setGoalRefreshKey((key) => key + 1)) + const uploader = userIdRef.current; + void uploadRun(uploader, saved) + .then(() => { + setGoalRefreshKey((key) => key + 1); + void awardLeagueXp(cloudRunId(uploader, saved)); + }) .catch((error) => console.error("Unable to save cloud run", error)); } } catch { @@ -351,6 +356,7 @@ export default function App() { useEffect(() => { if (!verifiedResult?.verified) return; recordRankedVerified(verifiedResult.runId); + if (verifiedResult.runId) void awardLeagueXp(verifiedResult.runId); // The server stored the verified copy; link it so the local run is not uploaded again. if (lastSavedRunIdRef.current) setRunCloudId(lastSavedRunIdRef.current, verifiedResult.runId); checkAchievements(userIdRef.current); diff --git a/src/components/AuthProvider.tsx b/src/components/AuthProvider.tsx index ce41a1d..7acdaf0 100644 --- a/src/components/AuthProvider.tsx +++ b/src/components/AuthProvider.tsx @@ -10,6 +10,7 @@ import { signOut as appwriteSignOut, } from "@/lib/appwrite"; import { syncLocalRuns } from "@/lib/cloud"; +import { clearJwtToken } from "@/lib/ranked"; import { updateAccountPrefs } from "@/lib/account-prefs"; import { startAccountSync } from "@/lib/account-sync"; import { ensureHistoryIds, getSettings, getStreak, saveSettings } from "@/utils/storage"; @@ -185,6 +186,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const logout = useCallback(async () => { await appwriteSignOut(); + clearJwtToken(); setUser(null); setSyncStatus("idle"); }, []); diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 1241819..fa10a33 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -15,6 +15,7 @@ import { Palette, Search, Settings, + Shield, SlidersHorizontal, Swords, Trophy, @@ -39,6 +40,7 @@ const NAV: { to: string; label: string; icon: LucideIcon; title: string }[] = [ { to: "/duel", label: "Duel", icon: Swords, title: "1v1 live code race" }, { to: "/arcade", label: "Arcade", icon: Gamepad2, title: "Code Rain Arcade" }, { to: "/daily", label: "Daily", icon: CalendarDays, title: "Daily Challenge" }, + { to: "/league", label: "League", icon: Shield, title: "Weekly league and friends" }, { to: "/leaderboard", label: "Leaderboard", icon: Trophy, title: "Global ranked leaderboard" }, { to: "/analytics/keyboard", label: "Analytics", icon: Keyboard, title: "Keyboard analytics" }, ]; diff --git a/src/components/ResultsScreen.tsx b/src/components/ResultsScreen.tsx index f7cee88..381573b 100644 --- a/src/components/ResultsScreen.tsx +++ b/src/components/ResultsScreen.tsx @@ -14,6 +14,7 @@ import type { RankedStatus } from "@/hooks/useRankedGame"; import { cn } from "@/lib/utils"; import { PROSE_LANGUAGES } from "@/lib/text-practice"; import { Kap3D } from "@/components/streak/Kap3D"; +import { LeagueXpChip } from "@/components/social/LeagueXpChip"; import { KapMascot } from "@/components/streak/KapMascot"; import { useKapLook } from "@/hooks/useKapLook"; import { useStreak } from "@/hooks/useStreak"; @@ -110,6 +111,8 @@ export function ResultsScreen({ username={username} /> + + {/* Ranked Score Banner / Verification */} {verifiedResult?.verified && (
diff --git a/src/components/social/FollowButton.tsx b/src/components/social/FollowButton.tsx new file mode 100644 index 0000000..35ba17b --- /dev/null +++ b/src/components/social/FollowButton.tsx @@ -0,0 +1,32 @@ +import { useEffect, useState } from "react"; +import { Loader2, UserCheck, UserPlus } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { followPlayer, unfollowPlayer } from "@/lib/social"; + +/** Follow or unfollow a player; the server's answer decides the state shown. */ +export function FollowButton({ userId, initial, onChange }: { userId: string; initial: boolean; onChange?: (following: boolean) => void }) { + const [following, setFollowing] = useState(initial); + const [busy, setBusy] = useState(false); + useEffect(() => setFollowing(initial), [initial]); + return ( + + ); +} diff --git a/src/components/social/LeagueXpChip.tsx b/src/components/social/LeagueXpChip.tsx new file mode 100644 index 0000000..c516bb6 --- /dev/null +++ b/src/components/social/LeagueXpChip.tsx @@ -0,0 +1,29 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { Shield } from "lucide-react"; +import { DIVISION_COLORS, DIVISION_TEXT, DIVISIONS, LEAGUE_XP_EVENT, type XpGain } from "@/lib/social"; +import { cn } from "@/lib/utils"; + +/** "+32 XP · Silver league" once the finished run has been counted. */ +export function LeagueXpChip() { + const [gain, setGain] = useState(null); + useEffect(() => { + const onGain = (event: Event) => setGain((event as CustomEvent).detail); + window.addEventListener(LEAGUE_XP_EVENT, onGain); + return () => window.removeEventListener(LEAGUE_XP_EVENT, onGain); + }, []); + if (!gain?.gained) return null; + const division = gain.division ?? 0; + return ( +
+ + +{gain.gained} XP + · {DIVISIONS[division]} league{gain.weekXp ? ` · ${gain.weekXp} this week` : ""} + +
+ ); +} diff --git a/src/components/social/ProfileFollow.tsx b/src/components/social/ProfileFollow.tsx new file mode 100644 index 0000000..f3467ff --- /dev/null +++ b/src/components/social/ProfileFollow.tsx @@ -0,0 +1,41 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { useAuth } from "@/components/AuthProvider"; +import { fetchRelation } from "@/lib/social"; +import { FollowButton } from "./FollowButton"; + +/** Follower counts on a profile, and a Follow button when it is someone else's. */ +export function ProfileFollow({ userId, own }: { userId: string; own: boolean }) { + const { user, login } = useAuth(); + const [relation, setRelation] = useState<{ followers: number; following: number; isFollowing: boolean } | null>(null); + + useEffect(() => { + let active = true; + void fetchRelation(userId).then((next) => active && setRelation(next), () => active && setRelation(null)); + return () => { + active = false; + }; + }, [userId, user]); + + return ( +
+ {relation && ( + + {relation.followers} followers + {relation.following} following + + )} + {!own && (user ? ( + setRelation((current) => current && { ...current, isFollowing: following, followers: current.followers + (following ? 1 : -1) })} + /> + ) : ( + + ))} +
+ ); +} diff --git a/src/components/social/PushCard.tsx b/src/components/social/PushCard.tsx new file mode 100644 index 0000000..9b80dee --- /dev/null +++ b/src/components/social/PushCard.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from "react"; +import { BellRing, Loader2 } from "lucide-react"; +import { useAuth } from "@/components/AuthProvider"; +import { disablePush, enablePush, pushConfig, pushSupported, readPush, type PushPrefs } from "@/lib/push"; +import { cn } from "@/lib/utils"; + +/** Settings: push notifications that arrive even with Codey closed. */ +export function PushCard() { + const { user, login } = useAuth(); + const [prefs, setPrefs] = useState(readPush); + const [available, setAvailable] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!pushSupported()) return setAvailable(false); + void pushConfig().then((config) => setAvailable(config.enabled)); + }, []); + + const apply = async (next: Pick, on: boolean) => { + setBusy(true); + setError(null); + try { + setPrefs(on && (next.streak || next.league) ? await enablePush(next) : await disablePush()); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Notifications could not be updated."); + } finally { + setBusy(false); + } + }; + + const detail = available === false + ? pushSupported() ? "Push isn't set up on this server yet." : "This browser can't receive push notifications. On iPhone, install Codey to your home screen first." + : !user ? "Sign in to get reminders, even with Codey closed." : prefs.enabled ? "On for this device." : "Get a nudge on this device, even with Codey closed."; + + return ( +
+
+
+ +
+
+

Notifications

+

{detail}

+
+ {available && (user ? ( + + ) : ( + + ))} +
+ {available && user && prefs.enabled && ( +
+ {([ + ["streak", "Streak reminder", "Around 7 PM (WIB) when your streak would break tonight."], + ["league", "League", "When someone passes you in your weekly league."], + ] as const).map(([key, label, hint]) => ( + + ))} +
+ )} + {error &&

{error}

} +
+ ); +} diff --git a/src/components/streak/KapScene.ts b/src/components/streak/KapScene.ts index a39d3b4..74484cc 100644 --- a/src/components/streak/KapScene.ts +++ b/src/components/streak/KapScene.ts @@ -1,7 +1,7 @@ import * as THREE from "three"; import { RoundedBoxGeometry } from "three/examples/jsm/geometries/RoundedBoxGeometry.js"; import type { FlameTier } from "@/utils/flame-tiers"; -import { colorway, KAP_INK, type KapLook } from "@/utils/kap-art"; +import { colorway, KAP_INK, MEDAL_COLORS, type KapLook } from "@/utils/kap-art"; import type { StreakMood } from "@/lib/streak"; import { KAP_EVENT, type KapEvent } from "@/lib/kap-events"; @@ -414,6 +414,23 @@ export class KapScene { tail.position.set(width / 2 - 0.1, y - 0.3, faceZ(y) + 0.02); tail.rotation.z = 0.25; } + if (typeof look.medal === "number" && MEDAL_COLORS[look.medal]) { + const [face, rim] = MEDAL_COLORS[look.medal]; + const y = CENTER_Y - 0.42; + const medal = new THREE.Group(); + const disc = this.mesh(new THREE.CylinderGeometry(0.17, 0.17, 0.05, 28), this.material(face, { metalness: 0.75, roughness: 0.25, emissive: face, emissiveIntensity: 0.15 }), medal); + disc.rotation.x = Math.PI / 2; + const ring = this.mesh(new THREE.TorusGeometry(0.17, 0.025, 8, 28), this.material(rim, { metalness: 0.8, roughness: 0.3 }), medal); + ring.position.z = 0.02; + for (const [x, color] of [[-0.06, "#2563eb"], [0.06, "#dc2626"]] as const) { + const ribbon = this.mesh(new THREE.BoxGeometry(0.09, 0.3, 0.015), this.material(color, { roughness: 0.8 }), medal); + ribbon.position.set(x, 0.24, -0.01); + ribbon.rotation.z = x < 0 ? 0.25 : -0.25; + } + medal.position.set(0.42, y, halfWidthAt(y) + 0.05); + medal.rotation.x = -SLOPE; + this.body.add(medal); + } if (look.wear === "cape") { const cape = this.mesh(new THREE.PlaneGeometry(1.9, 1.7, 1, 6), this.material("#dc2626", { side: THREE.DoubleSide, roughness: 0.7 })); cape.position.set(0, CENTER_Y - 0.15, -halfWidthAt(CENTER_Y) - 0.12); diff --git a/src/components/streak/StreakReminder.tsx b/src/components/streak/StreakReminder.tsx index 5cf671d..f171ef0 100644 --- a/src/components/streak/StreakReminder.tsx +++ b/src/components/streak/StreakReminder.tsx @@ -1,5 +1,6 @@ import { useEffect } from "react"; import { useStreak } from "@/hooks/useStreak"; +import { readPush } from "@/lib/push"; import { lastNotifiedOn, markNotified, readReminder, reminderDue, reminderMessage, notificationsSupported } from "@/lib/streak-reminder"; /** Fires Kap's evening reminder while a Codey tab is open. Renders nothing. */ @@ -9,6 +10,9 @@ export function StreakReminder() { useEffect(() => { const check = () => { if (!notificationsSupported() || Notification.permission !== "granted") return; + // The server already sends the streak reminder to this device. + const push = readPush(); + if (push.enabled && push.streak) return; const now = new Date(); if (!reminderDue(readReminder(), status, now, lastNotifiedOn())) return; markNotified(now); diff --git a/src/lib/cloud.ts b/src/lib/cloud.ts index 61e002e..7d5372e 100644 --- a/src/lib/cloud.ts +++ b/src/lib/cloud.ts @@ -44,6 +44,11 @@ function runKey(run: RunResult): string { return run.id ?? `${run.timestamp}-${run.language}-${run.mode}`; } +/** The id a run is stored under in the cloud (its share link id). */ +export function cloudRunId(userId: string, run: RunResult): string { + return documentId(userId, run); +} + function documentId(userId: string, run: RunResult): string { if (run.cloudId) return run.cloudId; const value = `${userId}:${runKey(run)}`; diff --git a/src/lib/push.ts b/src/lib/push.ts new file mode 100644 index 0000000..9a1dfba --- /dev/null +++ b/src/lib/push.ts @@ -0,0 +1,93 @@ +import { apiError, getJwtToken, rankedApiUrl } from "./ranked"; + +/** + * Web Push from Codey's server: the streak reminder (sent every evening at + * 19:00 WIB when your streak would break tonight) and league nudges, even + * with every Codey tab closed. The choice is kept per device. + */ + +export const PUSH_KEY = "codey_push"; +export const PUSH_EVENT = "codey:push-changed"; + +export interface PushPrefs { + enabled: boolean; + streak: boolean; + league: boolean; +} + +export const DEFAULT_PUSH: PushPrefs = { enabled: false, streak: true, league: true }; + +export function readPush(): PushPrefs { + try { + return { ...DEFAULT_PUSH, ...(JSON.parse(localStorage.getItem(PUSH_KEY) ?? "null") as Partial | null) }; + } catch { + return DEFAULT_PUSH; + } +} + +function writePush(prefs: PushPrefs) { + try { + localStorage.setItem(PUSH_KEY, JSON.stringify(prefs)); + } catch { + // This tab only. + } + window.dispatchEvent(new CustomEvent(PUSH_EVENT)); +} + +export function pushSupported(): boolean { + return typeof window !== "undefined" && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window; +} + +let config: Promise<{ enabled: boolean; publicKey: string | null }> | null = null; +/** Whether this server sends push at all (VAPID keys set in Vercel). */ +export function pushConfig() { + config ??= fetch(rankedApiUrl("/api/push/config")) + .then((response) => (response.ok ? response.json() : { enabled: false, publicKey: null })) + .catch(() => ({ enabled: false, publicKey: null })); + return config; +} + +function keyBytes(base64: string): Uint8Array { + const padded = `${base64}${"=".repeat((4 - (base64.length % 4)) % 4)}`.replace(/-/g, "+").replace(/_/g, "/"); + const raw = atob(padded); + const bytes = new Uint8Array(new ArrayBuffer(raw.length)); + for (let index = 0; index < raw.length; index += 1) bytes[index] = raw.charCodeAt(index); + return bytes; +} + +async function post(path: string, body: unknown) { + const jwt = await getJwtToken(); + if (!jwt) throw new Error("Sign in with GitHub to get notifications."); + const response = await fetch(rankedApiUrl(path), { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${jwt}` }, + body: JSON.stringify(body), + }); + if (!response.ok) throw await apiError(response, "Notifications could not be updated."); + return response.json(); +} + +/** Asks for permission, subscribes this device and tells the server what to send. */ +export async function enablePush(choice: Pick): Promise { + const { enabled, publicKey } = await pushConfig(); + if (!enabled || !publicKey) throw new Error("Push notifications are not set up on this server yet."); + if ((await Notification.requestPermission()) !== "granted") throw new Error("Notifications are blocked for Codey in your browser settings."); + const registration = await navigator.serviceWorker.ready; + const subscription = (await registration.pushManager.getSubscription()) ?? (await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: keyBytes(publicKey) })); + await post("/api/push/subscribe", { ...subscription.toJSON(), ...choice, tzOffset: -new Date().getTimezoneOffset() }); + const prefs = { enabled: true, ...choice }; + writePush(prefs); + return prefs; +} + +export async function disablePush(): Promise { + const registration = await navigator.serviceWorker.ready.catch(() => null); + const subscription = await registration?.pushManager.getSubscription(); + if (subscription) { + await post("/api/push/unsubscribe", { endpoint: subscription.endpoint }).catch(() => undefined); + await subscription.unsubscribe().catch(() => undefined); + } + const prefs = { ...readPush(), enabled: false }; + writePush(prefs); + return prefs; +} diff --git a/src/lib/ranked.ts b/src/lib/ranked.ts index 528e2ce..78d1328 100644 --- a/src/lib/ranked.ts +++ b/src/lib/ranked.ts @@ -50,14 +50,29 @@ export interface RankedStart { expiresAt: string; } +// Appwrite JWTs last 15 minutes and creating them is rate limited, so one is reused for a while. +let cachedJwt: { jwt: string; until: number } | null = null; +let pendingJwt: Promise | null = null; + export async function getJwtToken(): Promise { if (!account) return null; - try { - const session = await account.createJWT(); - return session.jwt; - } catch { - return null; - } + if (cachedJwt && cachedJwt.until > Date.now()) return cachedJwt.jwt; + const client = account; + pendingJwt ??= client.createJWT().then( + (session) => { + cachedJwt = { jwt: session.jwt, until: Date.now() + 10 * 60_000 }; + return session.jwt; + }, + () => null, + ).finally(() => { + pendingJwt = null; + }); + return pendingJwt; +} + +/** Forget the cached JWT, e.g. after signing out. */ +export function clearJwtToken() { + cachedJwt = null; } export async function apiError(response: Response, fallback: string): Promise { diff --git a/src/lib/social.ts b/src/lib/social.ts new file mode 100644 index 0000000..da6a32b --- /dev/null +++ b/src/lib/social.ts @@ -0,0 +1,128 @@ +import { apiError, getJwtToken, rankedApiUrl } from "./ranked"; + +/** Client for /api/social: weekly leagues, follows, the friends board and feed. */ + +export const DIVISIONS = ["Bronze", "Silver", "Gold", "Diamond"] as const; +export const DIVISION_COLORS = ["#d97706", "#94a3b8", "#facc15", "#67e8f9"] as const; +/** Readable text colours per division, in both themes. */ +export const DIVISION_TEXT = ["text-amber-700 dark:text-amber-400", "text-slate-500 dark:text-slate-300", "text-yellow-600 dark:text-yellow-300", "text-cyan-700 dark:text-cyan-300"] as const; +export const LEAGUE_XP_EVENT = "codey:league-xp"; + +export interface LeagueStanding { + rank: number; + userId: string; + name: string; + username: string | null; + avatarUrl: string | null; + xp: number; +} + +export interface LeagueView { + week: string; + endsAt: string; + division: number; + divisionName: string; + bestDivision: number; + joined: boolean; + standings: LeagueStanding[]; + rules: { groupSize: number; promote: number; demote: number }; + lastWeek: { week: string; result: "up" | "down" | "stay" | string; rank: number; from: number; to: number } | null; +} + +export interface PublicPlayer { + userId: string; + name: string; + username: string | null; + avatarUrl: string | null; +} + +export interface FriendRow extends PublicPlayer { + you: boolean; + xp: number; + division: number | null; + bestWpm: number | null; + bestLanguage: string | null; +} + +export interface FeedItem { + id: string; + at: string; + wpm: number; + accuracy: number; + language: string; + mode: string; + verified: boolean; + player: PublicPlayer; +} + +export interface XpGain { + gained: number; + weekXp?: number; + division?: number; +} + +async function call(path: string, options: { method?: "GET" | "POST"; body?: unknown; auth?: boolean } = {}): Promise { + const headers: Record = {}; + const jwt = await getJwtToken(); + if (options.auth !== false && !jwt) throw new Error("Sign in with GitHub first."); + if (jwt) headers.Authorization = `Bearer ${jwt}`; + if (options.body !== undefined) headers["Content-Type"] = "application/json"; + const response = await fetch(rankedApiUrl(path), { + method: options.method ?? "GET", + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + if (!response.ok) throw await apiError(response, "Something went wrong. Try again shortly."); + return response.json() as Promise; +} + +export const fetchLeague = () => call("/api/social/league"); +export const fetchFriends = () => call<{ week: string; rows: FriendRow[]; following: number }>("/api/social/friends"); +export const fetchFeed = () => call<{ items: FeedItem[] }>("/api/social/feed"); +export const searchPlayers = (q: string) => call<{ results: (PublicPlayer & { isFollowing: boolean })[] }>(`/api/social/search?q=${encodeURIComponent(q)}`, { auth: false }); +export const fetchRelation = (userId: string) => call<{ followers: number; following: number; isFollowing: boolean }>(`/api/social/relation?userId=${encodeURIComponent(userId)}`, { auth: false }); +export const followPlayer = (userId: string) => call<{ following: boolean }>("/api/social/follow", { method: "POST", body: { userId } }); +export const unfollowPlayer = (userId: string) => call<{ following: boolean }>("/api/social/unfollow", { method: "POST", body: { userId } }); + +/** + * Counts a stored run towards this week's league and tells the results + * screen how much it earned. Quiet on failure: XP is a bonus, not the run. + */ +export async function awardLeagueXp(runId: string): Promise { + try { + const gain = await call("/api/social/xp", { method: "POST", body: { runId } }); + window.dispatchEvent(new CustomEvent(LEAGUE_XP_EVENT, { detail: gain })); + return gain; + } catch (error) { + console.warn("League XP was not counted:", error); + return null; + } +} + +/** "3d 4h", "5h 12m", "18m" until a moment. */ +export function timeLeft(until: string, now = Date.now()): string { + const ms = Math.max(0, new Date(until).getTime() - now); + const minutes = Math.floor(ms / 60_000); + const days = Math.floor(minutes / 1440); + const hours = Math.floor((minutes % 1440) / 60); + if (days) return `${days}d ${hours}h`; + if (hours) return `${hours}h ${minutes % 60}m`; + return `${minutes}m`; +} + +/** Which zone a rank sits in, for colouring the standings. */ +export function zoneOf(rank: number, size: number, rules: LeagueView["rules"]): "promote" | "demote" | "safe" { + if (rules.promote && rank <= rules.promote) return "promote"; + if (rules.demote && rank > size - rules.demote) return "demote"; + return "safe"; +} + +/** "2h ago", "3d ago". */ +export function ago(at: string, now = Date.now()): string { + const minutes = Math.max(0, Math.floor((now - new Date(at).getTime()) / 60_000)); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} diff --git a/src/main.tsx b/src/main.tsx index e962124..bfe0b55 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -19,7 +19,7 @@ import { PwaPrompt } from './components/PwaPrompt'; import { KapWardrobe } from './components/streak/KapWardrobe'; import './index.css'; -const { Settings, History, Leaderboard, Profile, Donate, Contributors, Duel, Arcade, Daily, KeyboardAnalytics, Achievements } = Pages; +const { Settings, History, Leaderboard, Profile, Donate, Contributors, Duel, Arcade, Daily, KeyboardAnalytics, Achievements, League } = Pages; createRoot(document.getElementById('root')!).render( @@ -44,6 +44,7 @@ createRoot(document.getElementById('root')!).render( } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/routes/League.tsx b/src/routes/League.tsx new file mode 100644 index 0000000..c550cef --- /dev/null +++ b/src/routes/League.tsx @@ -0,0 +1,387 @@ +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { Link } from "react-router-dom"; +import { ArrowDown, ArrowUp, Loader2, Medal, Minus, Rss, Search, Shield, Users } from "lucide-react"; +import { Header } from "@/components/Header"; +import { Footer } from "@/components/Footer"; +import { Button } from "@/components/ui/button"; +import { FollowButton } from "@/components/social/FollowButton"; +import { useAuth } from "@/components/AuthProvider"; +import { Kap3D } from "@/components/streak/Kap3D"; +import { KapMascot } from "@/components/streak/KapMascot"; +import { useKapLook } from "@/hooks/useKapLook"; +import { useStreak } from "@/hooks/useStreak"; +import { FLAME_TIERS } from "@/lib/streak"; +import { cn } from "@/lib/utils"; +import { + ago, + DIVISION_COLORS, + DIVISION_TEXT, + DIVISIONS, + fetchFeed, + fetchFriends, + fetchLeague, + searchPlayers, + timeLeft, + zoneOf, + type FeedItem, + type FriendRow, + type LeagueView, + type PublicPlayer, +} from "@/lib/social"; + +type Tab = "league" | "friends" | "feed"; + +function Avatar({ player, size = 32 }: { player: Pick; size?: number }) { + return player.avatarUrl ? ( + + ) : ( + + {player.name.slice(0, 1).toUpperCase()} + + ); +} + +function Panel({ children, className }: { children: ReactNode; className?: string }) { + return
{children}
; +} + +function useCountdown(until: string | undefined) { + const [, tick] = useState(0); + useEffect(() => { + const timer = window.setInterval(() => tick((value) => value + 1), 30_000); + return () => window.clearInterval(timer); + }, []); + return until ? timeLeft(until) : ""; +} + +/* ---- League tab ---- */ + +function LastWeekBanner({ lastWeek }: { lastWeek: NonNullable }) { + const up = lastWeek.result === "up"; + const down = lastWeek.result === "down"; + const Icon = up ? ArrowUp : down ? ArrowDown : Minus; + return ( +
+ +

+ + {up ? `Promoted to ${DIVISIONS[lastWeek.to]}!` : down ? `Moved down to ${DIVISIONS[lastWeek.to]}.` : `Stayed in ${DIVISIONS[lastWeek.to]}.`} + {" "} + {lastWeek.rank ? `You finished #${lastWeek.rank} last week.` : "Last week is settled."} +

+
+ ); +} + +function Standings({ view, meId }: { view: LeagueView; meId: string }) { + const size = view.standings.length; + return ( +
    + {view.standings.map((row) => { + const zone = zoneOf(row.rank, size, view.rules); + const me = row.userId === meId; + const firstDemote = view.rules.demote && row.rank === size - view.rules.demote + 1; + return ( +
  1. + + {row.rank} + + + {row.name} + {me && (you)} + + {row.xp.toLocaleString()} XP + + {view.rules.promote > 0 && row.rank === view.rules.promote && size > view.rules.promote && ( +

    + Promotion zone: top {view.rules.promote} move up to {DIVISIONS[view.division + 1]} +

    + )} +
  2. + ); + })} +
+ ); +} + +function LeagueTab({ view, meId }: { view: LeagueView; meId: string }) { + const look = useKapLook(); + const streak = useStreak(); + const tier = streak.tier ?? FLAME_TIERS[1]; + const left = useCountdown(view.endsAt); + const color = DIVISION_COLORS[view.division]; + const me = view.standings.find((row) => row.userId === meId); + const medalLook = { ...look, medal: view.division }; + + return ( +
+ +
+ } /> +

+ {view.divisionName} league +

+

Ends in {left}

+
+
+

This week

+

{me?.xp ?? 0} XP

+
+
+

Rank

+

{me ? `#${me.rank}` : "-"}

+
+
+
    + {DIVISIONS.map((name, index) => ( +
  1. + +
  2. + ))} +
+ + +
+ {view.lastWeek && } + {view.joined ? ( + +
+

This week's group

+

{view.standings.length} of {view.rules.groupSize} players

+
+
+ +
+
+ ) : ( + +

Finish a run to join this week's league

+

+ Every run earns XP: about one point for every ten correct characters, less when accuracy drops, and 1.5× for verified Ranked runs. You'll join a group of up to {view.rules.groupSize} {view.divisionName} players. +

+ +
+ )} +

+ Top {5} move up a division each week, the bottom {5} move down (in groups of 10 or more). XP is capped at 800 a day, so steady practice beats one marathon. +

+
+
+ ); +} + +/* ---- Friends tab ---- */ + +function FindPlayers({ onFollowed }: { onFollowed: () => void }) { + const [query, setQuery] = useState(""); + const [results, setResults] = useState<(PublicPlayer & { isFollowing: boolean })[]>([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const q = query.trim(); + if (q.replace(/^@/, "").length < 2) { + setResults([]); + return; + } + setLoading(true); + const timer = window.setTimeout(() => { + void searchPlayers(q).then((result) => setResults(result.results), () => setResults([])).finally(() => setLoading(false)); + }, 300); + return () => window.clearTimeout(timer); + }, [query]); + + return ( + +

Find friends

+
+ + setQuery(event.target.value)} + placeholder="GitHub username or name" + className="h-10 w-full rounded-xl border bg-background pl-9 pr-3 text-sm outline-none focus:border-amber-500/70" + aria-label="Search players" + /> + {loading && } +
+ {results.length > 0 && ( +
    + {results.map((player) => ( +
  • + + + {player.name} {player.username && @{player.username}} + + +
  • + ))} +
+ )} + {query.trim().length >= 2 && !loading && !results.length &&

Nobody by that name yet.

} +
+ ); +} + +function FriendsTab() { + const [rows, setRows] = useState(null); + const [error, setError] = useState(null); + const load = useCallback(() => { + void fetchFriends().then((result) => setRows(result.rows), (reason: Error) => setError(reason.message)); + }, []); + useEffect(load, [load]); + + return ( +
+ +
+

You and your friends this week

+

By league XP, with each player's best speed this week.

+
+ {error ? ( +

{error}

+ ) : !rows ? ( +
+ ) : ( +
    + {rows.map((row, index) => ( +
  1. + + {index + 1} + + + {row.name}{row.you && (you)} + + {row.division !== null ? `${DIVISIONS[row.division]} league` : "Not in a league this week"} + {row.bestWpm ? ` · best ${Math.round(row.bestWpm)} WPM${row.bestLanguage ? ` in ${row.bestLanguage}` : ""}` : ""} + + + {row.xp} XP + +
  2. + ))} +
+ )} + {rows && rows.length === 1 &&

Follow people to race them here. Find them on the right, or from any profile.

} +
+ +
+ ); +} + +/* ---- Feed tab ---- */ + +function FeedTab() { + const [items, setItems] = useState(null); + const [error, setError] = useState(null); + useEffect(() => { + void fetchFeed().then((result) => setItems(result.items), (reason: Error) => setError(reason.message)); + }, []); + + if (error) return

{error}

; + if (!items) return ; + if (!items.length) { + return ( + +

Nothing here yet

+

Runs from people you follow show up here.

+
+ ); + } + return ( + +
    + {items.map((item) => ( +
  1. + +

    + {item.player.name}{" "} + typed{" "} + {Math.round(item.wpm)} WPM{" "} + in {item.language} · {item.accuracy.toFixed(1)}% + {item.verified && Ranked} +

    + {ago(item.at)} +
  2. + ))} +
+
+ ); +} + +/* ---- Page ---- */ + +export default function League() { + const { user, login, configured } = useAuth(); + const [tab, setTab] = useState(() => (["friends", "feed"].includes(window.location.hash.slice(1)) ? (window.location.hash.slice(1) as Tab) : "league")); + const [view, setView] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!user) return; + void fetchLeague().then(setView, (reason: Error) => setError(reason.message)); + }, [user]); + + useEffect(() => { + window.history.replaceState(null, "", tab === "league" ? window.location.pathname : `#${tab}`); + }, [tab]); + + const tabs = useMemo(() => [ + { id: "league" as const, label: "League", icon: Shield }, + { id: "friends" as const, label: "Friends", icon: Users }, + { id: "feed" as const, label: "Feed", icon: Rss }, + ], []); + + return ( +
+
+
+
+

+ Weekly league +

+

League

+

Practice earns XP. Every week, the top of your group moves up a division: Bronze, Silver, Gold, Diamond.

+
+ + {!user ? ( + +

Sign in to join the league

+

Your runs earn XP each week, and you can follow friends to race them.

+ +
+ ) : ( + <> +
+ {tabs.map(({ id, label, icon: Icon }) => ( + + ))} +
+ {tab === "league" && (error ?

{error}

: view ? : )} + {tab === "friends" && } + {tab === "feed" && } + + )} +
+
+
+ ); +} + diff --git a/src/routes/Profile.tsx b/src/routes/Profile.tsx index b24b119..cfa6e9d 100644 --- a/src/routes/Profile.tsx +++ b/src/routes/Profile.tsx @@ -29,6 +29,7 @@ import { dateKey, streakStatus } from "@/lib/streak"; import type { ShareCardOptions } from "@/lib/share-result"; import { SharePreviewDialog } from "@/components/SharePreviewDialog"; import { ProfileShareDialog, type ProfileShareTarget } from "@/components/ProfileShareDialog"; +import { ProfileFollow } from "@/components/social/ProfileFollow"; import { computeKeyStatsFromCloudRuns, getPendingKeyboardStats, getStoredKeyStats, getVisibleKeyStats, mergeStatsMaps, type KeyboardStatsMap } from "@/utils/keyboard-analytics"; import { getCloudKeyboardStats } from "@/lib/keyboard-stats-cloud"; import { DivisionBadge } from "@/components/DivisionBadge"; @@ -232,6 +233,7 @@ export default function Profile() { {syncLabel} )} + {viewedUserId && } {best && (