Expand ecash explorer capabilities

Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
This commit is contained in:
michilis
2026-08-21 02:10:48 +02:00
parent aa1771ea20
commit 6f17b572b1
80 changed files with 7580 additions and 704 deletions
+240
View File
@@ -0,0 +1,240 @@
/**
* The card model: everything one social image renders, and nothing else.
*
* This object is the hash input for the manifest, so it must be derived purely from
* API data plus the day the build runs on. Two rules follow from that:
*
* - No relative times finer than a day. "3 minutes ago" in a static PNG is a lie by
* the time anyone sees it; day counts and month/year stamps go stale slowly enough
* to survive until the next scheduled rebuild regenerates them.
* - Everything the template touches is in the model, and the template touches
* nothing but the model. A design change is a TEMPLATE_VERSION bump, which busts
* every hash at once; a data change busts exactly the mints it happened to.
*
* The warning logic is imported from shared/, not restated: the image must never
* disagree with the page it decorates about whether a mint is frozen or gone.
*/
import { getMintWarnings, parseLimits, readCapabilities } from '@cashumints/shared';
/** Bump when the template's layout or copy changes, to regenerate every image. */
export const TEMPLATE_VERSION = 1;
const DAY = 86_400;
/** English month/year ("Mar 2026") — the images are one English render for all locales. */
export function monthYear(unix) {
return new Date(unix * 1000).toLocaleDateString('en-GB', {
month: 'short',
year: 'numeric',
timeZone: 'UTC',
});
}
/** "3 Aug 2026", matching f.date() on the pages. */
export function shortDate(unix) {
return new Date(unix * 1000).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
timeZone: 'UTC',
});
}
/* ---- mirrors of web/src/lib/format.ts, which is TypeScript this script cannot load.
Same algorithms, asserted by eyeball against the site's own fallback tiles. ---- */
export function displayDomain(url) {
return url.replace(/^https?:\/\//, '').replace(/\/+$/, '');
}
/** The site's deterministic gradient, resolved to hex pairs satori understands. */
export function iconGradient(seed) {
let hash = 0;
for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
const hue = hash % 360;
const hue2 = (hue + 40) % 360;
return [hsl(hue, 45, 55), hsl(hue2, 45, 45)];
}
function hsl(h, s, l) {
s /= 100;
l /= 100;
const k = (n) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
const hex = (v) => Math.round(v * 255).toString(16).padStart(2, '0');
return `#${hex(f(0))}${hex(f(8))}${hex(f(4))}`;
}
export function initials(name) {
const cleaned = name.replace(/^(https?:\/\/)?(mint\.|www\.)?/, '');
const first = cleaned.trim()[0] ?? '?';
if (/\d/.test(first)) return cleaned.slice(0, 2);
return first.toUpperCase();
}
export function truncateMiddle(value, head = 10, tail = 8) {
if (value.length <= head + tail + 1) return value;
return `${value.slice(0, head)}…${value.slice(-tail)}`;
}
/** 4–5 star share vs 1–2 star share, as the site's sentiment bar splits them. */
export function sentiment(dist) {
const positive = (dist['5'] ?? 0) + (dist['4'] ?? 0);
const negative = (dist['2'] ?? 0) + (dist['1'] ?? 0);
const total = positive + negative + (dist['3'] ?? 0);
if (total === 0) return null;
return {
pos: Math.round((positive / total) * 100),
neg: Math.round((negative / total) * 100),
};
}
/** "500k", "1M", "100" — sat amounts at stats-bar size. */
export function compactSats(n) {
if (n >= 1_000_000) return trim(n / 1_000_000) + 'M';
if (n >= 1_000) return trim(n / 1_000) + 'k';
return String(n);
}
function trim(n) {
const s = n.toFixed(1);
return s.endsWith('.0') ? s.slice(0, -2) : s;
}
/** The warning cell's two lines, from the same warning object the page banner uses. */
function warningCell(warning, mint, now) {
if (!warning) return null;
const since = mint.last_online;
const label = {
'gone': 'Likely gone',
'melt-disabled': 'No withdrawals',
'frozen': 'Frozen',
'melt-only': 'Melt only',
'offline-long': 'Offline',
'offline': 'Offline',
'fedimint-offline': 'Reported offline',
}[warning.kind];
if (!label) return null;
let sub;
switch (warning.kind) {
case 'gone':
case 'offline-long':
case 'offline':
case 'fedimint-offline':
sub = since !== null ? `Since ${shortDate(since)}` : 'Never reached';
break;
case 'melt-only':
sub = 'Deposits disabled';
break;
case 'melt-disabled':
sub = 'Deposits still open';
break;
case 'frozen':
sub = 'Nothing moves in or out';
break;
}
// The mint is also offline while a capability warning outranks that fact: the cell
// still has to say so, the way the page banner folds it into its last sentence.
if (['melt-only', 'melt-disabled', 'frozen'].includes(warning.kind) && mint.status === 'offline' && since !== null) {
sub = `Offline since ${shortDate(since)}`;
}
return { kind: warning.kind, severity: warning.severity, label, sub };
}
/**
* One Cashu mint's card model, from its detail payload.
*
* `iconHash` arrives from the caller (a digest of the cached icon bytes), so a mint
* that changes its logo behind the same URL regenerates. `now` is passed in rather
* than read here so fixtures render the same PNG forever.
*/
export function cashuModel(mint, { iconHash = null, now = Math.floor(Date.now() / 1000) } = {}) {
const domain = displayDomain(mint.url);
const name = (mint.name ?? '').trim() || domain.split('/')[0] || domain;
const warning = getMintWarnings(mint, { now })[0] ?? null;
const offline = mint.status === 'offline';
const offlineDays =
offline && mint.last_online !== null
? Math.max(0, Math.floor((now - mint.last_online) / DAY))
: null;
const limits = parseLimits(mint.info?.nuts, 4);
const caps = readCapabilities(mint.info?.nuts);
return {
v: TEMPLATE_VERSION,
type: 'cashu',
slug: mint.host,
name,
domain,
status: mint.status,
offlineDays,
rating: mint.rating_avg,
reviewCount: mint.review_count,
sentiment: sentiment(mint.rating_distribution ?? {}),
lastReviewMonth: mint.last_review_at ? monthYear(mint.last_review_at) : null,
limits:
limits && (limits.min !== null || limits.max !== null) && !caps.mintDisabled
? { min: limits.min, max: limits.max, unit: limits.unit }
: null,
warning: warningCell(warning, mint, now),
iconHash,
gradient: iconHash ? null : iconGradient(domain),
initials: iconHash ? null : initials(name),
};
}
/** One federation's card model. Same skeleton, only honest fields. */
export function fedimintModel(fed, { iconHash = null, now = Math.floor(Date.now() / 1000) } = {}) {
const federationId = fed.federation_id ?? '';
const name = (fed.name ?? '').trim() || 'Unnamed federation';
const warning = getMintWarnings(fed, { now })[0] ?? null;
const announced = fed.status === 'announced';
const offlineDays =
fed.status === 'offline' && fed.last_online !== null
? Math.max(0, Math.floor((now - fed.last_online) / DAY))
: null;
return {
v: TEMPLATE_VERSION,
type: 'fedimint',
slug: fed.host,
name,
/** `fed11…` prefix plus the id tail: what the identity row shows where a domain sits. */
shortId: federationId ? `fed11…${federationId.slice(-6)}` : fed.host,
status: fed.status,
offlineDays,
rating: fed.rating_avg,
reviewCount: fed.review_count,
sentiment: sentiment(fed.rating_distribution ?? {}),
lastReviewMonth: fed.last_review_at ? monthYear(fed.last_review_at) : null,
modules: fed.modules ?? [],
network: fed.network,
/** The soft warning: never a red cell, just this muted line under the identity row. */
announcedNote:
announced && warning?.kind === 'never-confirmed' && fed.announced_at
? `Announced ${shortDate(fed.announced_at)}, never confirmed`
: null,
warning: fed.status === 'offline' ? warningCell(warning, fed, now) : null,
iconHash,
gradient: iconHash ? null : iconGradient(federationId || fed.host),
initials: iconHash ? null : initials(name),
};
}
/** The default card: brand, tagline, and the network's own numbers. */
export function defaultModel(stats) {
return {
v: TEMPLATE_VERSION,
type: 'default',
slug: 'default',
mints: stats.cashu_total,
federations: stats.fedimint_total,
reviews: stats.reviews_total,
};
}