From 5f46de5ed2e2e41de037129aa1915da484e3f6cd Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 6 Nov 2026 09:59:45 -0500 Subject: [PATCH 1/7] feat(event-report): event id parse + section-1 track render --- scripts/event-report.mjs | 66 +++++++++++++++++++++++++++++++ test/scripts/event-report.test.js | 36 +++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 scripts/event-report.mjs create mode 100644 test/scripts/event-report.test.js diff --git a/scripts/event-report.mjs b/scripts/event-report.mjs new file mode 100644 index 000000000..4320ea2e3 --- /dev/null +++ b/scripts/event-report.mjs @@ -0,0 +1,66 @@ +import cds from '@sap/cds'; + +const NS = 'com.sap.developers.ims'; +const nf = new Intl.NumberFormat('en-US'); + +/** + * Accept a bare eventId/legacyId or an app-space URL (…?eventId=NNN…). + * @param {string} input + * @returns {string} the eventId + */ +export function parseEventId(input) { + const raw = String(input ?? '').trim(); + if (!raw) throw new Error('No eventId or app-space URL provided'); + // Only treat it as a URL if it parses AS one; otherwise it's a bare id. + let url; + try { + url = new URL(raw); + } catch { + return raw; // bare id + } + const id = url.searchParams.get('eventId'); + if (!id) throw new Error(`No eventId in URL: ${raw}`); + return id; +} + +/** + * Markdown for section 1 — "Tutorial completions by track". + * @param {{buckets:Array<{bucketName:string,count:number,percentage:number}>,totalCompletions:number}} bucketsResult + * @param {object} event resolved Events row (for the heading) + * @returns {string} + */ +export function renderTrackSection(bucketsResult, event) { + const { buckets, totalCompletions } = bucketsResult; + const lines = ['## Tutorial completions by track', '']; + if (!totalCompletions || buckets.length === 0) { + lines.push('_No completions in the event window._'); + return lines.join('\n'); + } + lines.push(`Total completions: ${nf.format(totalCompletions)}`, ''); + lines.push('| Track | Completions | Share |', '| --- | --- | --- |'); + for (const b of buckets) { + lines.push(`| ${b.bucketName} | ${nf.format(b.count)} | ${b.percentage}% |`); + } + return lines.join('\n'); +} + +/** + * SELECT.one Events by legacyId. Returns the full row or null. + * @param {string} eventLegacyId + * @param {object} db cds db service + */ +export async function resolveEvent(eventLegacyId, db) { + const { Events } = cds.entities(NS); + const event = await db.run(SELECT.one.from(Events).where({ legacyId: eventLegacyId })); + return event ?? null; +} + +/** + * Load the CDS model and connect to the bound db. + * @returns {Promise<{db: object}>} + */ +export async function bootstrap() { + cds.model = await cds.load(['srv', 'db', 'app']); + const db = await cds.connect.to('db'); + return { db }; +} diff --git a/test/scripts/event-report.test.js b/test/scripts/event-report.test.js new file mode 100644 index 000000000..5fec47148 --- /dev/null +++ b/test/scripts/event-report.test.js @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { parseEventId, renderTrackSection } from '../../scripts/event-report.mjs'; + +describe('parseEventId', () => { + it('returns a bare id unchanged', () => { + expect(parseEventId('10000003')).toBe('10000003'); + }); + + it('extracts eventId from an app-space URL', () => { + expect( + parseEventId('https://developers.sap.com/app-space/?eventId=10000003&theme=joule'), + ).toBe('10000003'); + }); + + it('throws when a URL has no eventId', () => { + expect(() => parseEventId('https://developers.sap.com/app-space/?theme=x')).toThrow(); + }); +}); + +describe('renderTrackSection', () => { + it('renders total + a track row with count and share', () => { + const md = renderTrackSection( + { buckets: [{ bucketName: 'ABAP', count: 227, percentage: 6.9 }], totalCompletions: 3285 }, + { name: 'E' }, + ); + expect(md).toContain('Total completions: 3,285'); + expect(md).toContain('ABAP'); + expect(md).toContain('227'); + expect(md).toContain('6.9%'); + }); + + it('reports no completions instead of an empty table', () => { + const md = renderTrackSection({ buckets: [], totalCompletions: 0 }, { name: 'E' }); + expect(md).toMatch(/no completions/i); + }); +}); From ee1792528417469385706f10402e422bddf57dad Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 6 Nov 2026 10:08:35 -0500 Subject: [PATCH 2/7] feat(event-report): CLI wiring + buildReport with not-found/no-window guards --- scripts/event-report.mjs | 63 +++++++++++++++++++ test/scripts/event-report-integration.test.js | 63 +++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 test/scripts/event-report-integration.test.js diff --git a/scripts/event-report.mjs b/scripts/event-report.mjs index 4320ea2e3..8b4388ef2 100644 --- a/scripts/event-report.mjs +++ b/scripts/event-report.mjs @@ -1,4 +1,5 @@ import cds from '@sap/cds'; +import { computePathBuckets } from '../srv/lib/event-path-buckets.js'; const NS = 'com.sap.developers.ims'; const nf = new Intl.NumberFormat('en-US'); @@ -64,3 +65,65 @@ export async function bootstrap() { const db = await cds.connect.to('db'); return { db }; } + +function renderHeader(event) { + const win = event.startDate && event.endDate + ? `${event.startDate} → ${event.endDate}` + : '(no window)'; + return [ + `# Event report: ${event.name ?? '(unnamed)'}`, + '', + `- Event legacyId: ${event.legacyId}`, + `- Window: ${win}`, + `- Mission: ${event.mission_ID ?? '(resolved via backlink)'}`, + '', + ].join('\n'); +} + +/** + * Orchestrate the report for one event. Returns the full markdown. + * Throws if the event is not found. Reports a missing window inline + * (does not throw) so the operator sees why there are no numbers. + * @param {string} eventId Events.legacyId + * @param {object} db cds db service + * @returns {Promise} + */ +export async function buildReport(eventId, db) { + const event = await resolveEvent(eventId, db); + if (!event) throw new Error(`Event not found: ${eventId}`); + + const parts = [renderHeader(event)]; + + if (!event.startDate || !event.endDate) { + parts.push('_This event has no date window (startDate/endDate); completion windowing cannot be applied._'); + return parts.join('\n'); + } + + const bucketsResult = await computePathBuckets(event, db); + parts.push(renderTrackSection(bucketsResult, event)); + + return parts.join('\n'); +} + +async function main() { + const argv = process.argv.slice(2); + const wantJson = argv.includes('--json'); + const input = argv.find((a) => !a.startsWith('--')); + const eventId = parseEventId(input); + const { db } = await bootstrap(); + const md = await buildReport(eventId, db); + console.log(md); + if (wantJson) { + const event = await resolveEvent(eventId, db); + const buckets = event?.startDate && event?.endDate ? await computePathBuckets(event, db) : null; + console.log(JSON.stringify({ event, buckets }, null, 2)); + } +} + +// Run only when invoked directly, not when imported by tests. +if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('event-report.mjs')) { + main().catch((e) => { + console.error('ERR:', e.message); + process.exit(1); + }); +} diff --git a/test/scripts/event-report-integration.test.js b/test/scripts/event-report-integration.test.js new file mode 100644 index 000000000..310d0853f --- /dev/null +++ b/test/scripts/event-report-integration.test.js @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import cds from '@sap/cds'; +import { buildReport } from '../../scripts/event-report.mjs'; + +cds.test('serve', '--project', '.', '--in-memory'); + +const MISSION_ID = '33333333-aaaa-0000-0000-000000000001'; +const PATH_ID = '44444444-aaaa-0000-0000-000000000001'; +const TUT = { ID: '55555555-aaaa-0000-0000-000000000001', legacyId: 70001, slug: 'win-tut', title: 'Windowed Tutorial', stepCount: 3 }; +const USER_A = '66666666-aaaa-0000-0000-00000000000a'; +const USER_B = '66666666-aaaa-0000-0000-00000000000b'; + +// Event 900: a valid window. Event 901: null endDate (no window). +const WIN_START = '2026-11-01T00:00:00.000Z'; +const WIN_END = '2026-11-30T23:59:59.000Z'; +const IN_WINDOW = '2026-11-05T12:00:00.000Z'; + +describe('buildReport — section 1 end-to-end (seeded db)', () => { + beforeAll(async () => { + const db = await cds.connect.to('db'); + const { Events, Missions, CompletionPaths, CompletionPathItems, Tutorials, TaskRecords } = + cds.entities('com.sap.developers.ims'); + + await INSERT.into(Tutorials).entries([TUT]); + await INSERT.into(Missions).entries({ ID: MISSION_ID, legacyId: 9001, title: 'M', slug: 'm', published: true }); + await INSERT.into(Events).entries([ + { ID: 'eeee0000-aaaa-0000-0000-000000000900', legacyId: 900, name: 'Windowed Event', eventType: 'OTHER', + mission_ID: MISSION_ID, startDate: WIN_START, endDate: WIN_END }, + { ID: 'eeee0000-aaaa-0000-0000-000000000901', legacyId: 901, name: 'No Window Event', eventType: 'OTHER', + mission_ID: MISSION_ID, startDate: WIN_START, endDate: null }, + ]); + await INSERT.into(CompletionPaths).entries({ ID: PATH_ID, legacyId: 9050, name: 'TrackA', mission_ID: MISSION_ID }); + await INSERT.into(CompletionPathItems).entries({ + ID: 'dddd0000-aaaa-0000-0000-000000000001', path_ID: PATH_ID, itemOrder: 1, + taskType: 'TUTORIAL', taskLegacyId: null, tutorial_ID: TUT.ID, + }); + await INSERT.into(TaskRecords).entries([ + { ID: 'aaaa0000-aaaa-0000-0000-000000000001', user_ID: USER_A, taskLegacyId: TUT.legacyId, + taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: IN_WINDOW, attemptNumber: 1 }, + { ID: 'aaaa0000-aaaa-0000-0000-000000000002', user_ID: USER_B, taskLegacyId: TUT.legacyId, + taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: IN_WINDOW, attemptNumber: 1 }, + ]); + }); + + it('renders header + total + track row for a windowed event', async () => { + const db = await cds.connect.to('db'); + const md = await buildReport('900', db); + expect(md).toContain('Windowed Event'); + expect(md).toContain('Total completions: 2'); + expect(md).toMatch(/TrackA/); + }); + + it('reports event-not-found clearly', async () => { + const db = await cds.connect.to('db'); + await expect(buildReport('nope', db)).rejects.toThrow(/not found/i); + }); + + it('reports no date window rather than a zero report', async () => { + const db = await cds.connect.to('db'); + const md = await buildReport('901', db); + expect(md).toMatch(/no date window/i); + }); +}); From a2335877e63e977ad41f4ec1522810abacb3b7d9 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 6 Nov 2026 10:23:33 -0500 Subject: [PATCH 3/7] feat(event-report): section 2 abandonment + step-drop histogram --- scripts/event-report.mjs | 137 +++++++++++++++++- test/scripts/event-report-abandonment.test.js | 99 +++++++++++++ 2 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 test/scripts/event-report-abandonment.test.js diff --git a/scripts/event-report.mjs b/scripts/event-report.mjs index 8b4388ef2..fd47a4a8f 100644 --- a/scripts/event-report.mjs +++ b/scripts/event-report.mjs @@ -1,5 +1,5 @@ import cds from '@sap/cds'; -import { computePathBuckets } from '../srv/lib/event-path-buckets.js'; +import { computePathBuckets, buildTrackMap, resolveEventMissionId } from '../srv/lib/event-path-buckets.js'; const NS = 'com.sap.developers.ims'; const nf = new Intl.NumberFormat('en-US'); @@ -66,6 +66,137 @@ export async function bootstrap() { return { db }; } +function median(values) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[mid] : Math.floor((sorted[mid - 1] + sorted[mid]) / 2); +} + +/** + * The distinct tutorials under the event's tracks (via buildTrackMap), hydrated + * with title + totalSteps (stepCount, falling back to a count of Steps rows). + * @param {object} event resolved Events row + * @param {object} db + * @returns {Promise>} + */ +export async function resolveEventTutorials(event, db) { + const missionId = await resolveEventMissionId(event, db); + if (!missionId) return []; + const { pathNameByToken } = await buildTrackMap(missionId, db); + const legacyIds = [...pathNameByToken.keys()] + .filter((k) => k.startsWith('TUTORIAL:')) + .map((k) => Number(k.slice('TUTORIAL:'.length))); + if (legacyIds.length === 0) return []; + + const { Tutorials, Steps } = cds.entities(NS); + const tuts = await db.run( + SELECT.from(Tutorials).columns('ID', 'legacyId', 'title', 'stepCount').where({ legacyId: { in: legacyIds } }), + ); + const out = []; + for (const t of tuts) { + let totalSteps = t.stepCount; + if (totalSteps == null) { + const steps = await db.run(SELECT.from(Steps).columns('ID').where({ tutorial_ID: t.ID })); + totalSteps = steps.length; + } + out.push({ ID: t.ID, legacyId: t.legacyId, title: t.title, totalSteps }); + } + return out; +} + +/** + * Per-tutorial abandonment: IN_PROGRESS TUTORIAL records whose modifiedAt is in + * the event window; per (user,tutorial) stop-step = count of COMPLETED STEP + * records at that user's current attemptNumber. + * @param {object} event resolved Events row (needs startDate/endDate) + * @param {Array<{legacyId:number,title:string,totalSteps:number}>} tutorials + * @param {object} db + * @returns {Promise}>>} + */ +export async function computeAbandonment(event, tutorials, db) { + const { TaskRecords, Steps } = cds.entities(NS); + const rows = []; + for (const t of tutorials) { + const inProgress = await db.run( + SELECT.from(TaskRecords) + .columns('user_ID', 'attemptNumber') + .where({ taskType: 'TUTORIAL', status: 'IN_PROGRESS', taskLegacyId: t.legacyId }) + .and(`modifiedAt >=`, event.startDate) + .and(`modifiedAt <=`, event.endDate), + ); + if (inProgress.length === 0) continue; + + const stepRows = await db.run( + SELECT.from(Steps) + .columns('legacyId', 'stepOrder', 'title') + .where({ tutorial_ID: t.ID }) + .orderBy('stepOrder'), + ); + const stepLegacyIds = stepRows.map((s) => s.legacyId).filter((x) => x != null); + + const stopSteps = []; + for (const rec of inProgress) { + let done = 0; + if (stepLegacyIds.length > 0) { + const completed = await db.run( + SELECT.from(TaskRecords).columns('ID').where({ + user_ID: rec.user_ID, taskType: 'STEP', status: 'COMPLETED', + taskLegacyId: { in: stepLegacyIds }, attemptNumber: rec.attemptNumber ?? 1, + }), + ); + done = completed.length; + } + stopSteps.push(done); + } + + const countByStep = new Map(); + for (const s of stopSteps) countByStep.set(s, (countByStep.get(s) ?? 0) + 1); + const total = stopSteps.length; + const perStep = [...countByStep.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([step, count]) => ({ step, count, pct: Math.round((count / total) * 100) })); + + rows.push({ + tutorialLegacyId: t.legacyId, + title: t.title, + totalSteps: t.totalSteps, + abandonedCount: total, + medianStopStep: median(stopSteps), + perStep, + stepTitles: Object.fromEntries(stepRows.map((s) => [s.stepOrder, s.title])), + }); + } + return rows.sort((a, b) => b.abandonedCount - a.abandonedCount); +} + +/** + * Markdown for section 2 — abandonment + step-drop histogram. + * @param {Array} rows output of computeAbandonment + * @returns {string} + */ +export function renderAbandonmentSection(rows) { + const totalAbandoned = rows.reduce((s, r) => s + r.abandonedCount, 0); + const lines = ['## Abandoned tutorials', '', `Total abandoned: ${nf.format(totalAbandoned)}`, '']; + if (rows.length === 0) { + lines.push('_No in-progress tutorials in the event window._'); + return lines.join('\n'); + } + for (const r of rows) { + lines.push( + `### ${r.title} — ${r.abandonedCount} abandoned, ${r.totalSteps} steps`, + `Median stop: step ${r.medianStopStep} of ${r.totalSteps}`, + '', + ); + for (const p of r.perStep) { + const title = r.stepTitles?.[p.step] ? ` "${r.stepTitles[p.step]}"` : ''; + lines.push(`- Step ${p.step}${title}: ${p.count} (${p.pct}%)`); + } + lines.push(''); + } + return lines.join('\n').trimEnd(); +} + function renderHeader(event) { const win = event.startDate && event.endDate ? `${event.startDate} → ${event.endDate}` @@ -102,6 +233,10 @@ export async function buildReport(eventId, db) { const bucketsResult = await computePathBuckets(event, db); parts.push(renderTrackSection(bucketsResult, event)); + const tutorials = await resolveEventTutorials(event, db); + const abandonment = await computeAbandonment(event, tutorials, db); + parts.push(renderAbandonmentSection(abandonment)); + return parts.join('\n'); } diff --git a/test/scripts/event-report-abandonment.test.js b/test/scripts/event-report-abandonment.test.js new file mode 100644 index 000000000..8a8149ed4 --- /dev/null +++ b/test/scripts/event-report-abandonment.test.js @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import cds from '@sap/cds'; +import { resolveEventTutorials, computeAbandonment } from '../../scripts/event-report.mjs'; + +cds.test('serve', '--project', '.', '--in-memory'); + +const MISSION_ID = '33333333-bbbb-0000-0000-000000000001'; +const PATH_ID = '44444444-bbbb-0000-0000-000000000001'; +const TUT = { ID: '55555555-bbbb-0000-0000-000000000001', legacyId: 80001, slug: 'aband-tut', title: 'Abandoned Tutorial', stepCount: 9 }; +const TUT_NOSTEPS = { ID: '55555555-bbbb-0000-0000-000000000002', legacyId: 80002, slug: 'nosteps', title: 'No Steps', stepCount: null }; +const EVENT_ID = 'eeee0000-bbbb-0000-0000-000000000902'; +const WIN_START = '2026-11-01T00:00:00.000Z'; +const WIN_END = '2026-11-30T23:59:59.000Z'; +const IN = '2026-11-05T12:00:00.000Z'; +const OUT = '2026-10-01T12:00:00.000Z'; + +// Step legacyIds for TUT (steps 1..9) +const stepLegacy = (n) => 81000 + n; + +describe('section 2 — abandonment + step-drop (seeded db)', () => { + let event; + beforeAll(async () => { + await cds.connect.to('db'); + const { Events, Missions, CompletionPaths, CompletionPathItems, Tutorials, Steps, TaskRecords } = + cds.entities('com.sap.developers.ims'); + + await INSERT.into(Tutorials).entries([TUT, TUT_NOSTEPS]); + // 9 Steps for TUT + await INSERT.into(Steps).entries( + Array.from({ length: 9 }, (_, i) => ({ + ID: `57000000-bbbb-0000-0000-0000000000${String(i + 1).padStart(2, '0')}`, + legacyId: stepLegacy(i + 1), title: `Step ${i + 1}`, tutorial_ID: TUT.ID, stepOrder: i + 1, + })), + ); + await INSERT.into(Missions).entries({ ID: MISSION_ID, legacyId: 9002, title: 'M2', slug: 'm2', published: true }); + await INSERT.into(Events).entries({ + ID: EVENT_ID, legacyId: 902, name: 'Aband Event', eventType: 'OTHER', + mission_ID: MISSION_ID, startDate: WIN_START, endDate: WIN_END, + }); + await INSERT.into(CompletionPaths).entries({ ID: PATH_ID, legacyId: 9052, name: 'T', mission_ID: MISSION_ID }); + await INSERT.into(CompletionPathItems).entries([ + { ID: 'dddd0000-bbbb-0000-0000-000000000001', path_ID: PATH_ID, itemOrder: 1, taskType: 'TUTORIAL', taskLegacyId: null, tutorial_ID: TUT.ID }, + { ID: 'dddd0000-bbbb-0000-0000-000000000002', path_ID: PATH_ID, itemOrder: 2, taskType: 'TUTORIAL', taskLegacyId: null, tutorial_ID: TUT_NOSTEPS.ID }, + ]); + + // 3 users IN_PROGRESS on TUT, modifiedAt in window; steps done 1,1,5 → stops at 1,1,5 (median 1) + const users = [ + { u: 'u0000000-bbbb-0000-0000-00000000000a', steps: 1 }, + { u: 'u0000000-bbbb-0000-0000-00000000000b', steps: 1 }, + { u: 'u0000000-bbbb-0000-0000-00000000000c', steps: 5 }, + ]; + for (const { u, steps } of users) { + await INSERT.into(TaskRecords).entries({ + ID: `ab000000-bbbb-0000-0000-${u.slice(-12)}`, user_ID: u, taskLegacyId: TUT.legacyId, + taskType: 'TUTORIAL', status: 'IN_PROGRESS', modifiedAt: IN, attemptNumber: 1, + }); + for (let s = 1; s <= steps; s++) { + await INSERT.into(TaskRecords).entries({ + ID: `st${s}00000-bbbb-0000-0000-${u.slice(-12)}`, user_ID: u, taskLegacyId: stepLegacy(s), + taskType: 'STEP', status: 'COMPLETED', completionDate: IN, attemptNumber: 1, + }); + } + } + // One IN_PROGRESS record OUTSIDE the window — must be excluded. + await INSERT.into(TaskRecords).entries({ + ID: 'abffff00-bbbb-0000-0000-00000000000d', user_ID: 'u0000000-bbbb-0000-0000-00000000000d', + taskLegacyId: TUT.legacyId, taskType: 'TUTORIAL', status: 'IN_PROGRESS', modifiedAt: OUT, attemptNumber: 1, + }); + + event = await SELECT.one.from(Events).where({ legacyId: 902 }); + }); + + it('resolves the event tutorial set with stepCount', async () => { + const db = await cds.connect.to('db'); + const tuts = await resolveEventTutorials(event, db); + const byId = Object.fromEntries(tuts.map((t) => [t.legacyId, t])); + expect(byId[80001].totalSteps ?? byId[80001].stepCount).toBe(9); + expect(tuts.length).toBe(2); + }); + + it('counts abandoned IN_PROGRESS in-window with median stop step', async () => { + const db = await cds.connect.to('db'); + const tuts = await resolveEventTutorials(event, db); + const rows = await computeAbandonment(event, tuts, db); + const tutRow = rows.find((r) => r.tutorialLegacyId === 80001); + expect(tutRow.abandonedCount).toBe(3); // out-of-window excluded + expect(tutRow.medianStopStep).toBe(1); // steps done 1,1,5 → median 1 + const perStepSum = tutRow.perStep.reduce((s, p) => s + p.count, 0); + expect(perStepSum).toBe(3); + }); + + it('handles a tutorial with null stepCount and no Steps without NaN', async () => { + const db = await cds.connect.to('db'); + const tuts = await resolveEventTutorials(event, db); + const rows = await computeAbandonment(event, tuts, db); + const nos = rows.find((r) => r.tutorialLegacyId === 80002) ?? { medianStopStep: 0, abandonedCount: 0 }; + expect(Number.isNaN(nos.medianStopStep)).toBe(false); + }); +}); From f7326d42e04c459a4740ec735205402b4cdcd4d5 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 6 Nov 2026 10:35:07 -0500 Subject: [PATCH 4/7] feat(event-report): sections 3-4 domains/audience + cf-target prod guard + SKILL.md --- .../skills/event-app-space-report/SKILL.md | 79 ++++++++++ scripts/event-report.mjs | 136 ++++++++++++++++++ test/scripts/event-report-domains.test.js | 84 +++++++++++ 3 files changed, 299 insertions(+) create mode 100644 .claude/skills/event-app-space-report/SKILL.md create mode 100644 test/scripts/event-report-domains.test.js diff --git a/.claude/skills/event-app-space-report/SKILL.md b/.claude/skills/event-app-space-report/SKILL.md new file mode 100644 index 000000000..9a21d2de6 --- /dev/null +++ b/.claude/skills/event-app-space-report/SKILL.md @@ -0,0 +1,79 @@ +--- +name: event-app-space-report +description: >- + Use when asked for completion statistics / a report for an SAP event (TechEd, + ASUG Tech Connect, DSAG, etc.) on the tutorials-ims / developers.sap.com + platform, given an app-space URL (…/app-space/?eventId=NNN) or a bare eventId. + Produces the four-section report: tutorial completions by Track, abandonment + + step-drop histogram, completer email-domain breakdown, and SAP-vs-external. + Triggers on "report/statistics from the event ", "app-space stats + for …", "event completion numbers". +--- + +# Event App-Space Report + +Produces a point-in-time, read-only completion report for one event, scoped by +the event's **date window** to the tutorials linked via its mission's +CompletionPaths (Tracks). Reuses the server helper `srv/lib/event-path-buckets.js` +so Track numbers match the live app-space page. + +## Input + +An app-space URL (`https://developers.sap.com/app-space/?eventId=10000003&theme=…`) +or a bare `eventId` (= `Events.legacyId`). The script parses either. + +## Steps + +1. **Parse the eventId** from the URL (or accept the bare id). +2. **Target PROD.** The report runs against PROD HANA. Confirm you are logged in + and targeted at the **prod** space first: + ```bash + cf target # org/space must be prod + cf target -s prod + ``` + The script enforces this (`assertCfTargetProd`) and refuses to run on any + other space — it is the only env-separation safeguard (binding follows the + current `cf` login; there is no prod-vs-dev named binding). +3. **Bind PROD HANA** if not already bound in this worktree: + ```bash + cds bind -2 tutorials-hana + ``` + **Do NOT use the `tutorials-hana:tutorials-hana-key` service-key** — per + `docs/developers/operations/gameboard-prod-data-troubleshooting.md` it maps to + a near-empty decoy HDI container, not the live app container. Use the plain + instance binding. +4. **Run the report** (read-only; SELECT/CQL only, no writes): + ```bash + cds bind --exec -- node scripts/event-report.mjs [--json] + ``` + `--json` additionally prints the raw event + bucket aggregates. +5. **Present** the emitted markdown to the user (the four sections). + +## Verification + +Cross-check the **Track** totals against the live app-space page or the admin +`getEventBuckets` OData call for that event when the user wants assurance. The +script reuses the same `computePathBuckets` helper, so a mismatch means a +binding/environment problem (wrong space, decoy container), not a logic +difference. + +## Report sections + +1. **Tutorial completions by track** — total + per-Track count and share + (`CompletionPaths.name`; FK-first tutorial resolution; windowed COMPLETED/ + SUPERSEDED completions). +2. **Abandoned tutorials** — IN_PROGRESS tutorials whose record was last touched + (`modifiedAt`) inside the window, with per-tutorial median stop-step and a + per-step drop histogram (stop-step = COMPLETED STEP records at the user's + current attempt vs `Tutorials.stepCount`). +3. **Completer email domains** — distinct completers per domain, completions, + and completions/user; most-engaged domains. +4. **SAP employees vs external** — `@sap.com` vs everyone else. + +## Notes + +- Read-only and prod-guarded. No writes, no deploy. +- `scripts/event-report.mjs` lives in the main repo, so it's runnable outside + this skill too (`cds bind --exec -- node scripts/event-report.mjs `). +- Scoping is by **date window**, never `TaskRecords.event_ID` (NULL on normal + completions). Track = `CompletionPaths.name`, not Mission title. diff --git a/scripts/event-report.mjs b/scripts/event-report.mjs index fd47a4a8f..a2040bd14 100644 --- a/scripts/event-report.mjs +++ b/scripts/event-report.mjs @@ -1,4 +1,5 @@ import cds from '@sap/cds'; +import { spawnSync } from 'node:child_process'; import { computePathBuckets, buildTrackMap, resolveEventMissionId } from '../srv/lib/event-path-buckets.js'; const NS = 'com.sap.developers.ims'; @@ -197,6 +198,136 @@ export function renderAbandonmentSection(rows) { return lines.join('\n').trimEnd(); } +/** + * Email-domain + SAP-vs-external breakdown of the event's windowed TUTORIAL + * completers. Counts completions (COMPLETED/SUPERSEDED) in window for the + * event's tutorials, joined to Users.email. + * @param {object} event resolved Events row (needs startDate/endDate) + * @param {Array<{legacyId:number}>} tutorials + * @param {object} db + * @returns {Promise<{domains:Array<{domain:string,users:number,completions:number,perUser:number}>,sap:{users:number,completions:number},external:{users:number,completions:number}}>} + */ +export async function computeDomainBreakdown(event, tutorials, db) { + const { TaskRecords, Users } = cds.entities(NS); + const legacyIds = tutorials.map((t) => t.legacyId); + if (legacyIds.length === 0) { + return { domains: [], sap: { users: 0, completions: 0 }, external: { users: 0, completions: 0 } }; + } + + const records = await db.run( + SELECT.from(TaskRecords) + .columns('user_ID') + .where({ taskType: 'TUTORIAL', status: { in: ['COMPLETED', 'SUPERSEDED'] }, taskLegacyId: { in: legacyIds } }) + .and(`completionDate >=`, event.startDate) + .and(`completionDate <=`, event.endDate), + ); + if (records.length === 0) { + return { domains: [], sap: { users: 0, completions: 0 }, external: { users: 0, completions: 0 } }; + } + + const userIds = [...new Set(records.map((r) => r.user_ID).filter(Boolean))]; + const users = await db.run(SELECT.from(Users).columns('ID', 'email').where({ ID: { in: userIds } })); + const emailById = new Map(users.map((u) => [u.ID, (u.email || '').toLowerCase()])); + + // per-domain: user set + completion count + const byDomain = new Map(); // domain → { users:Set, completions:number } + const sapUsers = new Set(); + let sapCompletions = 0; + const extUsers = new Set(); + let extCompletions = 0; + + for (const r of records) { + const email = emailById.get(r.user_ID) || ''; + const domain = email.includes('@') ? email.slice(email.indexOf('@') + 1) : '(unknown)'; + if (!byDomain.has(domain)) byDomain.set(domain, { users: new Set(), completions: 0 }); + const d = byDomain.get(domain); + d.users.add(r.user_ID); + d.completions += 1; + if (domain === 'sap.com') { + sapUsers.add(r.user_ID); + sapCompletions += 1; + } else { + extUsers.add(r.user_ID); + extCompletions += 1; + } + } + + const domains = [...byDomain.entries()] + .map(([domain, v]) => ({ + domain, + users: v.users.size, + completions: v.completions, + perUser: Math.round((v.completions / v.users.size) * 10) / 10, + })) + .sort((a, b) => b.users - a.users || b.completions - a.completions); + + return { + domains, + sap: { users: sapUsers.size, completions: sapCompletions }, + external: { users: extUsers.size, completions: extCompletions }, + }; +} + +/** Markdown for section 3 — top domains by user count + most-engaged by per-user. */ +export function renderDomainSection(breakdown) { + const { domains } = breakdown; + const lines = ['## Completer email domains', '']; + if (domains.length === 0) { + lines.push('_No completers in the event window._'); + return lines.join('\n'); + } + const totalUsers = domains.reduce((s, d) => s + d.users, 0); + const totalCompletions = domains.reduce((s, d) => s + d.completions, 0); + lines.push(`${nf.format(totalUsers)} distinct completers across ${nf.format(domains.length)} email domains.`, ''); + lines.push('### Top domains by user count', '', '| Domain | Users | Completions | Avg/user |', '| --- | --- | --- | --- |'); + for (const d of domains.slice(0, 15)) { + lines.push(`| ${d.domain} | ${nf.format(d.users)} | ${nf.format(d.completions)} | ${d.perUser} |`); + } + lines.push('', '### Most engaged by completions/user', '', '| Domain | Completions/user |', '| --- | --- |'); + const engaged = [...domains].filter((d) => d.users >= 3).sort((a, b) => b.perUser - a.perUser).slice(0, 10); + for (const d of engaged) lines.push(`| ${d.domain} | ${d.perUser} |`); + lines.push('', `_Total completions across domains: ${nf.format(totalCompletions)}._`); + return lines.join('\n'); +} + +/** Markdown for section 4 — SAP employees vs external. */ +export function renderAudienceSection(breakdown) { + const { sap, external } = breakdown; + const totalUsers = sap.users + external.users; + const totalCompletions = sap.completions + external.completions; + const pct = (n, d) => (d > 0 ? Math.round((n / d) * 100) : 0); + return [ + '## SAP employees vs external', + '', + '| Audience | Users | Completions |', + '| --- | --- | --- |', + `| SAP employees (@sap.com) | ${nf.format(sap.users)} | ${nf.format(sap.completions)} |`, + `| External | ${nf.format(external.users)} | ${nf.format(external.completions)} |`, + '', + `SAP ${pct(sap.users, totalUsers)}% of users / ${pct(sap.completions, totalCompletions)}% of completions; external the rest.`, + ].join('\n'); +} + +/** + * Refuse unless `cf target` reports the prod space. Ports deploy-mta.cjs + * guardCfTarget: parse `space:` from `cf target`, compare case-insensitively. + * @param {() => {status:number,stdout:string,stderr?:string}} [runCf] injectable cf runner (for tests) + */ +export function assertCfTargetProd(runCf) { + const run = runCf ?? (() => { + const r = spawnSync('cf', ['target'], { encoding: 'utf8' }); + return { status: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }; + }); + const t = run(); + if (t.status !== 0) { + throw new Error(`\`cf target\` failed — are you logged in? Run \`cf login\`.\n${t.stdout}${t.stderr ?? ''}`); + } + const space = (t.stdout.match(/space:\s*(\S+)/i) || [])[1] || ''; + if (space.toLowerCase() !== 'prod') { + throw new Error(`cf target space is "${space || '(unknown)'}", expected "prod". Fix: cf target -s prod`); + } +} + function renderHeader(event) { const win = event.startDate && event.endDate ? `${event.startDate} → ${event.endDate}` @@ -237,6 +368,10 @@ export async function buildReport(eventId, db) { const abandonment = await computeAbandonment(event, tutorials, db); parts.push(renderAbandonmentSection(abandonment)); + const breakdown = await computeDomainBreakdown(event, tutorials, db); + parts.push(renderDomainSection(breakdown)); + parts.push(renderAudienceSection(breakdown)); + return parts.join('\n'); } @@ -245,6 +380,7 @@ async function main() { const wantJson = argv.includes('--json'); const input = argv.find((a) => !a.startsWith('--')); const eventId = parseEventId(input); + assertCfTargetProd(); // fail closed before touching the DB const { db } = await bootstrap(); const md = await buildReport(eventId, db); console.log(md); diff --git a/test/scripts/event-report-domains.test.js b/test/scripts/event-report-domains.test.js new file mode 100644 index 000000000..9040d8eb3 --- /dev/null +++ b/test/scripts/event-report-domains.test.js @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import cds from '@sap/cds'; +import { computeDomainBreakdown, renderDomainSection, assertCfTargetProd } from '../../scripts/event-report.mjs'; + +cds.test('serve', '--project', '.', '--in-memory'); + +const MISSION_ID = '33333333-cccc-0000-0000-000000000001'; +const PATH_ID = '44444444-cccc-0000-0000-000000000001'; +const TUT = { ID: '55555555-cccc-0000-0000-000000000001', legacyId: 90001, slug: 'dom-tut', title: 'Domain Tutorial', stepCount: 2 }; +const WIN_START = '2026-11-01T00:00:00.000Z'; +const WIN_END = '2026-11-30T23:59:59.000Z'; +const IN = '2026-11-05T12:00:00.000Z'; + +describe('sections 3-4 — domain + audience breakdown (seeded db)', () => { + let event; + let tutorials; + beforeAll(async () => { + const db = await cds.connect.to('db'); + const { Events, Missions, CompletionPaths, CompletionPathItems, Tutorials, Users, TaskRecords } = + cds.entities('com.sap.developers.ims'); + + await INSERT.into(Tutorials).entries([TUT]); + await INSERT.into(Missions).entries({ ID: MISSION_ID, legacyId: 9003, title: 'M3', slug: 'm3', published: true }); + await INSERT.into(Events).entries({ + ID: 'eeee0000-cccc-0000-0000-000000000903', legacyId: 903, name: 'Domain Event', eventType: 'OTHER', + mission_ID: MISSION_ID, startDate: WIN_START, endDate: WIN_END, + }); + await INSERT.into(CompletionPaths).entries({ ID: PATH_ID, legacyId: 9053, name: 'T', mission_ID: MISSION_ID }); + await INSERT.into(CompletionPathItems).entries({ + ID: 'dddd0000-cccc-0000-0000-000000000001', path_ID: PATH_ID, itemOrder: 1, + taskType: 'TUTORIAL', taskLegacyId: null, tutorial_ID: TUT.ID, + }); + await INSERT.into(Users).entries([ + { ID: 'c0000000-cccc-0000-0000-00000000000a', email: 'a@gmail.com' }, + { ID: 'c0000000-cccc-0000-0000-00000000000b', email: 'b@gmail.com' }, + { ID: 'c0000000-cccc-0000-0000-00000000000c', email: 'c@sap.com' }, + ]); + // gmail: 2 users, 3 completions total; sap: 1 user, 1 completion. + await INSERT.into(TaskRecords).entries([ + { ID: 'd0000000-cccc-0000-0000-000000000001', user_ID: 'c0000000-cccc-0000-0000-00000000000a', taskLegacyId: TUT.legacyId, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: IN, attemptNumber: 1 }, + { ID: 'd0000000-cccc-0000-0000-000000000002', user_ID: 'c0000000-cccc-0000-0000-00000000000a', taskLegacyId: TUT.legacyId, taskType: 'TUTORIAL', status: 'SUPERSEDED', completionDate: IN, attemptNumber: 1 }, + { ID: 'd0000000-cccc-0000-0000-000000000003', user_ID: 'c0000000-cccc-0000-0000-00000000000b', taskLegacyId: TUT.legacyId, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: IN, attemptNumber: 1 }, + { ID: 'd0000000-cccc-0000-0000-000000000004', user_ID: 'c0000000-cccc-0000-0000-00000000000c', taskLegacyId: TUT.legacyId, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: IN, attemptNumber: 1 }, + ]); + + event = await SELECT.one.from(Events).where({ legacyId: 903 }); + tutorials = [{ ID: TUT.ID, legacyId: TUT.legacyId, title: TUT.title, totalSteps: 2 }]; + }); + + it('groups completions by email domain with per-user average', async () => { + const db = await cds.connect.to('db'); + const b = await computeDomainBreakdown(event, tutorials, db); + const gmail = b.domains.find((d) => d.domain === 'gmail.com'); + expect(gmail.users).toBe(2); + expect(gmail.completions).toBe(3); + expect(gmail.perUser).toBeCloseTo(1.5, 1); + }); + + it('splits SAP employees from external', async () => { + const db = await cds.connect.to('db'); + const b = await computeDomainBreakdown(event, tutorials, db); + expect(b.sap.users).toBe(1); + expect(b.sap.completions).toBe(1); + expect(b.external.users).toBe(2); + expect(b.external.completions).toBe(3); + }); + + it('renders a gmail row in the domain section', async () => { + const db = await cds.connect.to('db'); + const b = await computeDomainBreakdown(event, tutorials, db); + const md = renderDomainSection(b); + expect(md).toContain('gmail.com'); + }); + + it('cf-target guard dies unless space is prod', () => { + const runDev = () => ({ status: 0, stdout: 'API endpoint: https://api.cf.eu10-005.hana.ondemand.com\norg: o\nspace: dev\n' }); + expect(() => assertCfTargetProd(runDev)).toThrow(/prod/i); + }); + + it('cf-target guard passes when space is prod', () => { + const runProd = () => ({ status: 0, stdout: 'API endpoint: https://api.cf.eu10-005.hana.ondemand.com\norg: o\nspace: prod\n' }); + expect(() => assertCfTargetProd(runProd)).not.toThrow(); + }); +}); From 8cdd1840af64904e023628ba0f4bdf84f8720f96 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 6 Nov 2026 11:05:15 -0500 Subject: [PATCH 5/7] fix(event-report): reconcile domain/audience totals with section-1 hero; exclude unknown emails from SAP split --- .../skills/event-app-space-report/SKILL.md | 10 +++++ scripts/event-report.mjs | 22 ++++++----- test/scripts/event-report-domains.test.js | 39 +++++++++++++++++-- 3 files changed, 58 insertions(+), 13 deletions(-) diff --git a/.claude/skills/event-app-space-report/SKILL.md b/.claude/skills/event-app-space-report/SKILL.md index 9a21d2de6..add988d10 100644 --- a/.claude/skills/event-app-space-report/SKILL.md +++ b/.claude/skills/event-app-space-report/SKILL.md @@ -77,3 +77,13 @@ difference. this skill too (`cds bind --exec -- node scripts/event-report.mjs `). - Scoping is by **date window**, never `TaskRecords.event_ID` (NULL on normal completions). Track = `CompletionPaths.name`, not Mission title. +- **Sections 3–4 reconcile with the §1 total**: domain and SAP-vs-external + counts use the same all-windowed TUTORIAL completion set as the "Total + completions" headline, not only track-member tutorials. Anonymized/null-email + completers show as `(unknown)` in the domain table and are excluded from the + SAP-vs-external split. +- **Abandonment is an approximation, not an exact count.** Stop-step counts a + user's COMPLETED STEP records at their current `attemptNumber` with no + per-step date filter, so for returning/multi-attempt users (steps completed + before the event, or under a different attempt) the step-drop histogram and + median are estimates. Treat them as directional. diff --git a/scripts/event-report.mjs b/scripts/event-report.mjs index a2040bd14..34e674df2 100644 --- a/scripts/event-report.mjs +++ b/scripts/event-report.mjs @@ -200,24 +200,23 @@ export function renderAbandonmentSection(rows) { /** * Email-domain + SAP-vs-external breakdown of the event's windowed TUTORIAL - * completers. Counts completions (COMPLETED/SUPERSEDED) in window for the - * event's tutorials, joined to Users.email. + * completers. Counts the SAME all-windowed TUTORIAL completion set as section + * 1's hero total (computePathBuckets' totalCompletions) — COMPLETED/SUPERSEDED, + * completionDate in window — NOT restricted to track-member tutorials, so the + * domain/audience totals reconcile with the "Total completions" headline. + * `(unknown)` (anonymized / null-email) completions appear in the domain table + * but are excluded from the SAP-vs-external split. * @param {object} event resolved Events row (needs startDate/endDate) - * @param {Array<{legacyId:number}>} tutorials * @param {object} db * @returns {Promise<{domains:Array<{domain:string,users:number,completions:number,perUser:number}>,sap:{users:number,completions:number},external:{users:number,completions:number}}>} */ -export async function computeDomainBreakdown(event, tutorials, db) { +export async function computeDomainBreakdown(event, db) { const { TaskRecords, Users } = cds.entities(NS); - const legacyIds = tutorials.map((t) => t.legacyId); - if (legacyIds.length === 0) { - return { domains: [], sap: { users: 0, completions: 0 }, external: { users: 0, completions: 0 } }; - } const records = await db.run( SELECT.from(TaskRecords) .columns('user_ID') - .where({ taskType: 'TUTORIAL', status: { in: ['COMPLETED', 'SUPERSEDED'] }, taskLegacyId: { in: legacyIds } }) + .where({ taskType: 'TUTORIAL', status: { in: ['COMPLETED', 'SUPERSEDED'] } }) .and(`completionDate >=`, event.startDate) .and(`completionDate <=`, event.endDate), ); @@ -243,6 +242,9 @@ export async function computeDomainBreakdown(event, tutorials, db) { const d = byDomain.get(domain); d.users.add(r.user_ID); d.completions += 1; + // Unknown/anonymized emails are counted in the domain table but excluded + // from the SAP-vs-external split (neither bucket). + if (domain === '(unknown)') continue; if (domain === 'sap.com') { sapUsers.add(r.user_ID); sapCompletions += 1; @@ -368,7 +370,7 @@ export async function buildReport(eventId, db) { const abandonment = await computeAbandonment(event, tutorials, db); parts.push(renderAbandonmentSection(abandonment)); - const breakdown = await computeDomainBreakdown(event, tutorials, db); + const breakdown = await computeDomainBreakdown(event, db); parts.push(renderDomainSection(breakdown)); parts.push(renderAudienceSection(breakdown)); diff --git a/test/scripts/event-report-domains.test.js b/test/scripts/event-report-domains.test.js index 9040d8eb3..8465e7d55 100644 --- a/test/scripts/event-report-domains.test.js +++ b/test/scripts/event-report-domains.test.js @@ -49,7 +49,7 @@ describe('sections 3-4 — domain + audience breakdown (seeded db)', () => { it('groups completions by email domain with per-user average', async () => { const db = await cds.connect.to('db'); - const b = await computeDomainBreakdown(event, tutorials, db); + const b = await computeDomainBreakdown(event, db); const gmail = b.domains.find((d) => d.domain === 'gmail.com'); expect(gmail.users).toBe(2); expect(gmail.completions).toBe(3); @@ -58,7 +58,7 @@ describe('sections 3-4 — domain + audience breakdown (seeded db)', () => { it('splits SAP employees from external', async () => { const db = await cds.connect.to('db'); - const b = await computeDomainBreakdown(event, tutorials, db); + const b = await computeDomainBreakdown(event, db); expect(b.sap.users).toBe(1); expect(b.sap.completions).toBe(1); expect(b.external.users).toBe(2); @@ -67,7 +67,7 @@ describe('sections 3-4 — domain + audience breakdown (seeded db)', () => { it('renders a gmail row in the domain section', async () => { const db = await cds.connect.to('db'); - const b = await computeDomainBreakdown(event, tutorials, db); + const b = await computeDomainBreakdown(event, db); const md = renderDomainSection(b); expect(md).toContain('gmail.com'); }); @@ -81,4 +81,37 @@ describe('sections 3-4 — domain + audience breakdown (seeded db)', () => { const runProd = () => ({ status: 0, stdout: 'API endpoint: https://api.cf.eu10-005.hana.ondemand.com\norg: o\nspace: prod\n' }); expect(() => assertCfTargetProd(runProd)).not.toThrow(); }); + + it('counts the full windowed completion set, incl. completions not under any track (matches §1 hero)', async () => { + const db = await cds.connect.to('db'); + const { TaskRecords, Users } = cds.entities('com.sap.developers.ims'); + // A completion for a tutorial that is NOT in the event's track set, but IS + // a windowed TUTORIAL completion — §1 hero counts it, so §3-4 must too. + await INSERT.into(Users).entries({ ID: 'c0000000-cccc-0000-0000-00000000000e', email: 'e@zalando.de' }); + await INSERT.into(TaskRecords).entries({ + ID: 'd0000000-cccc-0000-0000-00000000000e', user_ID: 'c0000000-cccc-0000-0000-00000000000e', + taskLegacyId: 99999, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: IN, attemptNumber: 1, + }); + const b = await computeDomainBreakdown(event, db); + // 4 track completions (gmail x3, sap x1) + 1 off-track (zalando) = 5 total. + const sum = b.domains.reduce((s, d) => s + d.completions, 0); + expect(sum).toBe(5); + expect(b.domains.find((d) => d.domain === 'zalando.de')).toBeTruthy(); + }); + + it('excludes unknown/anonymized emails from the SAP-vs-external split', async () => { + const db = await cds.connect.to('db'); + const { TaskRecords, Users } = cds.entities('com.sap.developers.ims'); + await INSERT.into(Users).entries({ ID: 'c0000000-cccc-0000-0000-00000000000f', email: null }); + await INSERT.into(TaskRecords).entries({ + ID: 'd0000000-cccc-0000-0000-00000000000f', user_ID: 'c0000000-cccc-0000-0000-00000000000f', + taskLegacyId: 99999, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: IN, attemptNumber: 1, + }); + const b = await computeDomainBreakdown(event, db); + const totalSplit = b.sap.completions + b.external.completions; + const allCompletions = b.domains.reduce((s, d) => s + d.completions, 0); + // the null-email completion is in the domain table (as unknown) but NOT in the sap/external split + expect(totalSplit).toBeLessThan(allCompletions); + expect(b.sap.users + b.external.users).not.toContain(undefined); + }); }); From d0a2e1691df97fae1116ed9172cc8651cac2ac7d Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 6 Nov 2026 11:28:46 -0500 Subject: [PATCH 6/7] fix(event-report): resolve entities via linked model for standalone cds bind --exec context --- scripts/event-report.mjs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/scripts/event-report.mjs b/scripts/event-report.mjs index 34e674df2..13d28be29 100644 --- a/scripts/event-report.mjs +++ b/scripts/event-report.mjs @@ -5,6 +5,17 @@ import { computePathBuckets, buildTrackMap, resolveEventMissionId } from '../srv const NS = 'com.sap.developers.ims'; const nf = new Intl.NumberFormat('en-US'); +/** + * Resolve the IMS entity set. Under `cds.test`/`cds serve` the global + * `cds.entities` is a function; in a standalone `node` process (cds bind --exec) + * it is not, so fall back to the linked model. Both return the same entities. + * @returns {object} map of entity defs ({ Events, Tutorials, … }) + */ +function modelEntities() { + if (typeof cds.entities === 'function') return cds.entities(NS); + return cds.linked(cds.model).entities(NS); +} + /** * Accept a bare eventId/legacyId or an app-space URL (…?eventId=NNN…). * @param {string} input @@ -52,7 +63,7 @@ export function renderTrackSection(bucketsResult, event) { * @param {object} db cds db service */ export async function resolveEvent(eventLegacyId, db) { - const { Events } = cds.entities(NS); + const { Events } = modelEntities(); const event = await db.run(SELECT.one.from(Events).where({ legacyId: eventLegacyId })); return event ?? null; } @@ -90,7 +101,7 @@ export async function resolveEventTutorials(event, db) { .map((k) => Number(k.slice('TUTORIAL:'.length))); if (legacyIds.length === 0) return []; - const { Tutorials, Steps } = cds.entities(NS); + const { Tutorials, Steps } = modelEntities(); const tuts = await db.run( SELECT.from(Tutorials).columns('ID', 'legacyId', 'title', 'stepCount').where({ legacyId: { in: legacyIds } }), ); @@ -116,7 +127,7 @@ export async function resolveEventTutorials(event, db) { * @returns {Promise}>>} */ export async function computeAbandonment(event, tutorials, db) { - const { TaskRecords, Steps } = cds.entities(NS); + const { TaskRecords, Steps } = modelEntities(); const rows = []; for (const t of tutorials) { const inProgress = await db.run( @@ -211,7 +222,7 @@ export function renderAbandonmentSection(rows) { * @returns {Promise<{domains:Array<{domain:string,users:number,completions:number,perUser:number}>,sap:{users:number,completions:number},external:{users:number,completions:number}}>} */ export async function computeDomainBreakdown(event, db) { - const { TaskRecords, Users } = cds.entities(NS); + const { TaskRecords, Users } = modelEntities(); const records = await db.run( SELECT.from(TaskRecords) From a72f0dbf039a6a60a91fda78d3a4eaa47ad9373f Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 6 Nov 2026 11:47:02 -0500 Subject: [PATCH 7/7] =?UTF-8?q?feat(event-report):=20Berlin-parity=20analy?= =?UTF-8?q?sis=20=E2=80=94=20top-abandonees=20list,=20stop-pattern=20summa?= =?UTF-8?q?ry,=20%-through,=20org-domain=20rollup,=20engagement=20callout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../skills/event-app-space-report/SKILL.md | 15 ++-- scripts/event-report.mjs | 69 +++++++++++++++++-- test/scripts/event-report-abandonment.test.js | 25 ++++++- test/scripts/event-report-domains.test.js | 29 ++++++++ 4 files changed, 125 insertions(+), 13 deletions(-) diff --git a/.claude/skills/event-app-space-report/SKILL.md b/.claude/skills/event-app-space-report/SKILL.md index add988d10..ead03a7f8 100644 --- a/.claude/skills/event-app-space-report/SKILL.md +++ b/.claude/skills/event-app-space-report/SKILL.md @@ -62,12 +62,15 @@ difference. 1. **Tutorial completions by track** — total + per-Track count and share (`CompletionPaths.name`; FK-first tutorial resolution; windowed COMPLETED/ SUPERSEDED completions). -2. **Abandoned tutorials** — IN_PROGRESS tutorials whose record was last touched - (`modifiedAt`) inside the window, with per-tutorial median stop-step and a - per-step drop histogram (stop-step = COMPLETED STEP records at the user's - current attempt vs `Tutorials.stepCount`). -3. **Completer email domains** — distinct completers per domain, completions, - and completions/user; most-engaged domains. +2. **Abandoned tutorials** — a "Top abandonees by count" list, a computed + stop-pattern summary (minimum steps done before stopping; % of abandoners + who'd done 3+ steps), then per-tutorial median stop-step with "% through" + framing and a per-step drop histogram (stop-step = COMPLETED STEP records at + the user's current attempt vs `Tutorials.stepCount`). +3. **Completer email domains** — distinct completers per domain (with known + multi-domain orgs like NTT Data / EY rolled up via `ORG_DOMAIN_MAP`), + completions, completions/user; a most-engaged table + a prose callout naming + the standout high-engagement companies. 4. **SAP employees vs external** — `@sap.com` vs everyone else. ## Notes diff --git a/scripts/event-report.mjs b/scripts/event-report.mjs index 13d28be29..d443a94cd 100644 --- a/scripts/event-report.mjs +++ b/scripts/event-report.mjs @@ -5,6 +5,16 @@ import { computePathBuckets, buildTrackMap, resolveEventMissionId } from '../srv const NS = 'com.sap.developers.ims'; const nf = new Intl.NumberFormat('en-US'); +/** + * Known multi-domain orgs → their email domains. Sub-domains roll up into the + * single org label in the domain breakdown (matches the Berlin report's + * "NTT Data (combined)", "EY (combined)" tiers). Extend as needed. + */ +const ORG_DOMAIN_MAP = { + 'NTT Data': ['nttdata.com', 'global.ntt', 'nttdata-solutions.com', 'emeal.nttdata.com'], + EY: ['ey.com', 'gds.ey.com', 'it.ey.com'], +}; + /** * Resolve the IMS entity set. Under `cds.test`/`cds serve` the global * `cds.entities` is a function; in a standalone `node` process (cds bind --exec) @@ -175,6 +185,8 @@ export async function computeAbandonment(event, tutorials, db) { totalSteps: t.totalSteps, abandonedCount: total, medianStopStep: median(stopSteps), + medianStopPct: t.totalSteps > 0 ? Math.round((median(stopSteps) / t.totalSteps) * 100) : 0, + minStepsDone: Math.min(...stopSteps), perStep, stepTitles: Object.fromEntries(stepRows.map((s) => [s.stepOrder, s.title])), }); @@ -194,10 +206,39 @@ export function renderAbandonmentSection(rows) { lines.push('_No in-progress tutorials in the event window._'); return lines.join('\n'); } + + // Top abandonees by count (rows already sorted desc by abandonedCount). + lines.push('### Top abandonees by count', ''); + for (const r of rows.slice(0, 10)) { + lines.push(`- ${r.title} (${r.abandonedCount} abandoned)`); + } + + // Computed stop-pattern summary: where did people stop relative to the start? + const minAcross = Math.min(...rows.map((r) => (Number.isFinite(r.minStepsDone) ? r.minStepsDone : 0))); + const engagedShare = (() => { + // share of abandoners who had done >= 3 steps before stopping + let deep = 0; + let all = 0; + for (const r of rows) { + for (const p of r.perStep) { + all += p.count; + if (p.step >= 3) deep += p.count; + } + } + return all > 0 ? Math.round((deep / all) * 100) : 0; + })(); + lines.push( + '', + `**Stop pattern:** minimum steps completed before stopping across all abandoned records = ${minAcross}` + + (minAcross >= 1 ? ' — people engaged meaningfully before stopping; no "opened and immediately quit".' : ' — some records stopped at step 0.'), + `${engagedShare}% of abandoners had completed 3+ steps before stopping.`, + '', + ); + for (const r of rows) { lines.push( `### ${r.title} — ${r.abandonedCount} abandoned, ${r.totalSteps} steps`, - `Median stop: step ${r.medianStopStep} of ${r.totalSteps}`, + `Median stop: step ${r.medianStopStep} of ${r.totalSteps} (${r.medianStopPct}% through)`, '', ); for (const p of r.perStep) { @@ -219,10 +260,18 @@ export function renderAbandonmentSection(rows) { * but are excluded from the SAP-vs-external split. * @param {object} event resolved Events row (needs startDate/endDate) * @param {object} db + * @param {{orgMap?: Record}} [opts] org→domains rollup map (defaults to ORG_DOMAIN_MAP) * @returns {Promise<{domains:Array<{domain:string,users:number,completions:number,perUser:number}>,sap:{users:number,completions:number},external:{users:number,completions:number}}>} */ -export async function computeDomainBreakdown(event, db) { +export async function computeDomainBreakdown(event, db, opts = {}) { const { TaskRecords, Users } = modelEntities(); + const orgMap = opts.orgMap ?? ORG_DOMAIN_MAP; + // reverse lookup: raw domain → org label + const orgByDomain = new Map(); + for (const [org, doms] of Object.entries(orgMap)) { + for (const d of doms) orgByDomain.set(d.toLowerCase(), org); + } + const label = (domain) => orgByDomain.get(domain) ?? domain; const records = await db.run( SELECT.from(TaskRecords) @@ -248,15 +297,17 @@ export async function computeDomainBreakdown(event, db) { for (const r of records) { const email = emailById.get(r.user_ID) || ''; - const domain = email.includes('@') ? email.slice(email.indexOf('@') + 1) : '(unknown)'; + const rawDomain = email.includes('@') ? email.slice(email.indexOf('@') + 1) : '(unknown)'; + const domain = rawDomain === '(unknown)' ? '(unknown)' : label(rawDomain); if (!byDomain.has(domain)) byDomain.set(domain, { users: new Set(), completions: 0 }); const d = byDomain.get(domain); d.users.add(r.user_ID); d.completions += 1; // Unknown/anonymized emails are counted in the domain table but excluded - // from the SAP-vs-external split (neither bucket). - if (domain === '(unknown)') continue; - if (domain === 'sap.com') { + // from the SAP-vs-external split (neither bucket). SAP check uses the raw + // domain (org rollup never maps to sap.com). + if (rawDomain === '(unknown)') continue; + if (rawDomain === 'sap.com') { sapUsers.add(r.user_ID); sapCompletions += 1; } else { @@ -299,6 +350,12 @@ export function renderDomainSection(breakdown) { lines.push('', '### Most engaged by completions/user', '', '| Domain | Completions/user |', '| --- | --- |'); const engaged = [...domains].filter((d) => d.users >= 3).sort((a, b) => b.perUser - a.perUser).slice(0, 10); for (const d of engaged) lines.push(`| ${d.domain} | ${d.perUser} |`); + // Prose callout naming the standout high-engagement companies. + const standout = engaged.filter((d) => d.perUser >= 4).slice(0, 6); + if (standout.length > 0) { + const names = standout.map((d) => `${d.domain} (${d.users} users, ${d.completions} completions = ${d.perUser}/user)`); + lines.push('', `**Notable — highly engaged companies:** ${names.join('; ')}.`); + } lines.push('', `_Total completions across domains: ${nf.format(totalCompletions)}._`); return lines.join('\n'); } diff --git a/test/scripts/event-report-abandonment.test.js b/test/scripts/event-report-abandonment.test.js index 8a8149ed4..56ffc7d18 100644 --- a/test/scripts/event-report-abandonment.test.js +++ b/test/scripts/event-report-abandonment.test.js @@ -1,6 +1,6 @@ import { describe, it, expect, beforeAll } from 'vitest'; import cds from '@sap/cds'; -import { resolveEventTutorials, computeAbandonment } from '../../scripts/event-report.mjs'; +import { resolveEventTutorials, computeAbandonment, renderAbandonmentSection } from '../../scripts/event-report.mjs'; cds.test('serve', '--project', '.', '--in-memory'); @@ -96,4 +96,27 @@ describe('section 2 — abandonment + step-drop (seeded db)', () => { const nos = rows.find((r) => r.tutorialLegacyId === 80002) ?? { medianStopStep: 0, abandonedCount: 0 }; expect(Number.isNaN(nos.medianStopStep)).toBe(false); }); + + it('carries median % through on each row', async () => { + const db = await cds.connect.to('db'); + const tuts = await resolveEventTutorials(event, db); + const rows = await computeAbandonment(event, tuts, db); + const tutRow = rows.find((r) => r.tutorialLegacyId === 80001); + // median 1 of 9 steps → 11% + expect(tutRow.medianStopPct).toBe(11); + }); + + it('renders a Top abandonees by count list and a computed stop-pattern summary', async () => { + const db = await cds.connect.to('db'); + const tuts = await resolveEventTutorials(event, db); + const rows = await computeAbandonment(event, tuts, db); + const md = renderAbandonmentSection(rows); + expect(md).toMatch(/Top abandonees by count/i); + // The 9-step tutorial has 3 abandonees — the top list names it with its count. + expect(md).toMatch(/Abandoned Tutorial.*3/); + // Stop-pattern summary line reports the minimum steps-done across records + // (here 1,1,5 → min 1), i.e. people engaged before stopping. + expect(md).toMatch(/stop pattern/i); + expect(md).toMatch(/% through/i); + }); }); diff --git a/test/scripts/event-report-domains.test.js b/test/scripts/event-report-domains.test.js index 8465e7d55..1bced15ec 100644 --- a/test/scripts/event-report-domains.test.js +++ b/test/scripts/event-report-domains.test.js @@ -114,4 +114,33 @@ describe('sections 3-4 — domain + audience breakdown (seeded db)', () => { expect(totalSplit).toBeLessThan(allCompletions); expect(b.sap.users + b.external.users).not.toContain(undefined); }); + + it('rolls multiple sub-domains of one org into a single labeled row', async () => { + const db = await cds.connect.to('db'); + const { TaskRecords, Users } = cds.entities('com.sap.developers.ims'); + await INSERT.into(Users).entries([ + { ID: 'c0000000-cccc-0000-0000-0000000000e1', email: 'x@gds.ey.com' }, + { ID: 'c0000000-cccc-0000-0000-0000000000e2', email: 'y@it.ey.com' }, + ]); + await INSERT.into(TaskRecords).entries([ + { ID: 'd0000000-cccc-0000-0000-0000000000e1', user_ID: 'c0000000-cccc-0000-0000-0000000000e1', taskLegacyId: 99999, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: IN, attemptNumber: 1 }, + { ID: 'd0000000-cccc-0000-0000-0000000000e2', user_ID: 'c0000000-cccc-0000-0000-0000000000e2', taskLegacyId: 99999, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: IN, attemptNumber: 1 }, + ]); + const orgMap = { EY: ['gds.ey.com', 'it.ey.com'] }; + const b = await computeDomainBreakdown(event, db, { orgMap }); + const ey = b.domains.find((d) => d.domain === 'EY'); + expect(ey).toBeTruthy(); + expect(ey.users).toBe(2); + expect(ey.completions).toBe(2); + // the raw sub-domains must NOT also appear as separate rows + expect(b.domains.find((d) => d.domain === 'gds.ey.com')).toBeFalsy(); + expect(b.domains.find((d) => d.domain === 'it.ey.com')).toBeFalsy(); + }); + + it('renders a per-user engagement callout for the most engaged domains', async () => { + const db = await cds.connect.to('db'); + const b = await computeDomainBreakdown(event, db); + const md = renderDomainSection(b); + expect(md).toMatch(/engag/i); // "Notable: highly engaged" callout prose + }); });