@profullstack/leaderboard 0.1.0 → 0.2.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 +27 -1
- package/index.d.ts +17 -3
- package/package.json +50 -14
- package/src/embed.js +30 -3
- package/src/index.js +33 -12
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
|
|
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
|
|
|
@@ -41,8 +44,16 @@ export interface Badge { id: string; emoji?: string; label?: string; describe?:
|
|
|
41
44
|
export interface EarnedBadge { id: string; emoji: string; label: string; describe: string; at: number }
|
|
42
45
|
export function defaultBadges(names?: { sales?: string; properties?: string; niches?: string }): Badge[];
|
|
43
46
|
|
|
44
|
-
export interface BoardOptions {
|
|
45
|
-
|
|
47
|
+
export interface BoardOptions {
|
|
48
|
+
label?: string; metric?: string; format?: Format; unit?: string; min?: number;
|
|
49
|
+
order?: 'asc' | 'desc'; tiebreak?: string;
|
|
50
|
+
/** Money to this actor is `sell`, money from them is `buy`, volume with no money is `use`. Default `sell`. */
|
|
51
|
+
side?: Side;
|
|
52
|
+
/** What one row is, e.g. "Publisher" or "Crawler". Names the column and appears beside the title. */
|
|
53
|
+
actor?: string;
|
|
54
|
+
}
|
|
55
|
+
export interface Board { id: string; label: string; metric: string; format: Format; unit: string; side: Side; actor: string | null }
|
|
56
|
+
export interface SideGroup { side: Side; label: string; boards: Board[] }
|
|
46
57
|
|
|
47
58
|
export interface Profile {
|
|
48
59
|
id: string; name: string;
|
|
@@ -60,10 +71,11 @@ export interface Profile {
|
|
|
60
71
|
export interface Row {
|
|
61
72
|
rank: number | null; id: string; name: string; value: number; display: string;
|
|
62
73
|
streak: number; bestStreak: number; badges: Array<{ id: string; emoji: string; label: string }>;
|
|
74
|
+
/** Set only on `sell` boards: a commission rate is a property of earning. */
|
|
63
75
|
commission?: number; url: string;
|
|
64
76
|
}
|
|
65
77
|
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 }
|
|
78
|
+
export interface Top { board: Board; sides: SideGroup[]; period: Period; periods: Period[]; generatedAt: string; total: number; rows: Row[]; me?: Standing | null }
|
|
67
79
|
|
|
68
80
|
export interface LeaderboardOptions {
|
|
69
81
|
store?: Store;
|
|
@@ -73,6 +85,8 @@ export interface LeaderboardOptions {
|
|
|
73
85
|
boards?: Record<string, BoardOptions> | Array<BoardOptions & { id: string }>;
|
|
74
86
|
periods?: Period[];
|
|
75
87
|
defaultPeriod?: Period;
|
|
88
|
+
/** Display names per side. Defaults: sell "Earning", buy "Spending", use "Usage". */
|
|
89
|
+
sides?: Partial<Record<Side, string>>;
|
|
76
90
|
ladder?: Ladder | boolean;
|
|
77
91
|
badges?: Badge[];
|
|
78
92
|
cacheMs?: number;
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/leaderboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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,61 @@
|
|
|
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
|
+
}
|
|
35
64
|
},
|
|
36
65
|
"types": "./index.d.ts",
|
|
37
|
-
"files": [
|
|
38
|
-
|
|
66
|
+
"files": [
|
|
67
|
+
"src",
|
|
68
|
+
"index.d.ts",
|
|
69
|
+
"README.md",
|
|
70
|
+
"LICENSE"
|
|
71
|
+
],
|
|
72
|
+
"engines": {
|
|
73
|
+
"node": ">=20.11"
|
|
74
|
+
},
|
|
39
75
|
"sideEffects": false,
|
|
40
76
|
"scripts": {
|
|
41
77
|
"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
|
@@ -39,6 +39,7 @@ export function createLeaderboard(options = {}) {
|
|
|
39
39
|
const siteUrl = options.siteUrl ? trimSlash(options.siteUrl) : '';
|
|
40
40
|
const periods = (options.periods ?? ['all', 'week', 'month']).filter((p) => p in PERIODS);
|
|
41
41
|
const defaultPeriod = periods.includes(options.defaultPeriod) ? options.defaultPeriod : periods[0];
|
|
42
|
+
const sides = { ...DEFAULT_SIDES, ...(options.sides ?? {}) };
|
|
42
43
|
const boards = normaliseBoards(options.boards);
|
|
43
44
|
const defaultBoard = boards[0].id;
|
|
44
45
|
const ladder = options.ladder === undefined ? null : options.ladder === true ? commissionLadder() : options.ladder;
|
|
@@ -194,7 +195,7 @@ export function createLeaderboard(options = {}) {
|
|
|
194
195
|
}
|
|
195
196
|
rows.sort((a, c) => a.rank - c.rank);
|
|
196
197
|
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
|
+
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
199
|
}
|
|
199
200
|
|
|
200
201
|
async function standing({ player, board = defaultBoard, period = defaultPeriod } = {}) {
|
|
@@ -222,13 +223,17 @@ export function createLeaderboard(options = {}) {
|
|
|
222
223
|
rank: r, id: p.id, name: p.name, value, display: format(value, b.format),
|
|
223
224
|
streak: p.streak, bestStreak: p.bestStreak,
|
|
224
225
|
badges: p.badges.map(({ id, emoji, label }) => ({ id, emoji, label })),
|
|
225
|
-
|
|
226
|
+
// A commission rate is a property of earning. Showing one against a buyer
|
|
227
|
+
// or a usage count states something false about the number next to it.
|
|
228
|
+
commission: b.side === 'sell' && p.commission ? p.commission.rate : undefined,
|
|
226
229
|
url: profileUrl(p.id),
|
|
227
230
|
};
|
|
228
231
|
}
|
|
229
232
|
|
|
230
233
|
function boardsIndex() {
|
|
231
|
-
|
|
234
|
+
const grouped = SIDES.filter((side) => boards.some((b) => b.side === side))
|
|
235
|
+
.map((side) => ({ side, label: sides[side], boards: boards.filter((b) => b.side === side).map(pub) }));
|
|
236
|
+
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
237
|
}
|
|
233
238
|
|
|
234
239
|
/** Fetch-API handler: answers under basePath, null for anything else. */
|
|
@@ -272,7 +277,7 @@ export function createLeaderboard(options = {}) {
|
|
|
272
277
|
}
|
|
273
278
|
|
|
274
279
|
function rss(result) {
|
|
275
|
-
const title = `${siteName}: ${result.board.label} (${periodLabel(result.period)})`;
|
|
280
|
+
const title = `${siteName}: ${result.board.label}, ${sides[result.board.side]} (${periodLabel(result.period)})`;
|
|
276
281
|
const link = `${siteUrl}${basePath}?board=${result.board.id}&period=${result.period}`;
|
|
277
282
|
const items = result.rows.map((r) => {
|
|
278
283
|
const badgeText = r.badges.map((b) => `${b.emoji} ${b.label}`).join(', ');
|
|
@@ -299,7 +304,7 @@ ${items.join('\n')}
|
|
|
299
304
|
function embedPage({ board, period, limit, sticky, theme, me, full }) {
|
|
300
305
|
const attrs = [
|
|
301
306
|
`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)}"` : '',
|
|
307
|
+
sticky ? `data-sticky="${esc(sticky)}"` : '', theme ? `data-theme="${esc(theme)}"` : '', me ? `data-me="${esc(me)}"` : '', `data-side="${esc(board.side)}"`,
|
|
303
308
|
`data-base="${esc(basePath)}"`,
|
|
304
309
|
].filter(Boolean).join(' ');
|
|
305
310
|
const title = `${board.label}: ${siteName}`;
|
|
@@ -315,10 +320,13 @@ ${items.join('\n')}
|
|
|
315
320
|
}
|
|
316
321
|
|
|
317
322
|
function sharePage(p) {
|
|
318
|
-
const lines = boards.
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
323
|
+
const lines = SIDES.filter((side) => boards.some((b) => b.side === side)).map((side) => {
|
|
324
|
+
const items = boards.filter((b) => b.side === side).map((b) => {
|
|
325
|
+
const r = p.ranks[b.id]?.[defaultPeriod];
|
|
326
|
+
const v = p.values[b.id]?.[defaultPeriod];
|
|
327
|
+
return r ? `<li><strong>#${r}</strong> on ${esc(b.label)} <span class="muted">(${esc(periodLabel(defaultPeriod))}, ${esc(format(v, b.format))})</span></li>` : '';
|
|
328
|
+
}).filter(Boolean).join('');
|
|
329
|
+
return items ? `<h3 class="side">${esc(sides[side])}</h3><ul>${items}</ul>` : '';
|
|
322
330
|
}).filter(Boolean).join('');
|
|
323
331
|
const bestRank = Math.min(...boards.map((b) => p.ranks[b.id]?.[defaultPeriod] ?? Infinity));
|
|
324
332
|
const headline = Number.isFinite(bestRank) ? `#${bestRank} on ${siteName}` : `${p.name} on ${siteName}`;
|
|
@@ -337,11 +345,12 @@ ${items.join('\n')}
|
|
|
337
345
|
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
346
|
.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
347
|
a{color:#7cc4ff}.stats{display:flex;gap:24px;flex-wrap:wrap}.stats div b{display:block;font-size:22px}
|
|
348
|
+
h3.side{font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:#8b93a7;margin:16px 0 4px;font-weight:600}
|
|
340
349
|
</style></head><body><main>
|
|
341
350
|
<p class="muted"><a href="${esc(boardUrl)}">${esc(siteName)} leaderboard</a></p>
|
|
342
351
|
<h1>${esc(p.name)}</h1><p class="muted">${esc(headline)}</p>
|
|
343
352
|
<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
|
|
353
|
+
<section><h2>Boards</h2>${lines || '<p class="muted">Not on a board yet.</p>'}</section>
|
|
345
354
|
<section><h2>Badges</h2><ul>${badgeHtml}</ul></section>
|
|
346
355
|
${ladderHtml}
|
|
347
356
|
<p class="muted" style="margin-top:32px"><a href="${esc(p.shareUrl)}">Share this page</a></p>
|
|
@@ -367,17 +376,29 @@ ${ladderHtml}
|
|
|
367
376
|
};
|
|
368
377
|
}
|
|
369
378
|
|
|
379
|
+
/**
|
|
380
|
+
* Which side of the transaction a board ranks. Money flowing to someone is
|
|
381
|
+
* `sell`, money flowing from them is `buy`, and volume with no money attached
|
|
382
|
+
* is `use`. Boards are never mixed across sides: a seller earning $900 and a
|
|
383
|
+
* crawler spending $900 are not comparable, and showing them in one list is a
|
|
384
|
+
* lie about what the number means.
|
|
385
|
+
*/
|
|
386
|
+
export const SIDES = ['sell', 'buy', 'use'];
|
|
387
|
+
const DEFAULT_SIDES = { sell: 'Earning', buy: 'Spending', use: 'Usage' };
|
|
388
|
+
|
|
370
389
|
function normaliseBoards(input) {
|
|
371
390
|
const src = input && Object.keys(input).length ? input : { top: { label: 'Top', metric: 'score' } };
|
|
372
391
|
const list = Array.isArray(src) ? src : Object.entries(src).map(([id, b]) => ({ id, ...b }));
|
|
373
392
|
return list.map((b) => {
|
|
374
393
|
if (!b.id || !/^[a-z0-9_-]+$/i.test(b.id)) throw new TypeError(`board id must be [a-z0-9_-]: ${b.id}`);
|
|
375
|
-
|
|
394
|
+
const side = b.side ?? 'sell';
|
|
395
|
+
if (!SIDES.includes(side)) throw new TypeError(`board side must be one of ${SIDES.join(', ')}: ${side}`);
|
|
396
|
+
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
397
|
});
|
|
377
398
|
}
|
|
378
399
|
|
|
379
400
|
function pub(b) {
|
|
380
|
-
return { id: b.id, label: b.label, metric: b.metric, format: b.format, unit: b.unit };
|
|
401
|
+
return { id: b.id, label: b.label, metric: b.metric, format: b.format, unit: b.unit, side: b.side, actor: b.actor };
|
|
381
402
|
}
|
|
382
403
|
|
|
383
404
|
function blank(id) {
|