/*
* 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: `
`;
/*
* 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 = "") =>
`
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.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.
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}.
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.
`);
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)}.
`);
}
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.
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.
`);
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.
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.
`);
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.
${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(`
Liefdesbrievenstand 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.
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") });
}