/* * 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: `
\n${body}\n
` }); /* ---------- 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) => `
${esc(k)}
${v}
${s}
`; /* * 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) => `
${verzonnen ? `
modeluitkomst — dit volgt uit de generator, niet uit een gemeenschap
` : ""}${html}
`; const kv = (pairs) => `
${pairs.filter(Boolean).map(([k, v]) => `
${esc(k)}
${v}
`).join("")}
`; /* Eén staaf. `max` is de schaal, `tone` kiest tussen gewoon, dieper en "dit valt tegen" (rood). */ const barRow = (label, n, max, right, tone = "") => `
${esc(label)}` + `
` + `${esc(right ?? String(n))}
`; const table = (caption, headers, body, opts = {}) => `
` + `${headers.map((h) => ``).join("")}` + body.map((r, i) => `` + r.map((c) => ``).join("") + "").join("") + `
${esc(caption)}
${esc(h)}
${c}
`; /* ---------- 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(`
Liefdesbrieven · gemeenschapsrapport ${esc(dateLabel(d.generatedAt))} · venster ${w.days} dagen

Wat een nieuwkomer aantreft

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.

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

`); /* ---------- Wat een nieuwkomer mag verwachten ---------- */ push(`

Wat iemand die zich vandaag aanmeldt mag verwachten

Alles daaronder is de onderbouwing.

`); 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(`
`); /* ---------- A ---------- */ push(`
A

Wie er zijn


`); push(kv([ ["leden", `${o.members}${o.suspended_now ? `, waarvan ${o.suspended_now} geschorst` : ""}`], ["actief", `${o.active_window} in ${w.days} dagen, ${o.active7} in 7 — wie inlogt of post opent telt mee`], ["schrijvend actief", `${o.active_writing}; het verschil met hierboven is de stille lezer`], ["nieuw in dit venster", `${o.joined_window}`], ["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} sinds het begin` + (o.departed_window ? ` — mediaan na ${nl(o.departed_median_days, 0)} dagen lidmaatschap, waarvan ${o.departed_silent} zonder ooit een brief te schrijven` : "")], o.departed_by_reason ? ["waarom weg", Object.entries(o.departed_by_reason) .map(([k, n]) => `${n}× ${{ zelf: "zelf opgezegd", slapend: "opgeruimd na inactiviteit", maatregel: "na een maatregel" }[k] ?? esc(k)}`) .join(" · ")] : null, ])); if (o.with_radius) { push(bevinding(`

${o.with_radius} leden (${pctShort(o.with_radius, o.members)}) hebben een zoekstraal ingesteld — en die zijn onzichtbaar voor élke nieuwkomer. 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.

`, 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), ]), [`samen`, o.members, o.active_window, pctShort(o.active_window, o.members)]], { totalLast: true })); push(`

${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.

`); push(`
`); /* ---------- B ---------- */ const reg = total.registered || 1; push(`
B

De trechter van een nieuwkomer


Van alle ${total.registered} leden die er ooit binnenkwamen en er nu nog zijn: hoe ver kwamen ze?

`); 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(`

Kreeg ongevraagd post is aangeschreven zijn door iemand die je nog niet kende — niet post binnen een correspondentie die je zelf begon. Echte briefwisseling is ${REAL_CORRESPONDENCE_LETTERS} brieven, van beide kanten minstens ${REAL_CORRESPONDENCE_EACH}.

`); 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 ? "alles" : 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)} (${r.retained}/${r.mature})` : `—`, ]), { totalLast: true })); push(`

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 aangemeld telt wie zich toen aanmeldde én nu nóg een account heeft — wie opzegde is spoorloos.

`); /* ---------- C ---------- */ push(`
C

Krijg ik antwoord, en hoe lang wacht ik?


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 bínnen ${w.settle} dagen antwoord kwam. Teller en noemer krijgen zo evenveel klok.

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

Waar de post heen gaat


`); if (!spreadAll.members) { push(`

Nog niemand is ${w.grace} dagen of langer lid, dus er valt niets te verdelen. Met --grace 0 telt iedereen mee, ook wie zich vandaag aanmeldde.

`); } else { push(`

Hier liegt een gemiddelde het hardst: het deelt de post van de drukste correspondent uit over iedereen die niets kreeg. Gemiddeld ontving een lid ${nl(spreadAll.mean_got)} brieven in dit venster. De mediaan is ${nl(spreadAll.median_got)}.

`); 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(`
drukste 10% — ${t10} brieven volgende 15% — ${mid} onderste 75% — ${rest}
`); } push(kv([ ["grondslag", `${spreadAll.members} leden die minstens ${w.grace} dagen lid zijn`], ["ontvangen post", `${spreadAll.total_got} brieven in dit venster`], ["kreeg niets", `${pctShort(spreadAll.zero_window, spreadAll.members)} in dit venster (${spreadAll.zero_window}); ${pctShort(spreadAll.zero_ever, spreadAll.members)} kreeg nooit iets (${spreadAll.zero_ever})`], ["nooit aangeschreven", `${pctShort(spreadAll.zero_openers, spreadAll.members)} ontving nooit een eerste brief van een vreemde (${spreadAll.zero_openers})`], ["scheefheid", `Gini ${nl(spreadAll.gini_got, 2)} over ontvangen post; ${nl(spreadAll.gini_openers, 2)} over eerste brieven`], ])); if (spreadAll.mean_got > spreadAll.median_got) { push(bevinding(`

${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 is het rapport: het gemiddelde beschrijft de gemeenschap, de mediaan beschrijft het lid.

`, 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(`

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.

`); } push(`
`); /* ---------- E ---------- */ push(`
E

Is er iemand om aan te schrijven?


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.

`); if (reachAll) { push(kv([ ["gemeten", reachAll.measured < reachAll.actives ? `${reachAll.measured} van de ${reachAll.actives} actieve leden (steekproef)` : `alle ${reachAll.actives} actieve leden`], ["wederzijdse matches", `mediaan ${nl(reachAll.median, 0)}, p10 ${nl(reachAll.p10, 0)}, p90 ${nl(reachAll.p90, 0)}`], ["heeft er nul", `${reachAll.zero} leden (${pctShort(reachAll.zero, reachAll.measured)}) — voor hen is de app leeg zonder dat ze kunnen zien waarom`], reachMs != null ? ["rekentijd", `${nl(reachMs / 1000)} s`] : null, ])); } else { push(`

Bereikbaarheid overgeslagen (--fast).

`); } push(`

En voor wie er nog niet is

${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.

`); const gmax = Math.max(...cells.map((c) => c.matches), 1); const labels = [...new Set(cells.map((c) => c.label))]; push(`
`); for (const lab of labels) { push(`

${esc(lab)}

`); 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(`
${c.age}
${m} · ${rp}
`); } push(`
`); } push(`
`); /* * 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(`

Leeftijd bepaalt het bereik. Een nieuwkomer van ${ages[0]} vindt gemiddeld ${nl(jong, 0)} mensen; op ${ages[ages.length - 1]} zijn het er ${nl(oud, 0)}${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.

`, 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(`

Op ${midden} jaar loopt het bereik uiteen van ${Math.round(rij[0].matches)} (${esc(rij[0].label)}) tot ${Math.round(rij[rij.length - 1].matches)} (${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.

`); } } if (empty.length) { push(`

${empty.length} van de ${cells.length} proefprofielen vindt niemand: ${esc(empty.map(cellName).join(", "))}.

`); } push(`

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.

`); /* ---------- F ---------- */ const dmax = Math.max(s.p1, s.p2, s.p3, s.p4, 1); push(`
F

Hoe diep het gaat


`); 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(`
`); push(kv([ ["correspondenties", `${s.pairs}, waarvan ${s.alive} met post in dit venster`], ["wederkerig", `${pctShort(s.reciprocal, s.pairs)} kreeg ooit antwoord (${s.reciprocal})`], ["looptijd", `p90 ${nl(s.p90_span_days, 0)} dagen, langste ${nl(s.longest_days, 0)} dagen`], ["favoriet", `${s.pairs_with_favorite} paren (${pctShort(s.pairs_with_favorite, s.pairs)}), wederzijds bij ${s.pairs_favorite_mutual}`], ["brieflengte", `gemiddeld ${nl(s.avg_chars, 0)} tekens — de ondergrens is ${MIN_CHARS}`], ["geopend", `${pctShort(s.read_n, s.read_denom)} van de brieven die minstens ${w.readSettle} dagen liggen, mediaan na ${span(s.median_read_s)}`], ])); push(`

"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.

`); /* ---------- G ---------- */ push(`
G

Of het er prettig is


`); push(kv([ ["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`], ["meermaals geblokkeerd", `${g.blocked_by_two} leden door 2 of meer mensen, ${g.blocked_by_three} door 3 of meer`], ["correspondentie eindigde erin", `${pctShort(g.ended_in_block, g.pairs)} — de blokkade kwam ná de laatste brief`], ["vijandige eerste brief", `${pctShort(g.opener_blocked + g.opener_reported, g.openers_settled)} liep binnen ${w.hostile} dagen uit op een blokkade of melding (${g.opener_blocked + g.opener_reported} van ${g.openers_settled})`], ["geschorst", `${g.suspended_now} leden`], g.warnings_pending ? ["waarschuwingen", `${g.warnings_pending} 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]), ["samen", 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(`

Van de cijfers hierboven meet er één de beheerder in plaats van de leden: ${g.reports_open_old} meldingen staan langer dan een week open. Dat is het enige getal in deze sectie waar niemand anders iets aan kan doen.

`); } push(`

`); /* ---------- 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(`
H

Waar op gezocht wordt


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.

`); if (!d.searchTerms.length) { push(`

Nog niets gezocht in dit venster.

`); } 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(`

${gaten.length} woorden worden vaak gezocht maar staan in hoogstens twee profielen: ${gaten.slice(0, 8).map((t) => `${esc(t.term)} (${t.searches}×)`).join(", ")}.

`); } } push(`

`); } /* ---------- Voetnoot ---------- */ push(``); 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(`
Liefdesbrieven stand van ${esc(dateLabel(d.generatedAt))}

Hoe het ervoor staat

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.


`); if (o.example_accounts) { push(`
Let op: er staan voorbeeldaccounts in deze database (${o.example_accounts} van de ${o.members}). Dit is geen meting aan echte mensen.
`); } /* Te klein om iets te zeggen? Dan zegt de pagina dát, en verder niets. */ if (o.members < MIN_LEDEN) { push(`

De gemeenschap telt op dit moment ${o.members} 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.

`); return page({ title: `Hoe het ervoor staat — Liefdesbrieven`, mode: "verwijzen", body: out.join("\n") }); } /* ---------- Krijg je antwoord ---------- */ push(`

Krijg je hier antwoord?

`); push(tile("de leden", o.members, `waarvan er ${o.active_window} 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(`

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.

`); /* ---------- 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(`

Voor iemand als jij


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.

`); for (const lab of labels) { push(`

${esc(lab)}

`); for (const c of cells.filter((x) => x.label === lab)) { const m = Math.round(c.matches); push(`
${c.age}
${m}
`); } push(`
`); } push(`

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.

`); } /* ---------- Wat er niet goed gaat ---------- */ push(`

Wat er niet goed gaat


`); const stukken = []; if (genoeg(spreadAll.members)) { stukken.push(`

${pctShort(spreadAll.zero_window, spreadAll.members)} van de leden kreeg de afgelopen ${w.days} dagen géén enkele brief. Gemiddeld ontving een lid er ${nl(spreadAll.mean_got)}, maar dat gemiddelde wordt opgetrokken door een kleine groep die veel post krijgt: de drukste tien procent ontving ${q(spreadAll.top10_got, spreadAll.total_got)} van alle brieven.

`); } if (genoeg(s.pairs)) { stukken.push(`

${pctShort(s.p1, s.pairs)} 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.

`); } if (genoeg(o.departed_total) || o.departed_window) { stukken.push(`

In de afgelopen ${w.days} dagen zegden ${o.departed_window} mensen hun account op of werden ze na lange inactiviteit opgeruimd. Wie vertrekt neemt zijn brieven mee; ze zijn dan bij iedereen weg.

`); } push(stukken.length ? stukken.join("") : `

Er is nog te weinig gebeurd om hier iets zinnigs over te zeggen.

`); push(`
`); /* ---------- Veiligheid ---------- */ push(`

Veiligheid


`); if (genoeg(o.active_window)) { push(`

Er kwamen de afgelopen ${w.days} dagen ${g.reports_window} meldingen binnen over ${o.active_window} actieve leden — ${per100(g.reports_window, o.active_window)} actieve leden. ${g.reports_open ? `Daarvan staan er nu ${g.reports_open} 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 privacyverklaring.

`); } else { push(`

Nog te weinig leden om hier een zinnig cijfer over te geven.

`); } push(`
`); /* ---------- Hoe dit gemeten is ---------- */ push(`
`); return page({ title: "Hoe het ervoor staat — Liefdesbrieven", mode: "verwijzen", body: out.join("\n") }); }