From cbee3debf9612f8ffd49b176d20a8f4170a40a07 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 26 Oct 2026 20:42:02 +0100 Subject: [PATCH 1/3] fix(display): count tutorial completions per track + decouple hero total MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions from the initial track-grouping change, both reported live: 1. Per-track bars counted DISTINCT USERS, not completions. computePathBuckets deduped by (user, track), so 5 completions by one attendee read as 1. → count every TUTORIAL completion in a track (no user dedup). 2. The hero "total activity" counter summed bucket counts. Once buckets became track-scoped, the hero collapsed from all window completions (~62) to only track-member completions (3) — and double-counted tutorials shared across tracks. → computePathBuckets now returns { buckets, totalCompletions }; totalCompletions = ALL window TUTORIAL completions, track-independent. Frontend (event-stream.ts): totalCount is seeded from totalCompletions on fetch (falls back to bucket sum for older servers) and bumped by a new per-completion `completionTick` socket event — NOT by summing buckets. Track bars still driven by `tutorialCompleted` (one per track). A completion outside every track ticks the hero but adds no bar; a multi-track tutorial ticks the hero once (one tick per active event) while lighting each of its track bars. Backend socket emit (developer-service.js) now fires `completionTick` once per active event for every completion, plus `tutorialCompleted` per matching track. New `completionTick` event declared on both EventStreamService + DisplayService. Checkpoints DEFERRED: probe of event 10000002 shows 0 window checkpoint completions and all 7 checkpoint path items carry null taskLegacyId (no join key) — counting them would be inert. The two prize-only Joule tracks reading 0 is dead-slot path data (11 null-legacyId tutorial items in the mission), a content issue tracked separately, not a display bug. Verified against PROD event 10000002: totalCompletions=62, ABAP=3 (was 2 under user-dedup), Joule Studio=1. --- app/display-app/src/event-stream.ts | 28 +++++++++++---- srv/developer-service.js | 20 +++++++++-- srv/display-service.cds | 6 ++++ srv/display-service.js | 10 +++--- srv/event-stream-service.cds | 15 +++++--- srv/event-stream-service.js | 12 +++---- srv/lib/event-path-buckets.js | 51 ++++++++++++++++----------- test/lib/event-path-buckets.test.js | 38 ++++++++++++++++---- test/lib/event-stream-service.test.js | 3 ++ 9 files changed, 134 insertions(+), 49 deletions(-) diff --git a/app/display-app/src/event-stream.ts b/app/display-app/src/event-stream.ts index 19019ba5d..0256e86d9 100644 --- a/app/display-app/src/event-stream.ts +++ b/app/display-app/src/event-stream.ts @@ -39,8 +39,13 @@ export function useEventStream() { buckets.value.sort((a, b) => b.count !== a.count ? b.count - a.count : a.name.localeCompare(b.name)) } - function recalcTotal() { - totalCount.value = buckets.value.reduce((sum, b) => sum + b.count, 0) + // Hero "total activity" = ALL window tutorial completions, independent of the + // per-track buckets. Seeded from getEventBuckets.totalCompletions on fetch, + // then bumped by the per-completion `completionTick` socket event. (Previously + // summed bucket counts, which — once buckets became track-scoped — undercounted + // to only track-member completions and double-counted multi-track tutorials.) + function setTotalFromFetch(total: number) { + totalCount.value = total } function removeOutdatedTimestamps() { @@ -99,7 +104,6 @@ export function useEventStream() { bucketTimestamps.set(bucketName, ts) sortBuckets() - recalcTotal() setTimeout(() => { const b = buckets.value.find(b => b.name === bucketName) if (b) b.justUpdated = false @@ -121,7 +125,7 @@ export function useEventStream() { } if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`) const json = await res.json() - // getEventBuckets returns { eventName, eventType, hasLogo, buckets:[...] } (#2133); + // getEventBuckets returns { eventName, eventType, hasLogo, totalCompletions, buckets:[...] }; // fall back to legacy array shapes for safety. const data: Array<{ bucketName: string; count: number }> = json.buckets ?? json.value ?? (Array.isArray(json) ? json : []) @@ -129,7 +133,13 @@ export function useEventStream() { // above). Don't error out — proceed to the socket so late completions render (#2294). buckets.value = data.map(b => ({ name: b.bucketName, count: b.count, justUpdated: false })) sortBuckets() - recalcTotal() + // Hero total = ALL window completions (track-independent). Older servers that + // don't send totalCompletions fall back to the bucket sum. + setTotalFromFetch( + typeof json.totalCompletions === 'number' + ? json.totalCompletions + : buckets.value.reduce((sum, b) => sum + b.count, 0) + ) } catch (e) { connectionState.value = 'error' errorMessage.value = `Failed to fetch from ${url}: ${(e as Error).message}` @@ -166,6 +176,11 @@ export function useEventStream() { if (data.bucketName) applyUpdate(data.bucketName) }) + // Hero "total activity" — one tick per completion (track or not). + socket.on('completionTick', () => { + totalCount.value++ + }) + startCron() } @@ -177,10 +192,11 @@ export function useEventStream() { justUpdated: false, })) sortBuckets() - recalcTotal() + totalCount.value = buckets.value.reduce((sum, b) => sum + b.count, 0) demoInterval = setInterval(() => { const tag = DEMO_TAGS[Math.floor(Math.random() * DEMO_TAGS.length)] applyUpdate(tag) + totalCount.value++ }, 800 + Math.random() * 1200) startCron() } diff --git a/srv/developer-service.js b/srv/developer-service.js index 11ebc036c..b4d242ce5 100644 --- a/srv/developer-service.js +++ b/srv/developer-service.js @@ -804,13 +804,19 @@ export default class DeveloperService extends cds.ApplicationService { const { trackNamesForTutorial } = await import('./lib/event-path-buckets.js'); const db = await cds.connect.to('db'); - // Build [{ context, bucketName }] — one per (event, track) the completion lands in. + // Two distinct display signals: + // • completionTick — ONE per active event, fired for EVERY tutorial + // completion regardless of track membership. Drives the hero "total + // activity" counter (matches getEventBuckets.totalCompletions). + // • tutorialCompleted — one per (event, TRACK) the completion lands in. + // Drives the per-track bars. A completion outside every track fires + // none of these (no bar), but still ticks the hero above. + const tickContexts = events.map(ev => String(ev.legacyId)); const targets = []; for (const ev of events) { const names = await trackNamesForTutorial(ev, result.taskLegacyId, db); for (const name of names) targets.push({ context: String(ev.legacyId), bucketName: name }); } - if (!targets.length) return; const tutorial = await SELECT.one.from(dbTutorials).where({ legacyId: result.taskLegacyId }); const user = await SELECT.one.from(dbUsers).where({ ID: result.user_ID }); @@ -836,6 +842,16 @@ export default class DeveloperService extends cds.ApplicationService { const broadcast = async () => { const eventStream = await cds.connect.to('EventStreamService'); const display = await cds.connect.to('DisplayService'); + // Hero tick — one per active event, always. + if (tickContexts.length) { + await eventStream.tx({ user: privileged }, tx => + tx.emit('completionTick', { tutorialTitle }, { contexts: tickContexts }) + ); + await display.tx({ user: privileged }, tx => + tx.emit('completionTick', { tutorialTitle }, { contexts: tickContexts }) + ); + } + // Per-track bars. for (const t of targets) { const payload = { bucketName: t.bucketName, completeDate, tutorialTitle }; await eventStream.tx({ user: privileged }, tx => diff --git a/srv/display-service.cds b/srv/display-service.cds index 87ddda450..d3983c122 100644 --- a/srv/display-service.cds +++ b/srv/display-service.cds @@ -46,4 +46,10 @@ service DisplayService { tutorialTitle : String; userName : String; } + + // Fired once per active event for EVERY tutorial completion (track or not) — + // drives the hero "total activity" counter, independent of the per-track bars. + event completionTick { + tutorialTitle : String; + } } diff --git a/srv/display-service.js b/srv/display-service.js index 7fcf5cda3..660f846e3 100644 --- a/srv/display-service.js +++ b/srv/display-service.js @@ -26,13 +26,13 @@ export default class DisplayService extends cds.ApplicationService { const event = await resolveEvent(eventLegacyId, req); if (!event) return; - // Per-track ("CompletionPath") completion counts, grouped the SAME way as - // the app-space page (event → mission → paths), window-scoped. Shared with - // the anonymous EventStreamService.getEventBuckets via lib/event-path-buckets.js. - // Replaces the old event_ID join (NULL on all normal completions → always []). + // Per-track completion counts (same grouping as app-space + the anonymous + // EventStreamService). computePathBuckets returns { buckets, totalCompletions }; + // this admin function's contract is the bucket array, so return buckets only. return cached(`buckets:${eventLegacyId}`, CACHE_TTL, async () => { const db = await cds.connect.to('db'); - return computePathBuckets(event, db); + const { buckets } = await computePathBuckets(event, db); + return buckets; }); }); diff --git a/srv/event-stream-service.cds b/srv/event-stream-service.cds index 6e1860c87..c6a39fba4 100644 --- a/srv/event-stream-service.cds +++ b/srv/event-stream-service.cds @@ -8,15 +8,22 @@ service EventStreamService { tutorialTitle : String; } + // Fired once per active event for EVERY tutorial completion (track or not) — + // drives the hero "total activity" counter, independent of the per-track bars. + event completionTick { + tutorialTitle : String; + } + // Returns the event's display metadata alongside the completion buckets so the // event-display page can render the real event name + logo instead of a // theme-hardcoded label (#2133). `hasLogo` gates the anonymous // GET /api/event-logo?eventLegacyId=N fetch on the client. function getEventBuckets(eventLegacyId : Integer) returns { - eventName : String; - eventType : String; - hasLogo : Boolean; - buckets : many { + eventName : String; + eventType : String; + hasLogo : Boolean; + totalCompletions : Integer; // ALL window tutorial completions (hero total), track-independent + buckets : many { bucketName : String; count : Integer; percentage : Decimal; diff --git a/srv/event-stream-service.js b/srv/event-stream-service.js index e36dd1949..afa3c21da 100644 --- a/srv/event-stream-service.js +++ b/srv/event-stream-service.js @@ -15,12 +15,11 @@ export default class EventStreamService extends cds.ApplicationService { const event = await SELECT.one.from(Events).where({ legacyId: eventLegacyId }); if (!event) return req.reject(404, `Event with legacy ID ${eventLegacyId} not found`); - // Buckets = per-track ("CompletionPath") completion counts, grouped the - // SAME way the app-space page groups them: event → mission → paths, with - // completions matched to a path via CompletionPathItems (+ GroupPathItems - // for GROUP items). Window-scoped to [startDate,endDate]. See - // lib/event-path-buckets.js. Returns [] when there's no window/mission/path. - const buckets = await cached(`es-buckets:${eventLegacyId}`, CACHE_TTL, async () => { + // Per-track ("CompletionPath") completion counts, grouped the SAME way as + // the app-space page (event → mission → paths), window-scoped. totalCompletions + // = ALL window tutorial completions (the hero "total activity"), independent + // of track membership. Shared with the authed DisplayService via lib/event-path-buckets.js. + const { buckets, totalCompletions } = await cached(`es-buckets:${eventLegacyId}`, CACHE_TTL, async () => { const db = await cds.connect.to('db'); return computePathBuckets(event, db); }); @@ -29,6 +28,7 @@ export default class EventStreamService extends cds.ApplicationService { eventName: event.name || '', eventType: event.eventType ?? 'OTHER', hasLogo: Boolean(event.hasLogo), + totalCompletions, buckets, }; }); diff --git a/srv/lib/event-path-buckets.js b/srv/lib/event-path-buckets.js index 6d2e3b5e6..9d3947dae 100644 --- a/srv/lib/event-path-buckets.js +++ b/srv/lib/event-path-buckets.js @@ -107,55 +107,66 @@ export async function trackNamesForTutorial(event, tutorialLegacyId, db) { * + GroupPathItems for GROUP items (child tutorial → group → path) * * Then window-scoped TUTORIAL completions ([startDate,endDate]) are mapped to - * their path(s) and counted. A completion can belong to more than one path - * (a tutorial reused across tracks) — it is counted once per path it belongs - * to, matching how app-space shows the tutorial in each track. + * their track(s) and counted. Every completion counts (NOT distinct users) — + * a track with 5 tutorial completions reads 5, even by one person. A completion + * can belong to more than one track (a tutorial reused across tracks) and is + * counted once per track, matching how app-space shows it in each. * - * Returns [{ bucketName: path.name, count, percentage }] sorted desc by count. - * Empty array when the event has no window, no mission, or no paths. + * NOTE: only TUTORIAL completions are counted. CHECKPOINT path items in the + * current data carry null taskLegacyId (no join key) and there are no checkpoint + * completions to credit, so checkpoint counting is deferred (would be inert). + * + * Returns { buckets, totalCompletions }: + * buckets [{ bucketName: track.name, count, percentage }] desc by count + * totalCompletions ALL window TUTORIAL completions, regardless of track + * membership — the display hero number ("total activity"). + * buckets is [] when the event has no window/mission/paths; totalCompletions is + * still returned (0 when no window). * * @param {object} event resolved Events row (needs ID, startDate, endDate, mission_ID) * @param {object} db cds db service */ export async function computePathBuckets(event, db) { - if (!event?.startDate || !event?.endDate) return []; + if (!event?.startDate || !event?.endDate) return { buckets: [], totalCompletions: 0 }; const { TaskRecords } = cds.entities(NS); - // 1. Resolve the event's mission + the (taskType:taskLegacyId) → track-name - // map — SAME resolution the app-space page uses (shared helpers). - const missionId = await resolveEventMissionId(event, db); - if (!missionId) return []; - const { pathNameByToken } = await buildTrackMap(missionId, db); - if (pathNameByToken.size === 0) return []; - - // 2. Window-scoped TUTORIAL completions → count distinct users per track. + // Window-scoped TUTORIAL completions — the hero "total activity" counts ALL + // of these, regardless of whether the tutorial belongs to an event track. const records = await db.run( - SELECT.from(TaskRecords).columns('user_ID', 'taskLegacyId') + SELECT.from(TaskRecords).columns('taskLegacyId') .where({ taskType: 'TUTORIAL', status: { in: ['COMPLETED', 'SUPERSEDED'] } }) .and(`completionDate >=`, event.startDate) .and(`completionDate <=`, event.endDate) ); + const totalCompletions = records.length; - const seen = new Set(); // dedupe (user, track-name) + // Resolve the event's mission + the (taskType:taskLegacyId) → track-name map — + // SAME resolution the app-space page uses (shared helpers). + const missionId = await resolveEventMissionId(event, db); + if (!missionId) return { buckets: [], totalCompletions }; + const { pathNameByToken } = await buildTrackMap(missionId, db); + if (pathNameByToken.size === 0) return { buckets: [], totalCompletions }; + + // Per-track bars: count every completion (NOT distinct users) whose tutorial + // is a member of the track. One completion in N tracks counts once per track. const countByName = new Map(); for (const r of records) { const names = pathNameByToken.get(`TUTORIAL:${r.taskLegacyId}`); if (!names) continue; for (const name of names) { - const key = `${r.user_ID}|${name}`; - if (seen.has(key)) continue; - seen.add(key); countByName.set(name, (countByName.get(name) || 0) + 1); } } const total = [...countByName.values()].reduce((s, n) => s + n, 0); - return [...countByName.entries()] + const buckets = [...countByName.entries()] .map(([bucketName, count]) => ({ bucketName, count, percentage: total > 0 ? Math.round((count / total) * 10000) / 100 : 0, })) .sort((a, b) => b.count - a.count); + + return { buckets, totalCompletions }; } diff --git a/test/lib/event-path-buckets.test.js b/test/lib/event-path-buckets.test.js index 873992c90..a44f6fb08 100644 --- a/test/lib/event-path-buckets.test.js +++ b/test/lib/event-path-buckets.test.js @@ -65,25 +65,51 @@ describe('computePathBuckets', () => { ); }); - it('groups completions by CompletionPath track, counting distinct users per path, with GROUP expansion', async () => { + it('counts tutorial completions per track (not distinct users), with GROUP expansion', async () => { const event = await SELECT.one.from(cds.entities('com.sap.developers.ims').Events).where({ legacyId: 900001 }); - const buckets = await computePathBuckets(event, db); + const { buckets, totalCompletions } = await computePathBuckets(event, db); const byName = Object.fromEntries(buckets.map(b => [b.bucketName, b.count])); - // Alpha: u1 (901 or 902) + u2 (901) = 2 distinct users - expect(byName['Track Alpha']).toBe(2); + // Alpha: 3 completions in-window (u1/901, u1/902, u2/901) — counts completions, + // NOT distinct users (would be 2). Proves the user-dedup bug is gone. + expect(byName['Track Alpha']).toBe(3); // Beta: u1 via GROUP child 903 = 1 (proves GroupPathItems expansion works) expect(byName['Track Beta']).toBe(1); // tutorial 999 (not a path item) contributes to no track expect(buckets.length).toBe(2); // sorted desc by count expect(buckets[0].bucketName).toBe('Track Alpha'); + // Hero total = ALL in-window TUTORIAL completions regardless of track: + // tr-1,2,3 (Alpha) + tr-4 (Beta) + tr-6 (999, no track) = 5. tr-5 is out-of-window. + expect(totalCompletions).toBe(5); }); - it('returns empty when the event has no date window', async () => { + it('returns empty buckets but still counts totalCompletions when there are no tracks', async () => { + // event with a window + in-window completions but no mission/paths. + // Use a far-future window disjoint from the other fixtures (shared in-memory + // DB) so totalCompletions sees ONLY this test's two completions. + const { Events, Users, TaskRecords } = cds.entities('com.sap.developers.ims'); + const base = Date.UTC(2099, 0, 1); + await INSERT.into(Events).entries({ + ID: 'event-pb-notrack', legacyId: 900098, name: 'No Tracks', + startDate: new Date(base).toISOString(), endDate: new Date(base + 2 * 86400_000).toISOString(), + }); + await INSERT.into(Users).entries({ ID: 'u-nt', legacyId: 9810 }); + const at = new Date(base + 86400_000).toISOString(); + await INSERT.into(TaskRecords).entries( + { ID: 'tr-nt1', user_ID: 'u-nt', taskLegacyId: 7001, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: at }, + { ID: 'tr-nt2', user_ID: 'u-nt', taskLegacyId: 7002, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: at }, + ); + const event = await SELECT.one.from(Events).where({ legacyId: 900098 }); + const { buckets, totalCompletions } = await computePathBuckets(event, db); + expect(buckets).toEqual([]); + expect(totalCompletions).toBe(2); + }); + + it('returns empty buckets and zero total when the event has no date window', async () => { const { Events } = cds.entities('com.sap.developers.ims'); await INSERT.into(Events).entries({ ID: 'event-pb-nowin', legacyId: 900099, name: 'No Window' }); const event = await SELECT.one.from(Events).where({ legacyId: 900099 }); - expect(await computePathBuckets(event, db)).toEqual([]); + expect(await computePathBuckets(event, db)).toEqual({ buckets: [], totalCompletions: 0 }); }); }); diff --git a/test/lib/event-stream-service.test.js b/test/lib/event-stream-service.test.js index 4624083eb..d3f0ffa85 100644 --- a/test/lib/event-stream-service.test.js +++ b/test/lib/event-stream-service.test.js @@ -69,6 +69,9 @@ describe('EventStreamService', () => { expect(byName['Alpha']).toBe(2); expect(byName['Beta']).toBe(1); expect(body.buckets.length).toBe(2); + // Hero total = ALL in-window TUTORIAL completions (7701×2 + 7702×1 + 7799×1 = 4), + // including 7799 which belongs to no track. 7702-out-of-window is excluded. + expect(body.totalCompletions).toBe(4); await DELETE.from(TaskRecords).where({ user_ID: { in: [u1, u2] } }); await DELETE.from(Users).where({ ID: { in: [u1, u2] } }); From 3b2ab68406483c3e9b3bf6df41a4a807cdbdf731 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 26 Oct 2026 20:53:43 +0100 Subject: [PATCH 2/3] fix(display): resolve track path items by tutorial_ID FK, not just taskLegacyId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildTrackMap matched completions to tracks using only CompletionPathItems. taskLegacyId. But manually-authored event content (#1866) populates the item's tutorial_ID FK (UUID) and leaves taskLegacyId NULL — the normal shape for the TechEd/DSAG event missions, not a data defect. Those FK-only items fell through entirely, so whole tracks with real tutorials AND real completions ("Joule Studio with N8N", "Keynote Demo", "Agent Extension", "Command Line", "Frontend") counted 0. Resolve FK-first, mirroring path-item-task-resolver.buildTaskMap: - direct TUTORIAL items: prefer tutorial_ID → Tutorials.legacyId, else taskLegacyId - GROUP children (GroupPathItems): resolve tutorial_ID → legacyId too - CHECKPOINT/PETOBERFEST unchanged (no FK column) All collected tutorial_ID UUIDs resolved to legacyId in one batch, then the map is keyed by legacyId (what TaskRecords.taskLegacyId carries). Verified against PROD event 10000002: previously-zero tracks now count — "Joule Studio with N8N"=1, "Joule Studio Keynote Demo"=1 (the two tracks the reporter completed), alongside "Joule Studio"=1, "ABAP"=3; totalCompletions=66. New unit test: Track Gamma fed by an FK-only path item (tutorial_ID set, taskLegacyId NULL) + a completion carrying the resolved legacyId → counts 1. --- srv/lib/event-path-buckets.js | 50 +++++++++++++++++++++++++---- test/lib/event-path-buckets.test.js | 28 +++++++++++----- 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/srv/lib/event-path-buckets.js b/srv/lib/event-path-buckets.js index 9d3947dae..3f71582b1 100644 --- a/srv/lib/event-path-buckets.js +++ b/srv/lib/event-path-buckets.js @@ -27,10 +27,19 @@ export async function resolveEventMissionId(event, db) { * display bucket aggregation (computePathBuckets) and the realtime socket emit * so both label completions with the SAME track names the app-space page uses. * + * #1866: manually-authored event content populates a path/group item's + * `tutorial_ID` FK (UUID) and leaves `taskLegacyId` NULL — this is the normal + * shape for the TechEd/DSAG event missions, NOT a data defect. TaskRecords key + * completions by `taskLegacyId`, so every such item must be resolved FK-first + * (tutorial_ID → Tutorials.legacyId) before it can be matched to a completion. + * The earlier legacyId-only read missed all FK-only items, so whole tracks + * (e.g. "Joule Studio with N8N", "Frontend") counted 0 despite real tutorials + * and real activity. This mirrors path-item-task-resolver.buildTaskMap. + * * Returns { pathNameByToken: Map>, pathNameById: Map }. */ export async function buildTrackMap(missionId, db) { - const { CompletionPaths, CompletionPathItems, GroupPathItems } = cds.entities(NS); + const { Tutorials, CompletionPaths, CompletionPathItems, GroupPathItems } = cds.entities(NS); const pathNameByToken = new Map(); const paths = await db.run( @@ -41,7 +50,7 @@ export async function buildTrackMap(missionId, db) { const pathIds = paths.map(p => p.ID); const items = await db.run( - SELECT.from(CompletionPathItems).columns('path_ID', 'taskType', 'taskLegacyId', 'group_ID') + SELECT.from(CompletionPathItems).columns('path_ID', 'taskType', 'taskLegacyId', 'tutorial_ID', 'group_ID') .where({ path_ID: { in: pathIds } }) ); @@ -52,27 +61,56 @@ export async function buildTrackMap(missionId, db) { pathNameByToken.get(tok).add(name); }; - const groupToPaths = new Map(); // group_ID → Set(path_ID) + // Collect tutorial_ID UUIDs that need legacyId resolution (direct items +, + // after the GROUP query below, group children). Map each (tutorial_ID → path). + const tutUuidToPaths = new Map(); // tutorial_ID → Set(path_ID) + const groupToPaths = new Map(); // group_ID → Set(path_ID) + const addUuidPath = (uuid, pid) => { + if (!tutUuidToPaths.has(uuid)) tutUuidToPaths.set(uuid, new Set()); + tutUuidToPaths.get(uuid).add(pid); + }; + for (const it of items) { if (!it.path_ID) continue; if (it.taskType === 'GROUP' && it.group_ID) { if (!groupToPaths.has(it.group_ID)) groupToPaths.set(it.group_ID, new Set()); groupToPaths.get(it.group_ID).add(it.path_ID); + } else if (it.taskType === 'TUTORIAL') { + // FK-first (#1866): prefer tutorial_ID; else the item's own taskLegacyId. + if (it.tutorial_ID) addUuidPath(it.tutorial_ID, it.path_ID); + else if (it.taskLegacyId != null) addToken(`TUTORIAL:${it.taskLegacyId}`, it.path_ID); } else if (it.taskLegacyId != null) { + // CHECKPOINT / PETOBERFEST carry no FK — legacyId only (unchanged). addToken(`${it.taskType}:${it.taskLegacyId}`, it.path_ID); } } + // Expand GROUP items to their child tutorials (GroupPathItems), FK-first too. const groupIds = [...groupToPaths.keys()]; if (groupIds.length) { const gpi = await db.run( - SELECT.from(GroupPathItems).columns('group_ID', 'tutorial.legacyId as tutLegacyId') + SELECT.from(GroupPathItems).columns('group_ID', 'tutorial_ID') .where({ group_ID: { in: groupIds } }) ); for (const row of gpi) { - if (row.tutLegacyId == null) continue; + if (!row.tutorial_ID) continue; for (const pid of groupToPaths.get(row.group_ID) ?? []) { - addToken(`TUTORIAL:${row.tutLegacyId}`, pid); + addUuidPath(row.tutorial_ID, pid); + } + } + } + + // Resolve all collected tutorial_ID UUIDs → legacyId in one batch, then key + // the map by legacyId (what TaskRecords.taskLegacyId carries). + const uuids = [...tutUuidToPaths.keys()]; + if (uuids.length) { + const tuts = await db.run( + SELECT.from(Tutorials).columns('ID', 'legacyId').where({ ID: { in: uuids } }) + ); + for (const t of tuts) { + if (t.legacyId == null) continue; + for (const pid of tutUuidToPaths.get(t.ID) ?? []) { + addToken(`TUTORIAL:${t.legacyId}`, pid); } } } diff --git a/test/lib/event-path-buckets.test.js b/test/lib/event-path-buckets.test.js index a44f6fb08..c050f8eff 100644 --- a/test/lib/event-path-buckets.test.js +++ b/test/lib/event-path-buckets.test.js @@ -26,21 +26,26 @@ describe('computePathBuckets', () => { await INSERT.into(CompletionPaths).entries( { ID: 'path-A', legacyId: 900010, name: 'Track Alpha', mission_ID: misID }, { ID: 'path-B', legacyId: 900011, name: 'Track Beta', mission_ID: misID }, + { ID: 'path-C', legacyId: 900012, name: 'Track Gamma', mission_ID: misID }, ); await INSERT.into(Tutorials).entries( { ID: 'tut-1', legacyId: 901, title: 'T1', status: 'ACTIVE' }, { ID: 'tut-2', legacyId: 902, title: 'T2', status: 'ACTIVE' }, { ID: 'tut-3', legacyId: 903, title: 'T3', status: 'ACTIVE' }, // reached via GROUP on Track Beta + { ID: 'tut-4', legacyId: 904, title: 'T4', status: 'ACTIVE' }, // FK-only item on Track Gamma (#1866) ); await INSERT.into(Groups).entries({ ID: 'grp-1', legacyId: 950, title: 'G1' }); - // Track Alpha: direct TUTORIAL items 901, 902 - // Track Beta: a GROUP item (grp-1) that contains tutorial 903 + // Track Alpha: direct TUTORIAL items 901, 902 (legacyId-keyed) + // Track Beta: a GROUP item (grp-1) that contains tutorial 903 + // Track Gamma: an FK-ONLY direct item — tutorial_ID set, taskLegacyId NULL + // (the #1866 manually-authored event shape that broke before) await INSERT.into(CompletionPathItems).entries( { ID: 'cpi-1', legacyId: 9601, path_ID: 'path-A', taskType: 'TUTORIAL', taskLegacyId: 901, itemOrder: 1 }, { ID: 'cpi-2', legacyId: 9602, path_ID: 'path-A', taskType: 'TUTORIAL', taskLegacyId: 902, itemOrder: 2 }, { ID: 'cpi-3', legacyId: 9603, path_ID: 'path-B', taskType: 'GROUP', taskLegacyId: 950, group_ID: 'grp-1', itemOrder: 1 }, + { ID: 'cpi-4', legacyId: 9604, path_ID: 'path-C', taskType: 'TUTORIAL', tutorial_ID: 'tut-4', itemOrder: 1 }, ); await INSERT.into(GroupPathItems).entries( { ID: 'gpi-1', legacyId: 9701, group_ID: 'grp-1', tutorial_ID: 'tut-3', itemOrder: 1 }, @@ -52,12 +57,15 @@ describe('computePathBuckets', () => { const mid = new Date(now).toISOString(); const before = new Date(now - 10 * 86400_000).toISOString(); // outside window await INSERT.into(TaskRecords).entries( - // Alpha: u1 did 901 + 902 (2 distinct), u2 did 901 → Alpha count = 2 users (u1,u2) + // Alpha: u1 did 901 + 902, u2 did 901 → 3 completions { ID: 'tr-1', user_ID: 'u-1', taskLegacyId: 901, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: mid }, { ID: 'tr-2', user_ID: 'u-1', taskLegacyId: 902, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: mid }, { ID: 'tr-3', user_ID: 'u-2', taskLegacyId: 901, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: mid }, // Beta: u1 did 903 (the GROUP child) → Beta count = 1 { ID: 'tr-4', user_ID: 'u-1', taskLegacyId: 903, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: mid }, + // Gamma: u2 did 904 — the TaskRecord carries legacyId 904; the path item + // only had tutorial_ID. Proves FK-first resolution (#1866) → Gamma = 1. + { ID: 'tr-g', user_ID: 'u-2', taskLegacyId: 904, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: mid }, // Out of window — excluded { ID: 'tr-5', user_ID: 'u-2', taskLegacyId: 902, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: before }, // Not a path tutorial — excluded from any track @@ -65,7 +73,7 @@ describe('computePathBuckets', () => { ); }); - it('counts tutorial completions per track (not distinct users), with GROUP expansion', async () => { + it('counts tutorial completions per track (not distinct users), resolving FK-only items and GROUP children', async () => { const event = await SELECT.one.from(cds.entities('com.sap.developers.ims').Events).where({ legacyId: 900001 }); const { buckets, totalCompletions } = await computePathBuckets(event, db); const byName = Object.fromEntries(buckets.map(b => [b.bucketName, b.count])); @@ -73,15 +81,19 @@ describe('computePathBuckets', () => { // Alpha: 3 completions in-window (u1/901, u1/902, u2/901) — counts completions, // NOT distinct users (would be 2). Proves the user-dedup bug is gone. expect(byName['Track Alpha']).toBe(3); - // Beta: u1 via GROUP child 903 = 1 (proves GroupPathItems expansion works) + // Beta: u1 via GROUP child 903 = 1 (GroupPathItems expansion) expect(byName['Track Beta']).toBe(1); + // Gamma: u2 via an FK-only path item (tutorial_ID set, taskLegacyId NULL) = 1. + // Proves #1866 FK-first resolution — the bug that zeroed real tracks. + expect(byName['Track Gamma']).toBe(1); // tutorial 999 (not a path item) contributes to no track - expect(buckets.length).toBe(2); + expect(buckets.length).toBe(3); // sorted desc by count expect(buckets[0].bucketName).toBe('Track Alpha'); // Hero total = ALL in-window TUTORIAL completions regardless of track: - // tr-1,2,3 (Alpha) + tr-4 (Beta) + tr-6 (999, no track) = 5. tr-5 is out-of-window. - expect(totalCompletions).toBe(5); + // tr-1,2,3 (Alpha) + tr-4 (Beta) + tr-g (Gamma) + tr-6 (999, no track) = 6. + // tr-5 is out-of-window. + expect(totalCompletions).toBe(6); }); it('returns empty buckets but still counts totalCompletions when there are no tracks', async () => { From 5f98231ac841422667850ebfb249e6337cb9d3fc Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 26 Oct 2026 20:58:53 +0100 Subject: [PATCH 3/3] chore(release): bump MTA version to 1.41.5 Release the event-display track-grouping fixes (PR #2935): group buckets by app-space CompletionPath tracks, count completions (not users), decouple the hero total, and resolve path items FK-first (#1866 tutorial_ID). Deployed version shown by cf mtas / the mtar filename. --- .deploy/mta.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.deploy/mta.yaml b/.deploy/mta.yaml index fbd549634..4ada94261 100644 --- a/.deploy/mta.yaml +++ b/.deploy/mta.yaml @@ -10,7 +10,7 @@ ID: tutorials-ims # Bump this on each release you deploy — it's the version shown by `cf mtas` # and in the mtar filename (tutorials-ims_.mtar). Deploy is manual: # `cd .deploy && mbt build && cf deploy mta_archives/tutorials-ims_.mtar -e ../deploy/.mtaext -f`. -version: 1.41.4 +version: 1.41.5 # Top-level parameters (overridable per-env via deploy/.mtaext). parameters: