@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 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(); // one process, tests
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 { 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 }
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.1.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: score events in, ranked boards + RSS + a share card + an embeddable widget out. Zero dependencies, Fetch API core, Hono and Next adapters.",
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": { "type": "git", "url": "git+https://github.com/profullstack/leaderboard.git" },
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": { "url": "https://github.com/profullstack/leaderboard/issues" },
30
+ "bugs": {
31
+ "url": "https://github.com/profullstack/leaderboard/issues"
32
+ },
25
33
  "license": "MIT",
26
34
  "author": "Profullstack, LLC",
27
35
  "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" }
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": ["src", "index.d.ts", "README.md", "LICENSE"],
38
- "engines": { "node": ">=20.11" },
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
- commission: p.commission ? p.commission.rate : undefined,
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
- 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 })) };
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.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>` : '';
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><ul>${lines || '<li class="muted">Not on a board yet.</li>'}</ul></section>
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
- 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 ?? '' };
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
+ }