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
2 changes: 1 addition & 1 deletion .deploy/mta.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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_<version>.mtar). Deploy is manual:
# `cd .deploy && mbt build && cf deploy mta_archives/tutorials-ims_<version>.mtar -e ../deploy/<env>.mtaext -f`.
version: 1.41.4
version: 1.41.5

# Top-level parameters (overridable per-env via deploy/<env>.mtaext).
parameters:
Expand Down
28 changes: 22 additions & 6 deletions app/display-app/src/event-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand All @@ -121,15 +125,21 @@ 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 : [])
// Empty buckets = event exists but no completions yet (a 404 event is rejected
// 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}`
Expand Down Expand Up @@ -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()
}

Expand All @@ -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()
}
Expand Down
20 changes: 18 additions & 2 deletions srv/developer-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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 =>
Expand Down
6 changes: 6 additions & 0 deletions srv/display-service.cds
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
10 changes: 5 additions & 5 deletions srv/display-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
});

Expand Down
15 changes: 11 additions & 4 deletions srv/event-stream-service.cds
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 6 additions & 6 deletions srv/event-stream-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand All @@ -29,6 +28,7 @@ export default class EventStreamService extends cds.ApplicationService {
eventName: event.name || '',
eventType: event.eventType ?? 'OTHER',
hasLogo: Boolean(event.hasLogo),
totalCompletions,
buckets,
};
});
Expand Down
101 changes: 75 additions & 26 deletions srv/lib/event-path-buckets.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Set<string>>, 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(
Expand All @@ -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 } })
);

Expand All @@ -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);
}
}
}
Expand Down Expand Up @@ -107,55 +145,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.
*
* 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 [{ bucketName: path.name, count, percentage }] sorted desc by count.
* Empty array when the event has no window, no mission, or no paths.
* 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 };
}
Loading
Loading