OASIS_MOBILE/src/models/stats_model.js
SITO f354069af7 ssb: base de multicuenta y arreglo de las rutas fijas a ~/.ssb
ssb_config pasa a derivar la cuenta de OASIS_ACCOUNT. Como ssb-config construye la
ruta desde el appname y gui.js saca el socket de config.path, cambiar ese literal
arrastra de forma coherente el almacen, la base de datos, los blobs, el secret y el
socket. Sin la variable se usa 'ssb', asi que las instalaciones existentes siguen en
~/.ssb y no notan nada.

El nombre se valida contra [a-zA-Z0-9][a-zA-Z0-9_-]{0,31} y cae a 'ssb' si no encaja.
No es de adorno: acaba en rutas de fichero y hay operaciones destructivas detras.
Comprobado que OASIS_ACCOUNT=../../etc no sale de sitio.

statePath separa tambien por cuenta cuando se usa OASIS_STATE_DIR. Sin eso, dos
identidades compartirian el estado que 0.9.5 empezo a guardar ahi, que es justo el
camino a partir el registro.

Y el arreglo que importa: doce sitios construian la ruta como os.homedir() + '/.ssb'
en vez de usar la de la cuenta. El peor era panicmode_model, que con varias
identidades habria borrado la equivocada; tambien exportmode y legacy_model, que
habrian exportado o restaurado la identidad de otra cuenta. Los de lectura
—main_models, stats_model y backend.js con gossip.json y ebt— habrian mirado siempre
la cuenta por defecto.

Falta la parte visible: el selector de identidad y el reinicio del proceso al
cambiar. Esta base es la que hacia falta para que eso no sea peligroso.

Verificado: con OASIS_ACCOUNT sin definir la ruta sigue siendo ~/.ssb; con 'trabajo'
pasa a ~/.trabajo; con un valor con .. o espacios se rechaza. Diez rutas OK con la
app en marcha, incluidas /peers y /stats, que son las que leen esos ficheros.
2026-08-19 00:44:23 +02:00

630 lines
26 KiB
JavaScript

const pull = require('../server/node_modules/pull-stream');
const os = require('os');
const fs = require('fs');
const path = require('path');
const { getConfig } = require('../configs/config-manager.js');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const STORAGE_DIR = path.join(__dirname, "..", "configs");
const ADDR_FILE = path.join(STORAGE_DIR, "wallet_addresses.json");
function readAddrMap() {
try {
if (!fs.existsSync(ADDR_FILE)) return {};
const raw = fs.readFileSync(ADDR_FILE, 'utf8');
const obj = JSON.parse(raw || '{}');
return obj && typeof obj === 'object' ? obj : {};
} catch {
return {};
}
}
const listPubsFromEbt = () => {
try {
const ebtDir = path.join(require('../server/ssb_config').path, 'ebt');
const files = fs.readdirSync(ebtDir);
return files.filter(f => f.endsWith('.ed25519'));
} catch {
return [];
}
};
const HIDDEN_ENVELOPE_TYPES = new Set([
'tribe-keys-distrib',
'tribe-invite-msg',
'tribe-invite-tombstone'
]);
module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
const buildAccessibleTribeIds = async () => {
const set = new Set();
if (!tribesModel) return set;
try {
const list = await tribesModel.listAll();
for (const t of list) {
if (!t || !t.id) continue;
set.add(t.id);
try {
const chain = await tribesModel.getChainIds(t.id);
for (const cid of chain) set.add(cid);
} catch (_) {}
}
} catch (_) {}
return set;
};
const types = [
'bookmark','event','task','votes','report','feed','project','industry','industryBlueprint',
'image','torrent','audio','video','document','transfer','post','tribe',
'market','forum','job','aiExchange','map','shop','shopProduct','chat','chatMessage',
'pad','padEntry','gameScore','calendar','calendarDate','calendarNote','log',
'parliamentCandidature','parliamentTerm','parliamentProposal','parliamentRevocation','parliamentLaw',
'courtsCase','courtsEvidence','courtsAnswer','courtsVerdict','courtsSettlement','courtsSettlementProposal','courtsSettlementAccepted','courtsNomination','courtsNominationVote'
];
const getFolderSize = (folderPath) => {
let files;
try { files = fs.readdirSync(folderPath); } catch (_) { return 0; }
let totalSize = 0;
for (const file of files) {
const filePath = `${folderPath}/${file}`;
try {
const st = fs.statSync(filePath);
totalSize += st.isDirectory() ? getFolderSize(filePath) : st.size;
} catch (_) {}
}
return totalSize;
};
const formatSize = (sizeInBytes) => {
if (sizeInBytes < 1024) return `${sizeInBytes} B`;
const kb = 1024, mb = kb * 1024, gb = mb * 1024, tb = gb * 1024;
if (sizeInBytes < mb) return `${(sizeInBytes / kb).toFixed(2)} KB`;
if (sizeInBytes < gb) return `${(sizeInBytes / mb).toFixed(2)} MB`;
if (sizeInBytes < tb) return `${(sizeInBytes / gb).toFixed(2)} GB`;
return `${(sizeInBytes / tb).toFixed(2)} TB`;
};
const N = s => String(s || '').toUpperCase();
const sum = arr => arr.reduce((a, b) => a + b, 0);
const median = arr => {
if (!arr.length) return 0;
const a = [...arr].sort((x, y) => x - y);
const m = Math.floor(a.length / 2);
return a.length % 2 ? a[m] : (a[m - 1] + a[m]) / 2;
};
const parseAuctionMax = auctions_poll => {
if (!Array.isArray(auctions_poll) || auctions_poll.length === 0) return 0;
const amounts = auctions_poll.map(s => {
const parts = String(s).split(':');
const amt = parseFloat(parts[1]);
return isNaN(amt) ? 0 : amt;
});
return amounts.length ? Math.max(...amounts) : 0;
};
const dayKey = ts => new Date(ts || 0).toISOString().slice(0, 10);
const lastNDays = (n) => {
const out = [];
const today = new Date();
today.setUTCHours(0,0,0,0);
for (let i = n - 1; i >= 0; i--) {
const d = new Date(today);
d.setUTCDate(today.getUTCDate() - i);
out.push(d.toISOString().slice(0, 10));
}
return out;
};
const norm = s => String(s || '').normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim();
const bestContentTs = (c, fallbackTs = 0) =>
Number(c?.updatedAt ? Date.parse(c.updatedAt) : 0) ||
Number(c?.createdAt ? Date.parse(c.createdAt) : 0) ||
Number(c?.timestamp || 0) ||
Number(fallbackTs || 0);
const dedupeTribesNodes = (nodes = []) => {
const pick = new Map();
for (const n of nodes) {
const c = n?.content || {};
const title = c.title || c.name || '';
const author = n?.author || '';
const key = `${norm(title)}::${author}`;
const ts = bestContentTs(c, n?.ts || 0);
const prev = pick.get(key);
if (!prev || ts > prev._ts) pick.set(key, { ...n, _ts: ts });
}
return Array.from(pick.values());
};
const inferType = (c = {}) => {
if (c.vote) return 'vote';
if (c.votes) return 'votes';
if (c.address && c.coin === 'ECO' && c.type === 'wallet') return 'bankWallet';
if (typeof c.amount !== 'undefined' && c.epochId && c.allocationId) return 'bankClaim';
if (typeof c.item_type !== 'undefined' && typeof c.status !== 'undefined') return 'market';
if (typeof c.goal !== 'undefined' && typeof c.progress !== 'undefined') return 'project';
if (typeof c.members !== 'undefined' && typeof c.isAnonymous !== 'undefined') return 'tribe';
if (typeof c.date !== 'undefined' && typeof c.location !== 'undefined') return 'event';
if (typeof c.priority !== 'undefined' && typeof c.status !== 'undefined' && c.title) return 'task';
if (typeof c.confirmations !== 'undefined' && typeof c.severity !== 'undefined') return 'report';
if (typeof c.job_type !== 'undefined' && typeof c.status !== 'undefined') return 'job';
if (typeof c.url !== 'undefined' && typeof c.mimeType !== 'undefined' && c.type === 'audio') return 'audio';
if (typeof c.url !== 'undefined' && typeof c.mimeType !== 'undefined' && c.type === 'video') return 'video';
if (typeof c.url !== 'undefined' && c.title && c.key) return 'document';
if (typeof c.text !== 'undefined' && typeof c.refeeds !== 'undefined') return 'feed';
if (typeof c.text !== 'undefined' && typeof c.contentWarning !== 'undefined') return 'post';
if (typeof c.contact !== 'undefined') return 'contact';
if (typeof c.about !== 'undefined') return 'about';
if (typeof c.concept !== 'undefined' && typeof c.amount !== 'undefined' && c.status) return 'transfer';
if (c.type === 'map') return 'map';
if (c.type === 'shop') return 'shop';
if (c.type === 'shopProduct') return 'shopProduct';
if (c.type === 'chat') return 'chat';
if (c.type === 'chatMessage') return 'chatMessage';
return '';
};
const normalizeActionType = (a) => {
const t = a.type || a.content?.type || inferType(a.content) || '';
return String(t).toLowerCase();
};
const priorityBump = (p) => {
const s = String(p || '').toUpperCase();
if (s === 'HIGH') return 3;
if (s === 'MEDIUM') return 1;
return 0;
};
const severityBump = (s) => {
const x = String(s || '').toUpperCase();
if (x === 'CRITICAL') return 6;
if (x === 'HIGH') return 4;
if (x === 'MEDIUM') return 2;
return 0;
};
const calculateOpinionScore = (content) => {
const cats = content?.opinions || {};
let s = 0;
for (const k in cats) {
if (!Object.prototype.hasOwnProperty.call(cats, k)) continue;
if (k === 'interesting' || k === 'inspiring') s += 5;
else if (k === 'boring' || k === 'spam' || k === 'propaganda') s -= 3;
else s += 1;
}
return s;
};
const scoreMarketItem = (c) => {
const st = String(c.status || '').toUpperCase();
let s = 5;
if (st === 'SOLD') s += 8;
else if (st === 'ACTIVE') s += 3;
const bids = Array.isArray(c.auctions_poll) ? c.auctions_poll.length : 0;
s += Math.min(10, bids);
return s;
};
const scoreProjectItem = (c) => {
const st = String(c.status || 'ACTIVE').toUpperCase();
const prog = Number(c.progress || 0);
let s = 8 + Math.min(10, prog / 10);
if (st === 'FUNDED') s += 10;
return s;
};
const computeKarmaFromMsgs = (msgs) => {
let score = 0;
for (const m of msgs) {
const c = m.value?.content || {};
const t = normalizeActionType({ type: c.type, content: c });
const rawType = String(c.type || '').toLowerCase();
if (t === 'post') score += 10;
else if (t === 'comment') score += 5;
else if (t === 'like') score += 2;
else if (t === 'image') score += 8;
else if (t === 'video') score += 12;
else if (t === 'audio') score += 8;
else if (t === 'document') score += 6;
else if (t === 'map') score += 6;
else if (t === 'bookmark') score += 2;
else if (t === 'feed') score += 6;
else if (t === 'forum') score += c.root ? 5 : 10;
else if (t === 'vote') score += 3 + calculateOpinionScore(c);
else if (t === 'votes') score += Math.min(10, Number(c.totalVotes || 0));
else if (t === 'market') score += scoreMarketItem(c);
else if (t === 'project') score += scoreProjectItem(c);
else if (t === 'tribe') score += 6 + Math.min(10, Array.isArray(c.members) ? c.members.length * 0.5 : 0);
else if (t === 'event') score += 4 + Math.min(10, Array.isArray(c.attendees) ? c.attendees.length : 0);
else if (t === 'task') score += 3 + priorityBump(c.priority);
else if (t === 'report') score += 4 + (Array.isArray(c.confirmations) ? c.confirmations.length : 0) + severityBump(c.severity);
else if (t === 'curriculum') score += 5;
else if (t === 'aiexchange') score += Array.isArray(c.ctx) ? Math.min(10, c.ctx.length) : 0;
else if (t === 'job') score += 4 + (Array.isArray(c.subscribers) ? c.subscribers.length : 0);
else if (t === 'bankclaim') score += Math.min(20, Math.log(1 + Math.max(0, Number(c.amount) || 0)) * 5);
else if (t === 'bankwallet') score += 2;
else if (t === 'transfer') score += 1;
else if (t === 'about') score += 1 + ((c.visibilityPrefs && c.visibilityPrefs.fediverseHandle) ? 8 : 0);
else if (t === 'contact') score += 1;
else if (t === 'pub') score += 1;
else if (t === 'parliamentcandidature' || rawType === 'parliamentcandidature') score += 12;
else if (t === 'parliamentterm' || rawType === 'parliamentterm') score += 25;
else if (t === 'parliamentproposal' || rawType === 'parliamentproposal') score += 8;
else if (t === 'parliamentlaw' || rawType === 'parliamentlaw') score += 16;
else if (t === 'parliamentrevocation' || rawType === 'parliamentrevocation') score += 10;
else if (t === 'courts_case' || t === 'courtscase' || rawType === 'courts_case') score += 4;
else if (t === 'courts_evidence' || t === 'courtsevidence' || rawType === 'courts_evidence') score += 3;
else if (t === 'courts_answer' || t === 'courtsanswer' || rawType === 'courts_answer') score += 4;
else if (t === 'courts_verdict' || t === 'courtsverdict' || rawType === 'courts_verdict') score += 10;
else if (t === 'courts_settlement' || t === 'courtssettlement' || rawType === 'courts_settlement') score += 8;
else if (t === 'courts_nomination' || t === 'courtsnomination' || rawType === 'courts_nomination') score += 6;
else if (t === 'courts_nom_vote' || t === 'courtsnomvote' || rawType === 'courts_nom_vote') score += 3;
else if (t === 'courts_public_pref' || t === 'courtspublicpref' || rawType === 'courts_public_pref') score += 1;
else if (t === 'courts_mediators' || t === 'courtsmediators' || rawType === 'courts_mediators') score += 6;
else if (t === 'courts_open_support' || t === 'courtsopensupport' || rawType === 'courts_open_support') score += 2;
else if (t === 'courts_verdict_vote' || t === 'courtsverdictvote' || rawType === 'courts_verdict_vote') score += 3;
else if (t === 'courts_judge_assign' || t === 'courtsjudgeassign' || rawType === 'courts_judge_assign') score += 5;
else if (t === 'larpjoinhouse') score += 15;
else if (t === 'larphousepost') score += 6;
else if (t === 'larptestattempt') score += 3;
else if (t === 'torrent') score += 6;
else if (t === 'shop' || t === 'shopproduct') score += 6;
else if (t === 'shop-purchase') score += 2;
else if (t === 'pad' || t === 'padentry') score += 3;
else if (t === 'calendar' || t === 'calendarnote' || t === 'calendardate') score += 3;
else if (t === 'chat') score += 1;
else if (t === 'gamescore') score += 2;
else if (t === 'pixelia') score += 2;
else if (rawType === 'industry') score += 8;
else if (rawType === 'industryblueprint') score += 6;
else if (rawType === 'industrybuild') score += 8;
else if (rawType === 'industrycontribution') score += 4;
else if (rawType === 'industryallocation') score += 10;
else if (rawType === 'industryvote') score += 2;
else if (rawType === 'industrymember') score += 2;
else if (rawType === 'industryopinion') score += 1;
}
return Math.max(0, Math.round(score));
};
const getStats = async (filter = 'ALL') => {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const messages = await new Promise((res, rej) => {
pull(
ssbClient.createLogStream({ limit: logLimit, reverse: true }),
pull.collect((err, msgs) => err ? rej(err) : res(msgs))
);
});
const allMsgs = messages.filter(m => {
const c = m.value && m.value.content;
if (!c) return false;
if (typeof c === 'string' && c.endsWith('.box')) return false;
if (c.type && HIDDEN_ENVELOPE_TYPES.has(c.type)) return false;
return true;
});
const tombTargets = buildValidatedTombstoneSet(allMsgs);
const scopedMsgs = filter === 'MINE' ? allMsgs.filter(m => m.value.author === userId) : allMsgs;
const byType = {};
const parentOf = {};
for (const t of types) {
byType[t] = new Map();
parentOf[t] = new Map();
}
const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null;
const accessibleTribeIds = await buildAccessibleTribeIds();
for (const m of scopedMsgs) {
const k = m.key;
let c = m.value.content;
if (tribeCrypto && tribeCrypto.isTribeMsg(c)) {
const r = fpIdx ? tribeCrypto.unwrapMsg(c, fpIdx) : null;
if (!r || !r.body) continue;
const inner = r.body;
if (inner.k === 'tribe') {
c = { ...inner, type: 'tribe' };
} else if (inner.k === 'tribe-content' && inner.contentType) {
c = { ...inner, type: inner.contentType };
} else {
continue;
}
} else if (c && c.tribeId && !accessibleTribeIds.has(c.tribeId)) {
continue;
}
let theType = c.type;
if (!types.includes(theType)) continue;
byType[theType].set(k, { key: k, ts: m.value.timestamp, content: c, author: m.value.author });
if (c.replaces) parentOf[theType].set(k, c.replaces);
}
const findRoot = (t, id) => {
let cur = id;
const pMap = parentOf[t];
while (pMap.has(cur)) cur = pMap.get(cur);
return cur;
};
const tipOf = {};
for (const t of types) {
tipOf[t] = new Map();
const pMap = parentOf[t];
const fwd = new Map();
for (const [child, parent] of pMap.entries()) fwd.set(parent, child);
const allMap = byType[t];
const roots = new Set(Array.from(allMap.keys()).map(id => findRoot(t, id)));
for (const root of roots) {
let tip = root;
while (fwd.has(tip)) tip = fwd.get(tip);
if (tombTargets.has(tip)) continue;
const node = allMap.get(tip) || allMap.get(root);
if (node) tipOf[t].set(root, node);
}
}
const tribeTipNodes = Array.from(tipOf['tribe'].values());
const tribeDedupNodes = dedupeTribesNodes(tribeTipNodes);
const tribeDedupContents = tribeDedupNodes.map(n => n.content);
const tribePublic = tribeDedupContents.filter(c => c.isAnonymous === false);
const tribePrivate = tribeDedupContents.filter(c => c.isAnonymous !== false);
const tribePublicNames = tribePublic.map(c => c.name || c.title || c.id).filter(Boolean);
const tribePublicCount = tribePublicNames.length;
const tribePrivateCount = tribePrivate.length;
const allTribesPublic = tribeDedupNodes
.filter(n => n.content?.isAnonymous === false)
.map(n => ({ id: findRoot('tribe', n.key), name: n.content.name || n.content.title || n.key }));
const allTribes = allTribesPublic.map(t => t.name);
const memberTribesDetailed = tribeDedupNodes
.filter(n => Array.isArray(n.content?.members) && n.content.members.includes(userId))
.map(n => ({ id: findRoot('tribe', n.key), name: n.content.name || n.content.title || n.key }));
const myPrivateTribesDetailed = tribeDedupNodes
.filter(n => n.content?.isAnonymous !== false && Array.isArray(n.content?.members) && n.content.members.includes(userId))
.map(n => ({ id: findRoot('tribe', n.key), name: n.content.name || n.content.title || n.key }));
const content = {};
const opinions = {};
for (const t of types) {
let vals;
if (t === 'tribe') {
vals = tribeDedupContents;
} else {
vals = Array.from(tipOf[t].values()).map(v => v.content);
if (t === 'forum') vals = vals.filter(c => !(c.root && tombTargets.has(c.root)));
}
content[t] = vals.length || 0;
opinions[t] = vals.filter(e => Array.isArray(e.opinions_inhabitants) && e.opinions_inhabitants.length > 0).length || 0;
}
if (filter === 'MINE') {
const myMsgs = allMsgs.filter(m => m.value.author === userId);
content['karmaScore'] = computeKarmaFromMsgs(myMsgs);
} else {
const msgsByAuthor = new Map();
for (const m of allMsgs) {
const a = m.value.author;
if (!msgsByAuthor.has(a)) msgsByAuthor.set(a, []);
msgsByAuthor.get(a).push(m);
}
let sumKarma = 0;
for (const authorMsgs of msgsByAuthor.values()) {
sumKarma += computeKarmaFromMsgs(authorMsgs);
}
content['karmaScore'] = sumKarma;
}
const inhabitants = new Set(allMsgs.map(m => m.value.author)).size;
const secretStat = fs.statSync((require('../server/ssb_config').path + '/secret'));
const createdAt = secretStat.birthtime.toLocaleString();
const folderSize = getFolderSize((require('../server/ssb_config').path + ''));
const flumeSize = getFolderSize((require('../server/ssb_config').path + '/flume'));
const blobsSize = getFolderSize((require('../server/ssb_config').path + '/blobs'));
const allTs = scopedMsgs.map(m => m.value.timestamp || 0).filter(Boolean);
const lastTs = allTs.length ? Math.max(...allTs) : 0;
const mapDay = new Map();
for (const m of scopedMsgs) {
const dk = dayKey(m.value.timestamp || 0);
mapDay.set(dk, (mapDay.get(dk) || 0) + 1);
}
const days7 = lastNDays(7).map(d => ({ day: d, count: mapDay.get(d) || 0 }));
const days30 = lastNDays(30).map(d => ({ day: d, count: mapDay.get(d) || 0 }));
const daily7Total = sum(days7.map(o => o.count));
const daily30Total = sum(days30.map(o => o.count));
const jobsVals = Array.from(tipOf['job'].values()).map(v => v.content);
const jobOpen = jobsVals.filter(j => N(j.status) === 'OPEN').length;
const jobClosed = jobsVals.filter(j => N(j.status) === 'CLOSED').length;
const jobSalaries = jobsVals.map(j => parseFloat(j.salary)).filter(n => isFinite(n));
const jobVacantsOpen = jobsVals.filter(j => N(j.status) === 'OPEN').map(j => parseInt(j.vacants || 0, 10) || 0);
const jobSubsTotal = jobsVals.map(j => Array.isArray(j.subscribers) ? j.subscribers.length : 0);
const marketVals = Array.from(tipOf['market'].values()).map(v => v.content);
const mkForSale = marketVals.filter(m => N(m.status) === 'FOR SALE').length;
const mkReserved = marketVals.filter(m => N(m.status) === 'RESERVED').length;
const mkClosed = marketVals.filter(m => N(m.status) === 'CLOSED').length;
const mkSold = marketVals.filter(m => N(m.status) === 'SOLD').length;
let revenueECO = 0;
const soldPrices = [];
for (const m of marketVals) {
if (N(m.status) !== 'SOLD') continue;
let price = 0;
if (String(m.item_type || '').toLowerCase() === 'auction') {
price = parseAuctionMax(m.auctions_poll);
} else {
price = parseFloat(m.price || 0) || 0;
}
soldPrices.push(price);
revenueECO += price;
}
const projectVals = Array.from(tipOf['project'].values()).map(v => v.content);
const prActive = projectVals.filter(p => N(p.status) === 'ACTIVE').length;
const prCompleted = projectVals.filter(p => N(p.status) === 'COMPLETED').length;
const prPaused = projectVals.filter(p => N(p.status) === 'PAUSED').length;
const prCancelled = projectVals.filter(p => N(p.status) === 'CANCELLED').length;
const prGoals = projectVals.map(p => parseFloat(p.goal || 0) || 0);
const prPledged = projectVals.map(p => parseFloat(p.pledged || 0) || 0);
const prProgress = projectVals.map(p => parseFloat(p.progress || 0) || 0);
const activeFundingRates = projectVals
.filter(p => N(p.status) === 'ACTIVE' && parseFloat(p.goal || 0) > 0)
.map(p => (parseFloat(p.pledged || 0) / parseFloat(p.goal || 1)) * 100);
const projectsKPIs = {
total: projectVals.length,
active: prActive,
completed: prCompleted,
paused: prPaused,
cancelled: prCancelled,
ecoGoalTotal: sum(prGoals),
ecoPledgedTotal: sum(prPledged),
successRate: projectVals.length ? (prCompleted / projectVals.length) * 100 : 0,
avgProgress: prProgress.length ? (sum(prProgress) / prProgress.length) : 0,
medianProgress: median(prProgress),
activeFundingAvg: activeFundingRates.length ? (sum(activeFundingRates) / activeFundingRates.length) : 0
};
const topAuthorsMap = new Map();
for (const m of scopedMsgs) {
const a = m.value.author;
topAuthorsMap.set(a, (topAuthorsMap.get(a) || 0) + 1);
}
const topAuthors = Array.from(topAuthorsMap.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([id, count]) => ({ id, count }));
const tagCount = new Map();
for (const t of types) {
for (const v of Array.from(tipOf[t].values())) {
const tags = v.content && Array.isArray(v.content.tags) ? v.content.tags : [];
for (const tg of tags) {
const key = String(tg || '').trim();
if (!key) continue;
tagCount.set(key, (tagCount.get(key) || 0) + 1);
}
}
}
const topTags = Array.from(tagCount.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([tag, count]) => ({ tag, count }));
const totalTypeCount = types.reduce((s, t) => s + (Array.from(tipOf[t].values()).length || 0), 0);
const topTypeBlacklist = new Set(['shopProduct','chatMessage','padEntry','calendarDate','calendarNote','log']);
const topTypes = types
.filter(t => !topTypeBlacklist.has(t))
.map(t => ({ type: t, count: Array.from(tipOf[t].values()).length || 0 }))
.filter(o => o.count > 0)
.sort((a, b) => b.count - a.count)
.slice(0, 10);
const myMsgsAll = allMsgs.filter(m => m.value.author === userId);
const myShare = allMsgs.length ? (myMsgsAll.length / allMsgs.length) * 100 : 0;
const avgMsgsPerInhabitant = inhabitants ? allMsgs.length / inhabitants : 0;
const validatedTombstoneCount = tombTargets.size;
const tombstoneRatio = allMsgs.length ? (validatedTombstoneCount / allMsgs.length) * 100 : 0;
const totalsTs = allMsgs.map(m => m.value.timestamp || 0).filter(Boolean).sort((a, b) => a - b);
const networkSpanDays = totalsTs.length >= 2 ? (totalsTs[totalsTs.length - 1] - totalsTs[0]) / 86400000 : 0;
const networkMsgsPerDay = networkSpanDays > 0 ? (allMsgs.length / networkSpanDays) : 0;
const addrMap = readAddrMap();
const myAddress = addrMap[userId] || null;
const banking = {
ecoWalletConfigured: !!myAddress,
myAddress,
myAddressCount: myAddress ? 1 : 0,
totalAddresses: Object.keys(addrMap).length
};
const pubsCount = listPubsFromEbt().length;
const stats = {
id: userId,
createdAt,
inhabitants,
content,
opinions,
memberTribes: memberTribesDetailed.map(t => t.name),
memberTribesDetailed,
myPrivateTribesDetailed,
allTribes,
allTribesPublic,
tribePublicNames,
tribePublicCount,
tribePrivateCount,
userTombstoneCount: scopedMsgs.filter(m => m.value.content.type === 'tombstone' && m.value.author === userId).length,
networkTombstoneCount: validatedTombstoneCount,
folderSize: formatSize(folderSize),
statsBlockchainSize: formatSize(flumeSize),
statsBlobsSize: formatSize(blobsSize),
pubsCount,
activity: {
lastMessageAt: lastTs ? new Date(lastTs).toISOString() : null,
daily7: days7,
daily30Total,
daily7Total
},
jobsKPIs: {
total: jobsVals.length,
open: jobOpen,
closed: jobClosed,
avgSalary: jobSalaries.length ? (sum(jobSalaries) / jobSalaries.length) : 0,
medianSalary: median(jobSalaries),
openVacants: sum(jobVacantsOpen),
subscribersTotal: sum(jobSubsTotal)
},
marketKPIs: {
total: marketVals.length,
forSale: mkForSale,
reserved: mkReserved,
closed: mkClosed,
sold: mkSold,
revenueECO,
avgSoldPrice: soldPrices.length ? (sum(soldPrices) / soldPrices.length) : 0
},
projectsKPIs,
usersKPIs: {
totalInhabitants: inhabitants,
topAuthors
},
tombstoneKPIs: {
networkTombstoneCount: validatedTombstoneCount,
ratio: tombstoneRatio
},
networkKPIs: {
totalMsgs: allMsgs.length,
myMsgs: myMsgsAll.length,
myShare,
avgMsgsPerInhabitant,
networkSpanDays,
networkMsgsPerDay
},
topTags,
topTypes,
totalTypeCount,
banking
};
return stats;
};
return { getStats };
};