Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
4e1ecd7
fix(help-docs): honor Docusaurus slug: frontmatter for arch-center URLs
jung-thomas Oct 29, 2026
897cd1d
Merge pull request #2948 from sap-tutorials/worktree-fix-archcenter-s…
jung-thomas Oct 29, 2026
1813043
fix(security): pin Node runtime floor to 22.23.4 for CVE-2026-111497
jung-thomas Oct 29, 2026
65a720b
fix(security): sync srv-qa engines.node floor to 22.23.4 (CVE-2026-11…
jung-thomas Oct 29, 2026
995119a
fix(security): pin srv-mcp engines.node floor to 22.23.4 (CVE-2026-11…
jung-thomas Oct 29, 2026
35eb124
fix(app-space): sync selected track to ?track=<id> URL param
jung-thomas Oct 29, 2026
940120e
Merge pull request #2950 from sap-tutorials/worktree-app-space-track-url
jung-thomas Oct 29, 2026
45934ca
Merge pull request #2949 from sap-tutorials/worktree-node-brotli-cve-pin
jung-thomas Oct 29, 2026
ab3ef92
fix(app-space): reduce hero banner padding to show more missions
jung-thomas Oct 30, 2026
b703e4f
Merge pull request #2953 from sap-tutorials/worktree-fix-app-space-ba…
jung-thomas Oct 30, 2026
97b0faf
fix(display-app): correct number-words for Arabic, Hebrew, English, M…
jung-thomas Oct 30, 2026
a19bf73
fix(display): reconcile live kiosk counts against getEventBuckets so …
jung-thomas Oct 30, 2026
d2ec1dd
fix(display): drive Tutorials-Per-Hour from completionTick, not only …
jung-thomas Oct 30, 2026
c9559ef
Merge pull request #2954 from sap-tutorials/worktree-fix-number-words…
jung-thomas Oct 30, 2026
8cf5da8
Merge pull request #2955 from sap-tutorials/fix-display-live-reconcile
jung-thomas Oct 30, 2026
d94965e
fix(ci): handle multi-slug source errors in QA rebuild (#2951)
jung-thomas Oct 30, 2026
88f8ccc
docs: add implementation plan for feedback irrelevant flag (#2947)
jung-thomas Oct 30, 2026
d146602
feat(app-space): add Refresh button to re-check completion + reveal b…
jung-thomas Oct 30, 2026
1eb641a
Merge pull request #2956 from sap-tutorials/worktree-fix-qa-multi-slu…
jung-thomas Oct 30, 2026
aba7b63
Merge pull request #2957 from sap-tutorials/worktree-app-space-refres…
jung-thomas Oct 30, 2026
a9ed783
feat(feedback): add isIrrelevant field and filter aggregate views (#2…
jung-thomas Oct 30, 2026
f80824a
feat(feedback): add markFeedbackIrrelevant/unmarkFeedbackIrrelevant a…
jung-thomas Oct 30, 2026
491f6d5
fix(test): repair 3 CI unit test failures
jung-thomas Oct 30, 2026
914034f
feat(feedback): expose irrelevant flag in admin Fiori Elements UI (#2…
jung-thomas Oct 30, 2026
e07a21e
feat(feedback): exclude irrelevant entries from owner digest emails (…
jung-thomas Oct 30, 2026
78614c2
feat(feedback): add flag columns to tutorial feedback embedded panel …
jung-thomas Oct 30, 2026
18ff908
fix(feedback): convert mark/unmark to bound actions for correct FE ro…
jung-thomas Oct 30, 2026
adf41ed
Merge pull request #2959 from sap-tutorials/worktree-woolly-moseying-…
jung-thomas Oct 30, 2026
ed55fb0
Merge pull request #2958 from sap-tutorials/worktree-fix-ci-test-fail…
jung-thomas Oct 30, 2026
d5dbe80
fix(scanner-vue): redirect to login on 401 instead of showing error
jung-thomas Oct 30, 2026
e77a20d
Merge pull request #2960 from sap-tutorials/worktree-fix-scanner-auth…
jung-thomas Oct 30, 2026
6b6e511
fix(staging): pin engines.node to exact 22.23.4 for CF buildpack comp…
jung-thomas Nov 1, 2026
a370db1
Merge pull request #2961 from sap-tutorials/fix/node-engine-pin-exact
jung-thomas Nov 1, 2026
6180dad
fix(build): fall back to cached roster when fetch-advocates live fetc…
jung-thomas Nov 1, 2026
a210bdd
Merge pull request #2962 from sap-tutorials/fix/advocates-cache-fallback
jung-thomas Nov 1, 2026
8833a0d
chore(release): bump MTA version to 1.41.8
jung-thomas Nov 1, 2026
6d3eb86
Merge pull request #2963 from sap-tutorials/worktree-release-1.41.8
jung-thomas Nov 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .deploy/mta.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ ID: tutorials-ims
# Bump this on each release you deploy — it's the version shown by `cf mtas`
# and in the mtar filename (tutorials-ims_<version>.mtar). Deploy is manual:
# `cd .deploy && mbt build && cf deploy mta_archives/tutorials-ims_<version>.mtar -e ../deploy/<env>.mtaext -f`.
version: 1.41.7
version: 1.41.8

# Top-level parameters (overridable per-env via deploy/<env>.mtaext).
parameters:
Expand Down
79 changes: 54 additions & 25 deletions .github/workflows/rebuild-content-qa.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>/architecture.md` instead of `<slug>.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
Expand Down Expand Up @@ -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/<slug>/ 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
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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 }}
Expand Down
23 changes: 19 additions & 4 deletions app/admin-annotations.cds
Original file line number Diff line number Diff line change
Expand Up @@ -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 @(
Expand All @@ -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 },
Expand All @@ -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 },
Expand All @@ -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' }
]
);

Expand Down Expand Up @@ -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' }
]
};

Expand Down
98 changes: 73 additions & 25 deletions app/display-app/src/event-stream.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -32,6 +34,7 @@ export function useEventStream() {
let socket: Socket | null = null
let demoInterval: ReturnType<typeof setInterval> | null = null
let cronInterval: ReturnType<typeof setInterval> | null = null
let reconcileInterval: ReturnType<typeof setInterval> | null = null
const messageTimestamps: number[] = []
const bucketTimestamps = new Map<string, number[]>()

Expand Down Expand Up @@ -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()

Expand All @@ -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() },
Expand All @@ -110,41 +115,78 @@ 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<boolean> {
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()
// getEventBuckets returns { eventName, eventType, hasLogo, totalCompletions, buckets:[...] };
// fall back to legacy array shapes for safety.
const data: Array<{ bucketName: string; count: number }> =
json.buckets ?? json.value ?? (Array.isArray(json) ? json : [])
// Empty buckets = event exists but no completions yet (a 404 event is rejected
// above). Don't error out — proceed to the socket so late completions render (#2294).
buckets.value = data.map(b => ({ name: b.bucketName, count: b.count, justUpdated: false }))
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
Expand Down Expand Up @@ -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()
Expand All @@ -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'
}

Expand Down
Loading
Loading