@profullstack/leaderboard 0.1.0 → 0.3.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/README.md +50 -3
- package/index.d.ts +27 -3
- package/package.json +54 -14
- package/src/embed.js +30 -3
- package/src/index.js +34 -12
- package/src/store/projection.js +58 -0
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@ A public leaderboard that drops into any site. Score events in, ranked boards ou
|
|
|
4
4
|
|
|
5
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
6
|
|
|
7
|
+
It ranks either side of a transaction. Sellers earning, buyers spending, and plain usage are separate boards that never blend, because a publisher earning $900 and a crawler spending $900 are not the same fact.
|
|
8
|
+
|
|
7
9
|
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
10
|
|
|
9
11
|
## Install
|
|
@@ -84,6 +86,30 @@ Sticky mode is the retention feature. Add `data-sticky="bottom-right"` (or `bott
|
|
|
84
86
|
|
|
85
87
|
The widget fires `leaderboard:render` on its container with the JSON as `detail`, for anything you want to do with the numbers.
|
|
86
88
|
|
|
89
|
+
## Sides of the transaction
|
|
90
|
+
|
|
91
|
+
Every board declares a `side`:
|
|
92
|
+
|
|
93
|
+
| `side` | Money | Default label | Example |
|
|
94
|
+
| --- | --- | --- | --- |
|
|
95
|
+
| `sell` | to the actor | Earning | Publishers by revenue, affiliates by commission |
|
|
96
|
+
| `buy` | from the actor | Spending | Crawlers by what they paid for passes |
|
|
97
|
+
| `use` | none | Usage | Pages crawled, API calls, bytes served |
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
boards: {
|
|
101
|
+
earners: { label: 'Top earners', metric: 'cents', format: 'usd', side: 'sell', actor: 'Publisher' },
|
|
102
|
+
spenders: { label: 'Biggest spenders', metric: 'spent', format: 'usd', side: 'buy', actor: 'Crawler' },
|
|
103
|
+
crawled: { label: 'Most pages crawled', metric: 'pages', format: 'integer', side: 'use', actor: 'Crawler' },
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The widget puts a tab per side across the top and the boards of that side beneath, so only one side is ever on screen. `actor` names the column, so the same table reads "Publisher" on one side and "Crawler" on the other. The commission rate is shown only on `sell` boards, since a rate against a buyer would state something false.
|
|
108
|
+
|
|
109
|
+
The same identity can hold a rank on both sides at once. Their totals are never pooled.
|
|
110
|
+
|
|
111
|
+
Rename the tabs with `sides: { buy: 'Bots paying us' }`.
|
|
112
|
+
|
|
87
113
|
## Metrics
|
|
88
114
|
|
|
89
115
|
Three kinds, one vocabulary:
|
|
@@ -92,7 +118,7 @@ Three kinds, one vocabulary:
|
|
|
92
118
|
- **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
119
|
- **Derived**: `streak` (consecutive UTC days with any `record()`, still current through yesterday) and `bestStreak`.
|
|
94
120
|
|
|
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.
|
|
121
|
+
A board is `{ label, metric, format, unit, min, order, tiebreak, side, actor }`. `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
122
|
|
|
97
123
|
`record()` and `set()` return the badges newly earned, for a toast.
|
|
98
124
|
|
|
@@ -116,12 +142,33 @@ Every number is an option (`base`, `cap`, `perProperty`, `perNiche`, `maxPropert
|
|
|
116
142
|
## Stores
|
|
117
143
|
|
|
118
144
|
```js
|
|
119
|
-
import { memoryStore, sqlStore } from '@profullstack/leaderboard';
|
|
145
|
+
import { memoryStore, sqlStore, projectionStore } from '@profullstack/leaderboard';
|
|
120
146
|
|
|
121
|
-
memoryStore();
|
|
147
|
+
memoryStore(); // one process, tests
|
|
122
148
|
sqlStore({ execute, prefix: 'lb_', dialect: 'sqlite' }) // or 'postgres'
|
|
149
|
+
projectionStore({ events, gauges, badges }) // rank tables you already have
|
|
123
150
|
```
|
|
124
151
|
|
|
152
|
+
### Ranking data you already record
|
|
153
|
+
|
|
154
|
+
Most sites that want a leaderboard are already recording the facts it would rank. Copying those into a second set of tables is a dual write, and a dual write means the board and the ledger disagree the first time one of them fails. `projectionStore` reads the source instead:
|
|
155
|
+
|
|
156
|
+
```js
|
|
157
|
+
const store = projectionStore({
|
|
158
|
+
events: async ({ since }) => {
|
|
159
|
+
const rows = await sql`select payer, total_cents, created_at from crawl_sales
|
|
160
|
+
where created_at >= ${new Date(since)}`;
|
|
161
|
+
return rows.map((r) => ({
|
|
162
|
+
player: r.payer, name: r.payer, metric: 'spent',
|
|
163
|
+
delta: r.total_cents, at: +new Date(r.created_at),
|
|
164
|
+
}));
|
|
165
|
+
},
|
|
166
|
+
badges: sqlStore({ execute }), // badges are awarded, not derived, so they need a home
|
|
167
|
+
});
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`since` is `0` for an all-time board, so the query has to handle "everything". The store is read-only: `record()` and `set()` throw rather than drop a write the next projection would overwrite.
|
|
171
|
+
|
|
125
172
|
`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
173
|
|
|
127
174
|
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.
|
package/index.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export type Period = 'all' | 'day' | 'week' | 'month';
|
|
2
2
|
export type Format = 'number' | 'integer' | 'usd' | 'percent';
|
|
3
|
+
/** Which side of the transaction a board ranks. Boards are never mixed across sides. */
|
|
4
|
+
export type Side = 'sell' | 'buy' | 'use';
|
|
5
|
+
export const SIDES: Side[];
|
|
3
6
|
|
|
4
7
|
export interface Event { player: string; name?: string; metric: string; delta: number; at: number }
|
|
5
8
|
|
|
@@ -23,6 +26,16 @@ export interface SqlStoreOptions {
|
|
|
23
26
|
export interface SqlStore extends Store { schema: string[]; tables: { events: string; gauges: string; badges: string }; migrate(): Promise<void> }
|
|
24
27
|
export function sqlStore(options: SqlStoreOptions): SqlStore;
|
|
25
28
|
|
|
29
|
+
export interface ProjectionOptions {
|
|
30
|
+
/** Build events from rows you already have. `since` is 0 for all time. */
|
|
31
|
+
events(opts: { since: number }): Promise<Event[]> | Event[];
|
|
32
|
+
gauges?(): Promise<Record<string, { name?: string; at: number; values: Record<string, number> }>> | Record<string, { name?: string; at: number; values: Record<string, number> }>;
|
|
33
|
+
/** Where awarded badges persist. Defaults to memory, which does not survive a restart. */
|
|
34
|
+
badges?: Pick<Store, 'awardBadge' | 'badges'>;
|
|
35
|
+
}
|
|
36
|
+
/** A read-only store over existing tables. `record()` and `set()` throw. */
|
|
37
|
+
export function projectionStore(options: ProjectionOptions): Store;
|
|
38
|
+
|
|
26
39
|
export interface LadderOptions {
|
|
27
40
|
base?: number; cap?: number; perProperty?: number; perNiche?: number; maxProperties?: number; maxNiches?: number;
|
|
28
41
|
propertiesMetric?: string; nichesMetric?: string;
|
|
@@ -41,8 +54,16 @@ export interface Badge { id: string; emoji?: string; label?: string; describe?:
|
|
|
41
54
|
export interface EarnedBadge { id: string; emoji: string; label: string; describe: string; at: number }
|
|
42
55
|
export function defaultBadges(names?: { sales?: string; properties?: string; niches?: string }): Badge[];
|
|
43
56
|
|
|
44
|
-
export interface BoardOptions {
|
|
45
|
-
|
|
57
|
+
export interface BoardOptions {
|
|
58
|
+
label?: string; metric?: string; format?: Format; unit?: string; min?: number;
|
|
59
|
+
order?: 'asc' | 'desc'; tiebreak?: string;
|
|
60
|
+
/** Money to this actor is `sell`, money from them is `buy`, volume with no money is `use`. Default `sell`. */
|
|
61
|
+
side?: Side;
|
|
62
|
+
/** What one row is, e.g. "Publisher" or "Crawler". Names the column and appears beside the title. */
|
|
63
|
+
actor?: string;
|
|
64
|
+
}
|
|
65
|
+
export interface Board { id: string; label: string; metric: string; format: Format; unit: string; side: Side; actor: string | null }
|
|
66
|
+
export interface SideGroup { side: Side; label: string; boards: Board[] }
|
|
46
67
|
|
|
47
68
|
export interface Profile {
|
|
48
69
|
id: string; name: string;
|
|
@@ -60,10 +81,11 @@ export interface Profile {
|
|
|
60
81
|
export interface Row {
|
|
61
82
|
rank: number | null; id: string; name: string; value: number; display: string;
|
|
62
83
|
streak: number; bestStreak: number; badges: Array<{ id: string; emoji: string; label: string }>;
|
|
84
|
+
/** Set only on `sell` boards: a commission rate is a property of earning. */
|
|
63
85
|
commission?: number; url: string;
|
|
64
86
|
}
|
|
65
87
|
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 }
|
|
88
|
+
export interface Top { board: Board; sides: SideGroup[]; period: Period; periods: Period[]; generatedAt: string; total: number; rows: Row[]; me?: Standing | null }
|
|
67
89
|
|
|
68
90
|
export interface LeaderboardOptions {
|
|
69
91
|
store?: Store;
|
|
@@ -73,6 +95,8 @@ export interface LeaderboardOptions {
|
|
|
73
95
|
boards?: Record<string, BoardOptions> | Array<BoardOptions & { id: string }>;
|
|
74
96
|
periods?: Period[];
|
|
75
97
|
defaultPeriod?: Period;
|
|
98
|
+
/** Display names per side. Defaults: sell "Earning", buy "Spending", use "Usage". */
|
|
99
|
+
sides?: Partial<Record<Side, string>>;
|
|
76
100
|
ladder?: Ladder | boolean;
|
|
77
101
|
badges?: Badge[];
|
|
78
102
|
cacheMs?: number;
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/leaderboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "A public leaderboard with badges, streaks and a commission ladder that drops into any site
|
|
5
|
+
"description": "A public leaderboard with badges, streaks and a commission ladder that drops into any site. Ranks either side of a transaction, earners and spenders and usage, kept segregated. Zero dependencies, Fetch API core, Hono and Next adapters.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"leaderboard",
|
|
8
8
|
"gamification",
|
|
@@ -17,25 +17,65 @@
|
|
|
17
17
|
"hono",
|
|
18
18
|
"nextjs",
|
|
19
19
|
"libsql",
|
|
20
|
-
"turso"
|
|
20
|
+
"turso",
|
|
21
|
+
"marketplace",
|
|
22
|
+
"x402",
|
|
23
|
+
"crawler"
|
|
21
24
|
],
|
|
22
|
-
"repository": {
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/profullstack/leaderboard.git"
|
|
28
|
+
},
|
|
23
29
|
"homepage": "https://github.com/profullstack/leaderboard#readme",
|
|
24
|
-
"bugs": {
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/profullstack/leaderboard/issues"
|
|
32
|
+
},
|
|
25
33
|
"license": "MIT",
|
|
26
34
|
"author": "Profullstack, LLC",
|
|
27
35
|
"exports": {
|
|
28
|
-
".": {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"./
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
".": {
|
|
37
|
+
"types": "./index.d.ts",
|
|
38
|
+
"import": "./src/index.js"
|
|
39
|
+
},
|
|
40
|
+
"./hono": {
|
|
41
|
+
"types": "./index.d.ts",
|
|
42
|
+
"import": "./src/hono.js"
|
|
43
|
+
},
|
|
44
|
+
"./next": {
|
|
45
|
+
"types": "./index.d.ts",
|
|
46
|
+
"import": "./src/next.js"
|
|
47
|
+
},
|
|
48
|
+
"./ladder": {
|
|
49
|
+
"types": "./index.d.ts",
|
|
50
|
+
"import": "./src/ladder.js"
|
|
51
|
+
},
|
|
52
|
+
"./badges": {
|
|
53
|
+
"types": "./index.d.ts",
|
|
54
|
+
"import": "./src/badges.js"
|
|
55
|
+
},
|
|
56
|
+
"./store/memory": {
|
|
57
|
+
"types": "./index.d.ts",
|
|
58
|
+
"import": "./src/store/memory.js"
|
|
59
|
+
},
|
|
60
|
+
"./store/sql": {
|
|
61
|
+
"types": "./index.d.ts",
|
|
62
|
+
"import": "./src/store/sql.js"
|
|
63
|
+
},
|
|
64
|
+
"./store/projection": {
|
|
65
|
+
"types": "./index.d.ts",
|
|
66
|
+
"import": "./src/store/projection.js"
|
|
67
|
+
}
|
|
35
68
|
},
|
|
36
69
|
"types": "./index.d.ts",
|
|
37
|
-
"files": [
|
|
38
|
-
|
|
70
|
+
"files": [
|
|
71
|
+
"src",
|
|
72
|
+
"index.d.ts",
|
|
73
|
+
"README.md",
|
|
74
|
+
"LICENSE"
|
|
75
|
+
],
|
|
76
|
+
"engines": {
|
|
77
|
+
"node": ">=20.11"
|
|
78
|
+
},
|
|
39
79
|
"sideEffects": false,
|
|
40
80
|
"scripts": {
|
|
41
81
|
"test": "node --test"
|
package/src/embed.js
CHANGED
|
@@ -30,6 +30,7 @@ const WIDGET = String.raw`(function () {
|
|
|
30
30
|
var period = d.period || '';
|
|
31
31
|
var limit = d.limit || '10';
|
|
32
32
|
var me = d.me || '';
|
|
33
|
+
var side = d.side || '';
|
|
33
34
|
var sticky = d.sticky || '';
|
|
34
35
|
var theme = d.theme || 'auto';
|
|
35
36
|
var refresh = d.refresh === undefined ? 60 : Number(d.refresh) || 0;
|
|
@@ -41,6 +42,13 @@ const WIDGET = String.raw`(function () {
|
|
|
41
42
|
'.pflb *{box-sizing:border-box}.pflb a{color:inherit;text-decoration:none}' +
|
|
42
43
|
'.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
44
|
'.pflb-title{font-weight:700;font-size:15px;margin:0}.pflb-tabs{display:flex;gap:4px;flex-wrap:wrap}' +
|
|
45
|
+
'.pflb-sides{display:flex;gap:2px;padding:8px 14px 0;flex-wrap:wrap}' +
|
|
46
|
+
'.pflb-sides button{font:inherit;font-size:12px;font-weight:600;padding:6px 12px;border:0;border-bottom:2px solid transparent;background:transparent;color:var(--muted);cursor:pointer}' +
|
|
47
|
+
'.pflb-sides button.on{color:var(--fg);border-bottom-color:var(--accent)}' +
|
|
48
|
+
'.pflb-actor{font-size:11px;color:var(--muted);font-weight:400;margin-left:6px}' +
|
|
49
|
+
'.pflb-boards{display:flex;gap:4px;flex-wrap:wrap;padding:0 14px 10px}' +
|
|
50
|
+
'.pflb-boards button{font:inherit;font-size:12px;padding:3px 9px;border-radius:6px;border:1px solid var(--line);background:transparent;color:var(--muted);cursor:pointer}' +
|
|
51
|
+
'.pflb-boards button.on{color:var(--fg);border-color:var(--accent)}' +
|
|
44
52
|
'.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
53
|
'.pflb-tabs button.on{background:var(--accent);border-color:var(--accent);color:#fff}' +
|
|
46
54
|
'.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}' +
|
|
@@ -94,6 +102,17 @@ const WIDGET = String.raw`(function () {
|
|
|
94
102
|
if (!periods) periods = data.periods || ['all'];
|
|
95
103
|
period = data.period;
|
|
96
104
|
var title = d.title || data.board.label;
|
|
105
|
+
side = data.board.side;
|
|
106
|
+
var groups = data.sides || [];
|
|
107
|
+
// Segregate: one side at a time, never two sides in one list.
|
|
108
|
+
var sideBar = groups.length > 1 ? '<div class="pflb-sides">' + groups.map(function (g) {
|
|
109
|
+
return '<button type="button" data-side="' + esc(g.side) + '" class="' + (g.side === side ? 'on' : '') + '">' + esc(g.label) + '</button>';
|
|
110
|
+
}).join('') + '</div>' : '';
|
|
111
|
+
var mine = (groups.filter(function (g) { return g.side === side; })[0] || {}).boards || [];
|
|
112
|
+
var boardBar = mine.length > 1 ? '<div class="pflb-boards">' + mine.map(function (b) {
|
|
113
|
+
return '<button type="button" data-b="' + esc(b.id) + '" class="' + (b.id === board ? 'on' : '') + '">' + esc(b.label) + '</button>';
|
|
114
|
+
}).join('') + '</div>' : '';
|
|
115
|
+
var actor = data.board.actor ? '<span class="pflb-actor">' + esc(data.board.actor) + '</span>' : '';
|
|
97
116
|
var tabs = periods.map(function (p) { return '<button type="button" data-p="' + esc(p) + '" class="' + (p === period ? 'on' : '') + '">' + esc(label(p)) + '</button>'; }).join('');
|
|
98
117
|
var body = rows.length ? rows.map(function (r) {
|
|
99
118
|
var isMe = me && r.id === me;
|
|
@@ -112,13 +131,21 @@ const WIDGET = String.raw`(function () {
|
|
|
112
131
|
var m = data.me;
|
|
113
132
|
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
133
|
}
|
|
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>' +
|
|
134
|
+
root.innerHTML = sideBar +
|
|
135
|
+
'<div class="pflb-head"><h3 class="pflb-title">' + esc(title) + actor + '</h3><div class="pflb-tabs">' + tabs + '</div>' + (root.classList.contains('pflb-panel') ? '<button class="pflb-close" aria-label="Close">×</button>' : '') + '</div>' + boardBar +
|
|
117
136
|
(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>'
|
|
137
|
+
? '<table><thead><tr><th>#</th><th>' + esc(data.board.actor || '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
138
|
: '<div class="pflb-empty">Nobody on the board yet. Be first.</div>') +
|
|
120
139
|
'<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
140
|
root.querySelectorAll('.pflb-tabs button').forEach(function (b) { b.addEventListener('click', function () { period = b.dataset.p; load(root); }); });
|
|
141
|
+
root.querySelectorAll('.pflb-sides button').forEach(function (b) {
|
|
142
|
+
b.addEventListener('click', function () {
|
|
143
|
+
var g = groups.filter(function (x) { return x.side === b.dataset.side; })[0];
|
|
144
|
+
if (!g || !g.boards.length) return;
|
|
145
|
+
board = g.boards[0].id; side = g.side; load(root);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
root.querySelectorAll('.pflb-boards button').forEach(function (b) { b.addEventListener('click', function () { board = b.dataset.b; load(root); }); });
|
|
122
149
|
root.querySelectorAll('.share').forEach(function (s) { s.addEventListener('click', function (e) { e.preventDefault(); copy(s.dataset.share, s); }); });
|
|
123
150
|
var close = root.querySelector('.pflb-close');
|
|
124
151
|
if (close) close.addEventListener('click', function () { root.hidden = true; });
|
package/src/index.js
CHANGED
|
@@ -7,6 +7,7 @@ export { commissionLadder } from './ladder.js';
|
|
|
7
7
|
export { defaultBadges } from './badges.js';
|
|
8
8
|
export { memoryStore } from './store/memory.js';
|
|
9
9
|
export { sqlStore } from './store/sql.js';
|
|
10
|
+
export { projectionStore } from './store/projection.js';
|
|
10
11
|
|
|
11
12
|
const DAY = 86_400_000;
|
|
12
13
|
const PERIODS = { all: 0, day: DAY, week: 7 * DAY, month: 30 * DAY };
|
|
@@ -39,6 +40,7 @@ export function createLeaderboard(options = {}) {
|
|
|
39
40
|
const siteUrl = options.siteUrl ? trimSlash(options.siteUrl) : '';
|
|
40
41
|
const periods = (options.periods ?? ['all', 'week', 'month']).filter((p) => p in PERIODS);
|
|
41
42
|
const defaultPeriod = periods.includes(options.defaultPeriod) ? options.defaultPeriod : periods[0];
|
|
43
|
+
const sides = { ...DEFAULT_SIDES, ...(options.sides ?? {}) };
|
|
42
44
|
const boards = normaliseBoards(options.boards);
|
|
43
45
|
const defaultBoard = boards[0].id;
|
|
44
46
|
const ladder = options.ladder === undefined ? null : options.ladder === true ? commissionLadder() : options.ladder;
|
|
@@ -194,7 +196,7 @@ export function createLeaderboard(options = {}) {
|
|
|
194
196
|
}
|
|
195
197
|
rows.sort((a, c) => a.rank - c.rank);
|
|
196
198
|
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) };
|
|
199
|
+
return { board: pub(b), sides: boardsIndex().sides, period, periods, generatedAt: new Date(now()).toISOString(), total: rows.length, rows: rows.slice(Number(offset) || 0, (Number(offset) || 0) + n) };
|
|
198
200
|
}
|
|
199
201
|
|
|
200
202
|
async function standing({ player, board = defaultBoard, period = defaultPeriod } = {}) {
|
|
@@ -222,13 +224,17 @@ export function createLeaderboard(options = {}) {
|
|
|
222
224
|
rank: r, id: p.id, name: p.name, value, display: format(value, b.format),
|
|
223
225
|
streak: p.streak, bestStreak: p.bestStreak,
|
|
224
226
|
badges: p.badges.map(({ id, emoji, label }) => ({ id, emoji, label })),
|
|
225
|
-
|
|
227
|
+
// A commission rate is a property of earning. Showing one against a buyer
|
|
228
|
+
// or a usage count states something false about the number next to it.
|
|
229
|
+
commission: b.side === 'sell' && p.commission ? p.commission.rate : undefined,
|
|
226
230
|
url: profileUrl(p.id),
|
|
227
231
|
};
|
|
228
232
|
}
|
|
229
233
|
|
|
230
234
|
function boardsIndex() {
|
|
231
|
-
|
|
235
|
+
const grouped = SIDES.filter((side) => boards.some((b) => b.side === side))
|
|
236
|
+
.map((side) => ({ side, label: sides[side], boards: boards.filter((b) => b.side === side).map(pub) }));
|
|
237
|
+
return { site: siteName, siteUrl, basePath, periods, defaultPeriod, sides: grouped, 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
238
|
}
|
|
233
239
|
|
|
234
240
|
/** Fetch-API handler: answers under basePath, null for anything else. */
|
|
@@ -272,7 +278,7 @@ export function createLeaderboard(options = {}) {
|
|
|
272
278
|
}
|
|
273
279
|
|
|
274
280
|
function rss(result) {
|
|
275
|
-
const title = `${siteName}: ${result.board.label} (${periodLabel(result.period)})`;
|
|
281
|
+
const title = `${siteName}: ${result.board.label}, ${sides[result.board.side]} (${periodLabel(result.period)})`;
|
|
276
282
|
const link = `${siteUrl}${basePath}?board=${result.board.id}&period=${result.period}`;
|
|
277
283
|
const items = result.rows.map((r) => {
|
|
278
284
|
const badgeText = r.badges.map((b) => `${b.emoji} ${b.label}`).join(', ');
|
|
@@ -299,7 +305,7 @@ ${items.join('\n')}
|
|
|
299
305
|
function embedPage({ board, period, limit, sticky, theme, me, full }) {
|
|
300
306
|
const attrs = [
|
|
301
307
|
`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)}"` : '',
|
|
308
|
+
sticky ? `data-sticky="${esc(sticky)}"` : '', theme ? `data-theme="${esc(theme)}"` : '', me ? `data-me="${esc(me)}"` : '', `data-side="${esc(board.side)}"`,
|
|
303
309
|
`data-base="${esc(basePath)}"`,
|
|
304
310
|
].filter(Boolean).join(' ');
|
|
305
311
|
const title = `${board.label}: ${siteName}`;
|
|
@@ -315,10 +321,13 @@ ${items.join('\n')}
|
|
|
315
321
|
}
|
|
316
322
|
|
|
317
323
|
function sharePage(p) {
|
|
318
|
-
const lines = boards.
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
324
|
+
const lines = SIDES.filter((side) => boards.some((b) => b.side === side)).map((side) => {
|
|
325
|
+
const items = boards.filter((b) => b.side === side).map((b) => {
|
|
326
|
+
const r = p.ranks[b.id]?.[defaultPeriod];
|
|
327
|
+
const v = p.values[b.id]?.[defaultPeriod];
|
|
328
|
+
return r ? `<li><strong>#${r}</strong> on ${esc(b.label)} <span class="muted">(${esc(periodLabel(defaultPeriod))}, ${esc(format(v, b.format))})</span></li>` : '';
|
|
329
|
+
}).filter(Boolean).join('');
|
|
330
|
+
return items ? `<h3 class="side">${esc(sides[side])}</h3><ul>${items}</ul>` : '';
|
|
322
331
|
}).filter(Boolean).join('');
|
|
323
332
|
const bestRank = Math.min(...boards.map((b) => p.ranks[b.id]?.[defaultPeriod] ?? Infinity));
|
|
324
333
|
const headline = Number.isFinite(bestRank) ? `#${bestRank} on ${siteName}` : `${p.name} on ${siteName}`;
|
|
@@ -337,11 +346,12 @@ ${items.join('\n')}
|
|
|
337
346
|
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
347
|
.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
348
|
a{color:#7cc4ff}.stats{display:flex;gap:24px;flex-wrap:wrap}.stats div b{display:block;font-size:22px}
|
|
349
|
+
h3.side{font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:#8b93a7;margin:16px 0 4px;font-weight:600}
|
|
340
350
|
</style></head><body><main>
|
|
341
351
|
<p class="muted"><a href="${esc(boardUrl)}">${esc(siteName)} leaderboard</a></p>
|
|
342
352
|
<h1>${esc(p.name)}</h1><p class="muted">${esc(headline)}</p>
|
|
343
353
|
<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
|
|
354
|
+
<section><h2>Boards</h2>${lines || '<p class="muted">Not on a board yet.</p>'}</section>
|
|
345
355
|
<section><h2>Badges</h2><ul>${badgeHtml}</ul></section>
|
|
346
356
|
${ladderHtml}
|
|
347
357
|
<p class="muted" style="margin-top:32px"><a href="${esc(p.shareUrl)}">Share this page</a></p>
|
|
@@ -367,17 +377,29 @@ ${ladderHtml}
|
|
|
367
377
|
};
|
|
368
378
|
}
|
|
369
379
|
|
|
380
|
+
/**
|
|
381
|
+
* Which side of the transaction a board ranks. Money flowing to someone is
|
|
382
|
+
* `sell`, money flowing from them is `buy`, and volume with no money attached
|
|
383
|
+
* is `use`. Boards are never mixed across sides: a seller earning $900 and a
|
|
384
|
+
* crawler spending $900 are not comparable, and showing them in one list is a
|
|
385
|
+
* lie about what the number means.
|
|
386
|
+
*/
|
|
387
|
+
export const SIDES = ['sell', 'buy', 'use'];
|
|
388
|
+
const DEFAULT_SIDES = { sell: 'Earning', buy: 'Spending', use: 'Usage' };
|
|
389
|
+
|
|
370
390
|
function normaliseBoards(input) {
|
|
371
391
|
const src = input && Object.keys(input).length ? input : { top: { label: 'Top', metric: 'score' } };
|
|
372
392
|
const list = Array.isArray(src) ? src : Object.entries(src).map(([id, b]) => ({ id, ...b }));
|
|
373
393
|
return list.map((b) => {
|
|
374
394
|
if (!b.id || !/^[a-z0-9_-]+$/i.test(b.id)) throw new TypeError(`board id must be [a-z0-9_-]: ${b.id}`);
|
|
375
|
-
|
|
395
|
+
const side = b.side ?? 'sell';
|
|
396
|
+
if (!SIDES.includes(side)) throw new TypeError(`board side must be one of ${SIDES.join(', ')}: ${side}`);
|
|
397
|
+
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 ?? '', side, actor: b.actor ?? null };
|
|
376
398
|
});
|
|
377
399
|
}
|
|
378
400
|
|
|
379
401
|
function pub(b) {
|
|
380
|
-
return { id: b.id, label: b.label, metric: b.metric, format: b.format, unit: b.unit };
|
|
402
|
+
return { id: b.id, label: b.label, metric: b.metric, format: b.format, unit: b.unit, side: b.side, actor: b.actor };
|
|
381
403
|
}
|
|
382
404
|
|
|
383
405
|
function blank(id) {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { memoryStore } from './memory.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A read-through store over tables you already have.
|
|
5
|
+
*
|
|
6
|
+
* Most sites that could show a leaderboard are already recording the facts it
|
|
7
|
+
* would rank: sales, payments, referral commissions, requests served. Copying
|
|
8
|
+
* those into a second set of tables means a dual write, and a dual write means
|
|
9
|
+
* the board and the ledger disagree the first time one of them fails. So
|
|
10
|
+
* project instead: hand back events built from the rows you already trust.
|
|
11
|
+
*
|
|
12
|
+
* projectionStore({
|
|
13
|
+
* events: async ({ since }) => {
|
|
14
|
+
* const rows = await sql`select payer, total_cents, created_at from crawl_sales
|
|
15
|
+
* where created_at >= ${new Date(since)}`;
|
|
16
|
+
* return rows.map((r) => ({
|
|
17
|
+
* player: r.payer, name: r.payer, metric: 'spent',
|
|
18
|
+
* delta: r.total_cents, at: +new Date(r.created_at),
|
|
19
|
+
* }));
|
|
20
|
+
* },
|
|
21
|
+
* })
|
|
22
|
+
*
|
|
23
|
+
* `since` is 0 for an all-time board, so the query must handle "everything".
|
|
24
|
+
* Badges still need somewhere durable to live, since they are awarded rather
|
|
25
|
+
* than derived: pass `badges` (a `sqlStore`, or any object with `awardBadge`
|
|
26
|
+
* and `badges`). The default keeps them in memory, which is fine for a board
|
|
27
|
+
* with no badges and wrong for one with them.
|
|
28
|
+
*
|
|
29
|
+
* The store is read-only. `record()` and `set()` on a leaderboard backed by a
|
|
30
|
+
* projection throw, rather than silently dropping a write that the projection
|
|
31
|
+
* would overwrite on the next read anyway.
|
|
32
|
+
*/
|
|
33
|
+
export function projectionStore({ events, gauges, badges } = {}) {
|
|
34
|
+
if (typeof events !== 'function') throw new TypeError('projectionStore needs an events({ since }) function');
|
|
35
|
+
const badgeStore = badges ?? memoryStore();
|
|
36
|
+
const readOnly = (method) => () => {
|
|
37
|
+
throw new Error(`${method}() is not available on a projection store: write to the source table instead, the board reads it back`);
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
async list({ since = 0 } = {}) {
|
|
41
|
+
const rows = (await events({ since })) ?? [];
|
|
42
|
+
return rows.map((e) => ({
|
|
43
|
+
player: String(e.player),
|
|
44
|
+
name: e.name == null ? undefined : String(e.name),
|
|
45
|
+
metric: String(e.metric),
|
|
46
|
+
delta: Number(e.delta) || 0,
|
|
47
|
+
at: Number(e.at) || 0,
|
|
48
|
+
}));
|
|
49
|
+
},
|
|
50
|
+
async gauges() {
|
|
51
|
+
return (typeof gauges === 'function' ? await gauges() : null) ?? {};
|
|
52
|
+
},
|
|
53
|
+
append: readOnly('record'),
|
|
54
|
+
setGauge: readOnly('set'),
|
|
55
|
+
awardBadge: (...a) => badgeStore.awardBadge(...a),
|
|
56
|
+
badges: () => badgeStore.badges(),
|
|
57
|
+
};
|
|
58
|
+
}
|