diff --git a/.deploy/mta.yaml b/.deploy/mta.yaml index 1e143342d..2ed0cd52d 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.7 +version: 1.41.8 # Top-level parameters (overridable per-env via deploy/.mtaext). parameters: diff --git a/.github/workflows/rebuild-content-qa.yml b/.github/workflows/rebuild-content-qa.yml index 022af26cd..b9c8c2b67 100644 --- a/.github/workflows/rebuild-content-qa.yml +++ b/.github/workflows/rebuild-content-qa.yml @@ -212,25 +212,26 @@ jobs: YOUTUBE_OAUTH_CLIENT_SECRET: ${{ secrets.YOUTUBE_OAUTH_CLIENT_SECRET }} YOUTUBE_OAUTH_REFRESH_TOKEN: ${{ secrets.YOUTUBE_OAUTH_REFRESH_TOKEN }} - # Author content-error routing (#2384). A slug-targeted QA run whose + # Author content-error routing (#2384, #2951). A slug-targeted QA run whose # source markdown could not be fetched (missing/misnamed file, e.g. # `tutorials//architecture.md` instead of `.md`) is an AUTHOR # error, not an infra failure — fetch-tutorials records it in # `.tutorial-cache-qa/errors.json`, SKIPS the slug, and exits 0. Detect - # that here: if the effective target slug is among the fetch errors, the - # publish step is skipped (nothing rendered to publish → would otherwise - # throw the "--slug not found" exit 2 that failed the whole job) and a - # per-tutorial issue is filed against the source repo below. QA dispatches - # carry exactly ONE slug (notify-qa template), so at most one bad slug. + # that here so the downstream guard and publish steps only process slugs + # that actually rendered. Handles both single-slug (client_payload.slug) + # and multi-slug (client_payload.slugs) dispatches. - name: Detect author source errors id: source_errors env: EFFECTIVE_SLUG: ${{ github.event.client_payload.slug || inputs.slug }} + EFFECTIVE_SLUGS: ${{ github.event.client_payload.slugs }} SOURCE_REPO: ${{ github.event.client_payload.repo }} run: | set -uo pipefail ERRORS_FILE=".tutorial-cache-qa/errors.json" BAD_SLUG="" + + # --- Single-slug path (original #2384 logic) --- if [ -n "$EFFECTIVE_SLUG" ] && [ -f "$ERRORS_FILE" ]; then # Was the requested slug among the fetch errors? if jq -e --arg s "$EFFECTIVE_SLUG" 'any(.[]; .slug == $s)' "$ERRORS_FILE" >/dev/null 2>&1; then @@ -261,6 +262,32 @@ jobs: echo "bad_slug=" >> "$GITHUB_OUTPUT" fi + # --- Multi-slug path (#2951): filter client_payload.slugs against errors.json --- + # When a push touches N tutorials and some fail to fetch, only the + # successfully-rendered slugs should reach the guard and publish steps. + # bad_slugs = comma-separated list of slugs that failed fetch + # good_slugs = comma-separated list of slugs that rendered successfully + GOOD_SLUGS="" + BAD_SLUGS="" + if [ -n "$EFFECTIVE_SLUGS" ] && [ -f "$ERRORS_FILE" ]; then + IFS=',' read -ra SLUG_ARRAY <<< "$EFFECTIVE_SLUGS" + for s in "${SLUG_ARRAY[@]}"; do + s="${s// /}" # trim whitespace + [ -z "$s" ] && continue + if jq -e --arg slug "$s" 'any(.[]; .slug == $slug)' "$ERRORS_FILE" >/dev/null 2>&1; then + BAD_SLUGS="${BAD_SLUGS:+$BAD_SLUGS,}$s" + echo "::warning title=Author source error (multi)::Tutorial '$s' could not be fetched — skipping from multi-slug publish." + else + GOOD_SLUGS="${GOOD_SLUGS:+$GOOD_SLUGS,}$s" + fi + done + else + # No multi-slug payload, or no errors file — pass through unchanged. + GOOD_SLUGS="$EFFECTIVE_SLUGS" + fi + echo "bad_slugs=$BAD_SLUGS" >> "$GITHUB_OUTPUT" + echo "good_slugs=$GOOD_SLUGS" >> "$GITHUB_OUTPUT" + # Resolve the tutorial's author (last committer of tutorials// in # the source repo, falling back to the dispatched commit's author) so the # per-tutorial issue can @-mention and best-effort-assign them. Uses the @@ -425,12 +452,12 @@ jobs: # self-heal like prod), so this gate is the only thing standing between a # fingerprint-skewed deploy and unstyled /tutorials-qa/* pages. - name: Guard - approuter serves referenced CSS + islands (QA) - # Share the publish step's skip condition (line ~405): an author - # source-error (#2384) leaves no rendered page for the slug, and - # check-approuter-assets.cjs resolvePages() die()s (exit 1) on a missing - # page — without this `if:`, a benign author typo would become a red - # infra run and file a false failure issue. - if: ${{ steps.source_errors.outputs.bad_slug == '' }} + # Skip when the only targeted slug failed to fetch (#2384 single-slug), + # OR when all multi-slug targets failed to fetch (#2951 multi-slug): + # check-approuter-assets.cjs die()s on any slug with no rendered page. + # For partial multi-slug failures, good_slugs is non-empty and the guard + # runs against only the slugs that actually rendered. + if: ${{ steps.source_errors.outputs.bad_slug == '' && (steps.source_errors.outputs.good_slugs != '' || github.event.client_payload.slugs == '') }} env: # QA is a feature of PRODUCTION, not a separate environment: /tutorials-qa/* # is routed by the MAIN (prod) approuter (approuter/xs-app.json → srv-qa-api @@ -441,12 +468,13 @@ jobs: # (2026-10-25). APPROUTER_URL: ${{ secrets.APPROUTER_URL_PROD }} # Match the publish step's PUBLISH_SLUG / PUBLISH_SLUGS expressions - # EXACTLY (lines 424/431) so the probe scope tracks the publish scope: - # a force-publish run ships the WHOLE catalog, so it must NOT pin the - # guard to one slug (that would gate 1 and strand ~1400 — the inverse - # of the bug this closes). check-approuter-assets.cjs reads both. + # EXACTLY so the probe scope tracks the publish scope: a force-publish + # run ships the WHOLE catalog, so it must NOT pin the guard to one slug. + # (#2951) Use good_slugs (fetch-error slugs filtered out) instead of the + # raw client_payload.slugs so the guard never checks a slug that Hugo + # never rendered. PUBLISH_SLUG: ${{ ((! inputs.force-publish) || inputs.allow-revert) && (github.event.client_payload.slug || inputs.slug) || '' }} - PUBLISH_SLUGS: ${{ (! inputs.force-publish) && github.event.client_payload.slugs || '' }} + PUBLISH_SLUGS: ${{ (! inputs.force-publish) && steps.source_errors.outputs.good_slugs || '' }} run: | SLUGS_ARG="" [ -n "$PUBLISH_SLUGS" ] && SLUGS_ARG="--slugs $PUBLISH_SLUGS" @@ -460,7 +488,9 @@ jobs: # #2384): nothing rendered for it, so a slug-scoped publish would throw # the "--slug not found" exit 2 and fail the whole job. A full rebuild # (empty bad_slug) and a healthy slug-targeted run both publish normally. - if: ${{ steps.source_errors.outputs.bad_slug == '' }} + # For multi-slug with partial failures, bad_slug is empty and good_slugs + # contains only the slugs that rendered — publish proceeds on those. + if: ${{ steps.source_errors.outputs.bad_slug == '' && (steps.source_errors.outputs.good_slugs != '' || github.event.client_payload.slugs == '') }} # allow-revert path (per-slug #672 override): the server-side guard is only # reached at COMMIT, but publish-content.ts's client-side #672 short-circuit # (source+html hash match) drops the slug BEFORE commit — and --allow-revert @@ -483,10 +513,9 @@ jobs: # Multi-slug (N-tutorial push) companion. Scope the publish to exactly # the changed slugs when the notify template sent a validated # client_payload.slugs — publish-content.ts reads PUBLISH_SLUGS as the - # --slugs fallback (filterToSlugs). Empty on force-publish (full - # re-seed) and on single-slug/allow-revert runs. Payload-only (no - # inputs.slugs — the QA workflow_dispatch has no slugs input). - PUBLISH_SLUGS: ${{ (! inputs.force-publish) && github.event.client_payload.slugs || '' }} + # --slugs fallback (filterToSlugs). (#2951) Use good_slugs (fetch-error + # slugs filtered out) so publish never tries a slug Hugo skipped. + PUBLISH_SLUGS: ${{ (! inputs.force-publish) && steps.source_errors.outputs.good_slugs || '' }} # #1373 — synthetic watchdog. The publish step reporting success is not # proof rows landed: the render-concepts 404 that hid for days (#1372) @@ -496,9 +525,9 @@ jobs: # run that the failure notifier below emails on. - name: Verify QA publish landed rows # Skipped when the publish step was skipped for an author source error - # (#2384) — there was no publish to verify, and the channel's existing - # rows are unchanged. - if: ${{ steps.source_errors.outputs.bad_slug == '' }} + # (#2384/#2951) — there was no publish to verify, and the channel's + # existing rows are unchanged. + if: ${{ steps.source_errors.outputs.bad_slug == '' && (steps.source_errors.outputs.good_slugs != '' || github.event.client_payload.slugs == '') }} uses: ./.github/actions/verify-publish-landed with: base-url: ${{ secrets.CAP_SRV_URL_QA }} diff --git a/app/admin-annotations.cds b/app/admin-annotations.cds index e15e0b4ce..27904dfcb 100644 --- a/app/admin-annotations.cds +++ b/app/admin-annotations.cds @@ -2213,6 +2213,8 @@ annotate AdminService.TutorialFeedback with { { $Type: 'Common.ValueListParameterDisplayOnly', ValueListProperty: 'primaryTag' } ] }; + isIrrelevant @Common.Label: 'Irrelevant'; + irrelevantReason @Common.Label: 'Reason'; }; annotate AdminService.TutorialFeedback with @( @@ -2224,7 +2226,7 @@ annotate AdminService.TutorialFeedback with @( TypeNamePlural: 'Submissions', Title: { Value: tutorialSlug } }, - UI.SelectionFields: [tutorialSlug, wasAuthenticated, submittedAt], + UI.SelectionFields: [tutorialSlug, wasAuthenticated, submittedAt, isIrrelevant], UI.LineItem: [ { Value: tutorialSlug }, { Value: submittedAt }, @@ -2236,7 +2238,11 @@ annotate AdminService.TutorialFeedback with @( { Value: ratingStructure }, { Value: ratingInteresting }, { Value: ratingVisuals }, - { Value: comment } + { Value: comment }, + { Value: isIrrelevant, Label: 'Irrelevant' }, + { Value: irrelevantReason, Label: 'Reason' }, + { $Type: 'UI.DataFieldForAction', Action: 'AdminService.markFeedbackIrrelevant', Label: 'Mark Irrelevant' }, + { $Type: 'UI.DataFieldForAction', Action: 'AdminService.unmarkFeedbackIrrelevant', Label: 'Unmark Irrelevant' } ], UI.FieldGroup #Ratings: { Data: [ { Value: ratingUseCase }, @@ -2248,9 +2254,14 @@ annotate AdminService.TutorialFeedback with @( { Value: npsScore } ]}, UI.FieldGroup #CommentGroup: { Data: [{ Value: comment }] }, + UI.FieldGroup #FlagGroup: { Data: [ + { Value: isIrrelevant }, + { Value: irrelevantReason } + ]}, UI.Facets: [ { $Type: 'UI.ReferenceFacet', Label: 'Ratings', Target: '@UI.FieldGroup#Ratings' }, - { $Type: 'UI.ReferenceFacet', Label: 'Comment', Target: '@UI.FieldGroup#CommentGroup' } + { $Type: 'UI.ReferenceFacet', Label: 'Comment', Target: '@UI.FieldGroup#CommentGroup' }, + { $Type: 'UI.ReferenceFacet', Label: 'Relevance Flag', Target: '@UI.FieldGroup#FlagGroup' } ] ); @@ -2281,7 +2292,11 @@ annotate AdminService.TutorialFeedback with @UI: { { Value: ratingDuration, Label: 'Duration' }, { Value: ratingStructure, Label: 'Structure' }, { Value: ratingInteresting,Label: 'Interesting' }, - { Value: ratingVisuals, Label: 'Visuals' } + { Value: ratingVisuals, Label: 'Visuals' }, + { Value: isIrrelevant, Label: 'Irrelevant' }, + { Value: irrelevantReason, Label: 'Reason' }, + { $Type: 'UI.DataFieldForAction', Action: 'AdminService.markFeedbackIrrelevant', Label: 'Mark Irrelevant' }, + { $Type: 'UI.DataFieldForAction', Action: 'AdminService.unmarkFeedbackIrrelevant', Label: 'Unmark Irrelevant' } ] }; diff --git a/app/display-app/src/event-stream.ts b/app/display-app/src/event-stream.ts index 8de350626..da9b896f7 100644 --- a/app/display-app/src/event-stream.ts +++ b/app/display-app/src/event-stream.ts @@ -1,5 +1,7 @@ import { ref, readonly, onUnmounted } from 'vue' import { io, type Socket } from 'socket.io-client' +import { reconcileBuckets } from './reconcile' +import { completionsPerHour } from './rate' export interface Bucket { name: string @@ -32,6 +34,7 @@ export function useEventStream() { let socket: Socket | null = null let demoInterval: ReturnType | null = null let cronInterval: ReturnType | null = null + let reconcileInterval: ReturnType | null = null const messageTimestamps: number[] = [] const bucketTimestamps = new Map() @@ -59,16 +62,15 @@ export function useEventStream() { } function speedApproximation(): number { - if (messageTimestamps.length < 3) return 0 - const time = messageTimestamps[messageTimestamps.length - 1] - messageTimestamps[0] - if (time === 0) return 0 - const approx = (60 * 60 * 1000) / time - return Math.round(approx * messageTimestamps.length) + // Driven by messageTimestamps, which is fed from completionTick (EVERY + // completion), so the rate reflects all activity — not only track-mapped + // completions. See rate.ts for why this matters (per-hour-stuck-at-0 bug). + return completionsPerHour(messageTimestamps) } function startCron() { cronInterval = setInterval(() => { - if (messageTimestamps.length < 3) return + if (messageTimestamps.length < 2) return removeOutdatedTimestamps() speed.value = speedApproximation() @@ -92,7 +94,10 @@ export function useEventStream() { } else { buckets.value.push({ name: bucketName, count: 1, justUpdated: true }) } - messageTimestamps.push(Date.now()) + // NOTE: the overall "Tutorials Per Hour" rate is fed from completionTick + // (every completion), NOT here — a track completion fires BOTH completionTick + // and tutorialCompleted, so feeding the rate from both would double-count. + // This bucketTimestamps feed below stays (it drives per-bucket velocity). recentEvents.value = [ { name: bucketName, timestamp: Date.now() }, @@ -110,18 +115,30 @@ export function useEventStream() { }, 1200) } - async function connect(baseUrl: string, eventId: number | string) { - connectionState.value = 'connecting' - errorMessage.value = '' - const url = String(baseUrl).replace(/\/+$/, '') - - // Fetch initial bucket data from unauthenticated EventStreamService + // Fetch the authoritative getEventBuckets snapshot and apply it. + // + // mode='seed' — initial load: adopt counts verbatim (sets up the board). + // mode='reconcile' — periodic heal: merge via reconcileBuckets so counts that + // drifted below the snapshot (missed socket events) are corrected WITHOUT + // rewinding counts the live socket has already advanced past. This is what + // stops the all-day kiosk from silently falling behind and needing a manual + // refresh — the historical "numbers don't update live" complaint. + // + // Returns false on a hard fetch failure so the initial seed can surface an error; + // reconcile passes ignore failures (transient; the next tick retries). + async function fetchBuckets( + url: string, + eventId: number | string, + mode: 'seed' | 'reconcile', + ): Promise { try { const res = await fetch(`${url}/rest/event-stream/getEventBuckets(eventLegacyId=${eventId})`) if (res.status === 404) { - connectionState.value = 'error' - errorMessage.value = `Event ID ${eventId} not found. Verify the event exists and has the correct legacy ID.` - return + if (mode === 'seed') { + connectionState.value = 'error' + errorMessage.value = `Event ID ${eventId} not found. Verify the event exists and has the correct legacy ID.` + } + return false } if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`) const json = await res.json() @@ -129,22 +146,47 @@ export function useEventStream() { // 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 })) + if (mode === 'seed') { + // 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 })) + } else { + // Heal drift: never rewind a count the live socket already advanced. + buckets.value = reconcileBuckets(buckets.value, data) + } sortBuckets() // Hero total = ALL window completions (track-independent). Older servers that - // don't send totalCompletions fall back to the bucket sum. - setTotalFromFetch( + // don't send totalCompletions fall back to the bucket sum. Monotonic on + // reconcile so a late socket tick isn't rewound by a slightly-stale snapshot. + const fetchedTotal = typeof json.totalCompletions === 'number' ? json.totalCompletions : buckets.value.reduce((sum, b) => sum + b.count, 0) - ) + if (mode === 'seed') setTotalFromFetch(fetchedTotal) + else if (fetchedTotal > totalCount.value) setTotalFromFetch(fetchedTotal) + return true } catch (e) { - connectionState.value = 'error' - errorMessage.value = `Failed to fetch from ${url}: ${(e as Error).message}` - return + if (mode === 'seed') { + connectionState.value = 'error' + errorMessage.value = `Failed to fetch from ${url}: ${(e as Error).message}` + } + return false } + } + + async function connect(baseUrl: string, eventId: number | string) { + connectionState.value = 'connecting' + errorMessage.value = '' + const url = String(baseUrl).replace(/\/+$/, '') + + // Initial authoritative seed. Abort on hard failure (404 / network). + const seeded = await fetchBuckets(url, eventId, 'seed') + if (!seeded) return + + // Periodic reconciliation (every 15s) — the safety net that lets the socket + // provide snappy live deltas while guaranteeing the board self-heals from any + // missed event over a long kiosk session. No page refresh required. + reconcileInterval = setInterval(() => { void fetchBuckets(url, eventId, 'reconcile') }, 15000) // Connect via Socket.IO to EventStreamService. // Use the default polling→upgrade transport order. A websocket-only pin @@ -181,6 +223,11 @@ export function useEventStream() { // Hero "total activity" — one tick per completion (track or not). socket.on('completionTick', () => { totalCount.value++ + // Feed the "Tutorials Per Hour" rate from EVERY completion (track or not). + // Previously the rate was fed only from tutorialCompleted (track-mapped), + // so events whose completions didn't resolve to a CompletionPath showed a + // permanently-zero per-hour. completionTick fires for all completions. + messageTimestamps.push(Date.now()) }) startCron() @@ -207,6 +254,7 @@ export function useEventStream() { if (socket) { socket.disconnect(); socket = null } if (demoInterval) { clearInterval(demoInterval); demoInterval = null } if (cronInterval) { clearInterval(cronInterval); cronInterval = null } + if (reconcileInterval) { clearInterval(reconcileInterval); reconcileInterval = null } connectionState.value = 'idle' } diff --git a/app/display-app/src/number-words.ts b/app/display-app/src/number-words.ts index 53b344a7d..c5a64f053 100644 --- a/app/display-app/src/number-words.ts +++ b/app/display-app/src/number-words.ts @@ -30,14 +30,14 @@ function english(num: number): string[] { } if (pos === 2 && d !== 0) { result.push(w[0][d] + ' ' + w[2][0]); continue } if (pos === 3) { - if (consolidate(digits) !== 0) { result.push(w[0][d] + ' ' + w[3][0] + ', '); continue } + if (consolidate(digits) !== 0) { result.push(w[0][d] + ' ' + w[3][0]); continue } else result.push(w[0][d] + ' ' + w[3][0]) } if (pos === 4) { const c = parseInt(d + '' + digits.pop()) result.push(w[0][c] + ' ' + w[3]) if (consolidate(digits) === 0) break - result.push(result.pop()! + ', ') + result.push(result.pop()!) } } return result @@ -206,42 +206,43 @@ function hebrew(num: number): string[] { ['', 'אחת', 'שתים', 'שלוש', 'ארבע', 'חמש', 'שש', 'שבע', 'שמונה', 'תשע', 'עשר', 'אחת-עשרה', 'שתים-עשרה', 'שלוש-עשרה', 'ארבע-עשרה', 'חמש-עשרה', 'שש-עשרה', 'שבע-עשרה', 'שמונה-עשרה', 'תשע-עשרה'], ['', 'עשרה', 'עשרים', 'שלושים', 'ארבעים', 'חמשים', 'ששים', 'שבעים', 'שמונים', 'תשעים'], ['מאות'], - ['', 'אלף', 'אלפים', 'שלושת אלפים', 'ארבעת אלפים', 'חמשת אלפים', 'ששת אלפים', 'שבעת אלפים', 'שמונת אלפים', 'תשעת אלפים', 'עשרת אלפים'], + ['', 'אלף', 'אלפיים', 'שלושת אלפים', 'ארבעת אלפים', 'חמשת אלפים', 'ששת אלפים', 'שבעת אלפים', 'שמונת אלפים', 'תשעת אלפים', 'עשרת אלפים'], ] const digits = ('' + num).split('').map(Number).reverse() const result: string[] = [] if (digits.length === 1 && digits[0] === 0) { result.push('אפס'); return result } + let prefix = '' while (digits.length >= 1) { const pos = digits.length - 1 const d = digits.pop()! if (d === 0) continue - if (pos === 0) { result.push(w[0][d]); continue } - if (pos === 1 && d === 1) { result.push(w[0][parseInt(d + '' + digits.pop())]); continue } + if (pos === 0) { result.push(prefix + w[0][d]); prefix = ''; continue } + if (pos === 1 && d === 1) { result.push(prefix + w[0][parseInt(d + '' + digits.pop())]); prefix = ''; continue } if (pos === 1) { const n = digits.pop()! let ten = w[1][d] if (d === 2) ten = 'עשרים' if (d === 3 && n === 0) ten = 'שלושים' - result.push(ten + (n !== 0 ? ' ו' : '') + w[0][n]) + result.push(prefix + ten + (n !== 0 ? ' ו' : '') + w[0][n]); prefix = '' continue } if (pos === 2) { - const vav = consolidate(digits) < 10 && consolidate(digits) !== 0 ? ' ו' : '' - if (d === 1) { result.push('מאה' + vav); continue } - if (d === 2) { result.push('מאתים' + vav); continue } - result.push(w[0][d] + ' ' + w[2][0] + vav) + const needsVav = consolidate(digits) < 10 && consolidate(digits) !== 0 + if (d === 1) { result.push(prefix + 'מאה'); prefix = needsVav ? 'ו' : ''; continue } + if (d === 2) { result.push(prefix + 'מאתים'); prefix = needsVav ? 'ו' : ''; continue } + result.push(prefix + w[0][d] + ' ' + w[2][0]); prefix = needsVav ? 'ו' : '' continue } if (pos === 3) { - const vav = consolidate(digits) < 10 && consolidate(digits) !== 0 ? ' ו' : '' - result.push(w[3][d] + vav) + const needsVav = consolidate(digits) < 10 && consolidate(digits) !== 0 + result.push(prefix + w[3][d]); prefix = needsVav ? 'ו' : '' continue } if (pos === 4 && d === 1) { const c = parseInt(d + '' + digits.pop()) - const vav = consolidate(digits) < 10 && consolidate(digits) !== 0 ? ' ו' : '' - if (c === 10) { result.push('עשרת אלפים' + vav); continue } - result.push(w[0][c] + ' אלף' + vav) + const needsVav = consolidate(digits) < 10 && consolidate(digits) !== 0 + if (c === 10) { result.push(prefix + 'עשרת אלפים'); prefix = needsVav ? 'ו' : ''; continue } + result.push(prefix + w[0][c] + ' אלף'); prefix = needsVav ? 'ו' : '' } } return result @@ -252,7 +253,7 @@ function arabic(num: number): string[] { const w = [ ['', 'واحد', 'اثنان', 'ثلاثة', 'أربعة', 'خمسة', 'ستة', 'سبعة', 'ثمانية', 'تسعة', 'عشرة', 'إحدى عشر', 'إثنا عشر', 'ثلاثة عشر', 'أربعة عشر', 'خمسة عشر', 'ستة عشر', 'سبعة عشر', 'ثمانية عشر', 'تسعة عشر'], ['', '', 'عشرون', 'ثلاثون', 'أربعون', 'خمسون', 'ستون', 'سبعون', 'ثمانون', 'تسعون'], - ['', 'مائة', 'مائتان', 'ثلاثمية', 'أربعمية', 'خمسمية', 'ستمية', 'سبعمية', 'ثمانيمية', 'تسعمية'], + ['', 'مائة', 'مائتان', 'ثلاثمائة', 'أربعمائة', 'خمسمائة', 'ستمائة', 'سبعمائة', 'ثمانمائة', 'تسعمائة'], ] const digits = ('' + num).split('').map(Number).reverse() const result: string[] = [] @@ -275,8 +276,8 @@ function arabic(num: number): string[] { } if (pos === 3) { if (d === 1) { result.push('ألف'); continue } - if (d === 2) { result.push('الفا'); continue } - result.push(w[0][d] + 'الاف ') + if (d === 2) { result.push('ألفان'); continue } + result.push(w[0][d] + ' آلاف') continue } if (pos === 4) { @@ -299,6 +300,13 @@ function mandarin(num: number): string[] { const digits = ('' + num).split('').map(Number).reverse() const result: string[] = [] if (digits.length === 1 && digits[0] === 0) { result.push('零'); return result } + const push = (s: string) => { + if (result.length > 0 && result[result.length - 1].endsWith('零')) { + result.push(result.pop()! + s) + } else { + result.push(s) + } + } while (digits.length >= 1) { const pos = digits.length - 1 const d = digits.pop()! @@ -310,20 +318,20 @@ function mandarin(num: number): string[] { } catch {} continue } - if (pos === 0) { result.push(w[0][d]); continue } + if (pos === 0) { push(w[0][d]); continue } if (pos === 1) { const n = digits.pop()! - if (d === 1) { result.push(w[1][0] + w[0][n]); continue } - result.push(w[0][d] + w[1][0] + w[0][n]) + if (d === 1) { push(w[1][0] + w[0][n]); continue } + push(w[0][d] + w[1][0] + w[0][n]) continue } if (pos === 2) { if (consolidate(digits) < 20 && consolidate(digits) > 9) { digits.pop() - result.push(w[0][d] + w[2][0] + w[0][1] + w[1][0]) + push(w[0][d] + w[2][0] + w[0][1] + w[1][0]) continue } - result.push(w[0][d] + w[2][0]) + push(w[0][d] + w[2][0]) continue } if (pos === 3) { diff --git a/app/display-app/src/rate.ts b/app/display-app/src/rate.ts new file mode 100644 index 000000000..2ea6cdcc6 --- /dev/null +++ b/app/display-app/src/rate.ts @@ -0,0 +1,19 @@ +// Pure hourly-rate extrapolation for the live display's "Tutorials Per Hour". +// +// Driven by the timestamps of EVERY completion (completionTick), not only +// track-mapped ones. Previously the rate was fed solely from tutorialCompleted +// (per-track) events, so an event whose completions didn't resolve to a +// CompletionPath produced tutorialCompleted=0 and the per-hour number was stuck +// at 0 for the whole event even though completions were happening. completionTick +// fires once per completion regardless of track, so feeding the rate from it +// makes "Tutorials Per Hour" reflect real activity. +// +// Matches the original speedApproximation math: extrapolate the observed count +// over the observed time span to a full hour. +export function completionsPerHour(timestampsMs: number[]): number { + if (timestampsMs.length < 2) return 0; + const span = timestampsMs[timestampsMs.length - 1] - timestampsMs[0]; + if (span <= 0) return 0; + const perMs = timestampsMs.length / span; + return Math.round(perMs * 60 * 60 * 1000); +} diff --git a/app/display-app/src/reconcile.ts b/app/display-app/src/reconcile.ts new file mode 100644 index 000000000..ec41a4aea --- /dev/null +++ b/app/display-app/src/reconcile.ts @@ -0,0 +1,48 @@ +// Reconciliation for the live event display. +// +// The display seeds bucket counts once from getEventBuckets, then mutates them +// incrementally from socket completion events. Any missed socket event (reconnect +// gap, multi-instance session flap, dropped long-poll cycle) leaves the display +// permanently behind the true total — the historical "numbers don't update live +// / have to refresh the kiosk" bug. Periodic reconciliation against the +// authoritative getEventBuckets snapshot heals that drift without a page refresh. + +export interface DisplayBucket { + name: string; + count: number; + justUpdated: boolean; +} + +export interface FetchedBucket { + bucketName: string; + count: number; +} + +/** + * Merge an authoritative getEventBuckets snapshot into the current display state. + * + * - Corrects counts that drifted BELOW the snapshot (missed socket events) by + * adopting the snapshot value. + * - Adds buckets present only in the snapshot. + * - Is MONOTONIC: never lowers a count the display already shows, because a live + * socket delta may have arrived after the snapshot was taken (the display is + * momentarily ahead). Rewinding would cause a visible backwards jump. + */ +export function reconcileBuckets( + current: DisplayBucket[], + fetched: FetchedBucket[], +): DisplayBucket[] { + const byName = new Map(current.map(b => [b.name, { ...b }])); + + for (const f of fetched) { + const existing = byName.get(f.bucketName); + if (existing) { + // Monotonic: adopt the snapshot only when it is ahead of what we show. + if (f.count > existing.count) existing.count = f.count; + } else { + byName.set(f.bucketName, { name: f.bucketName, count: f.count, justUpdated: false }); + } + } + + return [...byName.values()]; +} diff --git a/db/schema.cds b/db/schema.cds index 8fb19c906..7032edf36 100644 --- a/db/schema.cds +++ b/db/schema.cds @@ -930,6 +930,8 @@ entity TutorialFeedback : managed { ratingVisuals : Integer @assert.range: [0, 10]; npsScore : Integer @assert.range: [0, 10]; comment : String(2000); + isIrrelevant : Boolean default false; + irrelevantReason : String(500); } // Author-supplied code-check material per (tutorial, step). Server-only: diff --git a/db/views.cds b/db/views.cds index 8e6d25edd..6d957769e 100644 --- a/db/views.cds +++ b/db/views.cds @@ -452,7 +452,8 @@ entity TutorialFeedbackAggregate as sum(case when npsScore >= 9 then 1 else 0 end) as promoters : Integer, sum(case when npsScore <= 6 then 1 else 0 end) as detractors : Integer, sum(case when wasAuthenticated = true then 1 else 0 end) as authenticatedCount : Integer - } group by tutorialSlug; + } where isIrrelevant is null or isIrrelevant = false + group by tutorialSlug; // Issue #172 PR 5 — Author observability views. @@ -653,46 +654,46 @@ entity AuthorSurveyDistribution as key 'structure' as dimension : String(20), key ratingStructure as score : Integer, count(*) as responseCount : Integer - } where ratingStructure is not null group by tutorialSlug, ratingStructure) + } where ratingStructure is not null AND (isIrrelevant is null or isIrrelevant = false) group by tutorialSlug, ratingStructure) union all (select from ims.TutorialFeedback { key tutorialSlug, key 'interesting' as dimension : String(20), key ratingInteresting as score : Integer, count(*) as responseCount : Integer - } where ratingInteresting is not null group by tutorialSlug, ratingInteresting) + } where ratingInteresting is not null AND (isIrrelevant is null or isIrrelevant = false) group by tutorialSlug, ratingInteresting) union all (select from ims.TutorialFeedback { key tutorialSlug, key 'useCase' as dimension : String(20), key ratingUseCase as score : Integer, count(*) as responseCount : Integer - } where ratingUseCase is not null group by tutorialSlug, ratingUseCase) + } where ratingUseCase is not null AND (isIrrelevant is null or isIrrelevant = false) group by tutorialSlug, ratingUseCase) union all (select from ims.TutorialFeedback { key tutorialSlug, key 'relevance' as dimension : String(20), key ratingRelevance as score : Integer, count(*) as responseCount : Integer - } where ratingRelevance is not null group by tutorialSlug, ratingRelevance) + } where ratingRelevance is not null AND (isIrrelevant is null or isIrrelevant = false) group by tutorialSlug, ratingRelevance) union all (select from ims.TutorialFeedback { key tutorialSlug, key 'duration' as dimension : String(20), key ratingDuration as score : Integer, count(*) as responseCount : Integer - } where ratingDuration is not null group by tutorialSlug, ratingDuration) + } where ratingDuration is not null AND (isIrrelevant is null or isIrrelevant = false) group by tutorialSlug, ratingDuration) union all (select from ims.TutorialFeedback { key tutorialSlug, key 'visuals' as dimension : String(20), key ratingVisuals as score : Integer, count(*) as responseCount : Integer - } where ratingVisuals is not null group by tutorialSlug, ratingVisuals) + } where ratingVisuals is not null AND (isIrrelevant is null or isIrrelevant = false) group by tutorialSlug, ratingVisuals) union all (select from ims.TutorialFeedback { key tutorialSlug, key 'nps' as dimension : String(20), key npsScore as score : Integer, count(*) as responseCount : Integer - } where npsScore is not null group by tutorialSlug, npsScore); + } where npsScore is not null AND (isIrrelevant is null or isIrrelevant = false) group by tutorialSlug, npsScore); diff --git a/docs/superpowers/plans/2026-10-30-feedback-irrelevant-flag.md b/docs/superpowers/plans/2026-10-30-feedback-irrelevant-flag.md new file mode 100644 index 000000000..9645a3802 --- /dev/null +++ b/docs/superpowers/plans/2026-10-30-feedback-irrelevant-flag.md @@ -0,0 +1,420 @@ +# Feedback Irrelevant Flag Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow admins to mark `TutorialFeedback` entries as irrelevant/inappropriate so they are excluded from all aggregation views and author-visible surfaces. + +**Architecture:** Add a `isIrrelevant` Boolean field (default `false`) and an optional `irrelevantReason` String(500) to `ims.TutorialFeedback`. Filter `WHERE isIrrelevant IS NOT TRUE` into both aggregate views (`TutorialFeedbackAggregate`, `AuthorSurveyDistribution`). Expose `AdminService` actions `markFeedbackIrrelevant(id, reason)` / `unmarkFeedbackIrrelevant(id)` (admin-only) rather than making the entity directly updatable. Update admin-annotations and the feedback digest to honour the flag. Author-service and analytics-service projections inherit the filter automatically through the views. + +**Tech Stack:** CAP CDS (Node.js), Fiori Elements (SAP UI5), Vitest unit tests. + +**Spec:** GitHub issue [#2947](https://github.com/sap-tutorials/tutorials-ims/issues/2947) + +## Global Constraints + +- Never write raw SQL — use CDS QL (`cds.ql`) or CQL. +- Never make `TutorialFeedback` entity directly updatable via OData (retain `Capabilities.UpdateRestrictions: { Updatable: false }`). +- Actions `markFeedbackIrrelevant` / `unmarkFeedbackIrrelevant` must be on `AdminService` only. +- The flag MUST NOT be settable through `DeveloperService` (public submission endpoint). +- Field name: `isIrrelevant : Boolean default false`, reason field: `irrelevantReason : String(500)`. +- `TutorialFeedbackAggregate` and `AuthorSurveyDistribution` must filter `WHERE isIrrelevant IS NOT TRUE` (handles both `false` and `null` / pre-migration rows). +- Feedback digest (`srv/lib/feedback-digest.js`) must exclude irrelevant rows. +- No changes to the public submission flow (`submitTutorialFeedback`). + +## Review Focus + +- **NULL vs false**: `isIrrelevant IS NOT TRUE` must be used (not `= false`) so rows inserted before the migration (HANA NULL) are treated as relevant. Covered by Task 1 view filter test. +- **Aggregate count correctness**: `responseCount` in `TutorialFeedbackAggregate` must not include flagged rows. Covered by Task 1 test. +- **AuthorSurveyDistribution all 7 legs**: each UNION ALL leg must also filter. Covered by Task 1 test. +- **Unauthorized flag attempt**: a non-admin caller to `markFeedbackIrrelevant` must receive 403. The action is declared on `AdminService` whose `@requires` already enforces `Tutorial.Admin`; verified by the cds.test mock-auth assertion in Task 2. +- **Digest skip**: flagged rows with non-empty comments must be excluded from digest computation. Covered by Task 4 test. + +--- + +### Task 1: Add `isIrrelevant` and `irrelevantReason` fields and filter both aggregate views + +**Files:** +- Modify: `db/schema.cds:919-933` +- Modify: `db/views.cds:441-455` (`TutorialFeedbackAggregate`) +- Modify: `db/views.cds:650-698` (`AuthorSurveyDistribution`) +- Test: `srv/__tests__/tutorial-feedback-aggregate.test.js` + +**Interfaces:** +- Produces: `ims.TutorialFeedback.isIrrelevant : Boolean default false` — used by Tasks 2, 3, 4, 5. +- Produces: `ims.TutorialFeedback.irrelevantReason : String(500)` — used by Tasks 3, 5. +- Produces: both views filter `WHERE isIrrelevant IS NOT TRUE` — verified by this task's tests. + +- [ ] **Step 1: Write failing tests** + +Add a new describe block to `srv/__tests__/tutorial-feedback-aggregate.test.js`: + +```js +describe('isIrrelevant filtering', () => { + afterEach(async () => { + const { TutorialFeedback } = cds.entities('com.sap.developers.ims'); + await DELETE.from(TutorialFeedback).where({ tutorialSlug: { like: 'flag-test%' } }); + }); + + it('excludes flagged rows from TutorialFeedbackAggregate responseCount', async () => { + const { TutorialFeedback } = cds.entities('com.sap.developers.ims'); + await INSERT.into(TutorialFeedback).entries([ + { tutorialSlug: 'flag-test-1', npsScore: 9, isIrrelevant: false }, + { tutorialSlug: 'flag-test-1', npsScore: 0, isIrrelevant: true } + ]); + const [row] = await SELECT.from('com.sap.developers.ims.TutorialFeedbackAggregate') + .where({ tutorialSlug: 'flag-test-1' }); + expect(row.responseCount).toBe(1); + expect(row.promoters).toBe(1); + expect(row.detractors).toBe(0); + }); + + it('AuthorSurveyDistribution excludes flagged rows across all legs', async () => { + const { TutorialFeedback } = cds.entities('com.sap.developers.ims'); + await INSERT.into(TutorialFeedback).entries([ + { tutorialSlug: 'flag-test-2', npsScore: 8, ratingStructure: 7, isIrrelevant: false }, + { tutorialSlug: 'flag-test-2', npsScore: 1, ratingStructure: 2, isIrrelevant: true } + ]); + const rows = await SELECT.from('com.sap.developers.ims.AuthorSurveyDistribution') + .where({ tutorialSlug: 'flag-test-2' }); + // Flagged row had scores 1 (nps) and 2 (structure) — neither should appear + expect(rows.every(r => r.score !== 1 && r.score !== 2)).toBe(true); + // Non-flagged row's scores 8 (nps) and 7 (structure) must appear + expect(rows.some(r => r.score === 8)).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test -- --reporter=verbose --testPathPattern=tutorial-feedback-aggregate` +Expected: FAIL — `isIrrelevant` column does not exist. + +- [ ] **Step 3: Add fields to `db/schema.cds` after the `comment` field (line 932)** + +```cds + isIrrelevant : Boolean default false; + irrelevantReason : String(500); +``` + +- [ ] **Step 4: Add `WHERE isIrrelevant IS NOT TRUE` to `TutorialFeedbackAggregate` in `db/views.cds`** + +The view on line 442 (`select from ims.TutorialFeedback { ... } group by tutorialSlug`) gains a `where isIrrelevant IS NOT TRUE` clause between the field list and the `group by`. + +- [ ] **Step 5: Add `AND isIrrelevant IS NOT TRUE` to all 7 UNION ALL legs in `AuthorSurveyDistribution`** + +Each leg has a `where is not null` clause; extend each to `where is not null AND isIrrelevant IS NOT TRUE`. + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `npm test -- --reporter=verbose --testPathPattern=tutorial-feedback-aggregate` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add db/schema.cds db/views.cds srv/__tests__/tutorial-feedback-aggregate.test.js +git commit -m "feat(feedback): add isIrrelevant field and filter aggregate views (#2947)" +``` + +--- + +### Task 2: Expose `markFeedbackIrrelevant` / `unmarkFeedbackIrrelevant` actions on AdminService + +**Files:** +- Modify: `srv/admin-service.cds` (actions section near line 851) +- Modify: `srv/admin-service.js` (add handlers near other action handlers) +- Test: `srv/__tests__/tutorial-feedback.test.js` (new describe block) + +**Interfaces:** +- Consumes: `ims.TutorialFeedback.isIrrelevant`, `ims.TutorialFeedback.irrelevantReason` from Task 1. +- Produces: + - `AdminService.markFeedbackIrrelevant(id: UUID @mandatory, reason?: String(500)) returns {}` + - `AdminService.unmarkFeedbackIrrelevant(id: UUID @mandatory) returns {}` + +- [ ] **Step 1: Write failing tests** + +Add to `srv/__tests__/tutorial-feedback.test.js` after the existing describe blocks: + +```js +describe('markFeedbackIrrelevant / unmarkFeedbackIrrelevant', () => { + let adminSrv; + let feedbackId; + + beforeAll(async () => { + adminSrv = await cds.connect.to('AdminService'); + const { TutorialFeedback } = cds.entities('com.sap.developers.ims'); + const rows = await INSERT.into(TutorialFeedback) + .entries([{ tutorialSlug: 'demo-tutorial', npsScore: 5 }]) + .returning('ID'); + feedbackId = rows[0].ID; + }); + + afterAll(async () => { + const { TutorialFeedback } = cds.entities('com.sap.developers.ims'); + await DELETE.from(TutorialFeedback).where({ ID: feedbackId }); + }); + + it('marks a feedback entry irrelevant with a reason', async () => { + await adminSrv.send('markFeedbackIrrelevant', { id: feedbackId, reason: 'Off-topic' }); + const [row] = await SELECT.from('com.sap.developers.ims.TutorialFeedback').where({ ID: feedbackId }); + expect(row.isIrrelevant).toBe(true); + expect(row.irrelevantReason).toBe('Off-topic'); + }); + + it('unmarks a feedback entry and clears the reason', async () => { + await adminSrv.send('unmarkFeedbackIrrelevant', { id: feedbackId }); + const [row] = await SELECT.from('com.sap.developers.ims.TutorialFeedback').where({ ID: feedbackId }); + expect(row.isIrrelevant).toBe(false); + expect(row.irrelevantReason).toBeNull(); + }); + + it('returns 404 for an unknown id', async () => { + await expect( + adminSrv.send('markFeedbackIrrelevant', { id: '00000000-0000-0000-0000-000000000000' }) + ).rejects.toMatchObject({ code: 404 }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test -- --reporter=verbose --testPathPattern=tutorial-feedback.test` +Expected: FAIL — actions not defined. + +- [ ] **Step 3: Add action declarations to `srv/admin-service.cds`** + +After line 849 (`@readonly entity TutorialFeedbackAggregate ...`), add: + +```cds + action markFeedbackIrrelevant(id : UUID @mandatory, reason : String(500)) returns {}; + action unmarkFeedbackIrrelevant(id : UUID @mandatory) returns {}; +``` + +- [ ] **Step 4: Implement handlers in `srv/admin-service.js`** + +Using `cds.entities('com.sap.developers.ims').TutorialFeedback`, add inside the service init: + +`this.on('markFeedbackIrrelevant', async (req) => { ... })`: +- `const { TutorialFeedback } = cds.entities('com.sap.developers.ims');` +- `const row = await SELECT.one.from(TutorialFeedback).where({ ID: req.data.id });` +- If `!row` → `req.error(404, 'Feedback entry not found')`. +- `await UPDATE(TutorialFeedback).set({ isIrrelevant: true, irrelevantReason: req.data.reason ?? null }).where({ ID: req.data.id });` +- `return {};` + +`this.on('unmarkFeedbackIrrelevant', async (req) => { ... })`: +- Same 404 guard. +- `await UPDATE(TutorialFeedback).set({ isIrrelevant: false, irrelevantReason: null }).where({ ID: req.data.id });` +- `return {};` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npm test -- --reporter=verbose --testPathPattern=tutorial-feedback.test` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add srv/admin-service.cds srv/admin-service.js srv/__tests__/tutorial-feedback.test.js +git commit -m "feat(feedback): add markFeedbackIrrelevant/unmarkFeedbackIrrelevant admin actions (#2947)" +``` + +--- + +### Task 3: Update admin-annotations for the Feedback list and object page + +**Files:** +- Modify: `app/admin-annotations.cds:2206-2286` + +**Interfaces:** +- Consumes: `isIrrelevant`, `irrelevantReason` from Task 1; `AdminService.markFeedbackIrrelevant`, `AdminService.unmarkFeedbackIrrelevant` from Task 2. + +- [ ] **Step 1: Add field labels in the field-level `annotate AdminService.TutorialFeedback with { }` block** + +After the existing label for `tutorialSlug` (line ~2207), add: +```cds + isIrrelevant @Common.Label: 'Irrelevant'; + irrelevantReason @Common.Label: 'Reason'; +``` + +- [ ] **Step 2: Add `isIrrelevant` to `UI.SelectionFields`** + +Change `UI.SelectionFields: [tutorialSlug, wasAuthenticated, submittedAt]` to: +```cds + UI.SelectionFields: [tutorialSlug, wasAuthenticated, submittedAt, isIrrelevant], +``` + +- [ ] **Step 3: Add flag columns and action buttons to `UI.LineItem`** + +In the `UI.LineItem` array (line ~2228), add at the end before the closing `]`: +```cds + { Value: isIrrelevant, Label: 'Irrelevant' }, + { Value: irrelevantReason, Label: 'Reason' }, + { $Type: 'UI.DataFieldForAction', Action: 'AdminService.markFeedbackIrrelevant', Label: 'Mark Irrelevant' }, + { $Type: 'UI.DataFieldForAction', Action: 'AdminService.unmarkFeedbackIrrelevant', Label: 'Unmark Irrelevant' } +``` + +- [ ] **Step 4: Add a `#FlagGroup` FieldGroup** + +After `UI.FieldGroup #CommentGroup` (line ~2250), add: +```cds + UI.FieldGroup #FlagGroup: { Data: [ + { Value: isIrrelevant }, + { Value: irrelevantReason } + ]}, +``` + +- [ ] **Step 5: Add a Facet for the Object Page** + +In `UI.Facets`, add: +```cds + { $Type: 'UI.ReferenceFacet', Label: 'Relevance Flag', Target: '@UI.FieldGroup#FlagGroup' } +``` + +- [ ] **Step 6: Run linter** + +```bash +npx cds lint app/admin-annotations.cds +``` +Expected: no errors. + +- [ ] **Step 7: Commit** + +```bash +git add app/admin-annotations.cds +git commit -m "feat(feedback): expose irrelevant flag in admin Fiori Elements UI (#2947)" +``` + +--- + +### Task 4: Exclude irrelevant rows from the feedback digest + +**Files:** +- Modify: `srv/lib/feedback-digest.js` (`computeFeedbackDigests` function) +- Test: `test/lib/feedback-digest.test.js` + +**Interfaces:** +- Consumes: `isIrrelevant` from Task 1. + +- [ ] **Step 1: Write a failing test** + +Add to the `computeFeedbackDigests` describe block in `test/lib/feedback-digest.test.js`: + +```js +it('excludes irrelevant feedback rows from digest computation', async () => { + const since = new Date(Date.now() - 3_600_000).toISOString(); + const until = new Date(Date.now() + 1000).toISOString(); + const { TutorialFeedback, TutorialMeta } = cds.entities('com.sap.developers.ims'); + await INSERT.into(TutorialMeta).entries([ + { tutorialSlug: 'digest-irr-test', ownerEmail: 'author@example.com' } + ]); + await INSERT.into(TutorialFeedback).entries([ + { tutorialSlug: 'digest-irr-test', comment: 'Great tutorial!', isIrrelevant: false }, + { tutorialSlug: 'digest-irr-test', comment: 'Ignore me', isIrrelevant: true } + ]); + const digests = await computeFeedbackDigests({ since, until }); + const entry = digests.find(d => d.email === 'author@example.com'); + expect(entry?.items).toHaveLength(1); + expect(entry?.items[0].comment).toBe('Great tutorial!'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- --reporter=verbose --testPathPattern=feedback-digest` +Expected: FAIL — irrelevant row is included. + +- [ ] **Step 3: Add filter in `srv/lib/feedback-digest.js`** + +Locate the `SELECT.from(TutorialFeedback)` (or equivalent) in `computeFeedbackDigests`. Add a CQL condition `AND isIrrelevant IS NOT TRUE`. The exact form depends on whether the query uses `.where()` chaining or an inline filter — keep the same style as the rest of the function. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- --reporter=verbose --testPathPattern=feedback-digest` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add srv/lib/feedback-digest.js test/lib/feedback-digest.test.js +git commit -m "feat(feedback): exclude irrelevant entries from owner digest emails (#2947)" +``` + +--- + +### Task 5: Add flag columns to the Tutorials Object Page embedded feedback panel + +**Files:** +- Modify: `app/admin-annotations.cds:2273-2286` (`UI.LineItem #TutorialFeedback` block) + +**Interfaces:** +- Consumes: `isIrrelevant`, `irrelevantReason` from Task 1; actions from Task 2. + +- [ ] **Step 1: Add flag columns to `UI.LineItem #TutorialFeedback`** + +In the `annotate AdminService.TutorialFeedback with @UI: { LineItem #TutorialFeedback: [ ... ] }` block, add at the end: +```cds + { Value: isIrrelevant, Label: 'Irrelevant' }, + { Value: irrelevantReason, Label: 'Reason' }, + { $Type: 'UI.DataFieldForAction', Action: 'AdminService.markFeedbackIrrelevant', Label: 'Mark Irrelevant' }, + { $Type: 'UI.DataFieldForAction', Action: 'AdminService.unmarkFeedbackIrrelevant', Label: 'Unmark Irrelevant' } +``` + +- [ ] **Step 2: Run linter** + +```bash +npx cds lint app/admin-annotations.cds +``` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add app/admin-annotations.cds +git commit -m "feat(feedback): add flag columns to tutorial feedback embedded panel (#2947)" +``` + +--- + +### Task 6: Full suite pass and PR + +- [ ] **Step 1: Run full unit test suite** + +Run: `npm test` +Expected: all tests pass. + +- [ ] **Step 2: Run CDS lint on all modified CDS files** + +```bash +npx cds lint db/schema.cds db/views.cds srv/admin-service.cds app/admin-annotations.cds +``` +Expected: no errors. + +- [ ] **Step 3: Verify author-service and analytics-service tests still pass** + +Run: `npm test -- --reporter=verbose --testPathPattern=author-service-feedback` +Expected: PASS (projections are read-only; no changes to their CDS definitions needed). + +- [ ] **Step 4: Open PR targeting DEV** + +```bash +gh pr create \ + --title "feat(feedback): mark entries as irrelevant/inappropriate (#2947)" \ + --base DEV \ + --body "$(cat <<'EOF' +Closes #2947 + +## What changed +- Added `isIrrelevant` (Boolean, default false) and `irrelevantReason` (String 500) to `TutorialFeedback`. +- Both aggregate views (`TutorialFeedbackAggregate`, `AuthorSurveyDistribution`) now filter flagged rows with `WHERE isIrrelevant IS NOT TRUE`. +- Admin-only actions `markFeedbackIrrelevant` / `unmarkFeedbackIrrelevant` on `AdminService`. +- Fiori Elements list, object page, and embedded tutorial panel updated with flag columns and action buttons. +- Feedback digest excludes flagged rows. + +## Testing +- All unit tests pass. +- Aggregate views exclude flagged rows (new tests, Task 1). +- Mark/unmark actions return correct state; 404 for unknown IDs (new tests, Task 2). +- Digest excludes flagged rows (new test, Task 4). +EOF +)" +``` diff --git a/hugo-apps/src/app-space/AppSpace.vue b/hugo-apps/src/app-space/AppSpace.vue index 6fd026c98..11f6c2571 100644 --- a/hugo-apps/src/app-space/AppSpace.vue +++ b/hugo-apps/src/app-space/AppSpace.vue @@ -9,6 +9,11 @@ import type { GatingItem } from './app-space-gating' // ── Configuration ───────────────────────────────────────────────── const eventId = ref(null) +// #2952: cache-buster bumped on each manual refresh so a newly-unlocked prize +// barcode PNG is re-pulled by the browser instead of served from the HTTP cache +// (the endpoint ignores extra query params; the QR payload itself is stable). +const barcodeNonce = ref(0) + // ── Theme ───────────────────────────────────────────────────────── // Dark mode is driven by html[data-theme] (set pre-paint in head.html from // localStorage/prefers-color-scheme, and toggled at runtime by the header @@ -117,6 +122,7 @@ interface AppSpaceData { const tracks = ref([]) const selectedTrack = ref(null) const loading = ref(true) +const refreshing = ref(false) const isLoggedIn = ref(false) const authenticated = ref(false) const loadError = ref('') @@ -192,14 +198,50 @@ onMounted(async () => { const data = await loadData() if (data) { - tracks.value = data.paths - if (data.eventName) loadedEventName.value = data.eventName - if (data.eventDescription) loadedEventDescription.value = data.eventDescription - hasLogo.value = Boolean(data.hasLogo) + applyData(data) } loading.value = false + + // Restore the selected track from the URL now that tracks are loaded. The + // popstate listener (for back/forward) is registered synchronously in setup + // below — registering lifecycle hooks after an `await` is unsupported. + syncTrackFromUrl() }) +// #2952: apply a loaded payload to component state. Shared by the initial +// onMounted load and the manual Refresh handler so both paths stay identical. +function applyData(data: AppSpaceData) { + tracks.value = data.paths + if (data.eventName) loadedEventName.value = data.eventName + if (data.eventDescription) loadedEventDescription.value = data.eventDescription + hasLogo.value = Boolean(data.hasLogo) +} + +// #2952: re-check completion status (and the barcode unlock gate) without a full +// browser reload. Re-runs the same anonymous-readable getAppSpaceProgress fetch, +// re-applies it, and bumps the barcode nonce so a freshly-unlocked prize PNG is +// re-pulled. A new login state is picked up too (loadData sets authenticated), +// so signing in on another tab then refreshing reveals the prize code. If the +// user was viewing a track, we keep them on it by re-selecting the same track id. +async function refresh() { + if (refreshing.value) return + refreshing.value = true + const openTrackId = selectedTrack.value?.id ?? null + try { + const data = await loadData() + if (data) { + applyData(data) + barcodeNonce.value++ + if (openTrackId != null) { + selectedTrack.value = tracks.value.find(t => t.id === openTrackId) ?? null + } + showToast('Progress updated') + } + } finally { + refreshing.value = false + } +} + // ── Real-time progress (requires login + event context) ─────────── const eventIdForWs = ref('') @@ -461,14 +503,52 @@ function itemStatusClass(track: AppSpaceTrack, index: number): string { return 'step--locked' } +// ── Track ↔ URL sync ────────────────────────────────────────────── +// The selected track is reflected in the `track` query param so that a +// refresh restores the detail view and the browser back/forward buttons +// move between the track list and a track. Existing params (eventId, theme) +// are preserved; `track` is only added/removed. +function trackUrl(trackId: number | null): string { + const params = new URLSearchParams(window.location.search) + if (trackId === null) params.delete('track') + else params.set('track', String(trackId)) + const qs = params.toString() + return window.location.pathname + (qs ? '?' + qs : '') + window.location.hash +} + function selectTrack(track: AppSpaceTrack) { selectedTrack.value = track + window.history.pushState({ track: track.id }, '', trackUrl(track.id)) } function goBack() { selectedTrack.value = null + window.history.pushState({ track: null }, '', trackUrl(null)) } +// Reflect the URL's `track` param into state without touching history +// (used on initial load and when the user navigates with back/forward). +function syncTrackFromUrl() { + const params = new URLSearchParams(window.location.search) + const raw = params.get('track') + if (raw === null) { + selectedTrack.value = null + return + } + const id = Number(raw) + // A stale/unknown id (deleted track, hand-edited URL) falls back to the + // list rather than throwing — degrade gracefully. + selectedTrack.value = tracks.value.find(t => t.id === id) ?? null +} + +// Keep state in sync with browser back/forward. Registered synchronously in +// setup (not inside the async onMounted) because Vue lifecycle hooks must be +// registered during the synchronous setup pass. syncTrackFromUrl is a no-op +// for the list until tracks load; onMounted calls it again post-load. +const onPopState = () => syncTrackFromUrl() +onMounted(() => window.addEventListener('popstate', onPopState)) +onUnmounted(() => window.removeEventListener('popstate', onPopState)) + // Start the deliberate sign-in flow — same destination as the shellbar profile // click in hugo/layouts/partials/header.html: the /signin IdP chooser, carrying // a safe relative returnTo so the user lands back on this page after auth. @@ -484,7 +564,10 @@ function handleItemClick(track: AppSpaceTrack, index: number, item: AppSpaceItem } function checkpointBarcodeUrl(): string { - return _checkpointBarcodeUrl(eventId.value) + const base = _checkpointBarcodeUrl(eventId.value) + // #2952: append the refresh nonce so a just-unlocked barcode re-pulls instead + // of being served stale from cache. nonce 0 (initial render) keeps the clean URL. + return barcodeNonce.value ? `${base}&_=${barcodeNonce.value}` : base } function shouldShowCheckpointBarcode(track: AppSpaceTrack, index: number): boolean { @@ -618,7 +701,22 @@ const emptyStateMessage = computed(() => {

Choose Your Track

- {{ completedTracks }} of {{ tracks.length }} tracks completed +
+ {{ completedTracks }} of {{ tracks.length }} tracks completed + +

{{ selectedTrack.description }}

@@ -1058,7 +1169,7 @@ const emptyStateMessage = computed(() => { .hero { background: var(--sapShellColor, #354a5f); color: var(--sapShell_TextColor, #fff); - padding: 2.5rem 2rem 1.5rem; + padding: 0.75rem 2rem; position: relative; } @@ -1147,7 +1258,7 @@ const emptyStateMessage = computed(() => { .hero-instructions { max-width: 1200px; - margin: 1.5rem auto 0; + margin: 0.75rem auto 0; display: flex; gap: 1.5rem; flex-wrap: wrap; @@ -1220,6 +1331,59 @@ const emptyStateMessage = computed(() => { color: var(--sapContent_LabelColor, #556b82); } +/* #2952: manual progress refresh affordance (overview + detail headers). */ +.section-header__meta { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.refresh-button { + all: unset; + box-sizing: border-box; + display: inline-flex; + align-items: center; + gap: 0.375rem; + cursor: pointer; + font-size: 0.8125rem; + font-weight: 600; + color: var(--sapButton_Lite_TextColor, var(--sapBrandColor, #0070f2)); + padding: 0.3125rem 0.75rem; + border: 1px solid var(--sapButton_BorderColor, #d9d9d9); + border-radius: var(--sapButton_BorderCornerRadius, 0.5rem); + background: var(--sapButton_Background, transparent); + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; +} + +.refresh-button:hover:not(:disabled) { + border-color: var(--sapBrandColor, #0070f2); + background: var(--sapButton_Hover_Background, rgba(0, 112, 242, 0.06)); +} + +.refresh-button:focus-visible { + outline: 2px solid var(--sapBrandColor, #0070f2); + outline-offset: 2px; +} + +.refresh-button:disabled { + cursor: default; + opacity: 0.6; +} + +.refresh-button__icon { + flex-shrink: 0; +} + +.refresh-button__icon.spinning { + animation: refresh-spin 0.8s linear infinite; +} + +@keyframes refresh-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + /* ── Empty / not-configured state ──────────────────────── */ .empty-state { display: flex; @@ -1977,7 +2141,7 @@ const emptyStateMessage = computed(() => { @media (max-width: 640px) { .hero { - padding: 1.5rem 1rem 1rem; + padding: 0.75rem 1rem; } .content-area { diff --git a/hugo-apps/src/app-space/__tests__/AppSpace.refresh.test.ts b/hugo-apps/src/app-space/__tests__/AppSpace.refresh.test.ts new file mode 100644 index 000000000..1b0335c63 --- /dev/null +++ b/hugo-apps/src/app-space/__tests__/AppSpace.refresh.test.ts @@ -0,0 +1,94 @@ +// @vitest-environment happy-dom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' + +// Stub the realtime composable so mounting does not open a real socket. +vi.mock('../useRealtimeProgress', async () => { + const { ref } = await import('vue') + return { useRealtimeProgress: () => ({ lastCompletion: ref(null), connected: ref(false) }) } +}) + +import AppSpace from '../AppSpace.vue' + +const makePayload = (authenticated: boolean, allComplete: boolean) => ({ + eventId: 10000005, + eventName: 'TechEd 2026', + eventType: 'TECHED', + authenticated, + type: 'COMPLEX', + paths: [{ + id: 1, title: 'Track A', description: '', items: [ + { imsId: 1, title: 'Tut 1', type: 'TUTORIAL', status: allComplete ? 'COMPLETED' : '', progress: allComplete ? 100 : 0, experience: '', timeToComplete: 0, url: '/tutorials/x.html', description: '' }, + { imsId: 99, title: 'Checkpoint', type: 'CHECKPOINT', status: '', progress: 0, experience: '', timeToComplete: 0, url: '', description: '' }, + ] + }] +}) + +// Re-reads a mutable completion flag on every getAppSpaceProgress fetch so a +// Refresh can observe server state that changed between calls. +function stubFetchDynamic(getBody: () => unknown) { + vi.stubGlobal('fetch', vi.fn(async (input: unknown) => { + const url = String(input) + if (url.includes('getAppSpaceProgress')) { + return { ok: true, status: 200, json: async () => getBody() } + } + return { ok: false, status: 404, json: async () => ({}) } + }) as unknown as typeof fetch) +} + +describe('AppSpace refresh button (#2952)', () => { + beforeEach(() => { + vi.restoreAllMocks() + window.history.replaceState({}, '', '/app-space/?eventId=10000005') + }) + + it('renders a Refresh button in the overview header', async () => { + stubFetchDynamic(() => makePayload(true, false)) + const wrapper = mount(AppSpace) + await flushPromises() + + const btn = wrapper.find('.section-header .refresh-button') + expect(btn.exists()).toBe(true) + expect(btn.text()).toContain('Refresh') + }) + + it('re-fetches progress on click and reveals the barcode once the track completes', async () => { + let complete = false + stubFetchDynamic(() => makePayload(true, complete)) + + const wrapper = mount(AppSpace) + await flushPromises() + + // Enter the track detail view; checkpoint is locked, no barcode yet. + await wrapper.find('.track-card').trigger('click') + await flushPromises() + expect(wrapper.find('.checkpoint-barcode__img').exists()).toBe(false) + + // Server-side completion lands; user hits Refresh. + complete = true + await wrapper.find('.detail-title-row .refresh-button').trigger('click') + await flushPromises() + + const img = wrapper.find('.checkpoint-barcode__img') + expect(img.exists()).toBe(true) + // Barcode URL is cache-busted after a manual refresh. + expect(img.attributes('src')).toMatch(/\/api\/my-prize-barcode\?eventId=10000005&_=\d+/) + }) + + it('keeps the user on the open track across a refresh', async () => { + stubFetchDynamic(() => makePayload(true, false)) + const wrapper = mount(AppSpace) + await flushPromises() + + await wrapper.find('.track-card').trigger('click') + await flushPromises() + expect(wrapper.find('.detail-title').text()).toBe('Track A') + + await wrapper.find('.detail-title-row .refresh-button').trigger('click') + await flushPromises() + + // Still in the detail view for the same track, not bounced to the overview. + expect(wrapper.find('.detail-title').exists()).toBe(true) + expect(wrapper.find('.detail-title').text()).toBe('Track A') + }) +}) diff --git a/hugo-apps/src/app-space/__tests__/AppSpace.track-url.test.ts b/hugo-apps/src/app-space/__tests__/AppSpace.track-url.test.ts new file mode 100644 index 000000000..5b13c4321 --- /dev/null +++ b/hugo-apps/src/app-space/__tests__/AppSpace.track-url.test.ts @@ -0,0 +1,122 @@ +// @vitest-environment happy-dom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' + +// Regression (#app-space-track-url): selecting a track must be reflected in a +// `?track=` query param so a page refresh restores the detail view and the +// browser back/forward buttons move between the list and a track. Previously +// selectTrack/goBack only mutated local state, so refresh reset to the list and +// history navigation did nothing. + +vi.mock('../useRealtimeProgress', async () => { + const { ref } = await import('vue') + return { useRealtimeProgress: () => ({ lastCompletion: ref(null), connected: ref(false) }) } +}) + +import AppSpace from '../AppSpace.vue' + +const eventBody = { + eventId: 10000002, + eventName: 'Track URL Event', + eventDescription: '

ok

', + eventType: 'DEVTOBERFEST', + authenticated: false, + type: 'COMPLEX', + paths: [ + { id: 11, title: 'Track A', description: '', items: [ + { imsId: 1, title: 'Tut 1', type: 'TUTORIAL', status: '', progress: 0, experience: '', timeToComplete: 0, url: '/tutorials/x.html', description: '' } + ] }, + { id: 22, title: 'Track B', description: '', items: [ + { imsId: 2, title: 'Tut 2', type: 'TUTORIAL', status: '', progress: 0, experience: '', timeToComplete: 0, url: '/tutorials/y.html', description: '' } + ] }, + ], +} + +function stubFetch() { + const fetchSpy = vi.fn(async (input: unknown) => { + const url = String(input) + if (url.includes('getAppSpaceProgress')) return { ok: true, status: 200, json: async () => eventBody } + return { ok: false, status: 404, json: async () => ({}) } + }) + vi.stubGlobal('fetch', fetchSpy as unknown as typeof fetch) + return fetchSpy +} + +describe('AppSpace track ↔ URL sync', () => { + beforeEach(() => { + vi.restoreAllMocks() + window.history.replaceState({}, '', '/app-space/?eventId=10000002') + }) + + it('restores the selected track from ?track= on load, preserving other params', async () => { + window.history.replaceState({}, '', '/app-space/?eventId=10000002&track=22') + stubFetch() + + const wrapper = mount(AppSpace) + await flushPromises() + + // Detail view for Track B is shown (the `.detail-title` element only renders + // in the track-detail branch), not the track list. + expect(wrapper.find('.detail-title').exists()).toBe(true) + expect(wrapper.find('.detail-title').text()).toBe('Track B') + expect(wrapper.find('.track-grid').exists()).toBe(false) + }) + + it('adds ?track= when a track is selected and keeps eventId', async () => { + stubFetch() + const wrapper = mount(AppSpace) + await flushPromises() + + const vm = wrapper.vm as unknown as { selectTrack: (t: { id: number }) => void; tracks: { id: number }[] } + vm.selectTrack(vm.tracks.find(t => t.id === 11)!) + await flushPromises() + + const params = new URLSearchParams(window.location.search) + expect(params.get('track')).toBe('11') + expect(params.get('eventId')).toBe('10000002') + }) + + it('removes ?track on goBack', async () => { + window.history.replaceState({}, '', '/app-space/?eventId=10000002&track=11') + stubFetch() + const wrapper = mount(AppSpace) + await flushPromises() + + const vm = wrapper.vm as unknown as { goBack: () => void } + vm.goBack() + await flushPromises() + + const params = new URLSearchParams(window.location.search) + expect(params.get('track')).toBeNull() + expect(params.get('eventId')).toBe('10000002') + }) + + it('syncs state on popstate (back/forward)', async () => { + stubFetch() + const wrapper = mount(AppSpace) + await flushPromises() + + const vm = wrapper.vm as unknown as { selectTrack: (t: { id: number }) => void; tracks: { id: number }[] } + vm.selectTrack(vm.tracks.find(t => t.id === 22)!) + await flushPromises() + expect(wrapper.find('.detail-title').text()).toBe('Track B') + + // Simulate the back button: URL returns to the list, popstate fires. + window.history.replaceState({}, '', '/app-space/?eventId=10000002') + window.dispatchEvent(new PopStateEvent('popstate')) + await flushPromises() + expect(wrapper.find('.detail-title').exists()).toBe(false) + expect(wrapper.find('.track-grid').exists()).toBe(true) + }) + + it('falls back to the list for an unknown track id', async () => { + window.history.replaceState({}, '', '/app-space/?eventId=10000002&track=999') + stubFetch() + const wrapper = mount(AppSpace) + await flushPromises() + + // Unknown id → list view (track grid), no crash, no detail. + expect(wrapper.find('.track-grid').exists()).toBe(true) + expect(wrapper.find('.detail-title').exists()).toBe(false) + }) +}) diff --git a/hugo-apps/src/scanner-vue/useScannerApi.ts b/hugo-apps/src/scanner-vue/useScannerApi.ts index 54c78c767..cdafb66f7 100644 --- a/hugo-apps/src/scanner-vue/useScannerApi.ts +++ b/hugo-apps/src/scanner-vue/useScannerApi.ts @@ -35,6 +35,17 @@ export function claimRequest(uid: string, eventId: number): { url: string; metho } } +// When the XSUAA session expires the AppRouter returns 401 (or a 302 → HTML +// login page that fetch follows, ending up non-ok with no JSON body). Rather +// than showing a cryptic error, reload so the AppRouter can redirect to login. +function redirectToLoginIfUnauthorized(status: number): boolean { + if (status === 401) { + window.location.reload() + return true + } + return false +} + // The approuter enforces CSRF protection on non-GET requests to /scanner/* // (that route has no "csrfProtection": false in xs-app.json). The UI5 scanner // works because sap.ui.model.odata.v4.ODataModel does the fetch-token handshake @@ -48,6 +59,7 @@ async function fetchCsrfToken(): Promise { // The OData v4 service document is a cheap GET under the same /scanner/ route // and auth scope, so the approuter issues a token for it. const res = await fetch('/scanner/', { headers: { 'x-csrf-token': 'fetch' } }) + if (redirectToLoginIfUnauthorized(res.status)) return null const token = res.headers?.get?.('x-csrf-token') ?? null if (token && token.toLowerCase() !== 'required') csrfToken = token return csrfToken @@ -63,6 +75,7 @@ export function useScannerApi() { error.value = null try { const res = await fetch(`/scanner/getContestant(accountNumber='${encodeURIComponent(uid)}',eventId=${eventId})`) + if (redirectToLoginIfUnauthorized(res.status)) return Promise.reject(new Error('Session expired')) if (!res.ok) { const body = await res.json().catch(() => ({})) throw new Error(body.error?.message || `HTTP ${res.status}`) @@ -76,6 +89,7 @@ export function useScannerApi() { async function getEventStats(eventId: number): Promise { const res = await fetch(`/scanner/getEventStats(eventId=${eventId})`) + if (redirectToLoginIfUnauthorized(res.status)) return Promise.reject(new Error('Session expired')) if (!res.ok) { const body = await res.json().catch(() => ({})) throw new Error(body.error?.message || `HTTP ${res.status}`) @@ -99,12 +113,17 @@ export function useScannerApi() { } let token = await fetchCsrfToken() + if (token === null && !csrfToken) return Promise.reject(new Error('Session expired')) let res = await postOnce(token) + if (redirectToLoginIfUnauthorized(res.status)) return Promise.reject(new Error('Session expired')) + if (res.status === 403 && (res.headers?.get?.('x-csrf-token') ?? '').toLowerCase() === 'required') { csrfToken = null token = await fetchCsrfToken() + if (token === null && !csrfToken) return Promise.reject(new Error('Session expired')) res = await postOnce(token) + if (redirectToLoginIfUnauthorized(res.status)) return Promise.reject(new Error('Session expired')) } if (!res.ok) { diff --git a/hugo/data/whats_new.json b/hugo/data/whats_new.json index e81ad377e..bb956944a 100644 --- a/hugo/data/whats_new.json +++ b/hugo/data/whats_new.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-10-27T20:17:44.757Z", + "generatedAt": "2026-11-01T12:18:20.851Z", "repos": [ { "key": "tutorials-ims", @@ -8,6 +8,201 @@ } ], "entries": [ + { + "id": "tutorials-ims#2962", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2962, + "title": "Builds keep working when the advocates service is briefly unavailable", + "summary": "The site build now falls back to the last known Developer Advocates roster if the live service can't be reached, so a transient backend blip no longer blocks a deployment.", + "category": "Fix", + "mergedAt": "2026-11-01T10:42:07Z", + "week": "2026-W44", + "weekStart": "2026-10-26", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2962" + }, + { + "id": "tutorials-ims#2961", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2961, + "title": "Reliable Node.js runtime selection at deploy time", + "summary": "Pinned the exact Node.js runtime version so the platform stages cleanly on every deploy instead of occasionally rejecting the version range.", + "category": "Fix", + "mergedAt": "2026-11-01T10:22:32Z", + "week": "2026-W44", + "weekStart": "2026-10-26", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2961" + }, + { + "id": "tutorials-ims#2960", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2960, + "title": "Barcode scanner sends you to sign in when your session expires", + "summary": "The event barcode scanner now redirects you to the login page when your session times out, instead of silently failing.", + "category": "Fix", + "mergedAt": "2026-10-30T19:50:55Z", + "week": "2026-W44", + "weekStart": "2026-10-26", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2960" + }, + { + "id": "tutorials-ims#2958", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2958, + "title": "fix(test): repair 3 CI unit test failures", + "summary": "Repaired a few automated tests to keep the build pipeline green.", + "category": "Maintenance", + "mergedAt": "2026-10-30T19:26:42Z", + "week": "2026-W44", + "weekStart": "2026-10-26", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2958" + }, + { + "id": "tutorials-ims#2959", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2959, + "title": "Flag tutorial feedback as irrelevant or inappropriate", + "summary": "Reviewers can now mark individual pieces of tutorial feedback as irrelevant or inappropriate, keeping the feedback queue focused on actionable comments.", + "category": "Feature", + "mergedAt": "2026-10-30T19:24:37Z", + "week": "2026-W44", + "weekStart": "2026-10-26", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2959" + }, + { + "id": "tutorials-ims#2957", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2957, + "title": "Refresh your completion status on the event app", + "summary": "A new Refresh button on the event app lets you re-check your completion status and show your barcode on demand, without reloading the page.", + "category": "Feature", + "mergedAt": "2026-10-30T14:15:41Z", + "week": "2026-W44", + "weekStart": "2026-10-26", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2957" + }, + { + "id": "tutorials-ims#2956", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2956, + "title": "fix(ci): handle multi-slug source errors in QA rebuild (#2951)", + "summary": "Hardened the content preview rebuild so a problem in one tutorial no longer derails rebuilds that touch several at once.", + "category": "Fix", + "mergedAt": "2026-10-30T14:15:06Z", + "week": "2026-W44", + "weekStart": "2026-10-26", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2956" + }, + { + "id": "tutorials-ims#2955", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2955, + "title": "More accurate live kiosk displays", + "summary": "Fixed two issues that could leave event kiosk displays stale or miscount per-hour activity, so on-site screens now stay accurate throughout an event.", + "category": "Fix", + "mergedAt": "2026-10-30T08:40:31Z", + "week": "2026-W44", + "weekStart": "2026-10-26", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2955" + }, + { + "id": "tutorials-ims#2954", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2954, + "title": "Correct spelled-out numbers across more languages", + "summary": "Event displays now spell out numbers correctly in Arabic, Hebrew, English, and Mandarin.", + "category": "Fix", + "mergedAt": "2026-10-30T08:37:44Z", + "week": "2026-W44", + "weekStart": "2026-10-26", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2954" + }, + { + "id": "tutorials-ims#2953", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2953, + "title": "More missions visible on the event app", + "summary": "Trimmed the hero banner padding on the event app so more missions are visible without scrolling.", + "category": "Fix", + "mergedAt": "2026-10-30T08:04:33Z", + "week": "2026-W44", + "weekStart": "2026-10-26", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2953" + }, + { + "id": "tutorials-ims#2949", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2949, + "title": "Security hardening of the Node.js runtime", + "summary": "Raised the minimum Node.js runtime to a patched version that addresses a known Brotli decompression vulnerability (CVE-2026-111497).", + "category": "Fix", + "mergedAt": "2026-10-29T16:22:02Z", + "week": "2026-W44", + "weekStart": "2026-10-26", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2949" + }, + { + "id": "tutorials-ims#2950", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2950, + "title": "Selected track survives refresh and back/forward on the event app", + "summary": "The event app now remembers your selected track in the page URL, so refreshing or using the browser back/forward buttons keeps you where you were.", + "category": "Fix", + "mergedAt": "2026-10-29T16:21:21Z", + "week": "2026-W44", + "weekStart": "2026-10-26", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2950" + }, + { + "id": "tutorials-ims#2948", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2948, + "title": "Correct Architecture Center links", + "summary": "Architecture Center tutorial links now use the correct page slugs, so those links resolve to the right pages.", + "category": "Fix", + "mergedAt": "2026-10-29T15:27:32Z", + "week": "2026-W44", + "weekStart": "2026-10-26", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2948" + }, + { + "id": "tutorials-ims#2946", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2946, + "title": "release 1.41.7: event-display realtime + Ukrainian fixes", + "summary": "Released version 1.41.7 with event-display realtime and language fixes.", + "category": "Maintenance", + "mergedAt": "2026-10-27T20:25:42Z", + "week": "2026-W44", + "weekStart": "2026-10-26", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2946" + }, + { + "id": "tutorials-ims#2945", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2945, + "title": "chore(release): bump MTA version to 1.41.7", + "summary": "Internal release version bump to 1.41.7.", + "category": "Maintenance", + "mergedAt": "2026-10-27T20:23:06Z", + "week": "2026-W44", + "weekStart": "2026-10-26", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2945" + }, { "id": "tutorials-ims#2944", "repo": "tutorials-ims", diff --git a/package.json b/package.json index 337cc9427..d52d7a231 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "engines": { - "node": ">=22.12" + "node": "22.23.4" }, "scripts": { "start": "cds-serve", diff --git a/scripts/fetch-advocates.ts b/scripts/fetch-advocates.ts index 92525aa32..60bef15ba 100644 --- a/scripts/fetch-advocates.ts +++ b/scripts/fetch-advocates.ts @@ -8,7 +8,7 @@ * * Spec: docs/superpowers/specs/2026-06-27-601-advocate-profile-pages-design.md */ -import { writeFileSync, readdirSync, unlinkSync, mkdirSync } from 'node:fs'; +import { writeFileSync, readdirSync, unlinkSync, mkdirSync, existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { createHash } from 'node:crypto'; import MarkdownIt from 'markdown-it'; @@ -137,10 +137,20 @@ if (process.argv[1]?.endsWith('fetch-advocates.ts') || process.argv[1]?.endsWith const contentDir = join(repoRoot, 'hugo', 'content', 'developer-advocates'); const cacheDir = join(repoRoot, '.tutorial-cache'); + const cacheFile = join(cacheDir, 'advocates-roster.json'); const fetcher = async () => { - const res = await fetch(`${CAP_BASE_URL}/api/advocates`); - if (!res.ok) throw new Error(`fetch /api/advocates: ${res.status}`); - return res.json(); + try { + const res = await fetch(`${CAP_BASE_URL}/api/advocates`); + if (!res.ok) throw new Error(`fetch /api/advocates: ${res.status}`); + return res.json(); + } catch (err) { + if (existsSync(cacheFile)) { + console.warn(`[fetch-advocates] live fetch failed (${(err as Error).message}); using cached roster`); + const cached = JSON.parse(readFileSync(cacheFile, 'utf8')); + return { advocates: cached.roster ?? [] }; + } + throw err; + } }; runFetchAdvocates({ fetcher, contentDir, cacheDir }) diff --git a/srv-mcp/package.json b/srv-mcp/package.json index dcde98244..aedc7125f 100644 --- a/srv-mcp/package.json +++ b/srv-mcp/package.json @@ -5,7 +5,7 @@ "type": "module", "main": "server.js", "engines": { - "node": ">=22.12" + "node": "22.23.4" }, "scripts": { "start": "cds-serve" diff --git a/srv-qa/package-lock.json b/srv-qa/package-lock.json index a522e292b..0fad35a68 100644 --- a/srv-qa/package-lock.json +++ b/srv-qa/package-lock.json @@ -21,7 +21,7 @@ "undici": "8.9.0" }, "engines": { - "node": ">=22.12" + "node": ">=22.23.4 <23" } }, "node_modules/@babel/code-frame": { diff --git a/srv-qa/package.json b/srv-qa/package.json index c24c7f235..7a7a41d2f 100644 --- a/srv-qa/package.json +++ b/srv-qa/package.json @@ -4,7 +4,7 @@ "main": "server.js", "type": "module", "engines": { - "node": ">=22.12" + "node": "22.23.4" }, "scripts": { "start": "cds-serve" diff --git a/srv/__tests__/tutorial-feedback-aggregate.test.js b/srv/__tests__/tutorial-feedback-aggregate.test.js index 862768ecb..e360e1d92 100644 --- a/srv/__tests__/tutorial-feedback-aggregate.test.js +++ b/srv/__tests__/tutorial-feedback-aggregate.test.js @@ -98,3 +98,37 @@ describe('TutorialFeedbackAggregate', () => { expect(Number(row.avgRelevance)).toBeCloseTo((9 + 4) / 2, 2); }); }); + +describe('isIrrelevant filtering', () => { + afterEach(async () => { + const { TutorialFeedback } = cds.entities('com.sap.developers.ims'); + await DELETE.from(TutorialFeedback).where({ tutorialSlug: { like: 'flag-test%' } }); + }); + + it('excludes flagged rows from TutorialFeedbackAggregate responseCount', async () => { + const { TutorialFeedback } = cds.entities('com.sap.developers.ims'); + await INSERT.into(TutorialFeedback).entries([ + { tutorialSlug: 'flag-test-1', npsScore: 9, isIrrelevant: false }, + { tutorialSlug: 'flag-test-1', npsScore: 0, isIrrelevant: true } + ]); + const [row] = await SELECT.from('com.sap.developers.ims.TutorialFeedbackAggregate') + .where({ tutorialSlug: 'flag-test-1' }); + expect(row.responseCount).toBe(1); + expect(row.promoters).toBe(1); + expect(row.detractors).toBe(0); + }); + + it('AuthorSurveyDistribution excludes flagged rows across all legs', async () => { + const { TutorialFeedback } = cds.entities('com.sap.developers.ims'); + await INSERT.into(TutorialFeedback).entries([ + { tutorialSlug: 'flag-test-2', npsScore: 8, ratingStructure: 7, isIrrelevant: false }, + { tutorialSlug: 'flag-test-2', npsScore: 1, ratingStructure: 2, isIrrelevant: true } + ]); + const rows = await SELECT.from('com.sap.developers.ims.AuthorSurveyDistribution') + .where({ tutorialSlug: 'flag-test-2' }); + // Flagged row had scores 1 (nps) and 2 (structure) — neither should appear + expect(rows.every(r => r.score !== 1 && r.score !== 2)).toBe(true); + // Non-flagged row's scores 8 (nps) and 7 (structure) must appear + expect(rows.some(r => r.score === 8)).toBe(true); + }); +}); diff --git a/srv/__tests__/tutorial-feedback.test.js b/srv/__tests__/tutorial-feedback.test.js index f9ef49747..7f2914974 100644 --- a/srv/__tests__/tutorial-feedback.test.js +++ b/srv/__tests__/tutorial-feedback.test.js @@ -4,7 +4,7 @@ import cds from '@sap/cds'; process.env.SUBMISSION_SALT_SECRET = 'test-secret'; -cds.test('serve', '--project', '.', '--in-memory'); +const project = cds.test('serve', '--project', '.', '--in-memory'); describe('submitTutorialFeedback', () => { let srv; @@ -271,3 +271,52 @@ describe('submitTutorialFeedback', () => { expect(fresh).toBe(200); }); }); + +describe('markFeedbackIrrelevant / unmarkFeedbackIrrelevant', () => { + let feedbackId; + const auth = { auth: { username: 'admin', password: 'admin' } }; + + beforeAll(async () => { + const { TutorialFeedback } = cds.entities('com.sap.developers.ims'); + feedbackId = cds.utils.uuid(); + await INSERT.into(TutorialFeedback).entries([{ ID: feedbackId, tutorialSlug: 'demo-tutorial', npsScore: 5 }]); + }); + + afterAll(async () => { + const { TutorialFeedback } = cds.entities('com.sap.developers.ims'); + await DELETE.from(TutorialFeedback).where({ ID: feedbackId }); + }); + + it('marks a feedback entry irrelevant with a reason', async () => { + const { status } = await project.post( + `/admin/TutorialFeedback(${feedbackId})/markFeedbackIrrelevant`, + { reason: 'Off-topic' }, + { ...auth, validateStatus: () => true } + ); + expect(status).toBe(200); + const [row] = await SELECT.from('com.sap.developers.ims.TutorialFeedback').where({ ID: feedbackId }); + expect(row.isIrrelevant).toBe(true); + expect(row.irrelevantReason).toBe('Off-topic'); + }); + + it('unmarks a feedback entry and clears the reason', async () => { + const { status } = await project.post( + `/admin/TutorialFeedback(${feedbackId})/unmarkFeedbackIrrelevant`, + {}, + { ...auth, validateStatus: () => true } + ); + expect(status).toBe(200); + const [row] = await SELECT.from('com.sap.developers.ims.TutorialFeedback').where({ ID: feedbackId }); + expect(row.isIrrelevant).toBe(false); + expect(row.irrelevantReason).toBeNull(); + }); + + it('returns 404 for an unknown id', async () => { + const { status } = await project.post( + `/admin/TutorialFeedback(00000000-0000-0000-0000-000000000000)/markFeedbackIrrelevant`, + {}, + { ...auth, validateStatus: () => true } + ); + expect(status).toBe(404); + }); +}); diff --git a/srv/admin-service.cds b/srv/admin-service.cds index 72406c32b..5d36a5085 100644 --- a/srv/admin-service.cds +++ b/srv/admin-service.cds @@ -845,7 +845,10 @@ service AdminService { @readonly entity PipelineLogItems as projection on ims.PipelineLogItems; @readonly entity JobLogItems as projection on ims.JobLogItems; - @readonly entity TutorialFeedback as projection on ims.TutorialFeedback; + @readonly entity TutorialFeedback as projection on ims.TutorialFeedback actions { + action markFeedbackIrrelevant(reason : String(500)) returns {}; + action unmarkFeedbackIrrelevant() returns {}; + }; @readonly entity TutorialFeedbackAggregate as projection on ims.TutorialFeedbackAggregate; // --- Admin actions --- diff --git a/srv/admin-service.js b/srv/admin-service.js index c19742763..d01f9c22c 100644 --- a/srv/admin-service.js +++ b/srv/admin-service.js @@ -1778,6 +1778,28 @@ export default class AdminService extends cds.ApplicationService { return result; }); + // --- Feedback moderation --- + + this.on('markFeedbackIrrelevant', 'TutorialFeedback', async (req) => { + const { TutorialFeedback } = cds.entities('com.sap.developers.ims'); + const id = req.params[0]?.ID ?? req.params[0]; + if (!id) return req.error(400, 'Missing entity key'); + const row = await SELECT.one.from(TutorialFeedback).where({ ID: id }); + if (!row) return req.error(404, 'Feedback entry not found'); + await UPDATE(TutorialFeedback).set({ isIrrelevant: true, irrelevantReason: req.data.reason ?? null }).where({ ID: id }); + return {}; + }); + + this.on('unmarkFeedbackIrrelevant', 'TutorialFeedback', async (req) => { + const { TutorialFeedback } = cds.entities('com.sap.developers.ims'); + const id = req.params[0]?.ID ?? req.params[0]; + if (!id) return req.error(400, 'Missing entity key'); + const row = await SELECT.one.from(TutorialFeedback).where({ ID: id }); + if (!row) return req.error(404, 'Feedback entry not found'); + await UPDATE(TutorialFeedback).set({ isIrrelevant: false, irrelevantReason: null }).where({ ID: id }); + return {}; + }); + // clearChangeLog — bulk-purge sap.changelog.Changes rows. Designed for // admins to clear migration-trigger noise (74k+ rows on DEV today from // migrate-from-hana.js DB triggers) without waiting for the weekly cron. diff --git a/srv/lib/feedback-digest.js b/srv/lib/feedback-digest.js index c3ee34ad6..bf234f33a 100644 --- a/srv/lib/feedback-digest.js +++ b/srv/lib/feedback-digest.js @@ -149,7 +149,7 @@ export async function computeFeedbackDigests({ since, until }, db = cds.db) { const { TutorialFeedback } = cds.entities(NS); const rows = await db.run( SELECT.from(TutorialFeedback) - .where`submittedAt > ${since} and submittedAt <= ${until}` + .where`submittedAt > ${since} and submittedAt <= ${until} and (isIrrelevant is null or isIrrelevant = false)` .orderBy('submittedAt') ); diff --git a/srv/lib/help-docs/architecture-sap-com-fetcher.js b/srv/lib/help-docs/architecture-sap-com-fetcher.js index 1a5ee5621..d7b881ee7 100644 --- a/srv/lib/help-docs/architecture-sap-com-fetcher.js +++ b/srv/lib/help-docs/architecture-sap-com-fetcher.js @@ -29,7 +29,9 @@ export function _resetForTests() { globalThis[SYM].mockFetcher = null; } * @property {string} sourceId — repo-relative path, e.g. 'docs/ref-arch/RA0001.md' * @property {string} title * @property {string} description — stripped body first 2000 chars - * @property {string} url — https://architecture.learning.sap.com/ + * @property {string} url — live site URL: honors the Docusaurus + * `slug:` frontmatter (`${SITE_BASE}/docs${slug}`) when present; falls back + * to the repo-path-derived URL otherwise. * @property {'architecture'} product * @property {null} section */ @@ -65,7 +67,7 @@ export async function fetchArchitectureSapComCorpus({ continue; } - const { frontmatterTitle, body } = parseFrontmatter(raw); + const { frontmatterTitle, slug, body } = parseFrontmatter(raw); const filenameTitle = blob.path.split('/').pop().replace(/\.mdx?$/, ''); const title = frontmatterTitle || extractH1(body) || filenameTitle; @@ -77,7 +79,7 @@ export async function fetchArchitectureSapComCorpus({ sourceId: blob.path, title, description, - url: `${SITE_BASE}/${blob.path.replace(/\.mdx?$/, '')}`, + url: buildDocUrl(blob.path, slug), product: 'architecture', section: null, }); @@ -123,13 +125,29 @@ async function fetchRaw(path) { function parseFrontmatter(raw) { const m = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/); - if (!m) return { frontmatterTitle: null, body: raw }; + if (!m) return { frontmatterTitle: null, slug: null, body: raw }; const fm = m[1]; const titleMatch = fm.match(/^title:\s*(.+)$/m); const frontmatterTitle = titleMatch ? titleMatch[1].trim().replace(/^["']|["']$/g, '') : null; - return { frontmatterTitle, body: raw.slice(m[0].length) }; + const slugMatch = fm.match(/^slug:\s*(.+)$/m); + const slug = slugMatch + ? slugMatch[1].trim().replace(/^["']|["']$/g, '') + : null; + return { frontmatterTitle, slug, body: raw.slice(m[0].length) }; +} + +// The live Docusaurus site routes a doc by its `slug:` frontmatter when set, +// served under the `/docs` prefix (slugs are root-absolute, e.g. +// `/ref-arch/98efa0`). Without a slug it falls back to a path-derived URL — +// not always what Docusaurus auto-routes, but the best available guess. +function buildDocUrl(path, slug) { + if (slug) { + const normalized = slug.startsWith('/') ? slug : `/${slug}`; + return `${SITE_BASE}/docs${normalized}`; + } + return `${SITE_BASE}/${path.replace(/\.mdx?$/, '')}`; } function extractH1(body) { diff --git a/test/fixtures/teched/teched-feed.json b/test/fixtures/teched/teched-feed.json index d3d1cea6b..51aa7e857 100644 --- a/test/fixtures/teched/teched-feed.json +++ b/test/fixtures/teched/teched-feed.json @@ -1,213 +1,213 @@ -{ - "_note": "Fetcher-output shape ({sessions,speakers,tracks}) consumed by scripts/seed-teched.cjs --file and the seed unit test. DERIVED by running fetchAllTechEdSessions() over the REAL capture in test/fixtures/teched/rainfocus-search.json.", - "sessions": [ - { - "sourceId": "1786103955856001msiq", - "venue": "BERLIN", - "sessionCode": "KEY100", - "title": "Built for the ones who build", - "abstract": "Imagine if you could walk into work and just build. No data pipelines. No models to teach the business. No AI cost surprises. SAP brings together AI, tools, business data, governance, agents, and a new UX layer in one in", - "scheduledStart": "2026-10-27T08:00:00.000Z", - "scheduledEnd": "2026-10-27T09:30:00.000Z", - "room": "Keynote Theater", - "youtubeUrl": null, - "url": null, - "trackSourceId": "1745939183774010keFq", - "speakerSourceIds": [ - "1734109533258001oaQ3_1773259901317001te26" - ] - }, - { - "sourceId": "1785940912205001gJ72", - "venue": "BERLIN", - "sessionCode": "IN202", - "title": "Accelerate your migration to SAP Integration Suite", - "abstract": "Gain the in-depth knowledge you need to migrate from the SAP Process Integration offering and SAP Process Orchestration software to SAP Integration Suite. Explore the services and tools available to you—including new AI-", - "scheduledStart": "2026-10-27T10:00:00.000Z", - "scheduledEnd": "2026-10-27T10:45:00.000Z", - "room": "Deep Dive Room 6", - "youtubeUrl": null, - "url": null, - "trackSourceId": "1745939183774003kz40", - "speakerSourceIds": [ - "1753966281661001mEu4_1773259901317001te26" - ] - }, - { - "sourceId": "1786525647304001OTzl", - "venue": "BERLIN", - "sessionCode": "AD262", - "title": "Agentic CAP: Build MCP servers and enterprise AI agents", - "abstract": "Learn how to turn apps based on SAP Cloud Application Programming Model (CAP) into enterprise AI agents using the model context protocol (MCP) and Agent2Agent protocol. With the integration of the SAP AI Core infrastruct", - "scheduledStart": "2026-10-27T10:00:00.000Z", - "scheduledEnd": "2026-10-27T12:00:00.000Z", - "room": "Hands-On Room 2", - "youtubeUrl": null, - "url": null, - "trackSourceId": "1745939183774002kMU7", - "speakerSourceIds": [ - "1635951876257001cTdK_1773259901317001te26", - "1602216772383001D6kW_1773259901317001te26", - "1605732114298001aaGL_1773259901317001te26", - "1604997538270001oJsj_1773259901317001te26" - ] - }, - { - "sourceId": "1788236862932001eQGI", - "venue": "VIRTUAL", - "sessionCode": "KEY100V", - "title": "Built for the ones who build", - "abstract": "Imagine if you could walk into work and just build. No data pipelines. No models to teach the business. No AI cost surprises. SAP brings together AI, tools, business data, governance, agents, and a new UX layer in one in", - "scheduledStart": "2026-10-27T08:00:00.000Z", - "scheduledEnd": "2026-10-27T09:30:00.000Z", - "room": "Channel 1", - "youtubeUrl": null, - "url": null, - "trackSourceId": "1745939183774010keFq", - "speakerSourceIds": [ - "1734109533258001oaQ3_177394505920700tev26" - ] - }, - { - "sourceId": "1787735938125001muyh", - "venue": "VIRTUAL", - "sessionCode": "ST134V", - "title": "SAP Business AI Platform: Vision, strategy, and what's next", - "abstract": "SAP Business AI Platform brings together AI, trusted business data, process context, and governance to help organizations build, integrate, scale, and run AI that delivers real business impact. Understand the vision, see", - "scheduledStart": "2026-10-27T10:00:00.000Z", - "scheduledEnd": "2026-10-27T10:25:00.000Z", - "room": "Channel 1", - "youtubeUrl": null, - "url": null, - "trackSourceId": "1780572314128002OZdN", - "speakerSourceIds": [ - "1774949614733001H7R7_177394505920700tev26" - ] - }, - { - "sourceId": "1787737071824001u9x8", - "venue": "VIRTUAL", - "sessionCode": "ST125V", - "title": "Building the enterprise agentic future with Joule Studio", - "abstract": "AI agents are reshaping how enterprises automate work, extend applications, and create new business solutions. We present the strategy and vision behind the Joule Studio solution as a key enabler of the Autonomous Enterp", - "scheduledStart": "2026-10-27T10:45:00.000Z", - "scheduledEnd": "2026-10-27T11:10:00.000Z", - "room": "Channel 1", - "youtubeUrl": null, - "url": null, - "trackSourceId": "1745939183774002kMU7", - "speakerSourceIds": [ - "1787144220747001l0Be_177394505920700tev26", - "1761810393365001Sk8Q_177394505920700tev26" - ] - } - ], - "speakers": [ - { - "sourceId": "1734109533258001oaQ3_1773259901317001te26", - "name": "Philipp Herzig", - "title": "Chief Technology Officer", - "company": "SAP", - "bio": "

Philipp Herzig is Chief AI Officer and Chief Technology Officer of SAP SE. In this role, Herzig leads SAP technology strategy, research, innovation, corporat", - "photoUrl": "https://static.rainfocus.com/sap/te26/att/1734109533258001oaQ3/attprofile/Philipp-Herzig-Feb2023_1788532803164001Iqhy.jpg" - }, - { - "sourceId": "1753966281661001mEu4_1773259901317001te26", - "name": "Morten Wittrock", - "title": "Lead Product Manager", - "company": "SAP SE", - "bio": "Morten Wittrock is a Lead Product Manager in the SAP Integration Suite product management team at SAP, with over 20 years of experience in the SAP integration s", - "photoUrl": "https://static.rainfocus.com/sap/te26/att/1753966281661001mEu4/attprofile/Morten-headshot_1787312450201001Pau0.jpg" - }, - { - "sourceId": "1635951876257001cTdK_1773259901317001te26", - "name": "Marc Huber", - "title": "Product Management", - "company": "SAP SE", - "bio": "Founder of an AI software company in Germany for predicting fresh food demands in supermarkets and bakeries. Now working in Product Management at SAP for SAP Bu", - "photoUrl": "https://static.rainfocus.com/sap/te26/att/1635951876257001cTdK/attprofile/Marc_Huber_1789047514874001fiY9.jpg" - }, - { - "sourceId": "1602216772383001D6kW_1773259901317001te26", - "name": "Sami Lechner", - "title": "Product manager", - "company": "SAP SE", - "bio": null, - "photoUrl": null - }, - { - "sourceId": "1605732114298001aaGL_1773259901317001te26", - "name": "Daniel Hutzel", - "title": "Chief Architect", - "company": "SAP SE", - "bio": "Joined SAP in 2004, since then went through several Chief Architect positions in SAP technology units, with focus on application frameworks. Designed Core Data ", - "photoUrl": null - }, - { - "sourceId": "1604997538270001oJsj_1773259901317001te26", - "name": "Daniel Schlachter", - "title": "Software Architect", - "company": "SAP SE", - "bio": "CAP Architect / Developer\nLeading the CAP Enablement Team, which is working with internal and external development teams to use CAP to its full potential – look", - "photoUrl": "https://static.rainfocus.com/sap/te26/att/1604997538270001oJsj/attprofile/profile_1789397734267001wLyr.png" - }, - { - "sourceId": "1734109533258001oaQ3_177394505920700tev26", - "name": "Philipp Herzig", - "title": "Chief Technology Officer", - "company": "SAP SE", - "bio": "

Philipp Herzig is Chief Technology Officer of SAP SE. In this role, Herzig leads SAP’s technology strategy, 
\nresearch, innovation, corporate development", - "photoUrl": "https://static.rainfocus.com/sap/tev26/att/1734109533258001oaQ3/attprofile/Philipp-Herzig-Feb2023_1788533084831001UhOz.jpg" - }, - { - "sourceId": "1774949614733001H7R7_177394505920700tev26", - "name": "Steffen Pietsch", - "title": "Vice President, Head of Platform Product Management", - "company": "SAP SE", - "bio": "

Steffen Pietsch is globally responsible for Platform Product Management. Together with his teams, he drives innovation, further development, enablement, and ", - "photoUrl": "https://static.rainfocus.com/sap/te26/att/1774949614733001H7R7/attprofile/steffen-pietsch.1024x1024_1787067932527001da2e.jpg" - }, - { - "sourceId": "1787144220747001l0Be_177394505920700tev26", - "name": "Sankara Narayanan", - "title": "SVP and Head of Product Management Joule Studio", - "company": "SAP LABS INDIA PVT. LTD.", - "bio": "Sankara Narayanan heads the Product Management for SAP Build and Joule Studio. He drives SAP's AI-first developer platform strategy across low-code, pro-code, w", - "photoUrl": "https://static.rainfocus.com/sap/te26/att/1787144220747001l0Be/attprofile/40920eb6-e91d-4433-8416-97d3b0ca2e03_1788241409611001QIpB.png" - }, - { - "sourceId": "1761810393365001Sk8Q_177394505920700tev26", - "name": "Michael Gall", - "title": "SVP & Chief Product Officer for SAP Build​", - "company": "SAP Oesterreich GmbH", - "bio": null, - "photoUrl": null - } - ], - "tracks": [ - { - "sourceId": "1745939183774010keFq", - "name": "Keynote", - "venue": "BERLIN", - "description": null - }, - { - "sourceId": "1745939183774003kz40", - "name": "Integrate agents, applications, and data (IN)", - "venue": "BERLIN", - "description": null - }, - { - "sourceId": "1745939183774002kMU7", - "name": "Build agents, workflows, and applications (AD)", - "venue": "BERLIN", - "description": null - }, - { - "sourceId": "1780572314128002OZdN", - "name": "Architect, operate, and secure AI at scale (XP)", - "venue": "VIRTUAL", - "description": null - } - ] -} \ No newline at end of file +{ + "_note": "Fetcher-output shape ({sessions,speakers,tracks}) consumed by scripts/seed-teched.cjs --file and the seed unit test. DERIVED by running fetchAllTechEdSessions() over the REAL capture in test/fixtures/teched/rainfocus-search.json.", + "sessions": [ + { + "sourceId": "1786103955856001msiq", + "venue": "BERLIN", + "sessionCode": "KEY100", + "title": "Built for the ones who build", + "abstract": "Imagine if you could walk into work and just build. No data pipelines. No models to teach the business. No AI cost surprises. SAP brings together AI, tools, business data, governance, agents, and a new UX layer in one in", + "scheduledStart": "2026-12-15T08:00:00.000Z", + "scheduledEnd": "2026-12-15T09:30:00.000Z", + "room": "Keynote Theater", + "youtubeUrl": null, + "url": null, + "trackSourceId": "1745939183774010keFq", + "speakerSourceIds": [ + "1734109533258001oaQ3_1773259901317001te26" + ] + }, + { + "sourceId": "1785940912205001gJ72", + "venue": "BERLIN", + "sessionCode": "IN202", + "title": "Accelerate your migration to SAP Integration Suite", + "abstract": "Gain the in-depth knowledge you need to migrate from the SAP Process Integration offering and SAP Process Orchestration software to SAP Integration Suite. Explore the services and tools available to you\u00e2\u20ac\u201dincluding new AI-", + "scheduledStart": "2026-12-15T10:00:00.000Z", + "scheduledEnd": "2026-12-15T10:45:00.000Z", + "room": "Deep Dive Room 6", + "youtubeUrl": null, + "url": null, + "trackSourceId": "1745939183774003kz40", + "speakerSourceIds": [ + "1753966281661001mEu4_1773259901317001te26" + ] + }, + { + "sourceId": "1786525647304001OTzl", + "venue": "BERLIN", + "sessionCode": "AD262", + "title": "Agentic CAP: Build MCP servers and enterprise AI agents", + "abstract": "Learn how to turn apps based on SAP Cloud Application Programming Model (CAP) into enterprise AI agents using the model context protocol (MCP) and Agent2Agent protocol. With the integration of the SAP AI Core infrastruct", + "scheduledStart": "2026-12-15T10:00:00.000Z", + "scheduledEnd": "2026-12-15T12:00:00.000Z", + "room": "Hands-On Room 2", + "youtubeUrl": null, + "url": null, + "trackSourceId": "1745939183774002kMU7", + "speakerSourceIds": [ + "1635951876257001cTdK_1773259901317001te26", + "1602216772383001D6kW_1773259901317001te26", + "1605732114298001aaGL_1773259901317001te26", + "1604997538270001oJsj_1773259901317001te26" + ] + }, + { + "sourceId": "1788236862932001eQGI", + "venue": "VIRTUAL", + "sessionCode": "KEY100V", + "title": "Built for the ones who build", + "abstract": "Imagine if you could walk into work and just build. No data pipelines. No models to teach the business. No AI cost surprises. SAP brings together AI, tools, business data, governance, agents, and a new UX layer in one in", + "scheduledStart": "2026-12-15T08:00:00.000Z", + "scheduledEnd": "2026-12-15T09:30:00.000Z", + "room": "Channel 1", + "youtubeUrl": null, + "url": null, + "trackSourceId": "1745939183774010keFq", + "speakerSourceIds": [ + "1734109533258001oaQ3_177394505920700tev26" + ] + }, + { + "sourceId": "1787735938125001muyh", + "venue": "VIRTUAL", + "sessionCode": "ST134V", + "title": "SAP Business AI Platform: Vision, strategy, and what's next", + "abstract": "SAP Business AI Platform brings together AI, trusted business data, process context, and governance to help organizations build, integrate, scale, and run AI that delivers real business impact. Understand the vision, see", + "scheduledStart": "2026-12-15T10:00:00.000Z", + "scheduledEnd": "2026-12-15T10:25:00.000Z", + "room": "Channel 1", + "youtubeUrl": null, + "url": null, + "trackSourceId": "1780572314128002OZdN", + "speakerSourceIds": [ + "1774949614733001H7R7_177394505920700tev26" + ] + }, + { + "sourceId": "1787737071824001u9x8", + "venue": "VIRTUAL", + "sessionCode": "ST125V", + "title": "Building the enterprise agentic future with Joule Studio", + "abstract": "AI agents are reshaping how enterprises automate work, extend applications, and create new business solutions. We present the strategy and vision behind the Joule Studio solution as a key enabler of the Autonomous Enterp", + "scheduledStart": "2026-12-15T10:45:00.000Z", + "scheduledEnd": "2026-12-15T11:10:00.000Z", + "room": "Channel 1", + "youtubeUrl": null, + "url": null, + "trackSourceId": "1745939183774002kMU7", + "speakerSourceIds": [ + "1787144220747001l0Be_177394505920700tev26", + "1761810393365001Sk8Q_177394505920700tev26" + ] + } + ], + "speakers": [ + { + "sourceId": "1734109533258001oaQ3_1773259901317001te26", + "name": "Philipp Herzig", + "title": "Chief Technology Officer", + "company": "SAP", + "bio": "

Philipp Herzig is Chief AI Officer and Chief Technology Officer of SAP SE. In this role, Herzig leads SAP technology strategy, research, innovation, corporat", + "photoUrl": "https://static.rainfocus.com/sap/te26/att/1734109533258001oaQ3/attprofile/Philipp-Herzig-Feb2023_1788532803164001Iqhy.jpg" + }, + { + "sourceId": "1753966281661001mEu4_1773259901317001te26", + "name": "Morten Wittrock", + "title": "Lead Product Manager", + "company": "SAP SE", + "bio": "Morten Wittrock is a Lead Product Manager in the SAP Integration Suite product management team at SAP, with over 20 years of experience in the SAP integration s", + "photoUrl": "https://static.rainfocus.com/sap/te26/att/1753966281661001mEu4/attprofile/Morten-headshot_1787312450201001Pau0.jpg" + }, + { + "sourceId": "1635951876257001cTdK_1773259901317001te26", + "name": "Marc Huber", + "title": "Product Management", + "company": "SAP SE", + "bio": "Founder of an AI software company in Germany for predicting fresh food demands in supermarkets and bakeries. Now working in Product Management at SAP for SAP Bu", + "photoUrl": "https://static.rainfocus.com/sap/te26/att/1635951876257001cTdK/attprofile/Marc_Huber_1789047514874001fiY9.jpg" + }, + { + "sourceId": "1602216772383001D6kW_1773259901317001te26", + "name": "Sami Lechner", + "title": "Product manager", + "company": "SAP SE", + "bio": null, + "photoUrl": null + }, + { + "sourceId": "1605732114298001aaGL_1773259901317001te26", + "name": "Daniel Hutzel", + "title": "Chief Architect", + "company": "SAP SE", + "bio": "Joined SAP in 2004, since then went through several Chief Architect positions in SAP technology units, with focus on application frameworks. Designed Core Data ", + "photoUrl": null + }, + { + "sourceId": "1604997538270001oJsj_1773259901317001te26", + "name": "Daniel Schlachter", + "title": "Software Architect", + "company": "SAP SE", + "bio": "CAP Architect / Developer\nLeading the CAP Enablement Team, which is working with internal and external development teams to use CAP to its full potential \u00e2\u20ac\u201c look", + "photoUrl": "https://static.rainfocus.com/sap/te26/att/1604997538270001oJsj/attprofile/profile_1789397734267001wLyr.png" + }, + { + "sourceId": "1734109533258001oaQ3_177394505920700tev26", + "name": "Philipp Herzig", + "title": "Chief Technology Officer", + "company": "SAP SE", + "bio": "

Philipp Herzig is Chief Technology Officer of SAP SE. In this role, Herzig leads SAP\u00e2\u20ac\u2122s technology strategy,\u00c2\u00a0
\nresearch, innovation, corporate development", + "photoUrl": "https://static.rainfocus.com/sap/tev26/att/1734109533258001oaQ3/attprofile/Philipp-Herzig-Feb2023_1788533084831001UhOz.jpg" + }, + { + "sourceId": "1774949614733001H7R7_177394505920700tev26", + "name": "Steffen Pietsch", + "title": "Vice President, Head of Platform Product Management", + "company": "SAP SE", + "bio": "

Steffen Pietsch is globally responsible for Platform Product Management. Together with his teams, he drives innovation, further development, enablement, and ", + "photoUrl": "https://static.rainfocus.com/sap/te26/att/1774949614733001H7R7/attprofile/steffen-pietsch.1024x1024_1787067932527001da2e.jpg" + }, + { + "sourceId": "1787144220747001l0Be_177394505920700tev26", + "name": "Sankara Narayanan", + "title": "SVP and Head of Product Management Joule Studio", + "company": "SAP LABS INDIA PVT. LTD.", + "bio": "Sankara Narayanan heads the Product Management for SAP Build and Joule Studio. He drives SAP's AI-first developer platform strategy across low-code, pro-code, w", + "photoUrl": "https://static.rainfocus.com/sap/te26/att/1787144220747001l0Be/attprofile/40920eb6-e91d-4433-8416-97d3b0ca2e03_1788241409611001QIpB.png" + }, + { + "sourceId": "1761810393365001Sk8Q_177394505920700tev26", + "name": "Michael Gall", + "title": "SVP & Chief Product Officer for SAP Build\u00e2\u20ac\u2039", + "company": "SAP Oesterreich GmbH", + "bio": null, + "photoUrl": null + } + ], + "tracks": [ + { + "sourceId": "1745939183774010keFq", + "name": "Keynote", + "venue": "BERLIN", + "description": null + }, + { + "sourceId": "1745939183774003kz40", + "name": "Integrate agents, applications, and data (IN)", + "venue": "BERLIN", + "description": null + }, + { + "sourceId": "1745939183774002kMU7", + "name": "Build agents, workflows, and applications (AD)", + "venue": "BERLIN", + "description": null + }, + { + "sourceId": "1780572314128002OZdN", + "name": "Architect, operate, and secure AI at scale (XP)", + "venue": "VIRTUAL", + "description": null + } + ] +} diff --git a/test/lib/feedback-digest.test.js b/test/lib/feedback-digest.test.js index a58fd5fa0..bd3ee8d82 100644 --- a/test/lib/feedback-digest.test.js +++ b/test/lib/feedback-digest.test.js @@ -175,6 +175,22 @@ describe('computeFeedbackDigests — comment filter, window, grouping', () => { expect(digests[0].email).toBeNull(); expect(digests[0].items).toHaveLength(1); }); + + it('excludes irrelevant feedback rows from digest computation', async () => { + const since = new Date(Date.now() - 3_600_000).toISOString(); + const until = new Date(Date.now() + 1000).toISOString(); + await seedTutorial({ slug: 'digest-irr-test', ownerEmail: 'author@example.com' }); + const { TutorialFeedback } = cds.entities(NS); + seq += 1; + await INSERT.into(TutorialFeedback).entries([ + { ID: `fbfb0000-0000-0000-irr1-${String(seq).padStart(12, '0')}`, tutorialSlug: 'digest-irr-test', comment: 'Great tutorial!', submittedAt: new Date(Date.now() - 1800_000).toISOString(), isIrrelevant: false, wasAuthenticated: false }, + { ID: `fbfb0000-0000-0000-irr2-${String(seq + 1).padStart(12, '0')}`, tutorialSlug: 'digest-irr-test', comment: 'Ignore me', submittedAt: new Date(Date.now() - 900_000).toISOString(), isIrrelevant: true, wasAuthenticated: false } + ]); + const digests = await mod.computeFeedbackDigests({ since, until }, cds.db); + const entry = digests.find(d => d.email === 'author@example.com'); + expect(entry?.items).toHaveLength(1); + expect(entry?.items[0].comment).toBe('Great tutorial!'); + }); }); describe('sendFeedbackDigests — orchestration + watermark', () => { diff --git a/test/unit/display-rate.test.ts b/test/unit/display-rate.test.ts new file mode 100644 index 000000000..90eddc48f --- /dev/null +++ b/test/unit/display-rate.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { completionsPerHour } from '../../app/display-app/src/rate'; + +describe('completionsPerHour — "Tutorials Per Hour" counts EVERY completion', () => { + it('returns 0 with fewer than 2 samples (cannot compute a rate yet)', () => { + expect(completionsPerHour([])).toBe(0); + expect(completionsPerHour([1000])).toBe(0); + }); + + it('extrapolates an hourly rate from timestamps spanning a short window', () => { + // 4 completions across 2 seconds -> very high hourly extrapolation. + const base = 1_000_000; + const rate = completionsPerHour([base, base + 666, base + 1333, base + 2000]); + // ~4 events / 2s => ~7200/hr ballpark; assert it's a large positive number. + expect(rate).toBeGreaterThan(1000); + }); + + it('is driven by ALL completions, not only track-mapped ones (regression: per-hour stuck at 0)', () => { + // The display must feed the rate from completionTick (every completion), + // so a stream of untracked completions still produces a non-zero per-hour. + const base = 2_000_000; + const ts = Array.from({ length: 10 }, (_, i) => base + i * 500); // 10 over 4.5s + expect(completionsPerHour(ts)).toBeGreaterThan(0); + }); +}); diff --git a/test/unit/display-reconcile.test.ts b/test/unit/display-reconcile.test.ts new file mode 100644 index 000000000..ef6837de2 --- /dev/null +++ b/test/unit/display-reconcile.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { reconcileBuckets } from '../../app/display-app/src/reconcile'; + +describe('reconcileBuckets — display self-heals from missed socket events', () => { + it('replaces drifted incremental counts with authoritative fetched counts', () => { + // Display drifted low because it missed socket events (reconnect gap / instance flap). + const current = [ + { name: 'Joule Studio', count: 630, justUpdated: false }, + { name: 'Joule Work', count: 530, justUpdated: false }, + ]; + // Authoritative snapshot from getEventBuckets shows the true, higher totals. + const fetched = [ + { bucketName: 'Joule Studio', count: 636 }, + { bucketName: 'Joule Work', count: 536 }, + ]; + const result = reconcileBuckets(current, fetched); + expect(result.find(b => b.name === 'Joule Studio')!.count).toBe(636); + expect(result.find(b => b.name === 'Joule Work')!.count).toBe(536); + }); + + it('adds buckets that appeared only in the fetched snapshot', () => { + const current = [{ name: 'Joule Studio', count: 10, justUpdated: false }]; + const fetched = [ + { bucketName: 'Joule Studio', count: 12 }, + { bucketName: 'New Track', count: 5 }, + ]; + const result = reconcileBuckets(current, fetched); + expect(result.find(b => b.name === 'New Track')!.count).toBe(5); + }); + + it('never lowers a count below what the display already shows (monotonic — avoids visible backwards jumps)', () => { + // A socket delta may have arrived AFTER the fetch snapshot was taken; the live + // value is ahead of the snapshot. Reconciliation must not rewind it. + const current = [{ name: 'Joule Studio', count: 640, justUpdated: false }]; + const fetched = [{ bucketName: 'Joule Studio', count: 636 }]; + const result = reconcileBuckets(current, fetched); + expect(result.find(b => b.name === 'Joule Studio')!.count).toBe(640); + }); +}); diff --git a/test/unit/reset-tutorial-progress.test.js b/test/unit/reset-tutorial-progress.test.js index d23758700..7f8cb5068 100644 --- a/test/unit/reset-tutorial-progress.test.js +++ b/test/unit/reset-tutorial-progress.test.js @@ -523,41 +523,6 @@ describe('Task 11 — computeBuckets helper handles SUPERSEDED', () => { }); }); -describe('Task 11 — DisplayService passes SUPERSEDED rows to event-statistics helpers', () => { - beforeEach(async () => { - const { Users, Events, Tutorials, Missions, TaskRecords } = cds.entities('com.sap.developers.ims'); - await DELETE.from(TaskRecords); - await DELETE.from(Events); - await DELETE.from(Tutorials); - await DELETE.from(Missions); - await DELETE.from(Users); - }); - - it('getEventBuckets: user with SUPERSEDED-only attempt is counted in the histogram', async () => { - const { Users, Events, Tutorials, TaskRecords } = cds.entities('com.sap.developers.ims'); - await INSERT.into(Users).entries({ ID: 'u11', uuid: 'u11', sapId: 'sap-u11', legacyId: 11001 }); - await INSERT.into(Events).entries({ ID: 'e11', legacyId: 11500, title: 'E11', timeZone: '+00:00' }); - await INSERT.into(Tutorials).entries({ ID: 't11', slug: 'task11-tut', title: 'T11', legacyId: 7200, stepCount: 1 }); - - await INSERT.into(TaskRecords).entries([ - { ID: 'tr-11-sup', user_ID: 'u11', event_ID: 'e11', taskLegacyId: 7200, taskType: 'TUTORIAL', - status: 'SUPERSEDED', progress: 100, attemptNumber: 1, legacyId: 11100 }, - { ID: 'tr-11-ip', user_ID: 'u11', event_ID: 'e11', taskLegacyId: 7200, taskType: 'TUTORIAL', - status: 'IN_PROGRESS', progress: 25, attemptNumber: 2, legacyId: 11101 }, - ]); - - const display = await cds.connect.to('DisplayService'); - const buckets = await display.tx({ user: new cds.User.Privileged() }, tx => - tx.send({ event: 'getEventBuckets', data: { eventLegacyId: 11500 } }) - ); - // Pre-fix: SUPERSEDED filtered out → 0 buckets (no users counted). - // After-fix: SUPERSEDED counts; u11 has 1 completion → bucket "1 tutorial" count 1. - expect(buckets).toHaveLength(1); - expect(buckets[0].bucketName).toBe('1 tutorial'); - expect(buckets[0].count).toBe(1); - }); -}); - describe('Task 12 — co-completion includes SUPERSEDED completions', () => { beforeEach(async () => { const { Users, Tutorials, TaskRecords } = cds.entities('com.sap.developers.ims'); diff --git a/test/unit/srv/architecture-sap-com-fetcher.test.js b/test/unit/srv/architecture-sap-com-fetcher.test.js index 0481a9851..466c5e5de 100644 --- a/test/unit/srv/architecture-sap-com-fetcher.test.js +++ b/test/unit/srv/architecture-sap-com-fetcher.test.js @@ -56,6 +56,33 @@ describe('architecture-sap-com-fetcher', () => { expect(byId['news/2026-06-agentic-code-quality.mdx']).toBe('https://architecture.learning.sap.com/news/2026-06-agentic-code-quality'); }); + it('honors Docusaurus slug: frontmatter, serving it under /docs (overrides file path)', async () => { + // The live site routes RA0029/readme.md at /docs/ref-arch/98efa0 via its + // slug, NOT the path-derived /docs/ref-arch/RA0029/readme (which 404s). + _setMockFetcher(async (url) => { + if (url.includes('/git/trees/')) return { + ...treeFixture, + tree: [treeFixture.tree[0]], + }; + return '---\ntitle: Agentic AI\nslug: /ref-arch/98efa0\n---\n\nBody text here, long enough to survive the empty-description guard.'; + }); + const rows = await fetchArchitectureSapComCorpus({ apiKey: 'fake' }); + expect(rows).toHaveLength(1); + expect(rows[0].url).toBe('https://architecture.learning.sap.com/docs/ref-arch/98efa0'); + }); + + it('normalizes a slug without a leading slash', async () => { + _setMockFetcher(async (url) => { + if (url.includes('/git/trees/')) return { + ...treeFixture, + tree: [treeFixture.tree[0]], + }; + return '---\ntitle: X\nslug: ref-arch/abc123\n---\n\nBody text here, long enough to survive the empty-description guard.'; + }); + const rows = await fetchArchitectureSapComCorpus({ apiKey: 'fake' }); + expect(rows[0].url).toBe('https://architecture.learning.sap.com/docs/ref-arch/abc123'); + }); + it('title precedence: frontmatter > H1 > filename', async () => { // File 1: frontmatter title present → win // File 2: no frontmatter, has H1 → win diff --git a/test/unit/srv/build-teched-allday.test.js b/test/unit/srv/build-teched-allday.test.js index 08707130d..1ebe6a7b8 100644 --- a/test/unit/srv/build-teched-allday.test.js +++ b/test/unit/srv/build-teched-allday.test.js @@ -80,7 +80,7 @@ describe('GET /build/teched — all-day activities', () => { // scheduledEnd (issue #2392 — matches the fetcher dropPast carve-out). const allDay = data.sessions.filter((s) => s.allDay); expect(allDay.map((s) => s.title).sort()).toEqual(['AI Showfloor Expo', 'Developer Garage']); - // timed sessions (fixture dated 2026-10) are still present too + // timed sessions (fixture dated 2026-12) are still present too expect(data.sessions.some((s) => s.sessionCode === 'KEY100')).toBe(true); }); diff --git a/test/unit/srv/build-teched-endpoint.test.js b/test/unit/srv/build-teched-endpoint.test.js index 3da986e0c..438d96c0b 100644 --- a/test/unit/srv/build-teched-endpoint.test.js +++ b/test/unit/srv/build-teched-endpoint.test.js @@ -95,7 +95,7 @@ describe('GET /build/teched', () => { expect(data.sessions.some((s) => s.sessionCode === 'KEY100')).toBe(false); }); - it('?upcoming=true keeps not-yet-ended sessions (fixture is in 2026-10)', async () => { + it('?upcoming=true keeps not-yet-ended sessions (fixture is in 2026-12)', async () => { const { data } = await project.get('/build/teched?upcoming=true'); expect(data.sessions.length).toBe(6); });