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