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:
parent
b45fdad911
commit
63506f7d5c
21 changed files with 841 additions and 13 deletions
|
|
@ -191,3 +191,189 @@ describe("Saves (manual-entry, optional)", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Out-of-network reach (manual-entry, optional) — N16.
|
||||
*
|
||||
* LinkedIn surfaces the in-network/out-of-network split natively in post
|
||||
* analytics (Discovery section, under Impressions; progressive global rollout
|
||||
* from June 2026) as a PERCENTAGE split — not as two absolute counts, and not
|
||||
* in the CSV export. So the ingest is a percent cell the operator transcribes,
|
||||
* and the stored field is a single share: `outOfNetworkPct`. In-network is its
|
||||
* complement by definition, so storing both halves would only invite a
|
||||
* self-contradicting record.
|
||||
*/
|
||||
describe("Out-of-network reach (manual-entry, optional)", () => {
|
||||
it("should parse an Out-of-network percent cell written with a % suffix", () => {
|
||||
const filePath = join(fixturesDir, "reach-export.csv");
|
||||
const batch = parseLinkedInCSV(filePath, "reach-export.csv");
|
||||
|
||||
assert.equal(batch.postCount, 4, "Should have 4 posts");
|
||||
assert.equal(
|
||||
batch.posts[0].metrics.outOfNetworkPct,
|
||||
37,
|
||||
"'37%' must parse to the number 37"
|
||||
);
|
||||
});
|
||||
|
||||
it("should leave outOfNetworkPct undefined when the cell is blank (unknown != zero)", () => {
|
||||
const filePath = join(fixturesDir, "reach-export.csv");
|
||||
const batch = parseLinkedInCSV(filePath, "reach-export.csv");
|
||||
|
||||
assert.equal(
|
||||
batch.posts[1].metrics.outOfNetworkPct,
|
||||
undefined,
|
||||
"Blank Out-of-network cell must stay undefined, never coerced to 0"
|
||||
);
|
||||
});
|
||||
|
||||
it("should treat an explicit '0' as a genuine zero share (nothing left the network)", () => {
|
||||
const filePath = join(fixturesDir, "reach-export.csv");
|
||||
const batch = parseLinkedInCSV(filePath, "reach-export.csv");
|
||||
|
||||
assert.equal(
|
||||
batch.posts[2].metrics.outOfNetworkPct,
|
||||
0,
|
||||
"Explicit '0' is a real reading — must stay 0, not collapse to undefined"
|
||||
);
|
||||
});
|
||||
|
||||
it("should read a European decimal comma as a decimal, not a thousands separator", () => {
|
||||
const filePath = join(fixturesDir, "reach-export.csv");
|
||||
const batch = parseLinkedInCSV(filePath, "reach-export.csv");
|
||||
|
||||
// A share never carries a thousands separator, so "36,5" is 36.5 percent.
|
||||
// parseOptionalCount's US-thousands rule would read this as 365 — wrong here.
|
||||
assert.equal(
|
||||
batch.posts[3].metrics.outOfNetworkPct,
|
||||
36.5,
|
||||
"'36,5' must parse to 36.5 percent, never 365"
|
||||
);
|
||||
});
|
||||
|
||||
it("should leave outOfNetworkPct undefined for a standard export with no reach column (backward-compat)", () => {
|
||||
const filePath = join(fixturesDir, "sample-export.csv");
|
||||
const batch = parseLinkedInCSV(filePath, "sample-export.csv");
|
||||
|
||||
for (const post of batch.posts) {
|
||||
assert.equal(
|
||||
post.metrics.outOfNetworkPct,
|
||||
undefined,
|
||||
"Existing CSV exports without a reach column must round-trip unchanged"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should leave outOfNetworkPct undefined for a non-numeric cell (unknown, never 0)", () => {
|
||||
const filePath = join(fixturesDir, "reach-edge-export.csv");
|
||||
const batch = parseLinkedInCSV(filePath, "reach-edge-export.csv");
|
||||
|
||||
assert.equal(
|
||||
batch.posts[0].metrics.outOfNetworkPct,
|
||||
undefined,
|
||||
"Non-numeric reach cell must stay undefined — never coerced to 0"
|
||||
);
|
||||
});
|
||||
|
||||
it("should refuse a value above 100 — a count and a share are undecidable in one column", () => {
|
||||
const filePath = join(fixturesDir, "reach-edge-export.csv");
|
||||
const batch = parseLinkedInCSV(filePath, "reach-edge-export.csv");
|
||||
|
||||
// "1234" in a share column is almost certainly an absolute impression count.
|
||||
// We cannot tell which, so the honest answer is unknown — never a guess.
|
||||
assert.equal(
|
||||
batch.posts[1].metrics.outOfNetworkPct,
|
||||
undefined,
|
||||
"A share above 100 must stay undefined, never stored as-is"
|
||||
);
|
||||
});
|
||||
|
||||
it("should leave outOfNetworkPct undefined for a negative cell", () => {
|
||||
const filePath = join(fixturesDir, "reach-edge-export.csv");
|
||||
const batch = parseLinkedInCSV(filePath, "reach-edge-export.csv");
|
||||
|
||||
assert.equal(
|
||||
batch.posts[2].metrics.outOfNetworkPct,
|
||||
undefined,
|
||||
"A negative share is not a real reading — must stay undefined"
|
||||
);
|
||||
});
|
||||
|
||||
it("should accept exactly 100 as a real reading (the boundary is inclusive)", () => {
|
||||
const filePath = join(fixturesDir, "reach-edge-export.csv");
|
||||
const batch = parseLinkedInCSV(filePath, "reach-edge-export.csv");
|
||||
|
||||
assert.equal(
|
||||
batch.posts[3].metrics.outOfNetworkPct,
|
||||
100,
|
||||
"100 percent out-of-network is possible and must be kept"
|
||||
);
|
||||
});
|
||||
|
||||
it("should derive outOfNetworkPct from an In-network column as its complement", () => {
|
||||
const filePath = join(fixturesDir, "reach-in-network-export.csv");
|
||||
const batch = parseLinkedInCSV(filePath, "reach-in-network-export.csv");
|
||||
|
||||
// The operator transcribed the other half of the same split.
|
||||
assert.equal(
|
||||
batch.posts[0].metrics.outOfNetworkPct,
|
||||
37,
|
||||
"'In-network 63%' must store out-of-network 37"
|
||||
);
|
||||
assert.equal(
|
||||
batch.posts[1].metrics.outOfNetworkPct,
|
||||
undefined,
|
||||
"A blank In-network cell leaves neither half known"
|
||||
);
|
||||
});
|
||||
|
||||
it("should keep the out-of-network half when both columns agree", () => {
|
||||
const filePath = join(fixturesDir, "reach-both-export.csv");
|
||||
const batch = parseLinkedInCSV(filePath, "reach-both-export.csv");
|
||||
|
||||
assert.equal(
|
||||
batch.posts[0].metrics.outOfNetworkPct,
|
||||
37,
|
||||
"63 + 37 = 100 is consistent — keep the out-of-network reading"
|
||||
);
|
||||
});
|
||||
|
||||
it("should refuse a contradictory split rather than pick a half", () => {
|
||||
const filePath = join(fixturesDir, "reach-both-export.csv");
|
||||
const batch = parseLinkedInCSV(filePath, "reach-both-export.csv");
|
||||
|
||||
// 63 + 20 = 83. One of the two cells is a misreading and we cannot tell
|
||||
// which, so the record stays unknown instead of silently trusting one.
|
||||
assert.equal(
|
||||
batch.posts[1].metrics.outOfNetworkPct,
|
||||
undefined,
|
||||
"A split that does not sum to ~100 must stay undefined"
|
||||
);
|
||||
});
|
||||
|
||||
it("should tolerate one point of rounding slack between the two halves", () => {
|
||||
const filePath = join(fixturesDir, "reach-both-export.csv");
|
||||
const batch = parseLinkedInCSV(filePath, "reach-both-export.csv");
|
||||
|
||||
// 62 + 37 = 99: the UI rounds each half independently, so a one-point gap
|
||||
// is rounding, not a misreading.
|
||||
assert.equal(
|
||||
batch.posts[2].metrics.outOfNetworkPct,
|
||||
37,
|
||||
"A 99 or 101 sum is rounding slack — keep the out-of-network reading"
|
||||
);
|
||||
});
|
||||
|
||||
it("should NOT fold out-of-network reach into engagementRate", () => {
|
||||
const filePath = join(fixturesDir, "reach-export.csv");
|
||||
const batch = parseLinkedInCSV(filePath, "reach-export.csv");
|
||||
|
||||
// Row 1: (100+30+15+200)/5000 * 100 = 6.9. Reach is a distribution signal,
|
||||
// not engagement — it must not touch the rate.
|
||||
const expectedRate = ((100 + 30 + 15 + 200) / 5000) * 100;
|
||||
assert.ok(
|
||||
Math.abs(batch.posts[0].metrics.engagementRate - expectedRate) < 0.01,
|
||||
`engagementRate should exclude reach (~${expectedRate}), got ${batch.posts[0].metrics.engagementRate}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
4
scripts/analytics/tests/fixtures/reach-both-export.csv
vendored
Normal file
4
scripts/analytics/tests/fixtures/reach-both-export.csv
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"Content","Date","Impressions","Reactions","Comments","Shares","Clicks","In-network","Out-of-network"
|
||||
"Both halves transcribed and they sum to 100 - consistent, out-of-network wins...",2026-02-28,2900,70,22,9,130,63%,37%
|
||||
"Both halves transcribed but they contradict each other - refuse to guess which one is right...",2026-02-27,2800,70,22,9,130,63%,20%
|
||||
"Both halves with rounding slack - 62 + 37 = 99 is within the one-point tolerance...",2026-02-26,2700,70,22,9,130,62%,37%
|
||||
|
5
scripts/analytics/tests/fixtures/reach-edge-export.csv
vendored
Normal file
5
scripts/analytics/tests/fixtures/reach-edge-export.csv
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"Content","Date","Impressions","Reactions","Comments","Shares","Clicks","Out-of-network"
|
||||
"Non-numeric out-of-network cell - the user jotted a note, not a share; stays unknown...",2026-03-06,3500,70,22,9,130,n/a
|
||||
"Above 100 - most likely an absolute impression count pasted into a share column; undecidable, so unknown...",2026-03-05,3400,70,22,9,130,1234
|
||||
"Negative share - not a real reading; stays unknown...",2026-03-04,3300,70,22,9,130,-5
|
||||
"Exactly 100 - a real reading: every impression came from outside the network...",2026-03-03,3200,70,22,9,130,100
|
||||
|
5
scripts/analytics/tests/fixtures/reach-export.csv
vendored
Normal file
5
scripts/analytics/tests/fixtures/reach-export.csv
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"Content","Date","Impressions","Reactions","Comments","Shares","Clicks","Out-of-network"
|
||||
"An out-of-network share the user read off the native Discovery panel, with a percent sign...",2026-03-10,5000,100,30,15,200,37%
|
||||
"A post where the user left the Out-of-network cell blank - unknown, not zero...",2026-03-09,3000,60,20,8,120,
|
||||
"Explicit zero out-of-network - a real reading: nothing left the network...",2026-03-08,4000,80,25,10,150,0
|
||||
"A share written with a European decimal comma - 36,5 percent, not 365...",2026-03-07,2000,40,10,5,60,"36,5"
|
||||
|
3
scripts/analytics/tests/fixtures/reach-in-network-export.csv
vendored
Normal file
3
scripts/analytics/tests/fixtures/reach-in-network-export.csv
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"Content","Date","Impressions","Reactions","Comments","Shares","Clicks","In-network"
|
||||
"The user transcribed the in-network half of the split instead - out-of-network is the complement...",2026-03-02,3100,70,22,9,130,63%
|
||||
"In-network blank - neither half known...",2026-03-01,3000,60,20,8,120,
|
||||
|
|
|
@ -104,6 +104,31 @@ describe("generateMonthlyReport", () => {
|
|||
assert.equal(report.summary.totalSaves, undefined);
|
||||
});
|
||||
|
||||
test("rolls out-of-network shares up as an impressions-weighted average", () => {
|
||||
const withReach = (p: PostAnalytics, outOfNetworkPct: number): PostAnalytics => ({
|
||||
...p,
|
||||
metrics: { ...p.metrics, outOfNetworkPct },
|
||||
});
|
||||
const posts: PostAnalytics[] = [
|
||||
withReach(createPost("2026-03-03", 10000, 3.0), 20),
|
||||
withReach(createPost("2026-03-05", 1000, 4.0), 80),
|
||||
createPost("2026-03-10", 5000, 3.5), // no share entered — must not dilute
|
||||
];
|
||||
const root = setupTestRoot(posts);
|
||||
const report = generateMonthlyReport(root, "2026-03");
|
||||
assert.equal(
|
||||
report.summary.avgOutOfNetworkPct,
|
||||
25.5,
|
||||
"Should weight by impressions (25.5), not average the shares flat (50)"
|
||||
);
|
||||
});
|
||||
|
||||
test("leaves avgOutOfNetworkPct undefined for reach-free months (backward-compat)", () => {
|
||||
const root = setupTestRoot(marchPosts);
|
||||
const report = generateMonthlyReport(root, "2026-03");
|
||||
assert.equal(report.summary.avgOutOfNetworkPct, undefined);
|
||||
});
|
||||
|
||||
test("generates weekly breakdown within month", () => {
|
||||
const root = setupTestRoot(marchPosts);
|
||||
const report = generateMonthlyReport(root, "2026-03");
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
trendDirection,
|
||||
percentChange,
|
||||
deviationsFromMean,
|
||||
weightedOutOfNetworkPct,
|
||||
} from "../src/utils/stats.js";
|
||||
|
||||
describe("stats", () => {
|
||||
|
|
@ -136,4 +137,60 @@ describe("stats", () => {
|
|||
assert.ok(Math.abs(result) < 0.01);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Out-of-network reach aggregate (N16). The share is per-post, so the only
|
||||
* honest roll-up is impressions-weighted: a 50-impression post at 90% must
|
||||
* not outvote a 10,000-impression post at 20%.
|
||||
*/
|
||||
describe("weightedOutOfNetworkPct", () => {
|
||||
const post = (impressions: number, outOfNetworkPct?: number) => ({
|
||||
metrics: { impressions, outOfNetworkPct },
|
||||
});
|
||||
|
||||
test("should weight each share by that post's impressions", () => {
|
||||
// (10000*20 + 1000*80) / 11000 = 25.45… — an unweighted mean would say 50.
|
||||
const result = weightedOutOfNetworkPct([post(10000, 20), post(1000, 80)]);
|
||||
assert.equal(result, 25.5, "Should be the impressions-weighted share, not the plain mean");
|
||||
});
|
||||
|
||||
test("should exclude posts that carry no share from the weighting", () => {
|
||||
// The 5000-impression post has no reading; folding it in as 0 would drag
|
||||
// the answer to 17.5 and invent data that was never entered.
|
||||
const result = weightedOutOfNetworkPct([
|
||||
post(10000, 20),
|
||||
post(1000, 80),
|
||||
post(5000, undefined),
|
||||
]);
|
||||
assert.equal(result, 25.5, "Posts without a share must not dilute the aggregate");
|
||||
});
|
||||
|
||||
test("should keep a genuine 0 share in the weighting", () => {
|
||||
// (1000*0 + 1000*50) / 2000 = 25 — an explicit zero is data, not absence.
|
||||
const result = weightedOutOfNetworkPct([post(1000, 0), post(1000, 50)]);
|
||||
assert.equal(result, 25);
|
||||
});
|
||||
|
||||
test("should return undefined when no post carries a share", () => {
|
||||
const result = weightedOutOfNetworkPct([post(1000), post(2000)]);
|
||||
assert.equal(result, undefined, "Absent data must stay absent, never 0");
|
||||
});
|
||||
|
||||
test("should return undefined for an empty list", () => {
|
||||
assert.equal(weightedOutOfNetworkPct([]), undefined);
|
||||
});
|
||||
|
||||
test("should return undefined when the carrying posts have no impressions", () => {
|
||||
// A share of zero impressions has no meaning, and the weights sum to 0 —
|
||||
// returning 0 here would be a fabricated reading, and NaN a bug.
|
||||
const result = weightedOutOfNetworkPct([post(0, 40)]);
|
||||
assert.equal(result, undefined, "Zero total weight must yield undefined, never NaN or 0");
|
||||
});
|
||||
|
||||
test("should round to one decimal (the UI reading is itself rounded)", () => {
|
||||
// (3000*33.3 + 1000*66.7) / 4000 = 41.65 → 41.7
|
||||
const result = weightedOutOfNetworkPct([post(3000, 33.3), post(1000, 66.7)]);
|
||||
assert.equal(result, 41.7);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -319,6 +319,57 @@ describe("weekly", () => {
|
|||
assert.equal(report.summary.totalSaves, undefined, "Saves-free data must not introduce a totalSaves field");
|
||||
});
|
||||
|
||||
test("should roll out-of-network shares up as an impressions-weighted average", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const posts: PostAnalytics[] = [
|
||||
createTestPost({
|
||||
id: "reach1",
|
||||
publishedDate: "2026-01-12", // 2026-W03
|
||||
metrics: { impressions: 10000, reactions: 500, comments: 100, shares: 50, clicks: 200, engagementRate: 8.5, outOfNetworkPct: 20 },
|
||||
}),
|
||||
createTestPost({
|
||||
id: "reach2",
|
||||
publishedDate: "2026-01-13", // 2026-W03
|
||||
metrics: { impressions: 1000, reactions: 50, comments: 10, shares: 5, clicks: 20, engagementRate: 8.5, outOfNetworkPct: 80 },
|
||||
}),
|
||||
createTestPost({
|
||||
id: "reach3",
|
||||
publishedDate: "2026-01-14", // 2026-W03 — no share entered; must not dilute.
|
||||
metrics: { impressions: 5000, reactions: 250, comments: 50, shares: 25, clicks: 100, engagementRate: 8.5 },
|
||||
}),
|
||||
];
|
||||
|
||||
saveBatch(tempDir, createTestBatch({ dateRange: { from: "2026-01-12", to: "2026-01-14" }, posts }));
|
||||
|
||||
const report = generateWeeklyReport(tempDir, "2026-W03");
|
||||
|
||||
assert.equal(
|
||||
report.summary.avgOutOfNetworkPct,
|
||||
25.5,
|
||||
"Should weight by impressions (25.5), not average the shares flat (50)"
|
||||
);
|
||||
});
|
||||
|
||||
test("should leave avgOutOfNetworkPct undefined when no post carries a share (backward-compat)", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const posts: PostAnalytics[] = [
|
||||
createTestPost({ id: "noreach1", publishedDate: "2026-01-12" }),
|
||||
createTestPost({ id: "noreach2", publishedDate: "2026-01-13" }),
|
||||
];
|
||||
|
||||
saveBatch(tempDir, createTestBatch({ dateRange: { from: "2026-01-12", to: "2026-01-13" }, posts }));
|
||||
|
||||
const report = generateWeeklyReport(tempDir, "2026-W03");
|
||||
|
||||
assert.equal(
|
||||
report.summary.avgOutOfNetworkPct,
|
||||
undefined,
|
||||
"Reach-free data must not introduce an avgOutOfNetworkPct field"
|
||||
);
|
||||
});
|
||||
|
||||
test("should identify top performers and underperformers", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue