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.

45.6 kB Ruw bestand
/*
 * Hetzelfde rapport als `npm run health`, maar als één zelfstandig HTML-bestand:
 *
 *   npm run health -- --html > rapport.html
 *
 * Waarom dit bestaat naast de terminaluitvoer: die is om even te kijken, dit is om te bewaren en
 * door te sturen. Een venster van dertig dagen zegt weinig los van het vorige; twee pagina's naast
 * elkaar zeggen alles. Het bestand heeft niets van buiten nodig — geen stylesheet, geen lettertype
 * van een server — zodat het een jaar later nog opent zoals het bedoeld was.
 *
 * De opmaak is niet verzonnen maar geleend van de app zelf: palet, letters, luchtpostrand en de
 * donkere modus staan in server/huisstijl.js en zijn hetzelfde als op de publieke pagina's en in de
 * post. Wat hieronder staat is alleen wat een rapport eigen is — een brede kolom, tabellen, staven,
 * tegels — want dat heeft een verklaring of een brief niet nodig.
 *
 * Kleurgebruik is met opzet karig: één blauw voor "hoeveelheid", oplopend in drie stappen, en rood
 * uitsluitend voor wat tegenvalt — geen antwoord, geblokkeerd, blijft op één brief steken. Zo staat
 * rood nooit ergens waar het niets betekent, en is de kleur zelf een mededeling. Beide kleuren zijn
 * op onderscheidbaarheid nagelopen, ook voor kleurenblindheid en in het donkere thema.
 */
import { esc, omslag, SERIF, SANS, MONO } from "./huisstijl.js";
import {
  nl, pct, pctShort, share, per100, span, spanH, genderName, weekLabel, dateLabel, waitBuckets, median,
} from "./report-format.js";
import { REAL_CORRESPONDENCE_LETTERS, REAL_CORRESPONDENCE_EACH, REPORT_REASONS, FREE_ACCOUNTS,
         SESSION_TTL_DAYS, REPORT_RETENTION_DAYS, MIN_CHARS, RELATIONSHIP_LABEL } from "../src/constants.js";

/* Het omhulsel dat beide pagina's delen — palet, letters en donkere modus komen uit huisstijl.js */
const page = ({ title, mode, body }) => omslag({ title, mode, css: CSS, body: `<div class="wrap">\n${body}\n</div>` });

/* ---------- Opmaak: wat een rapport eigen is ---------- */

const CSS = `
body{padding:0 20px 96px}
.wrap{max-width:1000px;margin:0 auto}
.col{max-width:66ch}
.airmail{margin:34px 0 0}
h1{font-size:clamp(2rem,5vw,3.1rem);line-height:1.08;letter-spacing:-.02em}
h2{font-size:clamp(1.35rem,3vw,1.75rem);line-height:1.2;letter-spacing:-.01em}
h3{font-size:1.05rem}
p{margin:0 0 1em}
p:last-child{margin-bottom:0}
.lede{font-size:1.16rem;color:var(--ink-2)}
em.term{font-style:italic;color:var(--ink)}
header.masthead{padding:56px 0 8px}
.kicker{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;margin-bottom:22px}
.runmeta{font-family:${MONO};font-size:.78rem;color:var(--mute);font-variant-numeric:tabular-nums}
.warnband{border:1px solid var(--alert);border-left-width:4px;background:var(--panel);padding:14px 18px;
  margin:28px 0 0;border-radius:2px;font-size:.95rem;color:var(--ink-2)}
.warnband strong{color:var(--alert);font-weight:600}
section{margin-top:64px}
.sechead{display:flex;align-items:baseline;gap:12px;margin-bottom:6px}
.sechead .letter{font-family:${MONO};font-size:.8rem;color:var(--accent);
  border:1px solid var(--rule);border-radius:2px;padding:1px 7px;flex:none}
.sectionrule{border:0;border-top:1px solid var(--rule);margin:14px 0 26px}
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:2px;margin-top:26px}
.tile{background:var(--panel);padding:18px 20px 20px;border:1px solid var(--rule)}
.tile .k{font-family:${SANS};font-size:.72rem;letter-spacing:.1em;
  text-transform:uppercase;color:var(--mute)}
.tile .v{font-family:${MONO};font-variant-numeric:tabular-nums;font-size:1.85rem;
  line-height:1.15;margin:8px 0 4px;letter-spacing:-.03em}
.tile .v.warn{color:var(--alert)}
.tile .v small{font-size:1.1rem}
.tile .s{font-size:.88rem;color:var(--ink-2);line-height:1.4}
.chart{margin:26px 0 8px;display:grid;gap:9px}
.row{display:grid;grid-template-columns:minmax(120px,190px) 1fr auto;align-items:center;gap:14px}
.row .lab{font-family:${SANS};font-size:.84rem;color:var(--ink-2)}
.row .val{font-family:${MONO};font-variant-numeric:tabular-nums;font-size:.84rem;
  color:var(--ink-2);min-width:8ch;text-align:right}
.track{background:var(--track);height:20px;border-radius:0 3px 3px 0}
.fill{background:var(--b500);height:100%;border-radius:0 3px 3px 0;min-width:2px}
.fill.alert{background:var(--alert)}
.fill.deep{background:var(--b700)}
.nest{display:flex;height:100%;border-radius:0 3px 3px 0;overflow:hidden}
.nest .in{background:var(--b700);border-right:2px solid var(--panel-2)}
.nest .out{background:var(--b300)}
.track.on-paper{background:var(--panel-2)}
.stack{display:flex;height:42px;gap:2px;margin:24px 0 10px}
.stack>div{display:flex;align-items:center;justify-content:center;font-family:${MONO};
  font-size:.82rem;color:#FFF;min-width:2px;overflow:hidden}
.stackkey{display:flex;gap:22px;flex-wrap:wrap;font-size:.84rem;color:var(--ink-2)}
.stackkey span{display:flex;align-items:center;gap:7px}
.swatch{width:11px;height:11px;flex:none;border-radius:2px;display:inline-block}
.smalls{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:26px;margin-top:24px}
.small h3{font-family:${SANS};font-size:.8rem;letter-spacing:.1em;
  text-transform:uppercase;color:var(--mute);margin-bottom:12px;font-weight:600}
.small .row{grid-template-columns:3.6ch 1fr auto;gap:10px}
.small .track{height:16px}
.tablewrap{overflow-x:auto;margin:24px 0 0;border:1px solid var(--rule);background:var(--panel)}
table{border-collapse:collapse;width:100%;font-size:.9rem}
caption{text-align:left;font-family:${SANS};font-size:.72rem;
  letter-spacing:.12em;text-transform:uppercase;color:var(--mute);padding:14px 18px 0}
th,td{padding:9px 18px;text-align:right;white-space:nowrap}
th:first-child,td:first-child{text-align:left}
thead th{font-family:${SANS};font-weight:600;font-size:.75rem;
  letter-spacing:.06em;color:var(--mute);border-bottom:1px solid var(--rule)}
tbody td{font-family:${MONO};font-variant-numeric:tabular-nums;color:var(--ink-2)}
tbody td:first-child{font-family:${SERIF};color:var(--ink)}
tbody tr+tr td{border-top:1px solid var(--rule)}
tr.total td{background:var(--panel-2);color:var(--ink);font-weight:600}
td.dim,span.dim{color:var(--mute)}
.finding{border-left:3px solid var(--accent);padding:4px 0 4px 20px;margin:26px 0;color:var(--ink)}
.rows{margin:22px 0 0;display:grid;gap:0}
.kv{display:grid;grid-template-columns:minmax(170px,240px) 1fr;gap:18px;padding:9px 0;
  border-top:1px solid var(--rule);font-size:.95rem}
.kv:last-child{border-bottom:1px solid var(--rule)}
.kv dt{color:var(--mute);font-family:${SANS};font-size:.82rem;
  letter-spacing:.04em;padding-top:2px}
.kv dd{margin:0;color:var(--ink-2)}
.kv dd b{font-family:${MONO};font-variant-numeric:tabular-nums;color:var(--ink);font-weight:400}
.note{margin-top:14px;font-size:.9rem;color:var(--mute)}
footer{margin-top:76px;padding-top:26px;border-top:1px solid var(--rule);color:var(--mute);font-size:.9rem}
footer ul{padding-left:1.1em;margin:14px 0}
footer li{margin-bottom:.5em}
@media print{body{padding:0}}
`;

/* ---------- Bouwstenen ---------- */

const tile = (k, v, s, warn = false) =>
  `<div class="tile"><div class="k">${esc(k)}</div><div class="v${warn ? " warn" : ""}">${v}</div><div class="s">${s}</div></div>`;

/*
 * Een geduide uitspraak. Draait het rapport op voorbeeldaccounts, dan staat er een merkje bij: wat
 * er dan "gevonden" wordt is een eigenschap van db/generate.js en niet van een gemeenschap. Dat
 * onderscheid ís de valkuil van een simulatie — je leest je eigen aannames terug en houdt ze voor
 * een ontdekking.
 */
const bevinding = (html, verzonnen) =>
  `<div class="finding">${verzonnen ? `<div class="eyebrow" style="color:var(--alert);margin-bottom:6px">modeluitkomst &mdash; dit volgt uit de generator, niet uit een gemeenschap</div>` : ""}${html}</div>`;

const kv = (pairs) =>
  `<dl class="rows">${pairs.filter(Boolean).map(([k, v]) => `<div class="kv"><dt>${esc(k)}</dt><dd>${v}</dd></div>`).join("")}</dl>`;

/* Eén staaf. `max` is de schaal, `tone` kiest tussen gewoon, dieper en "dit valt tegen" (rood). */
const barRow = (label, n, max, right, tone = "") =>
  `<div class="row" title="${esc(label)}: ${n}"><span class="lab">${esc(label)}</span>` +
  `<div class="track"><div class="fill ${tone}" style="width:${(max ? (n / max) * 100 : 0).toFixed(1)}%"></div></div>` +
  `<span class="val">${esc(right ?? String(n))}</span></div>`;

const table = (caption, headers, body, opts = {}) =>
  `<div class="tablewrap"><table><caption>${esc(caption)}</caption>` +
  `<thead><tr>${headers.map((h) => `<th>${esc(h)}</th>`).join("")}</tr></thead><tbody>` +
  body.map((r, i) => `<tr${opts.totalLast && i === body.length - 1 ? ' class="total"' : ""}>` +
    r.map((c) => `<td>${c}</td>`).join("") + "</tr>").join("") +
  `</tbody></table></div>`;

/* ---------- Het rapport ---------- */

export function renderHtml(d, { reachMs = null } = {}) {
  const w = d.window;
  const o = d.overview;
  const s = d.shapes;
  const g = d.safety;
  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 total = d.funnel.find((r) => r.isTotal) ?? {};
  const cells = d.probeGrid ?? [];

  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].sort((a, b) => b.matches - a.matches)[0] ?? null;
  const empty = cells.filter((c) => Math.round(c.matches) === 0);
  const cellName = (c) => `${c.label}, ${c.age} jaar`;

  const out = [];
  const push = (...x) => out.push(...x);

  /* ---------- Kop ---------- */

  push(`<header class="masthead">
    <div class="kicker">
      <span class="eyebrow">Liefdesbrieven &middot; gemeenschapsrapport</span>
      <span class="runmeta">${esc(dateLabel(d.generatedAt))} &middot; venster ${w.days} dagen</span>
    </div>
    <h1>Wat een nieuwkomer aantreft</h1>
    <p class="lede col" style="margin-top:18px">Ledentallen beantwoorden de vraag niet die er het
      meest toe doet. 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. Dit is wat er te meten
      valt aan wat iemand die zich vandaag aanmeldt werkelijk aantreft.</p>`);

  /* Voorbeeldaccounts betekent: dit gaat niet over mensen. Dat hoort bovenaan, niet in een voetnoot */
  if (o.example_accounts) {
    push(`<div class="warnband"><strong>Dit gaat niet over echte mensen.</strong>
      ${o.example_accounts} van de ${o.members} accounts zijn voorbeeldaccounts
      (<code>@voorbeeld.nl</code>), aangemaakt door <code>npm run db:setup</code> of
      <code>npm run db:generate</code>. De cijfers hieronder zijn dus geen bevinding over
      Liefdesbrieven, maar een proef op de som van de meting zelf.</div>`);
  }
  push(`</header><hr class="airmail">`);

  /* ---------- Wat een nieuwkomer mag verwachten ---------- */

  push(`<section style="margin-top:40px">
    <div class="sechead"><h2>Wat iemand die zich vandaag aanmeldt mag verwachten</h2></div>
    <p class="col" style="color:var(--ink-2)">Alles daaronder is de onderbouwing.</p>
    <div class="tiles">`);

  push(tile("te schrijven", cells.length === 1 ? Math.round(cells[0].matches) : midMatches,
    cells.length === 1
      ? `mensen passen bij ${esc(cellName(cells[0]))}.`
      : `mensen, mediaan over ${cells.length} proefprofielen.` + (best ? ` Ruimst: ${esc(cellName(best))} met ${Math.round(best.matches)}.` : "")));
  push(tile("die terugschrijven", cells.length === 1 ? Math.round(cells[0].responsive) : midResponsive,
    `daarvan antwoordden de laatste ${w.responsive} dagen ook daadwerkelijk.`));
  push(tile("antwoordkans", `${pctShort(all.answered ?? 0, all.settled_n ?? 0)}`,
    `${all.answered ?? 0} van ${all.settled_n ?? 0} eerste brieven kreeg antwoord binnen ${w.settle} dagen.`));
  push(tile("wachttijd", spanH(all.median_h), `mediaan; p90 is ${spanH(all.p90_h)} — van wie antwoord kreeg.`));
  push(tile("wordt het wat", pctShort(s.real_corr, s.pairs),
    `${s.real_corr} van ${s.pairs} correspondenties groeide uit tot een echte briefwisseling.`, share(s.real_corr, s.pairs) < 0.15));
  push(tile("na een maand", total.mature ? pctShort(total.retained, total.mature) : "—",
    total.mature ? `${total.retained} van ${total.mature} leden die die maand gehad hebben, is er dan nog.`
                 : "nog geen cohort is 30 dagen oud."));
  push(tile("veiligheid", g.reports_window, `meldingen in dit venster, over ${o.active_window} actieve leden.`));
  push(`</div></section>`);

  /* ---------- A ---------- */

  push(`<section><div class="sechead"><span class="letter">A</span><h2>Wie er zijn</h2></div><hr class="sectionrule">`);
  push(kv([
    ["leden", `<b>${o.members}</b>${o.suspended_now ? `, waarvan <b>${o.suspended_now}</b> geschorst` : ""}`],
    ["actief", `<b>${o.active_window}</b> in ${w.days} dagen, <b>${o.active7}</b> in 7 — wie inlogt of post opent telt mee`],
    ["schrijvend actief", `<b>${o.active_writing}</b>; het verschil met hierboven is de stille lezer`],
    ["nieuw in dit venster", `<b>${o.joined_window}</b>`],
    ["leeftijd", `mediaan <b>${nl(o.median_age, 0)}</b> jaar (p10 <b>${nl(o.p10_age, 0)}</b>, p90 <b>${nl(o.p90_age, 0)}</b>)`],
    ["brieven", `<b>${o.letters_total}</b> in totaal, <b>${o.photos_total}</b> met foto; <b>${o.letters_window}</b> in dit venster`],
    ["gratis plekken", `<b>${o.slots_granted}</b> van de ${FREE_ACCOUNTS} uitgegeven`],
    o.purchases_total ? ["betaalde plekken", `<b>${o.purchases_total}</b> gekocht, <b>${o.purchases_alive}</b> met een account erop`] : null,
    ["vertrokken", `<b>${o.departed_window}</b> in dit venster, <b>${o.departed_total}</b> sinds het begin` +
      (o.departed_window
        ? ` — mediaan na <b>${nl(o.departed_median_days, 0)}</b> dagen lidmaatschap, waarvan <b>${o.departed_silent}</b> zonder ooit een brief te schrijven`
        : "")],
    o.departed_by_reason
      ? ["waarom weg", Object.entries(o.departed_by_reason)
          .map(([k, n]) => `<b>${n}</b>× ${{ zelf: "zelf opgezegd", slapend: "opgeruimd na inactiviteit", maatregel: "na een maatregel" }[k] ?? esc(k)}`)
          .join(" &middot; ")]
      : null,
  ]));

  if (o.with_radius) {
    push(bevinding(`<p><strong>${o.with_radius} leden (${pctShort(o.with_radius, o.members)})
      hebben een zoekstraal ingesteld — en die zijn onzichtbaar voor élke nieuwkomer.</strong> Een
      nieuw account heeft namelijk geen locatie, en de matchvoorwaarde eist dat je bínnen iemands
      straal valt. Dit is het cijfer waar het meest aan te doen valt, en het kost één telling om het
      te zien.</p>`, o.example_accounts));
  }

  push(table("Samenstelling", ["wie", "leden", "actief", "aandeel actief"],
    [...o.split.map((r) => [
      esc(`${genderName(r.gender)}${r.trans ? " (trans)" : ""}, ${RELATIONSHIP_LABEL[r.relationship]}`),
      r.n, r.active, pctShort(r.active, r.n),
    ]), [`<b>samen</b>`, o.members, o.active_window, pctShort(o.active_window, o.members)]],
    { totalLast: true }));

  push(`<p class="note col">${o.trans_members} leden zijn trans; ${o.seek_trans_members} staan open voor
    trans correspondenten. ${o.seek_relatie_members} zien ook mensen die al een relatie hebben.
    ${o.with_location} leden (${pctShort(o.with_location, o.members)}) hebben een locatie ingevuld.</p>`);
  push(`</section>`);

  /* ---------- B ---------- */

  const reg = total.registered || 1;
  push(`<section><div class="sechead"><span class="letter">B</span><h2>De trechter van een nieuwkomer</h2></div>
    <hr class="sectionrule">
    <p class="col">Van alle ${total.registered} leden die er ooit binnenkwamen en er nu nog zijn: hoe ver kwamen ze?</p>
    <div class="chart">`);
  push(barRow("aangemeld", total.registered, reg, String(total.registered), "deep"));
  for (const [label, n, tone] of [
    ["schreef ooit een brief", total.ever_sent, ""],
    ["kreeg ongevraagd post", total.ever_addressed, ""],
    ["kreeg antwoord", total.ever_answered, ""],
    ["echte briefwisseling", total.ever_real, "alert"],
  ]) push(barRow(label, n, reg, `${n} · ${pctShort(n, reg)}`, tone));
  push(`</div>
    <p class="note col"><em class="term">Kreeg ongevraagd post</em> is aangeschreven zijn door iemand
      die je nog niet kende — niet post binnen een correspondentie die je zelf begon.
      <em class="term">Echte briefwisseling</em> is ${REAL_CORRESPONDENCE_LETTERS} brieven, van beide
      kanten minstens ${REAL_CORRESPONDENCE_EACH}.</p>`);

  push(table("Per registratieweek",
    ["cohort", "aangemeld", "schreef", "kreeg post", "kreeg antwoord", "echte corr.", `na ${w.days === 30 ? 30 : 30} d terug`],
    d.funnel.map((r) => [
      r.isTotal ? "<b>alles</b>" : esc(weekLabel(r.week)),
      r.registered, pctShort(r.ever_sent, r.registered), pctShort(r.ever_addressed, r.registered),
      pctShort(r.ever_answered, r.registered), pctShort(r.ever_real, r.registered),
      r.mature ? `${pctShort(r.retained, r.mature)} <span class="dim">(${r.retained}/${r.mature})</span>` : `<span class="dim">—</span>`,
    ]), { totalLast: true }));

  push(`<p class="note col">De streepjes zijn geen nul: die cohorten zijn nog geen 30 dagen oud, dus
    de vraag "is hij er dan nog" kán nog niet beantwoord zijn. De noemer is telkens alleen het deel
    van het cohort dat die maand daadwerkelijk gehad heeft. En <em class="term">aangemeld</em> telt
    wie zich toen aanmeldde én nu nóg een account heeft — wie opzegde is spoorloos.</p></section>`);

  /* ---------- C ---------- */

  push(`<section><div class="sechead"><span class="letter">C</span><h2>Krijg ik antwoord, en hoe lang wacht ik?</h2></div>
    <hr class="sectionrule">
    <p class="col">Een eerste brief van gisteren is niet onbeantwoord — die is jong. Daarom telt
      alleen mee wat minstens ${w.settle} dagen oud is, en luidt de vraag of er
      <em class="term">bínnen</em> ${w.settle} dagen antwoord kwam. Teller en noemer krijgen zo
      evenveel klok.</p>
    <div class="chart">`);
  const buckets = waitBuckets(all, w.settle);
  const bmax = Math.max(all.b_never ?? 0, ...buckets.map((b) => b.n), 1);
  for (const b of buckets) push(barRow(b.label, b.n, bmax, String(b.n)));
  push(barRow("géén antwoord", all.b_never ?? 0, bmax, String(all.b_never ?? 0), "alert"));
  push(`</div>`);

  push(kv([
    ["eerste brieven", `<b>${all.openers}</b> in totaal, waarvan <b>${all.settled_n}</b> oud genoeg om mee te tellen`],
    ["nog te jong", `<b>${all.too_young}</b>, waarvan er al <b>${all.too_young_answered}</b> beantwoord zijn`],
    ["beantwoord", `<b>${all.answered}</b> binnen ${w.settle} dagen — <b>${pctShort(all.answered, all.settled_n)}</b>`],
    ["ooit beantwoord", `<b>${all.answered_ever}</b> — ter vergelijking; dit cijfer loopt vanzelf op naarmate de data ouder wordt`],
    ["wachttijd", `mediaan <b>${spanH(all.median_h)}</b>, p90 <b>${spanH(all.p90_h)}</b> — alleen van wie antwoord kreeg`],
  ]));

  const byGender = d.openers.filter((r) => r.gender != null);
  if (byGender.length) {
    push(table("Naar het gender van de schrijver",
      ["schrijver", "eerste brieven", "meegeteld", "beantwoord", "mediane wachttijd"],
      byGender.map((r) => [esc(genderName(r.gender)), r.openers, r.settled_n,
        `${pctShort(r.answered, r.settled_n)} <span class="dim">(${r.answered})</span>`, spanH(r.median_h)])));
  }
  push(`</section>`);

  /* ---------- D ---------- */

  push(`<section><div class="sechead"><span class="letter">D</span><h2>Waar de post heen gaat</h2></div><hr class="sectionrule">`);
  if (!spreadAll.members) {
    push(`<p class="col">Nog niemand is ${w.grace} dagen of langer lid, dus er valt niets te verdelen.
      Met <code>--grace 0</code> telt iedereen mee, ook wie zich vandaag aanmeldde.</p>`);
  } else {
    push(`<p class="col">Hier liegt een gemiddelde het hardst: het deelt de post van de drukste
      correspondent uit over iedereen die niets kreeg. Gemiddeld ontving een lid
      <b class="num">${nl(spreadAll.mean_got)}</b> brieven in dit venster. De mediaan is
      <b class="num">${nl(spreadAll.median_got)}</b>.</p>`);

    if (spreadAll.top10_got != null && spreadAll.total_got) {
      const t10 = spreadAll.top10_got;
      const t25 = spreadAll.top25_got ?? t10;
      const mid = Math.max(0, t25 - t10);
      const rest = Math.max(0, spreadAll.total_got - t25);
      const p = (n) => (n / spreadAll.total_got) * 100;
      push(`<div class="stack" role="img" aria-label="Verdeling van alle ontvangen post: de drukste 10 procent van de leden kreeg ${Math.round(p(t10))} procent, de volgende 15 procent kreeg ${Math.round(p(mid))} procent, de onderste 75 procent kreeg ${Math.round(p(rest))} procent.">
        <div style="background:var(--b700);width:${p(t10).toFixed(1)}%" title="drukste 10%: ${t10} brieven">${Math.round(p(t10))}%</div>
        <div style="background:var(--b500);width:${p(mid).toFixed(1)}%" title="volgende 15%: ${mid} brieven">${Math.round(p(mid))}%</div>
        <div style="background:var(--b300);width:${p(rest).toFixed(1)}%" title="onderste 75%: ${rest} brieven">${Math.round(p(rest))}%</div>
      </div>
      <div class="stackkey">
        <span><i class="swatch" style="background:var(--b700)"></i> drukste 10% &mdash; <span class="num">${t10}</span> brieven</span>
        <span><i class="swatch" style="background:var(--b500)"></i> volgende 15% &mdash; <span class="num">${mid}</span></span>
        <span><i class="swatch" style="background:var(--b300)"></i> onderste 75% &mdash; <span class="num">${rest}</span></span>
      </div>`);
    }

    push(kv([
      ["grondslag", `<b>${spreadAll.members}</b> leden die minstens ${w.grace} dagen lid zijn`],
      ["ontvangen post", `<b>${spreadAll.total_got}</b> brieven in dit venster`],
      ["kreeg niets", `<b>${pctShort(spreadAll.zero_window, spreadAll.members)}</b> in dit venster (${spreadAll.zero_window}); <b>${pctShort(spreadAll.zero_ever, spreadAll.members)}</b> kreeg <em class="term">nooit</em> iets (${spreadAll.zero_ever})`],
      ["nooit aangeschreven", `<b>${pctShort(spreadAll.zero_openers, spreadAll.members)}</b> ontving nooit een eerste brief van een vreemde (${spreadAll.zero_openers})`],
      ["scheefheid", `Gini <b>${nl(spreadAll.gini_got, 2)}</b> over ontvangen post; <b>${nl(spreadAll.gini_openers, 2)}</b> over eerste brieven`],
    ]));

    if (spreadAll.mean_got > spreadAll.median_got) {
      push(bevinding(`<p>${pctShort(spreadAll.zero_window, spreadAll.members)} van de leden
        kreeg in ${w.days} dagen geen enkele brief, terwijl het gemiddelde er ${nl(spreadAll.mean_got)}
        zegt. Dat verschil <em>is</em> het rapport: het gemiddelde beschrijft de gemeenschap, de
        mediaan beschrijft het lid.</p>`, o.example_accounts));
    }

    const gs = d.spread.filter((r) => r.gender != null);
    if (gs.length) {
      push(table("Naar gender van de ontvanger", ["wie", "leden", "kreeg niets", "gem. brieven", "mediaan"],
        gs.map((r) => [esc(genderName(r.gender)), r.members, pctShort(r.zero_window, r.members), nl(r.mean_got), nl(r.median_got)])));
    }
    push(`<p class="note col">Gini 0,00 betekent dat iedereen evenveel krijgt, 1,00 dat één lid alles
      krijgt. Het is geen cijfer om aan af te lezen hóe erg het is, maar om over de maanden heen te
      volgen — en het is niet vergelijkbaar met inkomens-Gini's.</p>`);
  }
  push(`</section>`);

  /* ---------- E ---------- */

  push(`<section><div class="sechead"><span class="letter">E</span><h2>Is er iemand om aan te schrijven?</h2></div>
    <hr class="sectionrule">
    <p class="col">Het filter in deze app is wederzijds: je moet in elkaars gender, leeftijdsbereik,
      relatiestatus én straal passen. Wie er te schrijven valt is daarmee een heel ander getal dan
      het ledental.</p>`);
  if (reachAll) {
    push(kv([
      ["gemeten", reachAll.measured < reachAll.actives
        ? `<b>${reachAll.measured}</b> van de ${reachAll.actives} actieve leden (steekproef)`
        : `alle <b>${reachAll.actives}</b> actieve leden`],
      ["wederzijdse matches", `mediaan <b>${nl(reachAll.median, 0)}</b>, p10 <b>${nl(reachAll.p10, 0)}</b>, p90 <b>${nl(reachAll.p90, 0)}</b>`],
      ["heeft er nul", `<b>${reachAll.zero}</b> leden (${pctShort(reachAll.zero, reachAll.measured)}) — voor hen is de app leeg zonder dat ze kunnen zien waarom`],
      reachMs != null ? ["rekentijd", `<b>${nl(reachMs / 1000)}</b> s`] : null,
    ]));
  } else {
    push(`<p class="note col">Bereikbaarheid overgeslagen (<code>--fast</code>).</p>`);
  }

  push(`<h3 style="margin-top:44px;font-family:${SERIF};font-size:1.1rem">En voor wie er nog niet is</h3>
    <p class="col" style="margin-top:8px">${cells.length === 1 ? "Eén bedacht profiel" : `${cells.length} bedachte nieuwkomers`}:
      wie je bent én wie je zoekt, want zonder dat tweede is het getal zinloos. De overige
      instellingen staan op wat een nieuw account krijgt — alleen vrijgezellen, geen trans
      correspondenten, eigen leeftijd ±5 — dus wie die openzet vindt meer. Elk profiel is
      doorgerekend vanuit ${cells[0]?.placements ?? 1} verschillende woonplaatsen, getrokken uit waar
      de leden werkelijk wonen; het getal is de mediaan daarvan. De donkere staaf is het deel dat de
      laatste ${w.responsive} dagen ook daadwerkelijk terugschreef.</p>`);

  const gmax = Math.max(...cells.map((c) => c.matches), 1);
  const labels = [...new Set(cells.map((c) => c.label))];
  push(`<div class="smalls">`);
  for (const lab of labels) {
    push(`<div class="small"><h3>${esc(lab)}</h3><div class="chart" style="gap:7px">`);
    for (const c of cells.filter((x) => x.label === lab)) {
      const m = Math.round(c.matches);
      const rp = Math.round(c.responsive);
      const outer = (c.matches / gmax) * 100;
      const inner = c.matches ? (c.responsive / c.matches) * 100 : 0;
      push(`<div class="row" title="${esc(cellName(c))}: ${m} passend (${c.matches_min}–${c.matches_max} afhankelijk van woonplaats), ${rp} antwoordend">
        <span class="lab num">${c.age}</span>
        <div class="track on-paper"><div class="nest" style="width:${outer.toFixed(1)}%">
          <div class="in" style="width:${inner.toFixed(1)}%"></div><div class="out" style="width:${(100 - inner).toFixed(1)}%"></div>
        </div></div>
        <span class="val">${m} · ${rp}</span></div>`);
    }
    push(`</div></div>`);
  }
  push(`</div>`);

  /*
   * Twee bevindingen die het raster zelf oplevert, en allebei alleen als er iets te vergelijken is:
   * hoe hard de leeftijd het bereik bepaalt, en hoeveel de oriëntatie uitmaakt bij gelijke leeftijd.
   */
  const ages = [...new Set(cells.map((c) => c.age))].sort((a, b) => a - b);
  if (ages.length > 1) {
    const gem = (age) => {
      const c = cells.filter((x) => x.age === age);
      return c.length ? c.reduce((s, x) => s + x.matches, 0) / c.length : 0;
    };
    const jong = gem(ages[0]);
    const oud = gem(ages[ages.length - 1]);
    if (jong > 0) {
      push(bevinding(`<p><strong>Leeftijd bepaalt het bereik.</strong>
        Een nieuwkomer van ${ages[0]} vindt gemiddeld <span class="num">${nl(jong, 0)}</span> mensen;
        op ${ages[ages.length - 1]} zijn het er <span class="num">${nl(oud, 0)}</span>${oud > 0 ? ` — ${nl(jong / oud, 0)} keer minder` : ""}.
        Wie deze app wil laten groeien onder oudere leden loopt niet tegen een voorkeursprobleem aan
        maar tegen een voorraadprobleem.</p>`, o.example_accounts));
    }
  }
  if (labels.length > 1) {
    const midden = ages[Math.floor((ages.length - 1) / 2)];
    const rij = cells.filter((c) => c.age === midden).sort((a, b) => b.matches - a.matches);
    if (rij.length > 1 && rij[rij.length - 1].matches > 0) {
      push(`<p class="note col">Op ${midden} jaar loopt het bereik uiteen van
        <b class="num">${Math.round(rij[0].matches)}</b> (${esc(rij[0].label)}) tot
        <b class="num">${Math.round(rij[rij.length - 1].matches)}</b> (${esc(rij[rij.length - 1].label)}).
        Wie je zoekt bepaalt dus evenveel als hoe oud je bent — en dat is precies waarom een proef
        met de standaardinstelling "alle genders" niets zegt: die telt alle oriëntaties bij elkaar op
        tot een getal dat voor niemand geldt.</p>`);
    }
  }
  if (empty.length) {
    push(`<p class="note col"><b>${empty.length} van de ${cells.length} proefprofielen vindt niemand:</b>
      ${esc(empty.map(cellName).join(", "))}.</p>`);
  }
  push(`<p class="note col">De spreiding tussen de woonplaatsen is klein zolang weinig leden een
    zoekstraal instellen (nu ${o.with_radius} van de ${o.members}); loopt dat op, dan gaat het
    verschil tussen de plaatsingen meetellen en staat het in de tooltip per cel.</p></section>`);

  /* ---------- F ---------- */

  const dmax = Math.max(s.p1, s.p2, s.p3, s.p4, 1);
  push(`<section><div class="sechead"><span class="letter">F</span><h2>Hoe diep het gaat</h2></div>
    <hr class="sectionrule"><div class="chart">`);
  push(barRow("1 brief", s.p1, dmax, `${s.p1} · ${pctShort(s.p1, s.pairs)}`, "alert"));
  push(barRow("2 tot 3 brieven", s.p2, dmax, `${s.p2} · ${pctShort(s.p2, s.pairs)}`));
  push(barRow("4 tot 9 brieven", s.p3, dmax, `${s.p3} · ${pctShort(s.p3, s.pairs)}`));
  push(barRow("10 of meer", s.p4, dmax, `${s.p4} · ${pctShort(s.p4, s.pairs)}`, "deep"));
  push(`</div>`);
  push(kv([
    ["correspondenties", `<b>${s.pairs}</b>, waarvan <b>${s.alive}</b> met post in dit venster`],
    ["wederkerig", `<b>${pctShort(s.reciprocal, s.pairs)}</b> kreeg ooit antwoord (${s.reciprocal})`],
    ["looptijd", `p90 <b>${nl(s.p90_span_days, 0)}</b> dagen, langste <b>${nl(s.longest_days, 0)}</b> dagen`],
    ["favoriet", `<b>${s.pairs_with_favorite}</b> paren (${pctShort(s.pairs_with_favorite, s.pairs)}), wederzijds bij <b>${s.pairs_favorite_mutual}</b>`],
    ["brieflengte", `gemiddeld <b>${nl(s.avg_chars, 0)}</b> tekens — de ondergrens is ${MIN_CHARS}`],
    ["geopend", `<b>${pctShort(s.read_n, s.read_denom)}</b> van de brieven die minstens ${w.readSettle} dagen liggen, mediaan na <b>${span(s.median_read_s)}</b>`],
  ]));
  push(`<p class="note col">"Geopend" is het openen van de correspondentie, niet het lezen van die
    ene brief: de app zet een hele correspondentie in één keer op gelezen.</p></section>`);

  /* ---------- G ---------- */

  push(`<section><div class="sechead"><span class="letter">G</span><h2>Of het er prettig is</h2></div><hr class="sectionrule">`);
  push(kv([
    ["meldingen", `<b>${g.reports_window}</b> in dit venster — <b>${per100(g.reports_window, o.active_window)}</b> actieve leden`],
    ["openstaand", `<b>${g.reports_open}</b>, waarvan <b>${g.reports_open_old}</b> langer dan een week`],
    ["doorlooptijd", g.median_handle_s == null ? "— (niets afgehandeld in dit venster)" : `mediaan <b>${span(g.median_handle_s)}</b> tot afhandeling`],
    ["blokkades", `<b>${g.blocks_window}</b> in dit venster, <b>${g.blocks_total}</b> in totaal`],
    ["meermaals geblokkeerd", `<b>${g.blocked_by_two}</b> leden door 2 of meer mensen, <b>${g.blocked_by_three}</b> door 3 of meer`],
    ["correspondentie eindigde erin", `<b>${pctShort(g.ended_in_block, g.pairs)}</b> — de blokkade kwam ná de laatste brief`],
    ["vijandige eerste brief", `<b>${pctShort(g.opener_blocked + g.opener_reported, g.openers_settled)}</b> liep binnen ${w.hostile} dagen uit op een blokkade of melding (${g.opener_blocked + g.opener_reported} van ${g.openers_settled})`],
    ["geschorst", `<b>${g.suspended_now}</b> leden`],
    g.warnings_pending ? ["waarschuwingen", `<b>${g.warnings_pending}</b> nog niet weggeklikt`] : null,
  ]));

  if (g.reports_by_reason) {
    const rows = Object.entries(g.reports_by_reason).sort((a, b) => b[1] - a[1]);
    push(table("Meldingen naar reden", ["reden", "meldingen"],
      [...rows.map(([k, n]) => [esc(REPORT_REASONS[k] ?? k), n]),
       ["<b>samen</b>", rows.reduce((t, [, n]) => t + n, 0)]], { totalLast: true }));
  }
  if (g.measures_window) {
    push(table("Genomen maatregelen", ["maatregel", "aantal"],
      Object.entries(g.measures_window).map(([k, n]) => [esc(k), n])));
  }
  if (g.reports_open_old) {
    push(`<p class="col" style="margin-top:18px">Van de cijfers hierboven meet er één de beheerder in
      plaats van de leden: <b class="num">${g.reports_open_old}</b> meldingen staan langer dan een
      week open. Dat is het enige getal in deze sectie waar niemand anders iets aan kan doen.</p>`);
  }
  push(`</section><hr class="airmail" style="margin-top:64px">`);

  /* ---------- H: alleen in het eigen rapport ---------- */

  /* d.searchTerms is null tenzij collectReport er uitdrukkelijk om gevraagd is; renderPublicHtml
     (de pagina /cijfers) is sowieso een andere functie, dus dit komt daar nooit terecht. */
  if (d.searchTerms) {
    push(`<section><div class="sechead"><span class="letter">H</span><h2>Waar op gezocht wordt</h2></div>
      <hr class="sectionrule">
      <p class="col">De vaakst ingetypte woorden in Zoeken, met het aantal profielen waarin ze
      voorkomen. Geteld per woord en per dag, zonder wie het zocht en zonder de zoekopdracht zelf —
      er is geen rij die over één persoon gaat. Een woord dat vaak gezocht wordt en in bijna geen
      profiel staat, is een gat in de gemeenschap.</p>`);
    if (!d.searchTerms.length) {
      push(`<p class="col">Nog niets gezocht in dit venster.</p>`);
    } else {
      push(table("De vaakst gezochte woorden", ["woord", "gezocht", "dagen", "profielen"],
        d.searchTerms.slice(0, 25).map((t) => [esc(t.term), t.searches, t.days_seen, t.profiles])));
      const gaten = d.searchTerms.filter((t) => t.searches >= 5 && t.profiles <= 2);
      if (gaten.length) {
        push(`<p class="col" style="margin-top:18px"><b class="num">${gaten.length}</b> woorden worden
          vaak gezocht maar staan in hoogstens twee profielen:
          ${gaten.slice(0, 8).map((t) => `<b>${esc(t.term)}</b> (${t.searches}×)`).join(", ")}.</p>`);
      }
    }
    push(`</section><hr class="airmail" style="margin-top:64px">`);
  }

  /* ---------- Voetnoot ---------- */

  push(`<footer>
    <div class="eyebrow" style="margin-bottom:10px">Wat deze cijfers niet kunnen zien</div>
    <ul class="col" style="max-width:none">
      <li><strong>Vertrokken leden nemen hun brieven mee.</strong> Accounts worden hard verwijderd, dus
        elke telling over het verleden krimpt — en altijd dezelfde kant op. Dát er iemand wegging wordt
        wél bijgehouden (${o.departed_total} sinds het begin), maar wat hij schreef is er niet meer:
        antwoordkansen en correspondentiediepte van vroeger zijn daardoor eerder te rooskleurig dan te
        somber.</li>
      <li><strong>Meldingen en blokkades zijn een ondergrens.</strong> Meldingen worden na
        ${REPORT_RETENTION_DAYS} dagen gewist, en beide verdwijnen zodra een van de twee accounts
        verdwijnt${g.reports_orphaned ? ` (${g.reports_orphaned} meldingen staan er al zonder melder)` : ""}.</li>
      <li><strong>"Actief" leunt op sessies</strong> — voorbij ${SESSION_TTL_DAYS} dagen is die sessierij
        opgeruimd en valt het terug op <code>last_active</code>, dat de stille lezer mist. Oude cohorten
        zijn daardoor eerder te laag dan te hoog.</li>
      <li><strong>Of iemand in Zoeken gezíen is, staat nergens.</strong> Bereikbaarheid zegt of er
        iemand te schrijven valt, niet of er iemand keek.</li>
    </ul>
    <p style="margin-top:22px">Gemeten met <code>npm run health</code> over een venster van ${w.days}
      dagen, een antwoordvenster van ${w.settle} dagen en een drempel van ${w.grace} dagen
      lidmaatschap. Opgemaakt op ${esc(dateLabel(d.generatedAt))}.</p>
  </footer>`);

  return page({
    title: `Wat een nieuwkomer aantreft — Liefdesbrieven, ${dateLabel(d.generatedAt)}`,
    mode: "insluiten",
    body: out.join("\n"),
  });
}

/* ==================== De maandelijkse pagina op de website ====================
 *
 * Een ánder document dan hierboven, met opzet. Het rapport voor de beheerder mag alles laten zien —
 * dat is er de bedoeling van. Deze pagina staat op het open internet, en dan gelden twee dingen die
 * daar niet gelden:
 *
 *   1. Kleine aantallen verraden mensen. "2 vrouwen in een relatie, waarvan 0 actief" is in een
 *      gemeenschap van deze omvang een aanwijzing naar een persoon. Alles met een noemer onder
 *      MIN_CEL wordt daarom niet getoond, en de uitsplitsing naar gender, trans-status en
 *      relatiestatus staat er helemaal niet op: dat zijn bijzondere persoonsgegevens (AVG art. 9)
 *      en die horen niet in een publieke statistiek, hoe geaggregeerd ook.
 *   2. De lezer is geen beheerder maar iemand die overweegt mee te doen, of al meedoet. Die heeft
 *      niets aan een cohorttrechter; die wil weten of hij hier antwoord krijgt.
 *
 * Wat er wél op staat is het slechte nieuws net zo goed als het goede. Een pagina die alleen de
 * gunstige helft publiceert is een reclamefolder, en dan kun je hem beter niet maken.
 */

const MIN_CEL = 20;   /* onder deze noemer geen percentage, maar een streepje */

/*
 * Onder dit ledental toont de pagina helemaal geen cijfers. Geëxporteerd omdat de webserver het moet
 * weten: zo'n pagina bevat niets om af te schermen, dus mag hij vaker ververst worden dan één keer
 * per maand — anders blijft er in de koudestartperiode een maand lang "nog te klein" staan terwijl de
 * eerste leden al binnen zijn.
 */
export const MIN_LEDEN = MIN_CEL * 2;

export function renderPublicHtml(d) {
  const o = d.overview;
  const s = d.shapes;
  const g = d.safety;
  const w = d.window;
  const all = d.openers.find((r) => r.gender == null) ?? {};
  const spreadAll = d.spread.find((r) => r.gender == null) ?? {};
  /* Zie `publiek` bij probeProfiles in server/report.js: niet elk profiel hoort op het open internet */
  const cells = (d.probeGrid ?? []).filter((c) => c.publiek !== false);

  /* Percentage, maar alleen als de noemer groot genoeg is om niemand aan te wijzen */
  const q = (n, of) => (of >= MIN_CEL ? pctShort(n, of) : "—");
  const genoeg = (of) => of >= MIN_CEL;

  const out = [];
  const push = (...x) => out.push(...x);

  push(`<header class="masthead">
    <div class="kicker">
      <span class="eyebrow">Liefdesbrieven</span>
      <span class="runmeta">stand van ${esc(dateLabel(d.generatedAt))}</span>
    </div>
    <h1>Hoe het ervoor staat</h1>
    <p class="lede col" style="margin-top:18px">Een datingapp kan van alles beweren over hoeveel
      leden hij heeft. Wat je wil weten is iets anders: krijg je hier antwoord als je een brief
      schrijft? Deze pagina zegt dat, één keer per maand, met de tegenvallers erbij.</p>
  </header><hr class="airmail">`);

  if (o.example_accounts) {
    push(`<div class="warnband" style="margin-top:34px"><strong>Let op:</strong> er staan
      voorbeeldaccounts in deze database (${o.example_accounts} van de ${o.members}). Dit is geen
      meting aan echte mensen.</div>`);
  }

  /* Te klein om iets te zeggen? Dan zegt de pagina dát, en verder niets. */
  if (o.members < MIN_LEDEN) {
    push(`<section style="margin-top:40px"><p class="lede col">De gemeenschap telt op dit moment
      <b class="num">${o.members}</b> leden. Dat is te weinig om er zinnige cijfers over te geven:
      elk percentage zou over een handvol mensen gaan, en dan zegt het meer over toeval dan over de
      gemeenschap — en het zou bovendien iets over herkenbare personen kunnen verraden. Zodra we
      groter zijn staat hier meer.</p></section>`);
    return page({ title: `Hoe het ervoor staat — Liefdesbrieven`, mode: "verwijzen", body: out.join("\n") });
  }

  /* ---------- Krijg je antwoord ---------- */

  push(`<section style="margin-top:40px">
    <div class="sechead"><h2>Krijg je hier antwoord?</h2></div>
    <div class="tiles">`);
  push(tile("de leden", o.members, `waarvan er <b class="num">${o.active_window}</b> de afgelopen ${w.days} dagen langs zijn geweest.`));
  push(tile("antwoordkans", q(all.answered, all.settled_n),
    genoeg(all.settled_n)
      ? `van de eerste brieven kreeg antwoord binnen ${w.settle} dagen (${all.answered} van ${all.settled_n}).`
      : "er zijn nog te weinig eerste brieven om dit te zeggen."));
  push(tile("wachttijd", spanH(all.median_h), `is de mediane wachttijd op antwoord, voor wie antwoord kreeg.`));
  push(tile("wordt het wat", q(s.real_corr, s.pairs),
    genoeg(s.pairs)
      ? `van de contacten groeide uit tot een briefwisseling van ${REAL_CORRESPONDENCE_LETTERS} brieven of meer.`
      : "er zijn nog te weinig contacten om dit te zeggen."));
  push(`</div>
    <p class="note col">Een brief van gisteren telt hier niet mee: die is nog niet onbeantwoord, die
      is jong. Alleen eerste brieven van minstens ${w.settle} dagen oud worden meegeteld, en de vraag
      is of er bínnen ${w.settle} dagen antwoord kwam.</p>
  </section>`);

  /* ---------- Voor iemand als jij ---------- */

  if (cells.length > 1) {
    const gmax = Math.max(...cells.map((c) => c.matches), 1);
    const labels = [...new Set(cells.map((c) => c.label))];
    push(`<section>
      <div class="sechead"><h2>Voor iemand als jij</h2></div><hr class="sectionrule">
      <p class="col">Het filter werkt hier twee kanten op: jij moet in iemands zoekprofiel passen en
        die persoon in het jouwe. Hoeveel mensen dat oplevert hangt af van wie je zoekt én van hoe
        oud je bent. Dit is wat er vandaag voor je zou zijn.</p>
      <div class="smalls">`);
    for (const lab of labels) {
      push(`<div class="small"><h3>${esc(lab)}</h3><div class="chart" style="gap:7px">`);
      for (const c of cells.filter((x) => x.label === lab)) {
        const m = Math.round(c.matches);
        push(`<div class="row" title="${esc(lab)}, ${c.age} jaar: ${m} mensen">
          <span class="lab num">${c.age}</span>
          <div class="track on-paper"><div class="fill" style="width:${((c.matches / gmax) * 100).toFixed(1)}%"></div></div>
          <span class="val">${m}</span></div>`);
      }
      push(`</div></div>`);
    }
    push(`</div>
      <p class="note col">Gerekend met de instellingen waarmee een account begint: alleen
        vrijgezellen, geen trans correspondenten, en mensen van je eigen leeftijd plus of min vijf
        jaar. Zet je die ruimer, dan vind je meer. Woonplaats maakt weinig uit zolang bijna niemand
        een zoekstraal instelt; elk getal hierboven is de mediaan over vijftien verschillende
        woonplaatsen.</p>
    </section>`);
  }

  /* ---------- Wat er niet goed gaat ---------- */

  push(`<section><div class="sechead"><h2>Wat er niet goed gaat</h2></div><hr class="sectionrule">`);
  const stukken = [];
  if (genoeg(spreadAll.members)) {
    stukken.push(`<p><b class="num">${pctShort(spreadAll.zero_window, spreadAll.members)}</b> van de
      leden kreeg de afgelopen ${w.days} dagen géén enkele brief. Gemiddeld ontving een lid er
      <b class="num">${nl(spreadAll.mean_got)}</b>, maar dat gemiddelde wordt opgetrokken door een
      kleine groep die veel post krijgt: de drukste tien procent ontving
      <b class="num">${q(spreadAll.top10_got, spreadAll.total_got)}</b> van alle brieven.</p>`);
  }
  if (genoeg(s.pairs)) {
    stukken.push(`<p><b class="num">${pctShort(s.p1, s.pairs)}</b> van de eerste brieven bleef zonder
      antwoord liggen. Dat is de eerlijke keerzijde van de cijfers hierboven: de meeste eerste
      brieven leiden tot niets, en dat hoort erbij — het is een reden om er meer dan één te
      schrijven, niet om te stoppen.</p>`);
  }
  if (genoeg(o.departed_total) || o.departed_window) {
    stukken.push(`<p>In de afgelopen ${w.days} dagen zegden <b class="num">${o.departed_window}</b>
      mensen hun account op of werden ze na lange inactiviteit opgeruimd. Wie vertrekt neemt zijn
      brieven mee; ze zijn dan bij iedereen weg.</p>`);
  }
  push(stukken.length ? stukken.join("") : `<p class="col">Er is nog te weinig gebeurd om hier iets
    zinnigs over te zeggen.</p>`);
  push(`</section>`);

  /* ---------- Veiligheid ---------- */

  push(`<section><div class="sechead"><h2>Veiligheid</h2></div><hr class="sectionrule">`);
  if (genoeg(o.active_window)) {
    push(`<p class="col">Er kwamen de afgelopen ${w.days} dagen <b class="num">${g.reports_window}</b>
      meldingen binnen over ${o.active_window} actieve leden — <b class="num">${per100(g.reports_window, o.active_window)}</b>
      actieve leden. ${g.reports_open ? `Daarvan staan er nu <b class="num">${g.reports_open}</b> nog open.` : "Ze zijn allemaal afgehandeld."}
      Meldingen worden met de hand beoordeeld; wat daaruit kan volgen — een waarschuwing, een brief
      weghalen, een schorsing, een account verwijderen — staat beschreven in de
      <a href="/privacy">privacyverklaring</a>.</p>`);
  } else {
    push(`<p class="col">Nog te weinig leden om hier een zinnig cijfer over te geven.</p>`);
  }
  push(`</section>`);

  /* ---------- Hoe dit gemeten is ---------- */

  push(`<hr class="airmail" style="margin-top:64px">
  <footer>
    <div class="eyebrow" style="margin-bottom:10px">Hoe dit gemeten is</div>
    <ul class="col" style="max-width:none">
      <li>De cijfers gaan over de laatste <strong>${w.days} dagen</strong> en worden één keer per
        maand ververst. Ze komen rechtstreeks uit de database; er wordt niets met de hand
        bijgesteld.</li>
      <li><strong>Niemand is hier terug te vinden.</strong> Op deze pagina staan alleen tellingen en
        medianen — geen namen, geen profielen, geen brieven. Alles wat over minder dan ${MIN_CEL}
        mensen zou gaan wordt weggelaten, en er staat geen uitsplitsing naar gender of
        relatiestatus op.</li>
      <li><strong>Vertrokken leden nemen hun brieven mee.</strong> Wat er ooit geschreven is door
        iemand die is opgezegd, telt hier niet meer mee. De cijfers over het verleden zijn daardoor
        eerder te gunstig dan te somber.</li>
      <li>Of iemand je profiel gezíen heeft, houden we niet bij. Deze pagina kan dus wel zeggen
        hoeveel mensen je zou kunnen schrijven, niet hoeveel er naar je gekeken hebben.</li>
    </ul>
    <p style="margin-top:22px"><a href="/">terug naar de voorpagina</a> &middot;
      <a href="/privacy">privacyverklaring</a></p>
  </footer>`);

  return page({ title: "Hoe het ervoor staat — Liefdesbrieven", mode: "verwijzen", body: out.join("\n") });
}