Een dating-app op basis van brieven, niet van gezichten!
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
/*
* Hoe de gemeenschap ervoor staat, en vooral: wat iemand die zich vandaag aanmeldt hier aan heeft.
*
* Ledentallen beantwoorden die vraag niet. Een app met duizend leden waarvan er tien alle post
* krijgen is voor een nieuwkomer leger dan een app met honderd leden die elkaar schrijven. Wat een
* nieuwkomer koopt is: hoeveel mensen hij überhaupt mág aanschrijven (het filter is wederzijds), de
* kans dat zijn eerste brief beantwoord wordt, hoe lang hij daarop wacht, en de kans dat er een
* correspondentie uit groeit. Dat meet dit.
*
* Er komt geen persoon uit. Geen naam, geen adres, geen brief — alleen tellingen, aandelen en
* medianen. `npm run reports` is de enige plek waar iemand anders dan de schrijver en de ontvanger
* een brief te zien krijgt, en dat hoort zo te blijven; wie hier een lijstje "meest geschreven
* leden" aan toevoegt maakt hier een tweede zo'n plek van.
*
* Elk percentage draagt zijn n mee. Er wordt niets verzwegen bij kleine aantallen — 100% over twee
* mensen mag er staan, zolang die twee er ook staat.
*
* npm run health # de laatste 30 dagen
* npm run health -- --days 90 # ander venster
* npm run health -- --weeks 12 # meer cohortrijen in de trechter
* npm run health -- --settle 21 # ruimer venster voor "is er al geantwoord?"
* npm run health -- --grace 0 # ook wie zich net aanmeldde meetellen in de scheefheid
* npm run health -- --probe m:v,35 # één profiel: man van 35 die vrouwen zoekt
* npm run health -- --sample 0 # bereikbaarheid over iedereen (traag boven ~2000 actieven)
* npm run health -- --fast # sla de bereikbaarheid over
* npm run health -- --json # dezelfde getallen als JSON, om te bewaren en te vergelijken
* npm run health -- --html > x.html # hetzelfde rapport als pagina, om te bewaren en te sturen
*
* Op de server: cd /opt/liefdesbrieven && npm run health
*
* Een cijfer van één moment zegt weinig; de beweging erin zegt alles. Vandaar --json:
* npm run health -- --json >> health.jsonl
*/
import { pool } from "../server/db.js";
import { REPORT_REASONS, REAL_CORRESPONDENCE_LETTERS, REAL_CORRESPONDENCE_EACH,
RELATIONSHIP_LABEL, FREE_ACCOUNTS, SESSION_TTL_DAYS, REPORT_RETENTION_DAYS } from "../src/constants.js";
import { nl, pct, per100, span, spanH, genderName, weekLabel, waitBuckets, median } from "../server/report-format.js";
import { renderHtml } from "../server/report-html.js";
import { collectReport, DEFAULTS } from "../server/report.js";
/*
* De vensters van de meting staan in server/report.js, samen met het ophalen zelf: de terminal, het
* HTML-bestand en de maandelijkse pagina op de website horen naar dezelfde meting te kijken.
*/
/* ---------- Argumenten ---------- */
const args = process.argv.slice(2);
const has = (name) => args.includes(`--${name}`);
const flag = (name) => {
const i = args.indexOf(`--${name}`);
return i === -1 ? null : args[i + 1] ?? "";
};
const fail = (msg) => {
console.error(msg);
process.exitCode = 1;
return null;
};
let bad = false;
const num = (name, fallback, { min = 0, max = 3650 } = {}) => {
const raw = flag(name);
if (raw === null) return fallback;
if (!/^\d+$/.test(raw) || Number(raw) < min || Number(raw) > max) {
bad = true;
return fail(`--${name} wil een geheel getal tussen ${min} en ${max}`);
}
return Number(raw);
};
/*
* "m:v,35" → een man van 35 die vrouwen zoekt. Wie je zoekt hoort erbij: zonder dat telt de proef
* alle oriëntaties bij elkaar op en komt er een getal uit dat voor niemand geldt.
*/
const probeArg = () => {
const raw = flag("probe");
if (raw === null) return null;
const m = /^([vmx]):([vmx]{1,3}),(\d{2,3})$/.exec(raw);
const uniek = m && new Set(m[2]).size === m[2].length;
if (!uniek || Number(m[3]) < 18 || Number(m[3]) > 120) {
bad = true;
return fail('gebruik --probe <wie ben je>:<wie zoek je>,<leeftijd> — bijvoorbeeld --probe m:v,35 (man zoekt vrouw) of --probe v:vm,40');
}
const zoekt = [...m[2]];
const naam = { v: "vrouw", m: "man", x: "non-binair" };
return {
label: `${naam[m[1]]} zoekt ${zoekt.length === 3 ? "iedereen" : zoekt.map((g) => naam[g]).join(" of ")}`,
gender: m[1], seeks: zoekt, age: Number(m[3]),
};
};
const days = num("days", DEFAULTS.days, { min: 1 });
const weeks = num("weeks", DEFAULTS.weeks, { min: 1, max: 520 });
const settle = num("settle", DEFAULTS.settle, { min: 1 });
const grace = num("grace", DEFAULTS.grace);
const sample = num("sample", DEFAULTS.sample, { max: 100000 });
const probe = probeArg();
const asJson = has("json");
/* ---------- Opmaak ---------- */
const line = () => console.log("─".repeat(78));
const head = (t) => {
console.log("");
console.log(t);
line();
};
/* De getalopmaak zelf staat in server/report-format.js — die deelt hij met de HTML-uitvoer */
/* Twee spaties inspringen, sleutels links uitgevuld op de breedste */
const rows = (pairs) => {
const shown = pairs.filter(Boolean);
const w = Math.max(0, ...shown.map(([k]) => k.length));
for (const [k, v] of shown) console.log(` ${k.padEnd(w)} : ${v}`);
};
const bar = (share, w = 22) => "▇".repeat(Math.max(0, Math.round((share || 0) * w))).padEnd(w, " ");
/* Kolommen op de breedste cel; alles behalve de eerste kolom rechts uitgelijnd */
const table = (headers, body) => {
const all = [headers, ...body].map((r) => r.map((c) => String(c ?? "—")));
const w = headers.map((_, i) => Math.max(...all.map((r) => (r[i] ?? "").length)));
const fmt = (r) => " " + r.map((c, i) => (i === 0 ? c.padEnd(w[i]) : c.padStart(w[i]))).join(" ");
console.log(fmt(all[0]));
console.log(" " + w.map((n) => "─".repeat(n)).join(" "));
for (const r of all.slice(1)) console.log(fmt(r));
};
/* ---------- Ophalen ---------- */
try {
if (bad) throw new Error("stop");
const { data, reachMs } = await collectReport({
days, weeks, settle, grace, sample,
reachability: !has("fast"),
searchTerms: true, // alleen hier: de publieke pagina roept collectReport zonder deze vlag aan
...(probe ? { probeProfiles: [probe], probeAges: [probe.age] } : {}),
});
if (asJson) console.log(JSON.stringify(data));
else if (has("html")) console.log(renderHtml(data, { reachMs }));
else render(data, reachMs);
} catch (e) {
if (e.message !== "stop") throw e;
} finally {
await pool.end();
}
/* ---------- Afdrukken ---------- */
function render(d, reachMs) {
const { overview: o, shapes: s, safety: g } = d;
const all = d.openers.find((r) => r.gender == null) ?? {};
const spreadAll = d.spread.find((r) => r.gender == null) ?? {};
const reachAll = d.reachability?.find((r) => r.gender == null) ?? null;
const totalCohort = d.funnel.find((r) => r.isTotal) ?? {};
/* ---------- Wat een nieuwkomer mag verwachten ---------- */
const cells = [...d.probeGrid].sort((a, b) => a.matches - b.matches);
const cellName = (c) => `${c.label}, ${c.age} jaar`;
const midMatches = Math.round(median(cells.map((c) => c.matches)) ?? 0);
const midResponsive = Math.round(median(cells.map((c) => c.responsive)) ?? 0);
const best = cells[cells.length - 1] ?? null;
const empty = cells.filter((c) => Math.round(c.matches) === 0);
console.log(`Liefdesbrieven — hoe de gemeenschap ervoor staat (venster: ${days} dagen)`);
line();
/* Voorbeeldaccounts betekent: dit gaat niet over mensen. Dat hoort bovenaan, niet in een voetnoot */
if (o.example_accounts) {
console.log(`LET OP: ${o.example_accounts} van de ${o.members} accounts zijn voorbeeldaccounts (@voorbeeld.nl).`);
console.log(`Dit is testdata uit db:setup of db:generate, geen meting aan echte mensen. Elk verband`);
console.log(`hieronder is een eigenschap van db/generate.js — je leest de knoppen van de generator`);
console.log(`terug, niet het gedrag van mensen.\n`);
}
console.log("Wat iemand die zich vandaag aanmeldt mag verwachten");
rows([
["te schrijven", cells.length === 1
? `${Math.round(cells[0].matches)} mensen passen bij ${cellName(cells[0])}`
: `mediaan ${midMatches} mensen, over ${cells.length} proefprofielen` +
(best ? `; ruimst ${cellName(best)} met ${Math.round(best.matches)}` : "")],
cells.length > 1 && empty.length
? ["voor niemand", `${empty.length} van de ${cells.length} proefprofielen vindt niemand` +
` (${empty.slice(0, 3).map(cellName).join(", ")}${empty.length > 3 ? `, en ${empty.length - 3} meer` : ""})`]
: null,
["die terugschrijven", cells.length === 1
? `${Math.round(cells[0].responsive)} daarvan antwoordden de laatste ${d.window.responsive} dagen ook echt`
: `mediaan ${midResponsive} antwoordden de laatste ${d.window.responsive} dagen ook echt`],
["antwoordkans", `${pct(all.answered ?? 0, all.settled_n ?? 0)} van de eerste brieven, binnen ${settle} dagen`],
["wachttijd", `mediaan ${spanH(all.median_h)}, p90 ${spanH(all.p90_h)} (van wie antwoord kreeg)`],
["wordt het wat", `${pct(s.real_corr, s.pairs)} van de correspondenties werd er echt een`],
["na een maand", totalCohort.mature
? `${pct(totalCohort.retained, totalCohort.mature)} is er dan nog`
: "— (nog geen cohort van 30 dagen oud)"],
["veiligheid", `${g.reports_window} meldingen in dit venster, over ${o.active_window} actieve leden`],
]);
/* ---------- A ---------- */
head("A. Wie er zijn");
rows([
["leden", `${o.members} (waarvan ${o.suspended_now} geschorst)`],
["actief", `${o.active_window} in ${days} dagen, ${o.active7} in 7 — wie inlogt of post opent telt mee`],
["schrijvend actief", `${o.active_writing} schreef of wijzigde iets (last_active); het verschil met hierboven is de lezer`],
["nieuw", `${o.joined_window} in dit venster`],
["leeftijd", `mediaan ${nl(o.median_age, 0)} jaar (p10 ${nl(o.p10_age, 0)}, p90 ${nl(o.p90_age, 0)})`],
["brieven", `${o.letters_total} in totaal (${o.photos_total} met foto), ${o.letters_window} in dit venster`],
["gratis plekken", `${o.slots_granted} van de ${FREE_ACCOUNTS} uitgegeven`],
o.purchases_total ? ["betaalde plekken", `${o.purchases_total} gekocht, ${o.purchases_alive} met een account erop`] : null,
["vertrokken", `${o.departed_window} in dit venster, ${o.departed_total} in totaal` +
(o.departed_window ? ` — mediaan na ${nl(o.departed_median_days, 0)} dagen lidmaatschap, ${o.departed_silent} zonder ooit een brief te schrijven` : "")],
o.departed_by_reason ? [" waarvan", Object.entries(o.departed_by_reason).map(([k, n]) => `${n}× ${k}`).join(", ")] : null,
]);
console.log("");
table(
["wie", "leden", "actief"],
d.overview.split.map((r) => [
`${genderName(r.gender)}${r.trans ? " (trans)" : ""}, ${RELATIONSHIP_LABEL[r.relationship]}`,
r.n,
r.active,
])
);
console.log("");
rows([
["met locatie", pct(o.with_location, o.members)],
["met een straal", `${pct(o.with_radius, o.members)} — en die zijn onzichtbaar voor élke nieuwkomer, want een nieuw account heeft geen locatie`],
["trans", `${o.trans_members} — en ${o.seek_trans_members} van de leden staan open voor trans correspondenten`],
["ziet ook mensen in een relatie", `${o.seek_relatie_members} van de leden`],
o.without_consent ? ["nog geen AVG-toestemming", `${o.without_consent} (de app vraagt het bij de eerstvolgende keer)`] : null,
]);
/* ---------- B ---------- */
head("B. De trechter van een nieuwkomer");
console.log(" Per registratieweek, en wat er van dat cohort geworden is.");
console.log("");
table(
["cohort", "aangemeld", "schreef", "kreeg post", "kreeg antwoord", "echte corr.", "na 30 d terug"],
d.funnel.map((r) => [
r.isTotal ? "alles" : weekLabel(r.week),
r.registered,
pct(r.ever_sent, r.registered),
pct(r.ever_addressed, r.registered),
pct(r.ever_answered, r.registered),
pct(r.ever_real, r.registered),
r.mature ? pct(r.retained, r.mature) : "—",
])
);
console.log("");
console.log(` "kreeg post" is aangeschreven zijn door iemand die jou nog niet kende, niet post binnen een`);
console.log(` correspondentie die je zelf begon. "echte corr." = ${REAL_CORRESPONDENCE_LETTERS} brieven, van beide kanten minstens ${REAL_CORRESPONDENCE_EACH}.`);
console.log(` "na 30 d terug" heeft als noemer alleen het deel van het cohort dat die 30 dagen ook gehad heeft;`);
console.log(` een streepje betekent dat het cohort daar nog te jong voor is, niet dat niemand terugkwam.`);
console.log(` Let op: "aangemeld" telt wie zich toen aanmeldde én nu nóg een account heeft. Wie opzegde is`);
console.log(` spoorloos — zie "vertrokken" bij A om te schatten hoe groot die vertekening is.`);
/* ---------- C ---------- */
head("C. Antwoordkans en wachttijd");
console.log(` Over de eerste brief van elke correspondentie. Een eerste brief telt pas mee als hij ${settle} dagen`);
console.log(` oud is, en de vraag is of er bínnen ${settle} dagen antwoord kwam — anders krijgt een oude brief meer`);
console.log(` klok dan een nieuwe en loopt het cijfer vanzelf op naarmate de data ouder wordt.`);
console.log("");
rows([
["eerste brieven", `${all.openers} in totaal`],
["meegeteld", `${all.settled_n} (oud genoeg)`],
["nog te jong", `${all.too_young}, waarvan er al ${all.too_young_answered} beantwoord zijn`],
["beantwoord", `${pct(all.answered, all.settled_n)} binnen ${settle} dagen`],
["ooit beantwoord", `${pct(all.answered_ever, all.settled_n)} — ter vergelijking; dit cijfer is niet vergelijkbaar over de tijd`],
["wachttijd", `mediaan ${spanH(all.median_h)}, p90 ${spanH(all.p90_h)} — alleen van wie antwoord kreeg`],
]);
console.log("");
for (const { label, n } of [...waitBuckets(all, settle), { label: `geen antwoord binnen ${settle} dagen`, n: all.b_never }]) {
console.log(` ${label.padEnd(30)} ${bar((n || 0) / (all.settled_n || 1))} ${String(n ?? 0).padStart(5)}`);
}
const byGender = d.openers.filter((r) => r.gender != null);
if (byGender.length) {
console.log("");
console.log(" Naar het gender van de schrijver:");
console.log("");
table(
["schrijver", "eerste brieven", "meegeteld", "beantwoord", "mediane wachttijd"],
byGender.map((r) => [genderName(r.gender), r.openers, r.settled_n, pct(r.answered, r.settled_n), spanH(r.median_h)])
);
}
/* ---------- D ---------- */
head("D. De verdeling van de aandacht");
if (!spreadAll.members) {
console.log(` Nog niemand is ${grace} dagen of langer lid, dus er valt niets te verdelen.`);
console.log(` Met --grace 0 telt iedereen mee, ook wie zich vandaag aanmeldde.`);
} else {
console.log(grace
? ` Over de ${spreadAll.members} leden die minstens ${grace} dagen lid zijn. Hier liegt een gemiddelde het hardst:`
: ` Over alle ${spreadAll.members} leden, ook wie zich net aanmeldde (--grace 0). Hier liegt een gemiddelde het hardst:`);
console.log(` het deelt de post van de drukste correspondent uit over iedereen die niets kreeg.`);
console.log("");
rows([
["ontvangen brieven", `${spreadAll.total_got} in dit venster`],
["per lid", `gemiddeld ${nl(spreadAll.mean_got)}, mediaan ${nl(spreadAll.median_got)}, p90 ${nl(spreadAll.p90_got)}`],
["kreeg niets in dit venster", pct(spreadAll.zero_window, spreadAll.members)],
["kreeg nooit iets", pct(spreadAll.zero_ever, spreadAll.members)],
["nooit ongevraagd aangeschreven", pct(spreadAll.zero_openers, spreadAll.members)],
["naar de drukste 10%", spreadAll.top10_got == null ? "— (te weinig leden)" : pct(spreadAll.top10_got, spreadAll.total_got)],
["naar de drukste 25%", spreadAll.top25_got == null ? "— (te weinig leden)" : pct(spreadAll.top25_got, spreadAll.total_got)],
["scheefheid (Gini)", `${nl(spreadAll.gini_got, 2)} over ontvangen post, ${nl(spreadAll.gini_openers, 2)} over eerste brieven`],
]);
console.log("");
console.log(` Gini 0,00 = iedereen krijgt evenveel; 1,00 = één lid krijgt alles. Het is geen cijfer om aan af`);
console.log(` te lezen hoe erg het is — het is een cijfer om over de maanden heen te vólgen. Vergelijk het niet`);
console.log(` met inkomens-Gini's; dat is een andere grootheid.`);
const gs = d.spread.filter((r) => r.gender != null);
if (gs.length) {
console.log("");
table(
["wie", "leden", "kreeg niets", "gem. brieven", "mediaan", "gem. eerste brieven"],
gs.map((r) => [
genderName(r.gender),
r.members,
pct(r.zero_window, r.members),
nl(r.mean_got),
nl(r.median_got),
nl(r.mean_openers, 2),
])
);
}
}
/* ---------- E ---------- */
head("E. Is er iemand om aan te schrijven");
if (reachAll) {
rows([
["actieve leden", `${reachAll.actives}`],
["gemeten", reachAll.measured < reachAll.actives
? `${reachAll.measured} (steekproef; --sample 0 meet iedereen)`
: `${reachAll.measured} (allemaal)`],
["wederzijdse matches", `mediaan ${nl(reachAll.median, 0)}, p10 ${nl(reachAll.p10, 0)}, p90 ${nl(reachAll.p90, 0)}, gemiddeld ${nl(reachAll.mean)}`],
["heeft er nul", `${pct(reachAll.zero, reachAll.measured)} — voor hen is de app leeg zonder dat ze kunnen zien waarom`],
["rekentijd", `${nl(reachMs / 1000)} s`],
]);
} else {
console.log(" (bereikbaarheid overgeslagen: --fast)");
}
console.log("");
console.log(probe ? ` Eén bedacht profiel:` : ` En voor wie er nog niet is — bedachte nieuwkomers:`);
console.log(` wie je bent én wie je zoekt, want zonder dat tweede telt de proef alle oriëntaties bij`);
console.log(` elkaar op tot een getal dat voor niemand geldt. De rest staat op de beginwaarden van een`);
console.log(` nieuw account: alleen vrijgezellen, geen trans correspondenten, eigen leeftijd ±5.`);
console.log("");
table(
["proefprofiel", "leeftijd", "passend", "spreiding", "actief (7 d)", `antwoordend (${d.window.responsive} d)`],
d.probeGrid.map((r) => [
r.label, r.age, Math.round(r.matches),
r.matches_min === r.matches_max ? `${r.matches_min}` : `${r.matches_min}–${r.matches_max}`,
Math.round(r.active7), Math.round(r.responsive),
])
);
console.log("");
console.log(` Elk getal is de mediaan over ${d.probeGrid[0]?.placements ?? 1} woonplaatsen, getrokken uit waar de leden werkelijk`);
console.log(` wonen; "spreiding" is de laagste en hoogste daarvan. Dat die smal is komt doordat maar`);
console.log(` ${o.with_radius} van de ${o.members} leden een zoekstraal instellen — loopt dat op, dan gaat woonplaats meetellen.`);
/* ---------- F ---------- */
head("F. Hoe diep het gaat");
rows([
["correspondenties", `${s.pairs}, waarvan ${s.alive} met post in dit venster en ${s.started_window} pas begonnen`],
["wederkerig", `${pct(s.reciprocal, s.pairs)} kreeg ooit antwoord`],
["echte correspondentie", `${pct(s.real_corr, s.pairs)} (${REAL_CORRESPONDENCE_LETTERS} brieven, beide kanten ${REAL_CORRESPONDENCE_EACH})`],
["brieven per wederkerig paar", `mediaan ${nl(s.median_letters_recip, 0)}`],
["looptijd", `p90 ${nl(s.p90_span_days, 0)} dagen, langste ${nl(s.longest_days, 0)} dagen`],
["favoriet", `${pct(s.pairs_with_favorite, s.pairs)} van de paren, wederzijds bij ${s.pairs_favorite_mutual}`],
["brieflengte", `gemiddeld ${nl(s.avg_chars, 0)} tekens in dit venster`],
["met foto", pct(s.photos_window, o.letters_window)],
["geopend", `${pct(s.read_n, s.read_denom)} van de brieven die minstens ${d.window.readSettle} dagen liggen, mediaan na ${span(s.median_read_s)}`],
]);
console.log("");
table(
["brieven per correspondentie", "aantal"],
[["1 (nooit beantwoord)", s.p1], ["2 tot 3", s.p2], ["4 tot 9", s.p3], ["10 of meer", s.p4]]
);
console.log("");
console.log(` "Geopend" is het openen van de correspondentie, niet het lezen van die ene brief: de app zet de`);
console.log(` hele correspondentie in één keer op gelezen.`);
/* ---------- G ---------- */
head("G. Of het er prettig is");
rows([
["meldingen", `${g.reports_window} in dit venster, ${per100(g.reports_window, o.active_window)} actieve leden`],
["openstaand", `${g.reports_open}, waarvan ${g.reports_open_old} langer dan een week`],
["doorlooptijd", g.median_handle_s == null
? "— (niets afgehandeld in dit venster)"
: `mediaan ${span(g.median_handle_s)} tot afhandeling`],
["blokkades", `${g.blocks_window} in dit venster, ${g.blocks_total} in totaal`],
["door meerdere geblokkeerd", `${g.blocked_by_two} leden door 2 of meer, ${g.blocked_by_three} door 3 of meer`],
["correspondentie eindigde erin", `${pct(g.ended_in_block, g.pairs)} — de blokkade kwam ná de laatste brief`],
["vijandige eerste brief", `${pct(g.opener_blocked + g.opener_reported, g.openers_settled)} liep binnen ${d.window.hostile} dagen uit op een blokkade of melding`],
["geschorst", `${g.suspended_now} leden`],
g.warnings_pending ? ["waarschuwingen", `${g.warnings_pending} nog niet weggeklikt`] : null,
]);
if (g.reports_by_reason) {
console.log("");
table(
["reden", "meldingen"],
Object.entries(g.reports_by_reason).map(([k, n]) => [REPORT_REASONS[k] ?? k, n])
);
}
if (g.measures_window) {
console.log("");
table(["maatregel", "genomen"], Object.entries(g.measures_window).map(([k, n]) => [k, n]));
}
/* ---------- H ---------- */
if (d.searchTerms) {
head("H. Waar op gezocht wordt");
if (!d.searchTerms.length) {
console.log(" (nog niets gezocht in dit venster)");
} else {
console.log(` De vaakst ingetypte woorden in Zoeken, en hoeveel profielen erop passen. Geteld per woord`);
console.log(` en per dag, zonder wie het zocht — er is geen rij die over één persoon gaat.\n`);
table(
["woord", "gezocht", "dagen", "profielen"],
d.searchTerms.slice(0, 25).map((t) => [t.term, t.searches, t.days_seen, t.profiles])
);
/* Het cijfer waar dit voor bestaat: vaak gezocht en nauwelijks te vinden */
const gaten = d.searchTerms.filter((t) => t.searches >= 5 && t.profiles <= 2);
console.log("");
rows([
["gaten", gaten.length
? `${gaten.length} woorden worden vaak gezocht maar staan in hoogstens 2 profielen: ` +
gaten.slice(0, 6).map((t) => `${t.term} (${t.searches}×, ${t.profiles})`).join(", ")
: "geen woord dat vaak gezocht wordt en nergens te vinden is"],
]);
}
}
/* ---------- Voetnoot ---------- */
head("Wat deze cijfers niet kunnen zien");
console.log(` · Vertrokken leden nemen hun brieven mee. Elke telling over het verleden krimpt dus, en altijd`);
console.log(` dezelfde kant op. "Vertrokken" bij A is het enige exacte cijfer erover.`);
console.log(` · De meldingen en blokkades zijn een ondergrens: meldingen worden na ${REPORT_RETENTION_DAYS} dagen gewist, en`);
console.log(` beide verdwijnen zodra een van de twee accounts verdwijnt${g.reports_orphaned ? ` (${g.reports_orphaned} meldingen zonder melder)` : ""}.`);
console.log(` · "Actief" leunt op sessies; voorbij ${SESSION_TTL_DAYS} dagen is die sessierij opgeruimd en valt het terug op`);
console.log(` last_active, dat de stille lezer mist. Oude cohorten zijn daardoor eerder te laag dan te hoog.`);
console.log(` · Of iemand in Zoeken gezíen is, staat nergens. Bereikbaarheid zegt of er iemand te schrijven`);
console.log(` valt, niet of er iemand keek.`);
if (d.searchTerms) {
console.log(` · De zoekwoorden zijn losse woorden, geen zoekopdrachten: "man met hond" telt bij man, met en`);
console.log(` hond op. Wie zocht staat er niet bij, dus "vaak gezocht" kan ook één volhardend lid zijn.`);
}
console.log("");
console.log(` --days ${days} · --weeks ${weeks} · --settle ${settle} · --grace ${grace} · --sample ${sample} · --probe <wie>:<zoekt>,<leeftijd> · --fast · --json · --html`);
}