diff --git a/daysnap.js b/daysnap.js new file mode 100644 index 0000000..cdb16a3 --- /dev/null +++ b/daysnap.js @@ -0,0 +1,233 @@ +// The day page (Marty, 2026-09-23): everything that happened across the WHOLE contract on one +// Central calendar day, at a dated address that keeps working forever. +// +// Why it exists: the daily Telegram snapshot outgrew Telegram. Past 1,000 members the post hit +// the 4,096-character limit and was rejected outright for two mornings, and capping the lists to +// fit meant deciding what to throw away. Manson caught the cost of that: the level-up list was +// ordered by time, so the cap hid 2 Culmen and 13 Fabrica behind 24 routine Ascensus upgrades. +// Now the post carries the headline and the climbs, and everything else lives here with no cap. +// +// Today is computed live from the chain index. A past day is read from a file written once when +// that day closed, because the index only keeps the most recent few hundred payouts (about four +// days at current volume) and an older day could never be rebuilt from it. +'use strict'; +const fs = require('fs'); +const path = require('path'); + +let DATA_DIR = null; +const LEVELS = ['Scintilla', 'Ascensus', 'Fabrica', 'Culmen', 'Apex', 'Fastigium', 'Vertex', 'Corona']; +const lname = n => (n >= 1 && n <= 8) ? LEVELS[n - 1] : 'L' + n; + +function init(opts) { DATA_DIR = opts.dataDir; try { fs.mkdirSync(path.join(DATA_DIR, 'days'), { recursive: true }); } catch (e) {} } +const indexFile = () => path.join(DATA_DIR, 'chain-index.json'); +const dayFile = key => path.join(DATA_DIR, 'days', key + '.json'); + +// Central calendar day, the same key the rest of the estate uses +const dayKey = ts => new Date(ts === undefined ? Date.now() : ts).toLocaleDateString('en-CA', { timeZone: 'America/Chicago' }); +const isKey = k => /^\d{4}-\d{2}-\d{2}$/.test(String(k || '')); +// midnight-to-midnight in Central, found by bisection so it is right on both clock-change days +function bounds(key) { + const probe = Date.parse(key + 'T12:00:00Z'); + let lo = probe - 36 * 3600000, hi = probe; + while (hi - lo > 1000) { const mid = Math.floor((lo + hi) / 2); if (dayKey(mid) < key) lo = mid; else hi = mid; } + const start = hi; + lo = start; hi = start + 36 * 3600000; + while (hi - lo > 1000) { const mid = Math.floor((lo + hi) / 2); if (dayKey(mid) <= key) lo = mid; else hi = mid; } + return { start, end: hi }; +} + +function readIndex() { try { return JSON.parse(fs.readFileSync(indexFile(), 'utf8')); } catch (e) { return null; } } + +// everything that happened on one Central day, company-wide: no team or org filter anywhere +function compute(key) { + const idx = readIndex(); + if (!idx) return null; + const { start, end } = bounds(key); + const s = Math.floor(start / 1000), e = Math.floor(end / 1000); + const members = idx.members || {}; + const all = idx.payouts || []; + + const joins = Object.keys(members) + .map(i => ({ id: Number(i), m: members[i] })) + .filter(x => { const t = Number(x.m.joinedAt || 0); return t >= s && t < e; }) + .sort((a, b) => Number(a.m.joinedAt) - Number(b.m.joinedAt)) + .map(x => ({ id: x.id, referrerId: Number(x.m.referrerId || 0) || null, tier: x.m.tier || null, level: Number(x.m.level || 0) || null })); + + const pays = all.filter(p => { const t = Number(p.ts || 0); return t >= s && t < e; }) + .map(p => ({ toId: Number(p.toId || 0), fromId: Number(p.fromId || 0), kind: p.kind || null, level: Number(p.level || 0) || null, + pol: Number(p.pol || 0), tx: p.tx || null, ts: Number(p.ts || 0), upgrade: p.upgrade || null })) + .sort((a, b) => b.pol - a.pol); + + const climbSeen = new Map(); + for (const p of pays) if (p.upgrade) climbSeen.set(p.upgrade.id + ':' + p.upgrade.newLevel, { id: Number(p.upgrade.id), lvl: Number(p.upgrade.newLevel), ts: p.ts }); + const climbs = [...climbSeen.values()].sort((a, b) => b.lvl - a.lvl || a.ts - b.ts); + + const recruited = {}; + for (const j of joins) if (j.referrerId) (recruited[j.referrerId] = recruited[j.referrerId] || []).push(j.id); + const recruiters = Object.keys(recruited).map(r => ({ + id: Number(r), brought: recruited[r], directs: Number((members[r] || {}).directCount || 0), + })).sort((a, b) => b.brought.length - a.brought.length || a.id - b.id); + + const polMoved = pays.reduce((n, p) => n + p.pol, 0); + // the index keeps a rolling payout window; if a day fills it the totals are a floor, not the whole day + const clipped = pays.length >= all.length && all.length > 0; + + return { + key, computedAt: Date.now(), clipped, + totals: { joins: joins.length, climbs: climbs.length, payouts: pays.length, polMoved: Math.round(polMoved * 1e6) / 1e6 }, + contract: { members: Object.keys(members).length, lifetimePol: Number((idx.totals || {}).pol || 0), lifetimePayouts: Number((idx.totals || {}).count || 0) }, + climbs, joins, recruiters, payouts: pays, + biggest: pays[0] || null, + }; +} + +// a finished day is written once and read from disk forever after +function get(key) { + if (!isKey(key)) return null; + const today = dayKey(); + if (key !== today) { + try { return JSON.parse(fs.readFileSync(dayFile(key), 'utf8')); } catch (e) {} + if (key > today) return null; + } + const d = compute(key); + if (d && key !== today) save(d); + return d; +} +function save(d) { try { fs.writeFileSync(dayFile(d.key), JSON.stringify(d)); } catch (e) {} } +function stored() { try { return fs.readdirSync(path.join(DATA_DIR, 'days')).filter(f => f.endsWith('.json')).map(f => f.slice(0, -5)).sort().reverse(); } catch (e) { return []; } } + +// hourly: close out any finished day that has not been written yet, so the archive never depends +// on someone opening the page before the payout window rolls past it +function tick() { + const today = dayKey(); + for (let back = 1; back <= 3; back++) { + const k = dayKey(Date.now() - back * 86400000); + if (k === today) continue; + if (fs.existsSync(dayFile(k))) continue; + const d = compute(k); + if (d && d.totals.payouts + d.totals.joins > 0) { save(d); console.log('daysnap: stored ' + k); } + } +} + +// ---- the page --------------------------------------------------------------------------------- +const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); +const n0 = v => Number(v || 0).toLocaleString('en-US', { maximumFractionDigits: 0 }); +const n2 = v => Number(v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +const SCAN = 'https://polygonscan.com/tx/'; +const pretty = key => { const [y, m, d] = key.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString('en-US', { timeZone: 'UTC', weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }); }; + +function render(key, opts) { + const o = opts || {}; + const d = get(key); + const today = dayKey(); + const prev = dayKey(bounds(key).start - 3600000); + const nextKey = dayKey(bounds(key).end + 3600000); + const title = 'The Circle on ' + pretty(key); + if (!d) return { status: 404, html: shell(title, '

No figures for that date yet.

', key, prev, null) }; + + const usd = Number(o.polUsd) || 0; + const dol = p => usd ? ' (~$' + n0(p * usd) + ')' : ''; + const high = d.climbs.filter(c => c.lvl >= 3); + const routine = d.climbs.length - high.length; + + const head = '
' + + tile(n0(d.totals.joins), 'new members') + + tile(n0(d.totals.climbs), 'upgrades') + + tile(n0(d.totals.polMoved) + ' POL', 'moved on chain' + (usd ? ' · ~$' + n0(d.totals.polMoved * usd) : '')) + + tile(n0(d.contract.members), 'on the contract') + + '
'; + + // grouped by rung, highest first: the ladder is the story, and a two-column table of the same + // thing wastes half the page and buries a Culmen among two dozen Ascensus rows + const byLevel = new Map(); + for (const c of d.climbs) { if (!byLevel.has(c.lvl)) byLevel.set(c.lvl, []); byLevel.get(c.lvl).push(c.id); } + const climbRows = d.climbs.length + ? [...byLevel.keys()].sort((a, b) => b - a).map(lv => '
' + + '
' + esc(lname(lv)) + ' ' + byLevel.get(lv).length + '
' + + '
' + byLevel.get(lv).map(i => '#' + i + '').join('') + '
').join('') + : '

No upgrades on this day.

'; + + const joinRows = d.joins.length + ? '' + + d.joins.map(j => '').join('') + + '
PositionInvited byEntry
#' + j.id + '' + (j.referrerId ? '#' + j.referrerId : '—') + '' + esc(j.tier === 2 || j.tier === 'Premium' ? 'Premium' : 'Standard') + '
' + : '

No new members on this day.

'; + + const recRows = d.recruiters.length + ? '' + + d.recruiters.map(r => '').join('') + + '
PositionBrought inWhoDirects
#' + r.id + '' + r.brought.length + '' + + r.brought.map(x => '#' + x).join(' ') + '' + (r.directs >= 2 ? 'qualified' : r.directs + '/2') + '
' + : '

Nobody added a personal referral on this day.

'; + + const payRows = d.payouts.length + ? '' + + d.payouts.map(p => '').join('') + + '
POLToFromWhyProof
' + n2(p.pol) + '' + dol(p.pol) + '#' + p.toId + '' + + (p.fromId ? '#' + p.fromId : '—') + '' + esc(p.upgrade ? 'upgrade to ' + lname(p.upgrade.newLevel) : (p.kind || 'payment')) + + '' + (p.tx ? 'Polygonscan ↗' : '—') + '
' + : '

No payments on this day.

'; + + const body = head + + (high.length ? '

Highest climb of the day: ' + esc(lname(high[0].lvl)) + ', reached by ' + + high.filter(c => c.lvl === high[0].lvl).map(c => '#' + c.id).join(', ') + '.

' : '') + + (d.biggest ? '

Biggest single payment: ' + n2(d.biggest.pol) + ' POL' + dol(d.biggest.pol) + + ', caught by #' + d.biggest.toId + '' + + (d.biggest.tx ? ' · Polygonscan ↗' : '') + '.

' : '') + + (d.clipped ? '

These payment totals are a floor: the index window filled for this day.

' : '') + + sec('Every climb', (high.length ? high.length + ' above Ascensus' : '') + (routine ? (high.length ? ' · ' : '') + routine + ' reached Ascensus' : ''), climbRows) + + sec('Every payment', d.totals.payouts + ' on chain, each one verifiable', payRows) + + sec('Every new member', d.totals.joins + ' joined', joinRows) + + sec('Recruiters', d.recruiters.length + ' added someone', recRows); + + return { status: 200, html: shell(title, body, key, prev, nextKey <= today && nextKey !== key ? nextKey : null) }; +} +const tile = (v, l) => '
' + v + '
' + l + '
'; +const sec = (h, sub, inner) => '

' + esc(h) + (sub ? ' ' + esc(sub) + '' : '') + '

' + + (/^' + inner + '' : inner) + ''; + +function shell(title, body, key, prev, next) { + return '' + + '' + esc(title) + ' — RM Circle' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + '
The whole contract, one day
' + + '

' + esc(title) + '

' + + '

Midnight to midnight, US Central. Every figure below is read from the Polygon contract, company-wide.

' + + body + + '
' + + (prev ? '← ' + esc(pretty(prev)) + '' : '') + + (next ? '' + esc(pretty(next)) + ' →' : '') + + 'Join The RM Circle' + + '
' + + '

Positions are shown by their contract id. No income is guaranteed and cryptocurrency carries risk of loss.

' + + '
'; +} + +module.exports = { init, dayKey, isKey, compute, get, save, stored, tick, render, bounds, lname }; diff --git a/server.js b/server.js index a10417b..14e3c87 100644 --- a/server.js +++ b/server.js @@ -4,6 +4,7 @@ const path = require('path'); const crypto = require('crypto'); const { URL } = require('url'); const chain = require('./chain'); +const daysnap = require('./daysnap'); // /day/: the whole contract, one Central day, kept forever const messages = require('./messages'); const profiles = require('./profiles'); const placement = require('./placement'); @@ -1847,6 +1848,14 @@ const server=http.createServer(async(req,res)=>{ const u=new URL(req.url,`http://${req.headers.host||'localhost'}`),pathname=decodeURIComponent(u.pathname); // /p/ = member Page Builder pages: dynamic HTML, so it must reach the // API handler rather than the static-file path (which 404s it). + if(pathname==='/day'||/^\/day\/\d{4}-\d{2}-\d{2}$/.test(pathname)){ + const key = pathname==='/day' ? daysnap.dayKey() : pathname.slice(5); + const r = daysnap.render(key, { polUsd: await getPolUsd() }); + const fresh = key === daysnap.dayKey(); + res.writeHead(r.status, securityHeaders({ 'Content-Type':'text/html; charset=utf-8', + 'Cache-Control': fresh ? 'public, max-age=120' : 'public, max-age=86400' })); + return res.end(r.html); + } if(pathname==='/health'||pathname==='/announce.ics'||pathname.startsWith('/api/')||pathname.startsWith('/tv/')||/^\/p\/\d{1,15}(\/[a-z0-9-]{1,24})?$/.test(pathname))return await handleApi(req,res,pathname); if(req.method!=='GET'&&req.method!=='HEAD')return send(res,405,'Method Not Allowed',{'Content-Type':'text/plain; charset=utf-8'}); // Members-area gate: the Circle Method lessons 2-10 + the e-gift cash-out @@ -1925,6 +1934,11 @@ function checkUpgradeAlerts(){ }catch(e){ console.error('upgrade alert check', e.message); } } setInterval(checkUpgradeAlerts, 5*60*1000).unref(); +// close out finished days so the archive never depends on someone opening the page before the +// chain index's rolling payout window scrolls past that day +daysnap.init({ dataDir: DATA_DIR }); +setTimeout(() => { try { daysnap.tick(); } catch (e) { console.error('daysnap', e.message); } }, 20000).unref(); +setInterval(() => { try { daysnap.tick(); } catch (e) { console.error('daysnap', e.message); } }, 60 * 60 * 1000).unref(); setTimeout(checkUpgradeAlerts, 30000).unref(); chain.startIndexer(evt=>{ // Any on-chain event makes cached member views stale (the new member, the