Een dating-app op basis van brieven, niet van gezichten!

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

23.6 kB Ruw bestand
/*
 * 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`);
}