@profullstack/leaderboard 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +150 -0
- package/index.d.ts +109 -0
- package/package.json +43 -0
- package/src/badges.js +41 -0
- package/src/embed.js +167 -0
- package/src/hono.js +21 -0
- package/src/index.js +432 -0
- package/src/ladder.js +76 -0
- package/src/next.js +18 -0
- package/src/store/memory.js +54 -0
- package/src/store/sql.js +88 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Profullstack, LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# @profullstack/leaderboard
|
|
2
|
+
|
|
3
|
+
A public leaderboard that drops into any site. Score events in, ranked boards out: all time, week, month, streaks, badges, a commission ladder, RSS, a share card per player and a widget any page can embed with one script tag.
|
|
4
|
+
|
|
5
|
+
Zero dependencies. The core is a Fetch API handler, so it runs in Node, Bun, Deno and at the edge. Adapters for Hono and Next.js. Stores: in-memory, or any SQL client with `execute({ sql, args })` (libsql/Turso, sqlite, Postgres).
|
|
6
|
+
|
|
7
|
+
Built for marketplaces where people sell through properties and niches and earn a growing cut, but every board, badge and metric is yours to define.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm i @profullstack/leaderboard
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Sixty seconds
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
import { createLeaderboard, sqlStore, commissionLadder } from '@profullstack/leaderboard';
|
|
19
|
+
import { leaderboard } from '@profullstack/leaderboard/hono';
|
|
20
|
+
|
|
21
|
+
const store = sqlStore({ execute: (q) => db.execute(q) }); // @libsql/client shape
|
|
22
|
+
await store.migrate();
|
|
23
|
+
|
|
24
|
+
const lb = createLeaderboard({
|
|
25
|
+
siteName: 'NicheDB',
|
|
26
|
+
siteUrl: 'https://nichedb.dev',
|
|
27
|
+
store,
|
|
28
|
+
boards: {
|
|
29
|
+
earnings: { label: 'Top earners', metric: 'cents', format: 'usd', unit: 'Earned' },
|
|
30
|
+
sales: { label: 'Most sales', metric: 'sales', tiebreak: 'cents' },
|
|
31
|
+
streaks: { label: 'Longest streaks', metric: 'streak', min: 1 },
|
|
32
|
+
},
|
|
33
|
+
ladder: commissionLadder(), // 20% -> 80%
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
app.use('*', leaderboard(lb));
|
|
37
|
+
|
|
38
|
+
// When something happens:
|
|
39
|
+
await lb.record({ player: user.id, name: user.name, metrics: { cents: 1900, sales: 1 } });
|
|
40
|
+
await lb.set({ player: user.id, metrics: { properties: 3, niches: 5 } });
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
That gives the site, under `/leaderboard`:
|
|
44
|
+
|
|
45
|
+
| Path | What |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| `/leaderboard` | A full leaderboard page (`?board=&period=&me=&theme=`) |
|
|
48
|
+
| `/leaderboard.json` | Boards, periods, badges and the ladder |
|
|
49
|
+
| `/leaderboard/earnings.json?period=week&limit=10&me=u_42` | Ranked rows, plus `me` (my standing, on the board or not) |
|
|
50
|
+
| `/leaderboard/earnings.xml?period=week` | RSS of the board |
|
|
51
|
+
| `/leaderboard/u/u_42` | A share card with Open Graph tags: ranks, streaks, badges, commission |
|
|
52
|
+
| `/leaderboard/u/u_42.json` | The same as data |
|
|
53
|
+
| `/leaderboard/embed?board=sales&sticky=bottom-right` | An iframe-able page |
|
|
54
|
+
| `/leaderboard/embed.js` | The widget script |
|
|
55
|
+
|
|
56
|
+
JSON answers carry `Access-Control-Allow-Origin: *`, so the widget works on any domain.
|
|
57
|
+
|
|
58
|
+
## The widget
|
|
59
|
+
|
|
60
|
+
On any page, yours or a partner's:
|
|
61
|
+
|
|
62
|
+
```html
|
|
63
|
+
<div data-leaderboard></div>
|
|
64
|
+
<script src="https://nichedb.dev/leaderboard/embed.js"
|
|
65
|
+
data-board="earnings" data-period="week" data-limit="10" data-me="u_42"></script>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Period tabs, badges next to names, the commission rate, streak flames, a highlighted row for `data-me` (and a "you" row below the cut when they are not in the top N), a share link per row that copies the player's card URL, RSS and full-board links, and a refresh every 60 seconds while the tab is visible.
|
|
69
|
+
|
|
70
|
+
Sticky mode is the retention feature. Add `data-sticky="bottom-right"` (or `bottom-left`, `top-right`, `top-left`) and the board becomes a corner pill reading "You are #4 this week" that opens the panel on click. It follows the reader around the site.
|
|
71
|
+
|
|
72
|
+
| Attribute | Default | Meaning |
|
|
73
|
+
| --- | --- | --- |
|
|
74
|
+
| `data-board` | first board | Board id |
|
|
75
|
+
| `data-period` | site default | `all`, `day`, `week`, `month` |
|
|
76
|
+
| `data-limit` | `10` | Rows |
|
|
77
|
+
| `data-me` | | Player id to highlight and report |
|
|
78
|
+
| `data-sticky` | | Corner pill mode |
|
|
79
|
+
| `data-theme` | `auto` | `light`, `dark`, `auto` |
|
|
80
|
+
| `data-title` | board label | Heading |
|
|
81
|
+
| `data-refresh` | `60` | Seconds between refreshes, `0` to disable |
|
|
82
|
+
| `data-target` | | CSS selector of the container |
|
|
83
|
+
| `data-base` | from `src` | The leaderboard path when the script is served elsewhere |
|
|
84
|
+
|
|
85
|
+
The widget fires `leaderboard:render` on its container with the JSON as `detail`, for anything you want to do with the numbers.
|
|
86
|
+
|
|
87
|
+
## Metrics
|
|
88
|
+
|
|
89
|
+
Three kinds, one vocabulary:
|
|
90
|
+
|
|
91
|
+
- **Counters** go in through `record()` and are summed. Boards on a counter honour the period.
|
|
92
|
+
- **Gauges** go in through `set()` and are the current value (properties owned, niches promoted, followers). Boards on a gauge show the same number in every period.
|
|
93
|
+
- **Derived**: `streak` (consecutive UTC days with any `record()`, still current through yesterday) and `bestStreak`.
|
|
94
|
+
|
|
95
|
+
A board is `{ label, metric, format, unit, min, order, tiebreak }`. `format` is `number`, `integer`, `usd` (cents in) or `percent`. Players below `min` do not appear. Ties break on `tiebreak`, then on who got there first.
|
|
96
|
+
|
|
97
|
+
`record()` and `set()` return the badges newly earned, for a toast.
|
|
98
|
+
|
|
99
|
+
## Badges
|
|
100
|
+
|
|
101
|
+
A badge is `{ id, emoji, label, describe, when(profile) }`. It is awarded the first time `when` is true and kept forever, so "top ten this week" stays earned after the week ends. The profile carries `totals`, `gauges`, `streak`, `bestStreak`, `ranks[board][period]` and `commission.rate`.
|
|
102
|
+
|
|
103
|
+
The default set is for a marketplace: first sale, ten sales, hundred sales, seven and thirty day streaks, three properties, five niches, top ten this week, number one, and the 80% club. Pass `badges: [...]` for your own; `onBadge({ player, name, badge })` fires on every award.
|
|
104
|
+
|
|
105
|
+
## The commission ladder
|
|
106
|
+
|
|
107
|
+
```js
|
|
108
|
+
const ladder = commissionLadder(); // base 20, cap 80, +5 per property (max 6), +5 per niche (max 6)
|
|
109
|
+
ladder.rate({ properties: 2, niches: 4 }); // 50
|
|
110
|
+
ladder.next({ properties: 2, niches: 4 }); // { add: 'property', rate: 55, from: 50, gain: 5, toCap: 30 }
|
|
111
|
+
ladder.table(); // every step, for a pricing page
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Every number is an option (`base`, `cap`, `perProperty`, `perNiche`, `maxProperties`, `maxNiches`, `propertiesMetric`, `nichesMetric`). Pass `ladder: true` for the defaults. The rate appears in every row and on the share card, with the next step spelled out: "Add one niche to reach 55%".
|
|
115
|
+
|
|
116
|
+
## Stores
|
|
117
|
+
|
|
118
|
+
```js
|
|
119
|
+
import { memoryStore, sqlStore } from '@profullstack/leaderboard';
|
|
120
|
+
|
|
121
|
+
memoryStore(); // one process, tests
|
|
122
|
+
sqlStore({ execute, prefix: 'lb_', dialect: 'sqlite' }) // or 'postgres'
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`sqlStore` needs `execute({ sql, args }) -> { rows }`, which is `@libsql/client` as is. `store.schema` is the DDL for your own migration tool; `store.migrate()` runs it. Three tables: events, gauges, badges.
|
|
126
|
+
|
|
127
|
+
Anything else implements six methods: `append`, `list({ since })`, `setGauge`, `gauges`, `awardBadge`, `badges`. The core reads all events and aggregates in memory, cached for `cacheMs` (15 s); that is fine into the hundreds of thousands of events. Call `lb.invalidate()` after writing to the store from elsewhere.
|
|
128
|
+
|
|
129
|
+
## Next.js
|
|
130
|
+
|
|
131
|
+
```js
|
|
132
|
+
// app/leaderboard/[[...path]]/route.js
|
|
133
|
+
import { leaderboardRoute } from '@profullstack/leaderboard/next';
|
|
134
|
+
export const { GET } = leaderboardRoute(lb);
|
|
135
|
+
export const dynamic = 'force-dynamic';
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Programmatic
|
|
139
|
+
|
|
140
|
+
```js
|
|
141
|
+
await lb.top({ board: 'earnings', period: 'month', limit: 25, offset: 0 });
|
|
142
|
+
await lb.standing({ player: 'u_42', board: 'earnings', period: 'week' });
|
|
143
|
+
await lb.profile('u_42');
|
|
144
|
+
await lb.rss({ board: 'sales' });
|
|
145
|
+
lb.index();
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
MIT
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export type Period = 'all' | 'day' | 'week' | 'month';
|
|
2
|
+
export type Format = 'number' | 'integer' | 'usd' | 'percent';
|
|
3
|
+
|
|
4
|
+
export interface Event { player: string; name?: string; metric: string; delta: number; at: number }
|
|
5
|
+
|
|
6
|
+
export interface Store {
|
|
7
|
+
append(event: Event): Promise<void>;
|
|
8
|
+
list(opts?: { since?: number }): Promise<Event[]>;
|
|
9
|
+
setGauge(player: string, metric: string, value: number, name: string | undefined, at: number): Promise<void>;
|
|
10
|
+
gauges(): Promise<Record<string, { name?: string; at: number; values: Record<string, number> }>>;
|
|
11
|
+
awardBadge(player: string, badge: string, at: number): Promise<boolean>;
|
|
12
|
+
badges(): Promise<Record<string, Record<string, number>>>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface MemoryStore extends Store { _dump(): unknown }
|
|
16
|
+
export function memoryStore(): MemoryStore;
|
|
17
|
+
|
|
18
|
+
export interface SqlStoreOptions {
|
|
19
|
+
execute: (q: { sql: string; args: unknown[] }) => Promise<{ rows: Record<string, unknown>[] }> | { rows: Record<string, unknown>[] };
|
|
20
|
+
prefix?: string;
|
|
21
|
+
dialect?: 'sqlite' | 'postgres';
|
|
22
|
+
}
|
|
23
|
+
export interface SqlStore extends Store { schema: string[]; tables: { events: string; gauges: string; badges: string }; migrate(): Promise<void> }
|
|
24
|
+
export function sqlStore(options: SqlStoreOptions): SqlStore;
|
|
25
|
+
|
|
26
|
+
export interface LadderOptions {
|
|
27
|
+
base?: number; cap?: number; perProperty?: number; perNiche?: number; maxProperties?: number; maxNiches?: number;
|
|
28
|
+
propertiesMetric?: string; nichesMetric?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface LadderState { properties?: number; niches?: number; [metric: string]: number | undefined }
|
|
31
|
+
export interface LadderStep { add: 'property' | 'niche'; rate: number; from: number; gain: number; toCap: number }
|
|
32
|
+
export interface Ladder extends Required<LadderOptions> {
|
|
33
|
+
rate(state?: LadderState): number;
|
|
34
|
+
next(state?: LadderState): LadderStep | null;
|
|
35
|
+
table(): Array<{ properties: number; niches: number; rate: number }>;
|
|
36
|
+
describe(): string;
|
|
37
|
+
}
|
|
38
|
+
export function commissionLadder(options?: LadderOptions): Ladder;
|
|
39
|
+
|
|
40
|
+
export interface Badge { id: string; emoji?: string; label?: string; describe?: string; when(profile: Profile): boolean }
|
|
41
|
+
export interface EarnedBadge { id: string; emoji: string; label: string; describe: string; at: number }
|
|
42
|
+
export function defaultBadges(names?: { sales?: string; properties?: string; niches?: string }): Badge[];
|
|
43
|
+
|
|
44
|
+
export interface BoardOptions { label?: string; metric?: string; format?: Format; unit?: string; min?: number; order?: 'asc' | 'desc'; tiebreak?: string }
|
|
45
|
+
export interface Board { id: string; label: string; metric: string; format: Format; unit: string }
|
|
46
|
+
|
|
47
|
+
export interface Profile {
|
|
48
|
+
id: string; name: string;
|
|
49
|
+
totals: Record<string, number>;
|
|
50
|
+
gauges: Record<string, number>;
|
|
51
|
+
streak: number; bestStreak: number;
|
|
52
|
+
firstAt: number; lastAt: number;
|
|
53
|
+
ranks: Record<string, Partial<Record<Period, number>>>;
|
|
54
|
+
values: Record<string, Partial<Record<Period, number>>>;
|
|
55
|
+
badges: EarnedBadge[];
|
|
56
|
+
commission?: { rate: number; next: LadderStep | null };
|
|
57
|
+
url: string; shareUrl: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface Row {
|
|
61
|
+
rank: number | null; id: string; name: string; value: number; display: string;
|
|
62
|
+
streak: number; bestStreak: number; badges: Array<{ id: string; emoji: string; label: string }>;
|
|
63
|
+
commission?: number; url: string;
|
|
64
|
+
}
|
|
65
|
+
export interface Standing extends Row { qualified: boolean; min: number }
|
|
66
|
+
export interface Top { board: Board; period: Period; periods: Period[]; generatedAt: string; total: number; rows: Row[]; me?: Standing | null }
|
|
67
|
+
|
|
68
|
+
export interface LeaderboardOptions {
|
|
69
|
+
store?: Store;
|
|
70
|
+
basePath?: string;
|
|
71
|
+
siteName?: string;
|
|
72
|
+
siteUrl?: string;
|
|
73
|
+
boards?: Record<string, BoardOptions> | Array<BoardOptions & { id: string }>;
|
|
74
|
+
periods?: Period[];
|
|
75
|
+
defaultPeriod?: Period;
|
|
76
|
+
ladder?: Ladder | boolean;
|
|
77
|
+
badges?: Badge[];
|
|
78
|
+
cacheMs?: number;
|
|
79
|
+
limitMax?: number;
|
|
80
|
+
profileUrl?: (id: string) => string;
|
|
81
|
+
now?: () => number;
|
|
82
|
+
onBadge?: (x: { player: string; name: string; badge: EarnedBadge }) => void | Promise<void>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface Leaderboard {
|
|
86
|
+
record(x: { player: string; name?: string; metrics: Record<string, number>; at?: number }): Promise<EarnedBadge[]>;
|
|
87
|
+
set(x: { player: string; name?: string; metrics: Record<string, number>; at?: number }): Promise<EarnedBadge[]>;
|
|
88
|
+
award(player: string): Promise<EarnedBadge[]>;
|
|
89
|
+
top(opts?: { board?: string; period?: Period; limit?: number; offset?: number }): Promise<Top>;
|
|
90
|
+
standing(opts: { player: string; board?: string; period?: Period }): Promise<Standing | null>;
|
|
91
|
+
profile(player: string): Promise<Profile | null>;
|
|
92
|
+
handle(request: Request): Promise<Response | null>;
|
|
93
|
+
rss(opts?: { board?: string; period?: Period; limit?: number }): Promise<string>;
|
|
94
|
+
index(): unknown;
|
|
95
|
+
widget(): string;
|
|
96
|
+
invalidate(): void;
|
|
97
|
+
boards: Board[]; periods: Period[]; basePath: string; ladder: Ladder | null; badges: Badge[];
|
|
98
|
+
}
|
|
99
|
+
export function createLeaderboard(options?: LeaderboardOptions): Leaderboard;
|
|
100
|
+
|
|
101
|
+
export function streaks(days: Iterable<number>, now?: number): { current: number; best: number };
|
|
102
|
+
export function format(value: number, kind?: Format, locale?: string): string;
|
|
103
|
+
export function periodLabel(period: Period): string;
|
|
104
|
+
|
|
105
|
+
/** Hono middleware. */
|
|
106
|
+
export function leaderboard(lbOrOptions: Leaderboard | LeaderboardOptions): (c: any, next: () => Promise<void>) => Promise<Response | void>;
|
|
107
|
+
/** Next.js Route Handler for `app/<basePath>/[[...path]]/route.js`. */
|
|
108
|
+
export function leaderboardRoute(lbOrOptions: Leaderboard | LeaderboardOptions): { GET: (request: Request) => Promise<Response>; HEAD: (request: Request) => Promise<Response> };
|
|
109
|
+
export function widgetSource(): string;
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@profullstack/leaderboard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "A public leaderboard with badges, streaks and a commission ladder that drops into any site: score events in, ranked boards + RSS + a share card + an embeddable widget out. Zero dependencies, Fetch API core, Hono and Next adapters.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"leaderboard",
|
|
8
|
+
"gamification",
|
|
9
|
+
"badges",
|
|
10
|
+
"streaks",
|
|
11
|
+
"ranking",
|
|
12
|
+
"affiliate",
|
|
13
|
+
"commission",
|
|
14
|
+
"embed",
|
|
15
|
+
"widget",
|
|
16
|
+
"rss",
|
|
17
|
+
"hono",
|
|
18
|
+
"nextjs",
|
|
19
|
+
"libsql",
|
|
20
|
+
"turso"
|
|
21
|
+
],
|
|
22
|
+
"repository": { "type": "git", "url": "git+https://github.com/profullstack/leaderboard.git" },
|
|
23
|
+
"homepage": "https://github.com/profullstack/leaderboard#readme",
|
|
24
|
+
"bugs": { "url": "https://github.com/profullstack/leaderboard/issues" },
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"author": "Profullstack, LLC",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": { "types": "./index.d.ts", "import": "./src/index.js" },
|
|
29
|
+
"./hono": { "types": "./index.d.ts", "import": "./src/hono.js" },
|
|
30
|
+
"./next": { "types": "./index.d.ts", "import": "./src/next.js" },
|
|
31
|
+
"./ladder": { "types": "./index.d.ts", "import": "./src/ladder.js" },
|
|
32
|
+
"./badges": { "types": "./index.d.ts", "import": "./src/badges.js" },
|
|
33
|
+
"./store/memory": { "types": "./index.d.ts", "import": "./src/store/memory.js" },
|
|
34
|
+
"./store/sql": { "types": "./index.d.ts", "import": "./src/store/sql.js" }
|
|
35
|
+
},
|
|
36
|
+
"types": "./index.d.ts",
|
|
37
|
+
"files": ["src", "index.d.ts", "README.md", "LICENSE"],
|
|
38
|
+
"engines": { "node": ">=20.11" },
|
|
39
|
+
"sideEffects": false,
|
|
40
|
+
"scripts": {
|
|
41
|
+
"test": "node --test"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/badges.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Badges are predicates over a player's profile. A badge is awarded once and
|
|
3
|
+
* kept forever, so `when` only has to be true at one moment: the week you were
|
|
4
|
+
* in the top ten counts even after you fall out.
|
|
5
|
+
*
|
|
6
|
+
* The profile handed to `when` carries: `totals` (summed counters, all time),
|
|
7
|
+
* `gauges` (absolute values you `set`), `streak`, `bestStreak`, `ranks`
|
|
8
|
+
* (`{ [boardId]: { [period]: rank } }`) and, when a ladder is configured,
|
|
9
|
+
* `commission.rate`.
|
|
10
|
+
*
|
|
11
|
+
* This is the default set for a marketplace with sellers, properties and
|
|
12
|
+
* niches. Pass your own array to `createLeaderboard({ badges })` for anything
|
|
13
|
+
* else; the shape is the whole contract.
|
|
14
|
+
*/
|
|
15
|
+
export function defaultBadges(names = {}) {
|
|
16
|
+
const sales = names.sales ?? 'sales';
|
|
17
|
+
const properties = names.properties ?? 'properties';
|
|
18
|
+
const niches = names.niches ?? 'niches';
|
|
19
|
+
const t = (p, k) => Number(p.totals?.[k] ?? 0);
|
|
20
|
+
const g = (p, k) => Number(p.gauges?.[k] ?? 0);
|
|
21
|
+
const bestRank = (p, period) => {
|
|
22
|
+
let best = Infinity;
|
|
23
|
+
for (const board of Object.values(p.ranks ?? {})) {
|
|
24
|
+
const r = board?.[period];
|
|
25
|
+
if (Number.isFinite(r) && r < best) best = r;
|
|
26
|
+
}
|
|
27
|
+
return best;
|
|
28
|
+
};
|
|
29
|
+
return [
|
|
30
|
+
{ id: 'first-sale', emoji: '🎉', label: 'First sale', describe: 'Made a first sale', when: (p) => t(p, sales) >= 1 },
|
|
31
|
+
{ id: 'ten-sales', emoji: '🔟', label: 'Ten sales', describe: 'Ten sales, all time', when: (p) => t(p, sales) >= 10 },
|
|
32
|
+
{ id: 'hundred-sales', emoji: '💯', label: 'Hundred sales', describe: 'One hundred sales, all time', when: (p) => t(p, sales) >= 100 },
|
|
33
|
+
{ id: 'streak-7', emoji: '🔥', label: 'Seven day streak', describe: 'Active seven days in a row', when: (p) => (p.bestStreak ?? 0) >= 7 },
|
|
34
|
+
{ id: 'streak-30', emoji: '🌋', label: 'Thirty day streak', describe: 'Active thirty days in a row', when: (p) => (p.bestStreak ?? 0) >= 30 },
|
|
35
|
+
{ id: 'three-properties', emoji: '🏠', label: 'Three properties', describe: 'Owns three properties', when: (p) => g(p, properties) >= 3 },
|
|
36
|
+
{ id: 'five-niches', emoji: '🎯', label: 'Five niches', describe: 'Promotes to five niches', when: (p) => g(p, niches) >= 5 },
|
|
37
|
+
{ id: 'top-ten-week', emoji: '🏅', label: 'Top ten this week', describe: 'Ranked in a weekly top ten', when: (p) => bestRank(p, 'week') <= 10 },
|
|
38
|
+
{ id: 'number-one', emoji: '👑', label: 'Number one', describe: 'Held first place on any board', when: (p) => bestRank(p, 'all') === 1 || bestRank(p, 'week') === 1 || bestRank(p, 'month') === 1 },
|
|
39
|
+
{ id: 'eighty-club', emoji: '💸', label: '80% club', describe: 'Reached the top commission rate', when: (p) => (p.commission?.rate ?? 0) >= 80 },
|
|
40
|
+
];
|
|
41
|
+
}
|
package/src/embed.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The embeddable widget, served by the leaderboard at `${basePath}/embed.js`.
|
|
3
|
+
* One script tag on any page, ours or a partner's:
|
|
4
|
+
*
|
|
5
|
+
* <div data-leaderboard></div>
|
|
6
|
+
* <script src="https://nichedb.dev/leaderboard/embed.js"
|
|
7
|
+
* data-board="earnings" data-period="week" data-limit="10"
|
|
8
|
+
* data-me="u_42" data-sticky="bottom-right" data-theme="dark"></script>
|
|
9
|
+
*
|
|
10
|
+
* Attributes: board, period, limit, me (highlight and show my standing),
|
|
11
|
+
* sticky (bottom-right | bottom-left | top-right | top-left: a corner pill
|
|
12
|
+
* that opens the board), theme (light | dark | auto), title, refresh (seconds,
|
|
13
|
+
* 0 to disable), target (a CSS selector for the container), base (the
|
|
14
|
+
* leaderboard path when the script is served from somewhere else).
|
|
15
|
+
*
|
|
16
|
+
* Kept as a string so the package has no build step and runs at the edge.
|
|
17
|
+
*/
|
|
18
|
+
export function widgetSource() {
|
|
19
|
+
return WIDGET;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const WIDGET = String.raw`(function () {
|
|
23
|
+
var script = document.currentScript;
|
|
24
|
+
if (!script) return;
|
|
25
|
+
var d = script.dataset;
|
|
26
|
+
var src = new URL(script.src, location.href);
|
|
27
|
+
var base = d.base || src.pathname.replace(/\/embed\.js$/, '');
|
|
28
|
+
var origin = src.origin;
|
|
29
|
+
var board = d.board || '';
|
|
30
|
+
var period = d.period || '';
|
|
31
|
+
var limit = d.limit || '10';
|
|
32
|
+
var me = d.me || '';
|
|
33
|
+
var sticky = d.sticky || '';
|
|
34
|
+
var theme = d.theme || 'auto';
|
|
35
|
+
var refresh = d.refresh === undefined ? 60 : Number(d.refresh) || 0;
|
|
36
|
+
var periods = null;
|
|
37
|
+
|
|
38
|
+
var CSS = '.pflb{--bg:#fff;--fg:#111827;--muted:#6b7280;--line:#e5e7eb;--accent:#2563eb;--gold:#b45309;--hi:#eff6ff;font:14px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:var(--fg);background:var(--bg);border:1px solid var(--line);border-radius:12px;overflow:hidden;box-sizing:border-box}' +
|
|
39
|
+
'.pflb.dark{--bg:#0f1219;--fg:#e8eaf0;--muted:#8b93a7;--line:#242a38;--accent:#7cc4ff;--gold:#fbbf24;--hi:#172033}' +
|
|
40
|
+
'@media (prefers-color-scheme:dark){.pflb.auto{--bg:#0f1219;--fg:#e8eaf0;--muted:#8b93a7;--line:#242a38;--accent:#7cc4ff;--gold:#fbbf24;--hi:#172033}}' +
|
|
41
|
+
'.pflb *{box-sizing:border-box}.pflb a{color:inherit;text-decoration:none}' +
|
|
42
|
+
'.pflb-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:12px 14px;border-bottom:1px solid var(--line);flex-wrap:wrap}' +
|
|
43
|
+
'.pflb-title{font-weight:700;font-size:15px;margin:0}.pflb-tabs{display:flex;gap:4px;flex-wrap:wrap}' +
|
|
44
|
+
'.pflb-tabs button{font:inherit;font-size:12px;padding:4px 10px;border-radius:999px;border:1px solid var(--line);background:transparent;color:var(--muted);cursor:pointer}' +
|
|
45
|
+
'.pflb-tabs button.on{background:var(--accent);border-color:var(--accent);color:#fff}' +
|
|
46
|
+
'.pflb table{width:100%;border-collapse:collapse}.pflb td,.pflb th{padding:9px 14px;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap}' +
|
|
47
|
+
'.pflb th{font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);font-weight:600}' +
|
|
48
|
+
'.pflb tr.me td{background:var(--hi)}.pflb td.rank{font-weight:800;color:var(--muted);width:44px}.pflb td.rank.top{color:var(--gold)}' +
|
|
49
|
+
'.pflb td.name{font-weight:600;white-space:normal}.pflb td.val{font-weight:700;text-align:right}.pflb td.streak{color:var(--muted);text-align:right}' +
|
|
50
|
+
'.pflb .badges{margin-left:6px;font-size:13px;letter-spacing:1px}.pflb .share{margin-left:8px;font-size:11px;color:var(--muted);cursor:pointer;opacity:0;transition:opacity .15s}.pflb tr:hover .share{opacity:1}' +
|
|
51
|
+
'.pflb-foot{display:flex;justify-content:space-between;gap:12px;padding:10px 14px;font-size:12px;color:var(--muted);flex-wrap:wrap}.pflb-foot a{color:var(--accent)}' +
|
|
52
|
+
'.pflb-empty{padding:24px 14px;color:var(--muted);text-align:center}' +
|
|
53
|
+
'.pflb-pill{position:fixed;z-index:2147483000;font:13px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#111827;color:#fff;border-radius:999px;padding:10px 14px;box-shadow:0 6px 24px rgba(0,0,0,.25);cursor:pointer;border:0;display:flex;align-items:center;gap:8px}' +
|
|
54
|
+
'.pflb-panel{position:fixed;z-index:2147483000;width:min(420px,calc(100vw - 24px));max-height:min(70vh,640px);overflow:auto;box-shadow:0 12px 40px rgba(0,0,0,.35)}' +
|
|
55
|
+
'.pflb-panel .pflb-close{font:inherit;background:transparent;border:0;color:var(--muted);cursor:pointer;font-size:18px;line-height:1}';
|
|
56
|
+
|
|
57
|
+
function ensureStyle() {
|
|
58
|
+
if (document.getElementById('pflb-style')) return;
|
|
59
|
+
var s = document.createElement('style');
|
|
60
|
+
s.id = 'pflb-style';
|
|
61
|
+
s.textContent = CSS;
|
|
62
|
+
document.head.appendChild(s);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function findTarget() {
|
|
66
|
+
if (d.target) { var t = document.querySelector(d.target); if (t) return t; }
|
|
67
|
+
var prev = script.previousElementSibling;
|
|
68
|
+
while (prev) { if (prev.hasAttribute('data-leaderboard')) return prev; prev = prev.previousElementSibling; }
|
|
69
|
+
var any = document.querySelector('[data-leaderboard]:empty');
|
|
70
|
+
if (any) return any;
|
|
71
|
+
var div = document.createElement('div');
|
|
72
|
+
script.parentNode.insertBefore(div, script);
|
|
73
|
+
return div;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }
|
|
77
|
+
function label(p) { return { all: 'All time', day: 'Today', week: 'Week', month: 'Month' }[p] || p; }
|
|
78
|
+
|
|
79
|
+
function url(kind) {
|
|
80
|
+
var u = origin + base + '/' + board + '.' + kind + '?period=' + encodeURIComponent(period) + '&limit=' + encodeURIComponent(limit);
|
|
81
|
+
if (me && kind === 'json') u += '&me=' + encodeURIComponent(me);
|
|
82
|
+
return u;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function copy(text, el) {
|
|
86
|
+
var done = function () { var was = el.textContent; el.textContent = 'copied'; setTimeout(function () { el.textContent = was; }, 1200); };
|
|
87
|
+
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, function () { prompt('Share link', text); });
|
|
88
|
+
else prompt('Share link', text);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function render(root, data) {
|
|
92
|
+
var rows = data.rows || [];
|
|
93
|
+
if (!board) board = data.board.id;
|
|
94
|
+
if (!periods) periods = data.periods || ['all'];
|
|
95
|
+
period = data.period;
|
|
96
|
+
var title = d.title || data.board.label;
|
|
97
|
+
var tabs = periods.map(function (p) { return '<button type="button" data-p="' + esc(p) + '" class="' + (p === period ? 'on' : '') + '">' + esc(label(p)) + '</button>'; }).join('');
|
|
98
|
+
var body = rows.length ? rows.map(function (r) {
|
|
99
|
+
var isMe = me && r.id === me;
|
|
100
|
+
var badges = (r.badges || []).map(function (b) { return '<span title="' + esc(b.label) + '">' + esc(b.emoji) + '</span>'; }).join('');
|
|
101
|
+
var share = origin + base + '/u/' + encodeURIComponent(r.id);
|
|
102
|
+
return '<tr class="' + (isMe ? 'me' : '') + '">' +
|
|
103
|
+
'<td class="rank ' + (r.rank <= 3 ? 'top' : '') + '">' + (r.rank < 10 ? '0' : '') + r.rank + '</td>' +
|
|
104
|
+
'<td class="name"><a href="' + esc(origin + r.url) + '">' + esc(r.name) + '</a>' + (badges ? '<span class="badges">' + badges + '</span>' : '') +
|
|
105
|
+
(r.commission != null ? ' <span style="color:var(--muted);font-weight:400;font-size:12px">' + r.commission + '%</span>' : '') +
|
|
106
|
+
'<span class="share" data-share="' + esc(share) + '">share</span></td>' +
|
|
107
|
+
'<td class="val">' + esc(r.display) + '</td>' +
|
|
108
|
+
'<td class="streak" title="streak">' + (r.streak ? '🔥' + r.streak : '') + '</td></tr>';
|
|
109
|
+
}).join('') : '';
|
|
110
|
+
var meRow = '';
|
|
111
|
+
if (data.me && !rows.some(function (r) { return r.id === me; })) {
|
|
112
|
+
var m = data.me;
|
|
113
|
+
meRow = '<tr class="me"><td class="rank">' + (m.rank ? (m.rank < 10 ? '0' : '') + m.rank : '–') + '</td><td class="name">' + esc(m.name) + ' <span style="color:var(--muted);font-weight:400">(you)</span></td><td class="val">' + esc(m.display) + '</td><td class="streak">' + (m.streak ? '🔥' + m.streak : '') + '</td></tr>';
|
|
114
|
+
}
|
|
115
|
+
root.innerHTML =
|
|
116
|
+
'<div class="pflb-head"><h3 class="pflb-title">' + esc(title) + '</h3><div class="pflb-tabs">' + tabs + '</div>' + (root.classList.contains('pflb-panel') ? '<button class="pflb-close" aria-label="Close">×</button>' : '') + '</div>' +
|
|
117
|
+
(rows.length || meRow
|
|
118
|
+
? '<table><thead><tr><th>#</th><th>Name</th><th style="text-align:right">' + esc(data.board.unit || data.board.label) + '</th><th></th></tr></thead><tbody>' + body + meRow + '</tbody></table>'
|
|
119
|
+
: '<div class="pflb-empty">Nobody on the board yet. Be first.</div>') +
|
|
120
|
+
'<div class="pflb-foot"><span>' + esc(label(period)) + ' · ' + esc(String(data.total || rows.length)) + ' ranked</span><span><a href="' + esc(url('xml')) + '">RSS</a> · <a href="' + esc(origin + base + '?board=' + encodeURIComponent(board) + '&period=' + encodeURIComponent(period)) + '">Full board</a></span></div>';
|
|
121
|
+
root.querySelectorAll('.pflb-tabs button').forEach(function (b) { b.addEventListener('click', function () { period = b.dataset.p; load(root); }); });
|
|
122
|
+
root.querySelectorAll('.share').forEach(function (s) { s.addEventListener('click', function (e) { e.preventDefault(); copy(s.dataset.share, s); }); });
|
|
123
|
+
var close = root.querySelector('.pflb-close');
|
|
124
|
+
if (close) close.addEventListener('click', function () { root.hidden = true; });
|
|
125
|
+
root.dispatchEvent(new CustomEvent('leaderboard:render', { bubbles: true, detail: data }));
|
|
126
|
+
return data;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function load(root) {
|
|
130
|
+
if (!board) {
|
|
131
|
+
return fetch(origin + base + '.json').then(function (r) { return r.json(); }).then(function (idx) {
|
|
132
|
+
board = (idx.boards[0] || {}).id; periods = idx.periods; period = period || idx.defaultPeriod; return load(root);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return fetch(url('json')).then(function (r) { return r.json(); }).then(function (data) { return render(root, data); }).catch(function () {
|
|
136
|
+
root.innerHTML = '<div class="pflb-empty">Leaderboard unavailable.</div>';
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
ensureStyle();
|
|
141
|
+
var root;
|
|
142
|
+
if (sticky) {
|
|
143
|
+
var pos = sticky.split('-');
|
|
144
|
+
var v = pos[0] === 'top' ? 'top' : 'bottom', h = pos[1] === 'left' ? 'left' : 'right';
|
|
145
|
+
var pill = document.createElement('button');
|
|
146
|
+
pill.className = 'pflb-pill';
|
|
147
|
+
pill.style[v] = '16px'; pill.style[h] = '16px';
|
|
148
|
+
pill.innerHTML = '🏆 <span>Leaderboard</span>';
|
|
149
|
+
root = document.createElement('div');
|
|
150
|
+
root.className = 'pflb pflb-panel ' + theme;
|
|
151
|
+
root.hidden = true;
|
|
152
|
+
root.style[v] = '64px'; root.style[h] = '16px';
|
|
153
|
+
document.body.appendChild(pill); document.body.appendChild(root);
|
|
154
|
+
pill.addEventListener('click', function () { root.hidden = !root.hidden; if (!root.hidden) load(root); });
|
|
155
|
+
root.addEventListener('leaderboard:render', function (e) {
|
|
156
|
+
var data = e.detail; var m = data.me; var top = (data.rows || [])[0];
|
|
157
|
+
pill.querySelector('span').textContent = m && m.rank ? 'You are #' + m.rank + ' ' + label(data.period).toLowerCase() : top ? '#1 ' + top.name : 'Leaderboard';
|
|
158
|
+
});
|
|
159
|
+
load(root);
|
|
160
|
+
} else {
|
|
161
|
+
root = findTarget();
|
|
162
|
+
root.classList.add('pflb', theme);
|
|
163
|
+
load(root);
|
|
164
|
+
}
|
|
165
|
+
if (refresh > 0) setInterval(function () { if (!document.hidden && !root.hidden) load(root); }, refresh * 1000);
|
|
166
|
+
})();
|
|
167
|
+
`;
|
package/src/hono.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createLeaderboard } from './index.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The leaderboard as Hono middleware: JSON, RSS, the share card, the embed
|
|
5
|
+
* page and the widget script, all under `basePath`.
|
|
6
|
+
*
|
|
7
|
+
* import { leaderboard } from '@profullstack/leaderboard/hono';
|
|
8
|
+
* const lb = createLeaderboard({ store, boards });
|
|
9
|
+
* app.use('*', leaderboard(lb));
|
|
10
|
+
*
|
|
11
|
+
* Takes either options or a leaderboard already created, so an app can keep
|
|
12
|
+
* the instance around for `record()` and `set()`.
|
|
13
|
+
*/
|
|
14
|
+
export function leaderboard(lbOrOptions) {
|
|
15
|
+
const lb = lbOrOptions && typeof lbOrOptions.handle === 'function' ? lbOrOptions : createLeaderboard(lbOrOptions);
|
|
16
|
+
return async (c, next) => {
|
|
17
|
+
const answer = await lb.handle(c.req.raw);
|
|
18
|
+
if (answer) return answer;
|
|
19
|
+
await next();
|
|
20
|
+
};
|
|
21
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import { memoryStore } from './store/memory.js';
|
|
2
|
+
import { commissionLadder } from './ladder.js';
|
|
3
|
+
import { defaultBadges } from './badges.js';
|
|
4
|
+
import { widgetSource } from './embed.js';
|
|
5
|
+
|
|
6
|
+
export { commissionLadder } from './ladder.js';
|
|
7
|
+
export { defaultBadges } from './badges.js';
|
|
8
|
+
export { memoryStore } from './store/memory.js';
|
|
9
|
+
export { sqlStore } from './store/sql.js';
|
|
10
|
+
|
|
11
|
+
const DAY = 86_400_000;
|
|
12
|
+
const PERIODS = { all: 0, day: DAY, week: 7 * DAY, month: 30 * DAY };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build a leaderboard.
|
|
16
|
+
*
|
|
17
|
+
* const lb = createLeaderboard({
|
|
18
|
+
* siteName: 'NicheDB', siteUrl: 'https://nichedb.dev',
|
|
19
|
+
* store: sqlStore({ execute }),
|
|
20
|
+
* boards: {
|
|
21
|
+
* earnings: { label: 'Top earners', metric: 'cents', format: 'usd' },
|
|
22
|
+
* sales: { label: 'Most sales', metric: 'sales' },
|
|
23
|
+
* streaks: { label: 'Longest streaks', metric: 'streak' },
|
|
24
|
+
* },
|
|
25
|
+
* ladder: commissionLadder(),
|
|
26
|
+
* });
|
|
27
|
+
* await lb.record({ player: 'u_42', name: 'Dr. Okafor', metrics: { cents: 1900, sales: 1 } });
|
|
28
|
+
* await lb.set({ player: 'u_42', metrics: { properties: 2, niches: 4 } });
|
|
29
|
+
*
|
|
30
|
+
* Counters go in through `record` and are summed per period; gauges go in
|
|
31
|
+
* through `set` and are the current value. `streak` and `bestStreak` are
|
|
32
|
+
* derived from the days a player recorded anything. Every board ranks one
|
|
33
|
+
* metric of any of those three kinds.
|
|
34
|
+
*/
|
|
35
|
+
export function createLeaderboard(options = {}) {
|
|
36
|
+
const store = options.store ?? memoryStore();
|
|
37
|
+
const basePath = trimSlash(options.basePath ?? '/leaderboard');
|
|
38
|
+
const siteName = options.siteName ?? 'Leaderboard';
|
|
39
|
+
const siteUrl = options.siteUrl ? trimSlash(options.siteUrl) : '';
|
|
40
|
+
const periods = (options.periods ?? ['all', 'week', 'month']).filter((p) => p in PERIODS);
|
|
41
|
+
const defaultPeriod = periods.includes(options.defaultPeriod) ? options.defaultPeriod : periods[0];
|
|
42
|
+
const boards = normaliseBoards(options.boards);
|
|
43
|
+
const defaultBoard = boards[0].id;
|
|
44
|
+
const ladder = options.ladder === undefined ? null : options.ladder === true ? commissionLadder() : options.ladder;
|
|
45
|
+
const badges = options.badges ?? (ladder ? defaultBadges({ properties: ladder.propertiesMetric, niches: ladder.nichesMetric }) : defaultBadges());
|
|
46
|
+
const cacheMs = Math.max(0, Number(options.cacheMs ?? 15_000));
|
|
47
|
+
const limitMax = Math.max(1, Number(options.limitMax ?? 100));
|
|
48
|
+
const profileUrl = options.profileUrl ?? ((id) => `${basePath}/u/${encodeURIComponent(id)}`);
|
|
49
|
+
const now = options.now ?? (() => Date.now());
|
|
50
|
+
const onBadge = options.onBadge ?? null;
|
|
51
|
+
|
|
52
|
+
let cache = null; // { at, all: Map<player, profile>, ordered }
|
|
53
|
+
|
|
54
|
+
async function record({ player, name, metrics, at } = {}) {
|
|
55
|
+
if (!player) throw new TypeError('record needs a player');
|
|
56
|
+
const when = at ?? now();
|
|
57
|
+
const entries = Object.entries(metrics ?? {}).filter(([, v]) => Number.isFinite(Number(v)));
|
|
58
|
+
if (!entries.length) throw new TypeError('record needs at least one numeric metric');
|
|
59
|
+
for (const [metric, delta] of entries) await store.append({ player: String(player), name, metric, delta: Number(delta), at: when });
|
|
60
|
+
cache = null;
|
|
61
|
+
return award(String(player));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function set({ player, name, metrics, at } = {}) {
|
|
65
|
+
if (!player) throw new TypeError('set needs a player');
|
|
66
|
+
const when = at ?? now();
|
|
67
|
+
for (const [metric, value] of Object.entries(metrics ?? {})) await store.setGauge(String(player), metric, Number(value), name, when);
|
|
68
|
+
cache = null;
|
|
69
|
+
return award(String(player));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Re-check every badge for one player; returns the badges newly awarded. */
|
|
73
|
+
async function award(player) {
|
|
74
|
+
const profiles = await computeAll();
|
|
75
|
+
const p = profiles.get(player);
|
|
76
|
+
if (!p) return [];
|
|
77
|
+
const fresh = [];
|
|
78
|
+
for (const b of badges) {
|
|
79
|
+
if (p.badges.some((x) => x.id === b.id)) continue;
|
|
80
|
+
let hit = false;
|
|
81
|
+
try { hit = Boolean(b.when(p)); } catch { hit = false; }
|
|
82
|
+
if (!hit) continue;
|
|
83
|
+
const at = now();
|
|
84
|
+
if (await store.awardBadge(player, b.id, at)) {
|
|
85
|
+
const earned = { id: b.id, emoji: b.emoji ?? '', label: b.label ?? b.id, describe: b.describe ?? '', at };
|
|
86
|
+
p.badges.push(earned);
|
|
87
|
+
fresh.push(earned);
|
|
88
|
+
if (onBadge) { try { await onBadge({ player, name: p.name, badge: earned }); } catch {} }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return fresh;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Every player's all-time profile, cached for `cacheMs`. */
|
|
95
|
+
async function computeAll() {
|
|
96
|
+
if (cache && now() - cache.at < cacheMs) return cache.all;
|
|
97
|
+
const t = now();
|
|
98
|
+
const [events, gauges, awarded] = await Promise.all([store.list({ since: 0 }), store.gauges(), store.badges()]);
|
|
99
|
+
const all = new Map();
|
|
100
|
+
const profile = (id) => all.get(id) ?? (all.set(id, blank(id)), all.get(id));
|
|
101
|
+
for (const e of events) {
|
|
102
|
+
const p = profile(e.player);
|
|
103
|
+
p.totals[e.metric] = (p.totals[e.metric] ?? 0) + e.delta;
|
|
104
|
+
if (e.name && e.at >= p.nameAt) { p.name = e.name; p.nameAt = e.at; }
|
|
105
|
+
if (e.at > p.lastAt) p.lastAt = e.at;
|
|
106
|
+
if (e.at < p.firstAt) p.firstAt = e.at;
|
|
107
|
+
p.days.add(dayOf(e.at));
|
|
108
|
+
}
|
|
109
|
+
for (const [id, row] of Object.entries(gauges)) {
|
|
110
|
+
const p = profile(id);
|
|
111
|
+
p.gauges = { ...row.values };
|
|
112
|
+
if (row.name && row.at >= p.nameAt) { p.name = row.name; p.nameAt = row.at; }
|
|
113
|
+
}
|
|
114
|
+
for (const p of all.values()) {
|
|
115
|
+
const s = streaks(p.days, t);
|
|
116
|
+
p.streak = s.current;
|
|
117
|
+
p.bestStreak = s.best;
|
|
118
|
+
if (ladder) p.commission = { rate: ladder.rate(p.gauges), next: ladder.next(p.gauges) };
|
|
119
|
+
delete p.days;
|
|
120
|
+
}
|
|
121
|
+
// Windows and ranks: each board, each period.
|
|
122
|
+
const byPeriod = {};
|
|
123
|
+
for (const period of periods) byPeriod[period] = windowTotals(events, all, period, t);
|
|
124
|
+
for (const b of boards) {
|
|
125
|
+
for (const period of periods) {
|
|
126
|
+
const ranked = rank(b, all, byPeriod[period]);
|
|
127
|
+
ranked.forEach((r, i) => {
|
|
128
|
+
const p = all.get(r.id);
|
|
129
|
+
(p.ranks[b.id] ??= {})[period] = i + 1;
|
|
130
|
+
(p.values[b.id] ??= {})[period] = r.value;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
for (const p of all.values()) {
|
|
135
|
+
const mine = awarded[p.id] ?? {};
|
|
136
|
+
p.badges = badges.filter((b) => mine[b.id]).map((b) => ({ id: b.id, emoji: b.emoji ?? '', label: b.label ?? b.id, describe: b.describe ?? '', at: mine[b.id] }));
|
|
137
|
+
}
|
|
138
|
+
cache = { at: t, all };
|
|
139
|
+
return all;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function windowTotals(events, all, period, t) {
|
|
143
|
+
if (period === 'all') return null;
|
|
144
|
+
const since = t - PERIODS[period];
|
|
145
|
+
const totals = new Map();
|
|
146
|
+
for (const e of events) {
|
|
147
|
+
if (e.at < since) continue;
|
|
148
|
+
const row = totals.get(e.player) ?? {};
|
|
149
|
+
row[e.metric] = (row[e.metric] ?? 0) + e.delta;
|
|
150
|
+
totals.set(e.player, row);
|
|
151
|
+
}
|
|
152
|
+
return totals;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function valueOf(board, p, window) {
|
|
156
|
+
const m = board.metric;
|
|
157
|
+
if (m === 'streak') return p.streak;
|
|
158
|
+
if (m === 'bestStreak') return p.bestStreak;
|
|
159
|
+
if (m in p.gauges) return p.gauges[m];
|
|
160
|
+
if (window) return window.get(p.id)?.[m] ?? 0;
|
|
161
|
+
return p.totals[m] ?? 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function rank(board, all, window) {
|
|
165
|
+
const rows = [];
|
|
166
|
+
for (const p of all.values()) {
|
|
167
|
+
const value = valueOf(board, p, window);
|
|
168
|
+
if (value < board.min) continue;
|
|
169
|
+
if (window && !window.has(p.id) && !(board.metric in p.gauges) && board.metric !== 'streak' && board.metric !== 'bestStreak') continue;
|
|
170
|
+
rows.push({ id: p.id, value, tie: board.tiebreak ? valueOf({ metric: board.tiebreak, min: -Infinity }, p, window) : 0, lastAt: p.lastAt, name: p.name });
|
|
171
|
+
}
|
|
172
|
+
const dir = board.order === 'asc' ? 1 : -1;
|
|
173
|
+
rows.sort((a, b) => (a.value - b.value) * dir || (b.tie - a.tie) || (a.lastAt - b.lastAt) || a.name.localeCompare(b.name));
|
|
174
|
+
return rows;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function boardOf(id) {
|
|
178
|
+
return boards.find((b) => b.id === id) ?? null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function periodOf(p) {
|
|
182
|
+
return periods.includes(p) ? p : defaultPeriod;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function top({ board = defaultBoard, period = defaultPeriod, limit = 25, offset = 0 } = {}) {
|
|
186
|
+
const b = boardOf(board);
|
|
187
|
+
if (!b) throw new RangeError(`unknown board: ${board}`);
|
|
188
|
+
period = periodOf(period);
|
|
189
|
+
const all = await computeAll();
|
|
190
|
+
const rows = [];
|
|
191
|
+
for (const p of all.values()) {
|
|
192
|
+
const r = p.ranks[b.id]?.[period];
|
|
193
|
+
if (r) rows.push(row(p, b, period, r));
|
|
194
|
+
}
|
|
195
|
+
rows.sort((a, c) => a.rank - c.rank);
|
|
196
|
+
const n = Math.min(limitMax, Math.max(1, Number(limit) || 25));
|
|
197
|
+
return { board: pub(b), period, periods, generatedAt: new Date(now()).toISOString(), total: rows.length, rows: rows.slice(Number(offset) || 0, (Number(offset) || 0) + n) };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function standing({ player, board = defaultBoard, period = defaultPeriod } = {}) {
|
|
201
|
+
const b = boardOf(board);
|
|
202
|
+
if (!b || !player) return null;
|
|
203
|
+
period = periodOf(period);
|
|
204
|
+
const all = await computeAll();
|
|
205
|
+
const p = all.get(String(player));
|
|
206
|
+
if (!p) return null;
|
|
207
|
+
const r = p.ranks[b.id]?.[period] ?? null;
|
|
208
|
+
return { ...row(p, b, period, r), qualified: r !== null, min: b.min };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function profile(player) {
|
|
212
|
+
const all = await computeAll();
|
|
213
|
+
const p = all.get(String(player));
|
|
214
|
+
if (!p) return null;
|
|
215
|
+
const { nameAt, ...rest } = p;
|
|
216
|
+
return { ...rest, url: profileUrl(p.id), shareUrl: `${siteUrl}${basePath}/u/${encodeURIComponent(p.id)}` };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function row(p, b, period, r) {
|
|
220
|
+
const value = p.values[b.id]?.[period] ?? 0;
|
|
221
|
+
return {
|
|
222
|
+
rank: r, id: p.id, name: p.name, value, display: format(value, b.format),
|
|
223
|
+
streak: p.streak, bestStreak: p.bestStreak,
|
|
224
|
+
badges: p.badges.map(({ id, emoji, label }) => ({ id, emoji, label })),
|
|
225
|
+
commission: p.commission ? p.commission.rate : undefined,
|
|
226
|
+
url: profileUrl(p.id),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function boardsIndex() {
|
|
231
|
+
return { site: siteName, siteUrl, basePath, periods, defaultPeriod, boards: boards.map(pub), ladder: ladder ? { base: ladder.base, cap: ladder.cap, describe: ladder.describe() } : null, badges: badges.map(({ id, emoji, label, describe }) => ({ id, emoji, label, describe })) };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Fetch-API handler: answers under basePath, null for anything else. */
|
|
235
|
+
async function handle(request) {
|
|
236
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') return null;
|
|
237
|
+
const url = new URL(request.url);
|
|
238
|
+
const path = trimSlash(url.pathname);
|
|
239
|
+
if (path !== basePath && !path.startsWith(basePath + '/') && path !== basePath + '.json') return null;
|
|
240
|
+
const rest = path === basePath ? '' : path.slice(basePath.length);
|
|
241
|
+
const q = url.searchParams;
|
|
242
|
+
const period = periodOf(q.get('period'));
|
|
243
|
+
const limit = q.get('limit') ?? 25;
|
|
244
|
+
|
|
245
|
+
if (rest === '.json' || rest === '/index.json') return json(boardsIndex());
|
|
246
|
+
if (rest === '/embed.js') return new Response(widgetSource(), { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'public, max-age=3600' } });
|
|
247
|
+
if (rest === '' || rest === '/embed' || rest === '/index.html') {
|
|
248
|
+
const b = boardOf(q.get('board') ?? defaultBoard) ?? boards[0];
|
|
249
|
+
return html(embedPage({ board: b, period, limit, sticky: q.get('sticky'), theme: q.get('theme'), me: q.get('me'), full: rest !== '/embed' }));
|
|
250
|
+
}
|
|
251
|
+
let m;
|
|
252
|
+
if ((m = rest.match(/^\/u\/([^/]+)\.json$/))) {
|
|
253
|
+
const p = await profile(decodeURIComponent(m[1]));
|
|
254
|
+
return p ? json(p) : json({ error: 'unknown player' }, 404);
|
|
255
|
+
}
|
|
256
|
+
if ((m = rest.match(/^\/u\/([^/]+)$/))) {
|
|
257
|
+
const p = await profile(decodeURIComponent(m[1]));
|
|
258
|
+
return p ? html(sharePage(p)) : html(notFoundPage(), 404);
|
|
259
|
+
}
|
|
260
|
+
if ((m = rest.match(/^\/([a-z0-9_-]+)\.(json|xml|rss)$/i))) {
|
|
261
|
+
const b = boardOf(m[1]);
|
|
262
|
+
if (!b) return json({ error: 'unknown board' }, 404);
|
|
263
|
+
const result = await top({ board: b.id, period, limit, offset: q.get('offset') ?? 0 });
|
|
264
|
+
if (m[2] === 'json') {
|
|
265
|
+
const me = q.get('me');
|
|
266
|
+
if (me) result.me = await standing({ player: me, board: b.id, period });
|
|
267
|
+
return json(result);
|
|
268
|
+
}
|
|
269
|
+
return new Response(rss(result), { headers: { 'content-type': 'application/rss+xml; charset=utf-8', 'cache-control': 'public, max-age=300' } });
|
|
270
|
+
}
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function rss(result) {
|
|
275
|
+
const title = `${siteName}: ${result.board.label} (${periodLabel(result.period)})`;
|
|
276
|
+
const link = `${siteUrl}${basePath}?board=${result.board.id}&period=${result.period}`;
|
|
277
|
+
const items = result.rows.map((r) => {
|
|
278
|
+
const badgeText = r.badges.map((b) => `${b.emoji} ${b.label}`).join(', ');
|
|
279
|
+
return ` <item>
|
|
280
|
+
<title>${esc(`#${r.rank} ${r.name}: ${r.display}`)}</title>
|
|
281
|
+
<link>${esc(absolute(r.url))}</link>
|
|
282
|
+
<guid isPermaLink="false">${esc(`${result.board.id}:${result.period}:${r.id}:${r.value}`)}</guid>
|
|
283
|
+
<description>${esc(`${r.name} is #${r.rank} on ${result.board.label} with ${r.display}. Streak ${r.streak} (best ${r.bestStreak}).${badgeText ? ' Badges: ' + badgeText + '.' : ''}`)}</description>
|
|
284
|
+
</item>`;
|
|
285
|
+
});
|
|
286
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
287
|
+
<rss version="2.0">
|
|
288
|
+
<channel>
|
|
289
|
+
<title>${esc(title)}</title>
|
|
290
|
+
<link>${esc(link)}</link>
|
|
291
|
+
<description>${esc(`Top ${result.rows.length} on ${result.board.label}, ${periodLabel(result.period)}.`)}</description>
|
|
292
|
+
<lastBuildDate>${new Date(result.generatedAt).toUTCString()}</lastBuildDate>
|
|
293
|
+
${items.join('\n')}
|
|
294
|
+
</channel>
|
|
295
|
+
</rss>
|
|
296
|
+
`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function embedPage({ board, period, limit, sticky, theme, me, full }) {
|
|
300
|
+
const attrs = [
|
|
301
|
+
`data-board="${esc(board.id)}"`, `data-period="${esc(period)}"`, `data-limit="${esc(String(limit))}"`,
|
|
302
|
+
sticky ? `data-sticky="${esc(sticky)}"` : '', theme ? `data-theme="${esc(theme)}"` : '', me ? `data-me="${esc(me)}"` : '',
|
|
303
|
+
`data-base="${esc(basePath)}"`,
|
|
304
|
+
].filter(Boolean).join(' ');
|
|
305
|
+
const title = `${board.label}: ${siteName}`;
|
|
306
|
+
return `<!doctype html>
|
|
307
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
308
|
+
<title>${esc(title)}</title>
|
|
309
|
+
<meta property="og:title" content="${esc(title)}"><meta property="og:description" content="${esc(`Live ${periodLabel(period)} ranking on ${siteName}.`)}">
|
|
310
|
+
<link rel="alternate" type="application/rss+xml" title="${esc(title)}" href="${esc(`${basePath}/${board.id}.xml?period=${period}`)}">
|
|
311
|
+
<style>body{margin:0;background:transparent;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif}${full ? 'main{max-width:760px;margin:0 auto;padding:24px 16px}' : 'main{padding:8px}'}</style>
|
|
312
|
+
</head><body><main><div data-leaderboard></div>
|
|
313
|
+
<script src="${esc(basePath)}/embed.js" ${attrs}></script>
|
|
314
|
+
</main></body></html>`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function sharePage(p) {
|
|
318
|
+
const lines = boards.map((b) => {
|
|
319
|
+
const r = p.ranks[b.id]?.[defaultPeriod];
|
|
320
|
+
const v = p.values[b.id]?.[defaultPeriod];
|
|
321
|
+
return r ? `<li><strong>#${r}</strong> on ${esc(b.label)} <span class="muted">(${esc(periodLabel(defaultPeriod))}, ${esc(format(v, b.format))})</span></li>` : '';
|
|
322
|
+
}).filter(Boolean).join('');
|
|
323
|
+
const bestRank = Math.min(...boards.map((b) => p.ranks[b.id]?.[defaultPeriod] ?? Infinity));
|
|
324
|
+
const headline = Number.isFinite(bestRank) ? `#${bestRank} on ${siteName}` : `${p.name} on ${siteName}`;
|
|
325
|
+
const desc = `${p.name}: streak ${p.streak} (best ${p.bestStreak}), ${p.badges.length} badge${p.badges.length === 1 ? '' : 's'}${p.commission ? `, ${p.commission.rate}% commission` : ''}.`;
|
|
326
|
+
const badgeHtml = p.badges.map((b) => `<li title="${esc(b.describe)}"><span class="e">${esc(b.emoji)}</span> ${esc(b.label)}</li>`).join('') || '<li class="muted">No badges yet.</li>';
|
|
327
|
+
const ladderHtml = p.commission ? `<section><h2>Commission</h2><p class="big">${p.commission.rate}%</p><p class="muted">${p.commission.next ? esc(`Add one ${p.commission.next.add} to reach ${p.commission.next.rate}%.`) : esc(`At the cap. ${ladder.describe()}`)}</p></section>` : '';
|
|
328
|
+
const boardUrl = `${siteUrl}${basePath}`;
|
|
329
|
+
return `<!doctype html>
|
|
330
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
331
|
+
<title>${esc(`${p.name}: ${headline}`)}</title>
|
|
332
|
+
<meta property="og:type" content="profile"><meta property="og:title" content="${esc(`${p.name}: ${headline}`)}"><meta property="og:description" content="${esc(desc)}"><meta property="og:url" content="${esc(p.shareUrl)}">
|
|
333
|
+
<meta name="twitter:card" content="summary"><meta name="twitter:title" content="${esc(`${p.name}: ${headline}`)}"><meta name="twitter:description" content="${esc(desc)}">
|
|
334
|
+
<link rel="alternate" type="application/json" href="${esc(`${basePath}/u/${encodeURIComponent(p.id)}.json`)}">
|
|
335
|
+
<style>
|
|
336
|
+
:root{color-scheme:light dark}body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#0b0d12;color:#e8eaf0}
|
|
337
|
+
main{max-width:640px;margin:0 auto;padding:40px 20px}h1{font-size:28px;margin:0 0 4px}h2{font-size:12px;text-transform:uppercase;letter-spacing:.12em;color:#8b93a7;margin:24px 0 8px}
|
|
338
|
+
.big{font-size:40px;font-weight:800;margin:0}.muted{color:#8b93a7}ul{list-style:none;padding:0;margin:0}li{padding:8px 0;border-bottom:1px solid #1f2430}.e{font-size:20px;margin-right:6px}
|
|
339
|
+
a{color:#7cc4ff}.stats{display:flex;gap:24px;flex-wrap:wrap}.stats div b{display:block;font-size:22px}
|
|
340
|
+
</style></head><body><main>
|
|
341
|
+
<p class="muted"><a href="${esc(boardUrl)}">${esc(siteName)} leaderboard</a></p>
|
|
342
|
+
<h1>${esc(p.name)}</h1><p class="muted">${esc(headline)}</p>
|
|
343
|
+
<div class="stats"><div><b>${p.streak}</b><span class="muted">day streak</span></div><div><b>${p.bestStreak}</b><span class="muted">best streak</span></div><div><b>${p.badges.length}</b><span class="muted">badges</span></div></div>
|
|
344
|
+
<section><h2>Boards</h2><ul>${lines || '<li class="muted">Not on a board yet.</li>'}</ul></section>
|
|
345
|
+
<section><h2>Badges</h2><ul>${badgeHtml}</ul></section>
|
|
346
|
+
${ladderHtml}
|
|
347
|
+
<p class="muted" style="margin-top:32px"><a href="${esc(p.shareUrl)}">Share this page</a></p>
|
|
348
|
+
</main></body></html>`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function notFoundPage() {
|
|
352
|
+
return `<!doctype html><meta charset="utf-8"><title>Not found</title><p>No such player.</p>`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function absolute(u) {
|
|
356
|
+
return /^https?:/.test(u) ? u : `${siteUrl}${u}`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
record, set, top, standing, profile, handle, award,
|
|
361
|
+
boards: boards.map(pub), periods, basePath, ladder, badges,
|
|
362
|
+
index: boardsIndex,
|
|
363
|
+
rss: async (opts) => rss(await top(opts)),
|
|
364
|
+
widget: widgetSource,
|
|
365
|
+
/** Drop the cache, e.g. after writing to the store from elsewhere. */
|
|
366
|
+
invalidate() { cache = null; },
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function normaliseBoards(input) {
|
|
371
|
+
const src = input && Object.keys(input).length ? input : { top: { label: 'Top', metric: 'score' } };
|
|
372
|
+
const list = Array.isArray(src) ? src : Object.entries(src).map(([id, b]) => ({ id, ...b }));
|
|
373
|
+
return list.map((b) => {
|
|
374
|
+
if (!b.id || !/^[a-z0-9_-]+$/i.test(b.id)) throw new TypeError(`board id must be [a-z0-9_-]: ${b.id}`);
|
|
375
|
+
return { id: b.id, label: b.label ?? b.id, metric: b.metric ?? 'score', format: b.format ?? 'number', min: Number.isFinite(b.min) ? b.min : Number.MIN_VALUE, order: b.order === 'asc' ? 'asc' : 'desc', tiebreak: b.tiebreak ?? null, unit: b.unit ?? '' };
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function pub(b) {
|
|
380
|
+
return { id: b.id, label: b.label, metric: b.metric, format: b.format, unit: b.unit };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function blank(id) {
|
|
384
|
+
return { id, name: id, nameAt: 0, totals: {}, gauges: {}, days: new Set(), streak: 0, bestStreak: 0, firstAt: Infinity, lastAt: 0, ranks: {}, values: {}, badges: [], commission: undefined };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function dayOf(at) {
|
|
388
|
+
return Math.floor(at / DAY);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Current = consecutive days ending today or yesterday; best = longest run ever. */
|
|
392
|
+
export function streaks(days, now = Date.now()) {
|
|
393
|
+
const sorted = [...days].sort((a, b) => a - b);
|
|
394
|
+
let best = 0, run = 0, prev = null;
|
|
395
|
+
for (const d of sorted) {
|
|
396
|
+
run = prev !== null && d === prev + 1 ? run + 1 : 1;
|
|
397
|
+
if (run > best) best = run;
|
|
398
|
+
prev = d;
|
|
399
|
+
}
|
|
400
|
+
const today = dayOf(now);
|
|
401
|
+
const last = sorted[sorted.length - 1];
|
|
402
|
+
const current = last === today || last === today - 1 ? run : 0;
|
|
403
|
+
return { current, best };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function format(value, kind = 'number', locale = 'en-US') {
|
|
407
|
+
const n = Number(value) || 0;
|
|
408
|
+
if (kind === 'usd') return (n / 100).toLocaleString(locale, { style: 'currency', currency: 'USD' });
|
|
409
|
+
if (kind === 'percent') return `${Math.round(n)}%`;
|
|
410
|
+
if (kind === 'integer') return Math.round(n).toLocaleString(locale);
|
|
411
|
+
return Number.isInteger(n) ? n.toLocaleString(locale) : n.toLocaleString(locale, { maximumFractionDigits: 2 });
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function periodLabel(p) {
|
|
415
|
+
return { all: 'all time', day: 'today', week: 'this week', month: 'this month' }[p] ?? p;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function trimSlash(s) {
|
|
419
|
+
return String(s).replace(/\/+$/, '') || '/';
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function esc(s) {
|
|
423
|
+
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function json(body, status = 200) {
|
|
427
|
+
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'public, max-age=15', 'access-control-allow-origin': '*' } });
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function html(body, status = 200) {
|
|
431
|
+
return new Response(body, { status, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'public, max-age=60' } });
|
|
432
|
+
}
|
package/src/ladder.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The commission ladder: a seller's cut climbs with the number of properties
|
|
3
|
+
* they own and the number of niches they can promote to. The defaults are the
|
|
4
|
+
* program as announced: start at 20%, top out at 80%, five points per property
|
|
5
|
+
* and five per niche, six of each to reach the cap.
|
|
6
|
+
*
|
|
7
|
+
* const ladder = commissionLadder();
|
|
8
|
+
* ladder.rate({ properties: 1, niches: 0 }); // 25
|
|
9
|
+
* ladder.next({ properties: 1, niches: 0 }); // { add: 'property', rate: 30, ... }
|
|
10
|
+
*
|
|
11
|
+
* Every number is an option, so a program with a different shape passes its
|
|
12
|
+
* own. `rate()` returns whole percent as an integer.
|
|
13
|
+
*/
|
|
14
|
+
export function commissionLadder(options = {}) {
|
|
15
|
+
const base = num(options.base, 20);
|
|
16
|
+
const cap = num(options.cap, 80);
|
|
17
|
+
const perProperty = num(options.perProperty, 5);
|
|
18
|
+
const perNiche = num(options.perNiche, 5);
|
|
19
|
+
const maxProperties = num(options.maxProperties, 6);
|
|
20
|
+
const maxNiches = num(options.maxNiches, 6);
|
|
21
|
+
const propertiesMetric = options.propertiesMetric ?? 'properties';
|
|
22
|
+
const nichesMetric = options.nichesMetric ?? 'niches';
|
|
23
|
+
|
|
24
|
+
function counts(state = {}) {
|
|
25
|
+
const properties = Math.max(0, Math.floor(num(state[propertiesMetric] ?? state.properties, 0)));
|
|
26
|
+
const niches = Math.max(0, Math.floor(num(state[nichesMetric] ?? state.niches, 0)));
|
|
27
|
+
return { properties, niches };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function rate(state) {
|
|
31
|
+
const { properties, niches } = counts(state);
|
|
32
|
+
const raw = base + perProperty * Math.min(properties, maxProperties) + perNiche * Math.min(niches, maxNiches);
|
|
33
|
+
return Math.min(cap, Math.max(0, Math.round(raw)));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The single cheapest step up from here, or null at the cap. */
|
|
37
|
+
function next(state) {
|
|
38
|
+
const c = counts(state);
|
|
39
|
+
const here = rate(c);
|
|
40
|
+
if (here >= cap) return null;
|
|
41
|
+
const candidates = [];
|
|
42
|
+
if (c.properties < maxProperties) {
|
|
43
|
+
candidates.push({ add: 'property', rate: rate({ properties: c.properties + 1, niches: c.niches }) });
|
|
44
|
+
}
|
|
45
|
+
if (c.niches < maxNiches) {
|
|
46
|
+
candidates.push({ add: 'niche', rate: rate({ properties: c.properties, niches: c.niches + 1 }) });
|
|
47
|
+
}
|
|
48
|
+
const better = candidates.filter((x) => x.rate > here).sort((a, b) => b.rate - a.rate);
|
|
49
|
+
const pick = better[0];
|
|
50
|
+
if (!pick) return null;
|
|
51
|
+
return { ...pick, from: here, gain: pick.rate - here, toCap: cap - here };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Every step on the ladder, for a docs table or a pricing page. */
|
|
55
|
+
function table() {
|
|
56
|
+
const rows = [];
|
|
57
|
+
for (let p = 0; p <= maxProperties; p++) {
|
|
58
|
+
for (let n = 0; n <= maxNiches; n++) rows.push({ properties: p, niches: n, rate: rate({ properties: p, niches: n }) });
|
|
59
|
+
}
|
|
60
|
+
return rows;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function describe() {
|
|
64
|
+
return `Start at ${base}%. Each property adds ${perProperty} points (up to ${maxProperties}), each niche adds ${perNiche} (up to ${maxNiches}). Cap ${cap}%.`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
base, cap, perProperty, perNiche, maxProperties, maxNiches, propertiesMetric, nichesMetric,
|
|
69
|
+
rate, next, table, describe,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function num(v, fallback) {
|
|
74
|
+
const n = Number(v);
|
|
75
|
+
return Number.isFinite(n) ? n : fallback;
|
|
76
|
+
}
|
package/src/next.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createLeaderboard } from './index.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A Route Handler for Next.js. Mount it on a catch-all under the base path:
|
|
5
|
+
*
|
|
6
|
+
* // app/leaderboard/[[...path]]/route.js
|
|
7
|
+
* import { leaderboardRoute } from '@profullstack/leaderboard/next';
|
|
8
|
+
* export const { GET } = leaderboardRoute(lb);
|
|
9
|
+
* export const dynamic = 'force-dynamic';
|
|
10
|
+
*
|
|
11
|
+
* Anything the leaderboard does not answer 404s, which is what a route under
|
|
12
|
+
* its own path should do.
|
|
13
|
+
*/
|
|
14
|
+
export function leaderboardRoute(lbOrOptions) {
|
|
15
|
+
const lb = lbOrOptions && typeof lbOrOptions.handle === 'function' ? lbOrOptions : createLeaderboard(lbOrOptions);
|
|
16
|
+
const GET = async (request) => (await lb.handle(request)) ?? new Response('Not found', { status: 404 });
|
|
17
|
+
return { GET, HEAD: GET };
|
|
18
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The in-memory store: the reference implementation of the store contract,
|
|
3
|
+
* and enough for a single process, tests, or a leaderboard that is rebuilt
|
|
4
|
+
* from somewhere else on boot.
|
|
5
|
+
*
|
|
6
|
+
* The contract every store meets:
|
|
7
|
+
* append(event) -> void event: { player, name, metric, delta, at }
|
|
8
|
+
* list({ since }) -> event[] at >= since, any order
|
|
9
|
+
* setGauge(player, metric, value, name, at) -> void
|
|
10
|
+
* gauges() -> { [player]: { name, at, values: { [metric]: number } } }
|
|
11
|
+
* awardBadge(player, badge, at) -> boolean true when newly awarded
|
|
12
|
+
* badges() -> { [player]: { [badge]: at } }
|
|
13
|
+
*/
|
|
14
|
+
export function memoryStore() {
|
|
15
|
+
const events = [];
|
|
16
|
+
const gauges = new Map();
|
|
17
|
+
const badges = new Map();
|
|
18
|
+
return {
|
|
19
|
+
async append(ev) {
|
|
20
|
+
events.push({ ...ev });
|
|
21
|
+
},
|
|
22
|
+
async list({ since = 0 } = {}) {
|
|
23
|
+
return since > 0 ? events.filter((e) => e.at >= since) : events.slice();
|
|
24
|
+
},
|
|
25
|
+
async setGauge(player, metric, value, name, at) {
|
|
26
|
+
const row = gauges.get(player) ?? { name, at, values: {} };
|
|
27
|
+
row.values[metric] = value;
|
|
28
|
+
if (name) row.name = name;
|
|
29
|
+
row.at = at;
|
|
30
|
+
gauges.set(player, row);
|
|
31
|
+
},
|
|
32
|
+
async gauges() {
|
|
33
|
+
const out = {};
|
|
34
|
+
for (const [player, row] of gauges) out[player] = { name: row.name, at: row.at, values: { ...row.values } };
|
|
35
|
+
return out;
|
|
36
|
+
},
|
|
37
|
+
async awardBadge(player, badge, at) {
|
|
38
|
+
const row = badges.get(player) ?? {};
|
|
39
|
+
if (row[badge]) return false;
|
|
40
|
+
row[badge] = at;
|
|
41
|
+
badges.set(player, row);
|
|
42
|
+
return true;
|
|
43
|
+
},
|
|
44
|
+
async badges() {
|
|
45
|
+
const out = {};
|
|
46
|
+
for (const [player, row] of badges) out[player] = { ...row };
|
|
47
|
+
return out;
|
|
48
|
+
},
|
|
49
|
+
/** Test helper: everything, for assertions. */
|
|
50
|
+
_dump() {
|
|
51
|
+
return { events: events.slice(), gauges: Object.fromEntries(gauges), badges: Object.fromEntries(badges) };
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
package/src/store/sql.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A store over any SQL client with `execute({ sql, args }) -> { rows }`:
|
|
3
|
+
* @libsql/client (Turso), better-sqlite3 behind a shim, node-postgres behind
|
|
4
|
+
* a shim, Bun's SQL. Three tables, created by `migrate()` or by running
|
|
5
|
+
* `schema` in your own migration tool.
|
|
6
|
+
*
|
|
7
|
+
* import { createClient } from '@libsql/client';
|
|
8
|
+
* const store = sqlStore({ execute: (q) => createClient({ url }).execute(q) });
|
|
9
|
+
* await store.migrate();
|
|
10
|
+
*
|
|
11
|
+
* `dialect: 'postgres'` swaps `?` placeholders for `$n` and picks Postgres
|
|
12
|
+
* types; the default is SQLite. Rows come back as objects keyed by column name
|
|
13
|
+
* in both clients.
|
|
14
|
+
*/
|
|
15
|
+
export function sqlStore({ execute, prefix = 'lb_', dialect = 'sqlite' } = {}) {
|
|
16
|
+
if (typeof execute !== 'function') throw new TypeError('sqlStore needs an execute({ sql, args }) function');
|
|
17
|
+
const T = { events: `${prefix}events`, gauges: `${prefix}gauges`, badges: `${prefix}badges` };
|
|
18
|
+
const pg = dialect === 'postgres';
|
|
19
|
+
const q = (sql) => {
|
|
20
|
+
if (!pg) return sql;
|
|
21
|
+
let i = 0;
|
|
22
|
+
return sql.replace(/\?/g, () => `$${++i}`);
|
|
23
|
+
};
|
|
24
|
+
const run = (sql, args = []) => execute({ sql: q(sql), args });
|
|
25
|
+
const rows = async (sql, args) => (await run(sql, args))?.rows ?? [];
|
|
26
|
+
const idType = pg ? 'BIGSERIAL PRIMARY KEY' : 'INTEGER PRIMARY KEY AUTOINCREMENT';
|
|
27
|
+
const schema = [
|
|
28
|
+
`CREATE TABLE IF NOT EXISTS ${T.events} (id ${idType}, player TEXT NOT NULL, name TEXT, metric TEXT NOT NULL, delta DOUBLE PRECISION NOT NULL, at BIGINT NOT NULL)`,
|
|
29
|
+
`CREATE INDEX IF NOT EXISTS ${T.events}_at ON ${T.events} (at)`,
|
|
30
|
+
`CREATE INDEX IF NOT EXISTS ${T.events}_player ON ${T.events} (player)`,
|
|
31
|
+
`CREATE TABLE IF NOT EXISTS ${T.gauges} (player TEXT NOT NULL, metric TEXT NOT NULL, value DOUBLE PRECISION NOT NULL, name TEXT, at BIGINT NOT NULL, PRIMARY KEY (player, metric))`,
|
|
32
|
+
`CREATE TABLE IF NOT EXISTS ${T.badges} (player TEXT NOT NULL, badge TEXT NOT NULL, at BIGINT NOT NULL, PRIMARY KEY (player, badge))`,
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
schema,
|
|
37
|
+
tables: T,
|
|
38
|
+
async migrate() {
|
|
39
|
+
for (const sql of schema) await run(sql);
|
|
40
|
+
},
|
|
41
|
+
async append(ev) {
|
|
42
|
+
await run(`INSERT INTO ${T.events} (player, name, metric, delta, at) VALUES (?, ?, ?, ?, ?)`, [
|
|
43
|
+
ev.player, ev.name ?? null, ev.metric, ev.delta, ev.at,
|
|
44
|
+
]);
|
|
45
|
+
},
|
|
46
|
+
async list({ since = 0 } = {}) {
|
|
47
|
+
const r = since > 0
|
|
48
|
+
? await rows(`SELECT player, name, metric, delta, at FROM ${T.events} WHERE at >= ?`, [since])
|
|
49
|
+
: await rows(`SELECT player, name, metric, delta, at FROM ${T.events}`);
|
|
50
|
+
return r.map((x) => ({ player: String(x.player), name: x.name ?? undefined, metric: String(x.metric), delta: Number(x.delta), at: Number(x.at) }));
|
|
51
|
+
},
|
|
52
|
+
async setGauge(player, metric, value, name, at) {
|
|
53
|
+
await run(
|
|
54
|
+
`INSERT INTO ${T.gauges} (player, metric, value, name, at) VALUES (?, ?, ?, ?, ?)
|
|
55
|
+
ON CONFLICT (player, metric) DO UPDATE SET value = excluded.value, name = COALESCE(excluded.name, ${T.gauges}.name), at = excluded.at`,
|
|
56
|
+
[player, metric, value, name ?? null, at],
|
|
57
|
+
);
|
|
58
|
+
},
|
|
59
|
+
async gauges() {
|
|
60
|
+
const out = {};
|
|
61
|
+
for (const x of await rows(`SELECT player, metric, value, name, at FROM ${T.gauges}`)) {
|
|
62
|
+
const p = String(x.player);
|
|
63
|
+
const row = out[p] ?? (out[p] = { name: undefined, at: 0, values: {} });
|
|
64
|
+
row.values[String(x.metric)] = Number(x.value);
|
|
65
|
+
const at = Number(x.at);
|
|
66
|
+
if (at >= row.at) {
|
|
67
|
+
row.at = at;
|
|
68
|
+
if (x.name) row.name = String(x.name);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
},
|
|
73
|
+
async awardBadge(player, badge, at) {
|
|
74
|
+
const before = await rows(`SELECT 1 AS one FROM ${T.badges} WHERE player = ? AND badge = ?`, [player, badge]);
|
|
75
|
+
if (before.length) return false;
|
|
76
|
+
await run(`INSERT INTO ${T.badges} (player, badge, at) VALUES (?, ?, ?) ON CONFLICT (player, badge) DO NOTHING`, [player, badge, at]);
|
|
77
|
+
return true;
|
|
78
|
+
},
|
|
79
|
+
async badges() {
|
|
80
|
+
const out = {};
|
|
81
|
+
for (const x of await rows(`SELECT player, badge, at FROM ${T.badges}`)) {
|
|
82
|
+
const p = String(x.player);
|
|
83
|
+
(out[p] ?? (out[p] = {}))[String(x.badge)] = Number(x.at);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|