Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 32 additions & 21 deletions srv/developer-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -789,25 +789,33 @@ export default class DeveloperService extends cds.ApplicationService {
try {
// A completion is event-agnostic — it carries no event_ID. Match it to
// any event whose [startDate,endDate] window contains the completion
// instant and broadcast to that event's WS context. (The old code bailed
// on `!result.event_ID`, so normal completions — which never set it —
// never reached the display; see getEventBuckets for the same fix.)
// instant, and label it with the TRACK(s) it belongs to in that event's
// mission — the SAME grouping the display buckets + app-space page use
// (lib/event-path-buckets.js). A tutorial outside every track emits
// nothing (no bar). (The old code bailed on `!result.event_ID` — NULL on
// normal completions — and keyed bucketName on primaryTag, which did not
// match the path-grouped buckets; see getEventBuckets for the fetch fix.)
const at = result.completionDate || new Date().toISOString();
const events = await SELECT.from(dbEvents)
.columns('legacyId')
.columns('ID', 'legacyId', 'startDate', 'endDate', 'mission_ID')
.where(`startDate <=`, at).and(`endDate >=`, at);
if (!events.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 });
const { trackNamesForTutorial } = await import('./lib/event-path-buckets.js');
const db = await cds.connect.to('db');

const payload = {
bucketName: tutorial?.primaryTag || 'unknown',
completeDate: new Date().toISOString().slice(0, 10),
tutorialTitle: tutorial?.title || 'Unknown Tutorial',
};
// Build [{ context, bucketName }] — one per (event, track) the completion lands in.
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 contexts = events.map(e => String(e.legacyId));
const tutorial = await SELECT.one.from(dbTutorials).where({ legacyId: result.taskLegacyId });
const user = await SELECT.one.from(dbUsers).where({ ID: result.user_ID });
const completeDate = new Date().toISOString().slice(0, 10);
const tutorialTitle = tutorial?.title || 'Unknown Tutorial';

// Broadcast to kiosks (unauthenticated, no user info).
// Broadcast to authenticated clients (with user name).
Expand All @@ -827,16 +835,19 @@ export default class DeveloperService extends cds.ApplicationService {
const privileged = cds.User.privileged;
const broadcast = async () => {
const eventStream = await cds.connect.to('EventStreamService');
await eventStream.tx({ user: privileged }, tx =>
tx.emit('tutorialCompleted', payload, { contexts })
);
const display = await cds.connect.to('DisplayService');
await display.tx({ user: privileged }, tx =>
tx.emit('tutorialCompleted',
{ ...payload, userName: user?.displayName || 'Someone' },
{ contexts }
)
);
for (const t of targets) {
const payload = { bucketName: t.bucketName, completeDate, tutorialTitle };
await eventStream.tx({ user: privileged }, tx =>
tx.emit('tutorialCompleted', payload, { contexts: [t.context] })
);
await display.tx({ user: privileged }, tx =>
tx.emit('tutorialCompleted',
{ ...payload, userName: user?.displayName || 'Someone' },
{ contexts: [t.context] }
)
);
}
};
broadcast().catch(e => cds.log('ws').warn('Failed to emit tutorialCompleted:', e.message));
} catch (e) {
Expand Down
15 changes: 8 additions & 7 deletions srv/display-service.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import cds from '@sap/cds';
import { computeBuckets, computeBurnup, computeTrackStats, computeCompletionSpeed, computeLeaderboard } from './lib/event-statistics.js';
import { computeBurnup, computeTrackStats, computeCompletionSpeed, computeLeaderboard } from './lib/event-statistics.js';
import { computePathBuckets } from './lib/event-path-buckets.js';
import { cached } from './lib/ttl-cache.js';

const CACHE_TTL = 600_000; // 600 seconds, matches IMS leaderboard cache
Expand All @@ -25,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 []).
return cached(`buckets:${eventLegacyId}`, CACHE_TTL, async () => {
const records = await SELECT.from(TaskRecords).where({
event_ID: event.ID,
taskType: 'TUTORIAL',
status: { in: COMPLETION_STATUSES }
});
return computeBuckets(records);
const db = await cds.connect.to('db');
return computePathBuckets(event, db);
});
});

Expand Down
27 changes: 9 additions & 18 deletions srv/event-stream-service.js
Original file line number Diff line number Diff line change
@@ -1,37 +1,28 @@
import cds from '@sap/cds';
import { computeBuckets } from './lib/event-statistics.js';
import { computePathBuckets } from './lib/event-path-buckets.js';
import { cached } from './lib/ttl-cache.js';

const CACHE_TTL = 600_000;

// "Has-ever-completed" semantic (issue #600) — see DisplayService for rationale.
const COMPLETION_STATUSES = ['COMPLETED', 'SUPERSEDED'];

export default class EventStreamService extends cds.ApplicationService {

async init() {
const { Events, TaskRecords } = cds.entities('com.sap.developers.ims');
const { Events } = cds.entities('com.sap.developers.ims');

this.on('getEventBuckets', async (req) => {
const { eventLegacyId } = req.data;

const event = await SELECT.one.from(Events).where({ legacyId: eventLegacyId });
if (!event) return req.reject(404, `Event with legacy ID ${eventLegacyId} not found`);

// Scope completions by the event's date window, NOT by TaskRecords.event_ID.
// event_ID is only stamped by the TechEd scan-in / app-space flow; normal
// tutorial completions leave it NULL, so an event_ID join reported 0 for
// every date-windowed event (TechEd 2026 showed "0 completed" despite live
// activity). The display is already scoped to one event via its URL
// eventId, so [startDate,endDate] unambiguously selects the right rows.
// No window configured → nothing to count.
// 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 () => {
if (!event.startDate || !event.endDate) return [];
const records = await SELECT.from(TaskRecords)
.where({ taskType: 'TUTORIAL', status: { in: COMPLETION_STATUSES } })
.and(`completionDate >=`, event.startDate)
.and(`completionDate <=`, event.endDate);
return computeBuckets(records);
const db = await cds.connect.to('db');
return computePathBuckets(event, db);
});

return {
Expand Down
161 changes: 161 additions & 0 deletions srv/lib/event-path-buckets.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import cds from '@sap/cds';

const NS = 'com.sap.developers.ims';

/**
* Resolve the event's mission ID using the SAME precedence as app-space
* (getAppSpaceProgress): forward Events.mission_ID, else the Missions.event_ID
* backlink (the only link the admin UI exposes), tie-broken by published then
* legacyId. Returns null when no mission is configured.
*/
export async function resolveEventMissionId(event, db) {
const { Missions } = cds.entities(NS);
if (event?.mission_ID) return event.mission_ID;
const linked = await db.run(
SELECT.one.from(Missions).columns('ID')
.where({ event_ID: event.ID })
.orderBy('published desc', 'legacyId')
);
return linked?.ID ?? null;
}

/**
* Build the map (taskType:taskLegacyId) → Set(path display-name) for an event's
* mission tracks — direct CompletionPathItems plus GROUP items expanded through
* GroupPathItems to their child tutorials. This is the single source of truth
* for "which track(s) does a completed tutorial belong to", shared by the
* display bucket aggregation (computePathBuckets) and the realtime socket emit
* so both label completions with the SAME track names the app-space page uses.
*
* Returns { pathNameByToken: Map<string, Set<string>>, pathNameById: Map }.
*/
export async function buildTrackMap(missionId, db) {
const { CompletionPaths, CompletionPathItems, GroupPathItems } = cds.entities(NS);
const pathNameByToken = new Map();

const paths = await db.run(
SELECT.from(CompletionPaths).columns('ID', 'name').where({ mission_ID: missionId }).orderBy('legacyId')
);
const pathNameById = new Map(paths.map(p => [p.ID, p.name || '']));
if (!paths.length) return { pathNameByToken, pathNameById };

const pathIds = paths.map(p => p.ID);
const items = await db.run(
SELECT.from(CompletionPathItems).columns('path_ID', 'taskType', 'taskLegacyId', 'group_ID')
.where({ path_ID: { in: pathIds } })
);

const addToken = (tok, pid) => {
const name = pathNameById.get(pid);
if (name == null) return;
if (!pathNameByToken.has(tok)) pathNameByToken.set(tok, new Set());
pathNameByToken.get(tok).add(name);
};

const groupToPaths = new Map(); // group_ID → Set(path_ID)
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.taskLegacyId != null) {
addToken(`${it.taskType}:${it.taskLegacyId}`, it.path_ID);
}
}

const groupIds = [...groupToPaths.keys()];
if (groupIds.length) {
const gpi = await db.run(
SELECT.from(GroupPathItems).columns('group_ID', 'tutorial.legacyId as tutLegacyId')
.where({ group_ID: { in: groupIds } })
);
for (const row of gpi) {
if (row.tutLegacyId == null) continue;
for (const pid of groupToPaths.get(row.group_ID) ?? []) {
addToken(`TUTORIAL:${row.tutLegacyId}`, pid);
}
}
}

return { pathNameByToken, pathNameById };
}

/**
* Track names a single completed tutorial belongs to within an event's mission.
* Used by the realtime socket emit to label a live completion with the SAME
* track name(s) the display buckets use. Returns [] if the tutorial is not a
* member of any track (→ no bar, consistent with the fetch aggregation).
*/
export async function trackNamesForTutorial(event, tutorialLegacyId, db) {
const missionId = await resolveEventMissionId(event, db);
if (!missionId) return [];
const { pathNameByToken } = await buildTrackMap(missionId, db);
return [...(pathNameByToken.get(`TUTORIAL:${tutorialLegacyId}`) ?? [])];
}

/**
* Per-path ("track") completion buckets for an event's display dashboard.
*
* A "track" is one CompletionPaths row under the event's Mission — the SAME
* definition the app-space page renders (srv/developer-service.js
* getAppSpaceProgress). We deliberately reuse that resolution rather than
* invent a new grouping:
*
* event → mission (Events.mission_ID, else Missions.event_ID backlink)
* → CompletionPaths where mission_ID (each row = one track)
* → CompletionPathItems where path_ID in … ((taskType,taskLegacyId) → path)
* + 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.
*
* Returns [{ bucketName: path.name, count, percentage }] sorted desc by count.
* Empty array when the event has no window, no mission, or no paths.
*
* @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 [];

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.
const records = await db.run(
SELECT.from(TaskRecords).columns('user_ID', 'taskLegacyId')
.where({ taskType: 'TUTORIAL', status: { in: ['COMPLETED', 'SUPERSEDED'] } })
.and(`completionDate >=`, event.startDate)
.and(`completionDate <=`, event.endDate)
);

const seen = new Set(); // dedupe (user, track-name)
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()]
.map(([bucketName, count]) => ({
bucketName,
count,
percentage: total > 0 ? Math.round((count / total) * 10000) / 100 : 0,
}))
.sort((a, b) => b.count - a.count);
}
20 changes: 14 additions & 6 deletions test/display-service.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ describe('DisplayService', () => {

describe('Event Data', () => {
beforeAll(async () => {
const { Events, Users, Tutorials, Missions, TaskRecords } = cds.entities('com.sap.developers.ims');
const { Events, Users, Tutorials, Missions, CompletionPaths, CompletionPathItems, TaskRecords } = cds.entities('com.sap.developers.ims');

await INSERT.into(Events).entries({
ID: 'aaaaaaaa-1111-0000-0000-000000000001',
Expand All @@ -42,11 +42,18 @@ describe('DisplayService', () => {
]);

await INSERT.into(Tutorials).entries([
{ ID: 'aaaaaaaa-1111-0000-0000-000000000021', title: 'Tut 1', slug: 'disp-tut-1', legacyId: 5021 },
{ ID: 'aaaaaaaa-1111-0000-0000-000000000021', title: 'Tut 1', slug: 'disp-tut-1', legacyId: 5021, status: 'ACTIVE' },
]);

// Mission linked to the event (backlink), with one track containing Tut 1.
await INSERT.into(Missions).entries([
{ ID: 'aaaaaaaa-1111-0000-0000-000000000031', title: 'Mission Alpha', legacyId: 5031 },
{ ID: 'aaaaaaaa-1111-0000-0000-000000000031', title: 'Mission Alpha', legacyId: 5031, event_ID: 'aaaaaaaa-1111-0000-0000-000000000001', published: true },
]);
await INSERT.into(CompletionPaths).entries([
{ ID: 'aaaaaaaa-1111-0000-0000-000000000041', name: 'Track One', legacyId: 5041, mission_ID: 'aaaaaaaa-1111-0000-0000-000000000031' },
]);
await INSERT.into(CompletionPathItems).entries([
{ ID: 'aaaaaaaa-1111-0000-0000-000000000051', legacyId: 5051, path_ID: 'aaaaaaaa-1111-0000-0000-000000000041', taskType: 'TUTORIAL', taskLegacyId: 5021, itemOrder: 1 },
]);

await INSERT.into(TaskRecords).entries([
Expand All @@ -69,13 +76,14 @@ describe('DisplayService', () => {
expect(data.value[1].cumulative).toBe(3);
});

it('getEventBuckets returns user distribution', async () => {
it('getEventBuckets returns per-track completion counts', async () => {
const { status, data } = await project.get(
'/display/getEventBuckets(eventLegacyId=5001)', displayAuth
);
expect(status).toBe(200);
expect(data.value.length).toBeGreaterThan(0);
expect(data.value[0].bucketName).toBe('1 tutorial');
// Tut 1 is in "Track One"; 3 users completed it in-window → Track One = 3.
expect(data.value.length).toBe(1);
expect(data.value[0].bucketName).toBe('Track One');
expect(data.value[0].count).toBe(3);
});

Expand Down
Loading
Loading