feat(linkedin-studio): N16 — out-of-network-andel + patterns-oppdatering + boundary-map [skip-docs]

Reach-splitten (in/out-of-network) er native i LinkedIns post-analytics siden juni
2026, men vises som PROSENT og finnes ikke i CSV-eksporten. Planen antok to
manuelle antall; verifiseringen viste prosent, så modellen er ett felt —
outOfNetworkPct — og in-network er komplementet.

- parseOptionalPercent: egen parser, ikke parseOptionalCount. Komma er desimal
  (36,5 -> 36.5, aldri 365), og verdi >100 avvises: i én kolonne kan ikke et
  absolutt antall skilles fra en andel, så svaret er unknown, ikke en gjetning.
  Blank/ikke-numerisk/negativ -> unknown; ekte 0 beholdes.
- Ett lagret halvpart, kryssjekket: In-network godtas og lagres som komplement;
  et transkribert par som ikke summerer til ~100 (±1 avrunding) forkastes som
  unknown i stedet for å bli halvveis trodd.
- weightedOutOfNetworkPct: impressions-vektet roll-up (avgOutOfNetworkPct, uke +
  måned). Flatt snitt lar en 50-visnings-post slå en på 10 000; poster uten
  avlesning ekskluderes, og null vekt gir undefined — aldri 0, aldri NaN.
- Reach inngår ALDRI i engagementRate (distribusjon != engasjement). Rapporten
  leser den som akvisisjon (ut) vs resonans (inn), og sier «ikke ført for denne
  perioden» framfor å estimere. En reach-innsikt går inn i N15s do-next-kanal.
- Step 7c (A2-F11): rapporten tilbyr diff mot brukerens engagement-patterns.md
  med eksplisitt go — aldri stille skriving, aldri inn i den shippede malen.
- Boundary-map (E#9): dwell eksplisitt umålbar, saves partner-gated, reach
  native men CSV-eksport uverifisert.
- Reach-frie importer er byte-identiske med før, på skjerm og på disk.

TDD: rødt bevist først (10 feilende), analytics 119 -> 144 tester, tsc ren.
test-runner 232 -> 247 (Section 16w, gulv 213 -> 228). Alle suiter grønne.
CHANGELOG: N15-oppføringen manglet og er backfilt sammen med N16.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QxvWAjte7vPcF79QeSRvRJ
This commit is contained in:
Kjell Tore Guttormsen 2026-07-25 15:56:04 +02:00
commit 63506f7d5c
21 changed files with 841 additions and 13 deletions

View file

@ -84,6 +84,64 @@ function parseOptionalCount(value: string): number | undefined {
return parsed;
}
/**
* Rounding slack, in percentage points, allowed between the two halves of the
* reach split. LinkedIn rounds each half independently for display, so a
* transcribed "63% / 37%" can legitimately sum to 99 or 101.
*/
const REACH_SPLIT_TOLERANCE = 1;
/** Round to one decimal — the UI reading is itself a rounded percentage. */
function round1(value: number): number {
return Math.round(value * 10) / 10;
}
/**
* Parse an OPTIONAL manually-entered PERCENTAGE share (out-of-network reach).
* Distinct from parseOptionalCount in two ways that matter:
* - a share never carries a thousands separator, so a comma is always the
* DECIMAL mark here ("36,5" 36.5). parseOptionalCount's US-thousands rule
* would read that as 365.
* - a share above 100 is not a share. It is most likely an absolute
* impression count pasted into a percent column, and one column cannot tell
* a count from a share so the honest answer is unknown, never a guess.
* Otherwise the same contract as the saves field:
* - blank / absent / non-numeric / negative undefined ("unknown", never 0)
* - a genuine "0" 0 (nothing left the network)
* - "37%", "37 %", "37" 37
*/
function parseOptionalPercent(value: string): number | undefined {
if (!value) return undefined;
const cleaned = value.replace(/"/g, "").replace(/%/g, "").trim();
if (cleaned === "") return undefined;
const parsed = Number(cleaned.replace(/,/g, "."));
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 100) return undefined;
return round1(parsed);
}
/**
* Reduce whichever halves of the reach split the user transcribed to the single
* stored value: the out-of-network share.
* - out-of-network only that value
* - in-network only its complement (they describe one split)
* - both, consistent the out-of-network reading
* - both, contradictory undefined. One cell is a misreading and we cannot
* tell which, so the record stays unknown rather than silently trusting one.
*/
function resolveOutOfNetworkPct(
outOfNetwork: number | undefined,
inNetwork: number | undefined
): number | undefined {
if (outOfNetwork !== undefined && inNetwork !== undefined) {
const sum = outOfNetwork + inNetwork;
return Math.abs(sum - 100) <= REACH_SPLIT_TOLERANCE ? outOfNetwork : undefined;
}
if (outOfNetwork !== undefined) return outOfNetwork;
if (inNetwork !== undefined) return round1(100 - inNetwork);
return undefined;
}
/**
* Normalizes date to YYYY-MM-DD format
* Handles: DD.MM.YYYY, MM/DD/YYYY, YYYY-MM-DD
@ -225,6 +283,19 @@ export function parseLinkedInCSV(
metrics.saves = saves;
}
// Optional manual-entry reach split: only when the user augmented this CSV
// with an Out-of-network (or In-network) column, read off the native
// Discovery panel in post analytics — LinkedIn does not export it. Either
// half is accepted and reduced to the out-of-network share; anything
// unreadable or self-contradicting stays undefined ("unknown", never 0).
const outOfNetworkPct = resolveOutOfNetworkPct(
parseOptionalPercent(findColumn(record, ["out-of-network", "out of network", "outofnetwork"])),
parseOptionalPercent(findColumn(record, ["in-network", "in network", "innetwork"]))
);
if (outOfNetworkPct !== undefined) {
metrics.outOfNetworkPct = outOfNetworkPct;
}
return {
id: generatePostId(title, date),
title,