From bfafc685e06147b37faf0ea1a5795550d2480836 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Wed, 15 Jul 2026 03:26:56 +0100 Subject: [PATCH] fix(exercise): persist completion policy and improve dev build workflow (#754) * fix(exercise): persist completion policy and pass threshold on save buildExerciseUpdateFields accepted completionPolicy and passThreshold in the validation schema but never mapped them to the database update, so exercise settings appeared to save while reverting on refresh. Co-authored-by: Rotimi Best * fix(db): build workspace deps before compiling @cio/db imports @cio/utils/notifications, which only exists after @cio/utils is built. Building db in isolation failed when utils dist was missing or stale. - Add a db prebuild step for question-types, utils, and email - Clean dist and tsbuildinfo before utils and email builds so incremental tsc cannot skip emission after dist was removed Co-authored-by: Rotimi Best * chore(dev): build api workspace deps before api:dev Run turbo build for @cio/api and @cio/jobs-worker dependencies before starting the dev servers, so missing dist/ folders do not break startup. Co-authored-by: Rotimi Best * feat(course): improve exercise progression grading UX --------- Co-authored-by: Rotimi Best --- apps/api/src/services/course/completion.ts | 70 +++-- apps/api/src/services/exercise/utils.ts | 46 ++- .../course-completion-modal.svelte | 9 +- .../components/exercise/edit-mode.svelte | 63 +++- .../exercise/exercise-settings-tab.svelte | 40 ++- .../course/components/exercise/functions.ts | 46 ++- .../exercise/submissions/individual.svelte | 178 +++++++++-- .../exercise/submissions/submissions.svelte | 4 +- .../exercise/submissions/summary.svelte | 4 +- .../components/exercise/view-mode.svelte | 127 ++++++-- .../lesson/content-navigation-actions.svelte | 12 +- .../components/question-container.svelte | 31 +- .../lib/features/course/pages/exercise.svelte | 291 +++++++++++++++++- .../lib/features/course/pages/settings.svelte | 72 ++++- .../features/course/pages/submissions.svelte | 6 +- .../utils/exercise-progression-utils.test.ts | 141 +++++++++ .../utils/exercise-progression-utils.ts | 106 +++++++ .../src/lib/utils/translations/da.json | 62 +++- .../src/lib/utils/translations/de.json | 62 +++- .../src/lib/utils/translations/en.json | 50 ++- .../src/lib/utils/translations/es.json | 64 +++- .../src/lib/utils/translations/fr.json | 62 +++- .../src/lib/utils/translations/hi.json | 62 +++- .../src/lib/utils/translations/pl.json | 62 +++- .../src/lib/utils/translations/pt.json | 62 +++- .../src/lib/utils/translations/ru.json | 62 +++- .../src/lib/utils/translations/vi.json | 62 +++- package.json | 2 +- packages/ai-assistant/src/prompt/teacher.ts | 16 +- packages/ai-assistant/src/tools/teacher.ts | 41 ++- .../src/services/agent/agent-tool-schemas.ts | 29 +- .../core/src/services/agent/chat-tools.ts | 13 +- packages/core/src/services/exercise/utils.ts | 46 ++- packages/core/src/services/lesson/lesson.ts | 3 +- packages/db/package.json | 1 + packages/db/src/queries/course/people.ts | 21 +- packages/db/src/schema.ts | 2 +- packages/email/package.json | 2 +- packages/question-types/src/index.ts | 1 + .../question-types/src/question-scoring.ts | 16 +- .../src/true-false-answer-key.ts | 59 ++++ .../tests/true-false-answer-key.test.ts | 34 ++ .../renderers/checkbox/submission.svelte | 25 +- .../renderers/fill-blank/submission.svelte | 25 +- .../submission-response-bar-chart.svelte | 48 +++ .../renderers/true-false/edit.svelte | 21 +- .../renderers/true-false/preview.svelte | 2 +- .../renderers/true-false/review.svelte | 4 +- .../true-false/true-false-correct.ts | 11 +- .../renderers/word-bank/submission.svelte | 25 +- packages/ui/src/output.css | 2 +- packages/utils/package.json | 2 +- .../utils/src/validation/exercise/exercise.ts | 4 + 53 files changed, 2026 insertions(+), 285 deletions(-) create mode 100644 apps/dashboard/src/lib/features/course/utils/exercise-progression-utils.test.ts create mode 100644 apps/dashboard/src/lib/features/course/utils/exercise-progression-utils.ts create mode 100644 packages/question-types/src/true-false-answer-key.ts create mode 100644 packages/question-types/tests/true-false-answer-key.test.ts create mode 100644 packages/ui/src/custom/exercise-question/renderers/shared/submission-response-bar-chart.svelte diff --git a/apps/api/src/services/course/completion.ts b/apps/api/src/services/course/completion.ts index 9907a3a8..3c38d5cf 100644 --- a/apps/api/src/services/course/completion.ts +++ b/apps/api/src/services/course/completion.ts @@ -15,7 +15,11 @@ import { getCourseProgress as getCourseProgressQuery, type TCourseCertificationRow } from '@cio/db/queries/course/course'; +import { claimMemberCertificateEarned } from '@cio/db/queries/course/people'; +import { getActiveOrganizationPlan } from '@cio/db/queries/organization'; import { ROLE } from '@cio/utils/constants'; +import { PLAN } from '@cio/utils/plans'; +import { env } from '@cio/core/config/env'; import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics'; export type CertificationBlocker = { @@ -230,38 +234,58 @@ export async function evaluateCourseCertification( let isNewCompletion = false; if (evaluation.eligibleForCertificate && !certificateEarnedAt && progress.groupMemberId) { - const earnedAt = new Date().toISOString(); - certificateEarnedAt = earnedAt; - isNewCompletion = true; + const certificatesEnabled = await orgHasCertificatesEnabled(courseRow.orgId); - scheduleCertificationCompletionWork({ - courseId, - profileId, - groupMemberId: progress.groupMemberId, - courseRow, - earnedAt - }); + if (certificatesEnabled) { + const earnedAt = new Date().toISOString(); + const didClaimCertificate = await claimMemberCertificateEarned(progress.groupMemberId, earnedAt); + certificateEarnedAt = earnedAt; - trackServerEvent({ - eventType: SERVER_EVENTS.COURSE_COMPLETED, - userId: profileId, - courseId, - props: { path: 'standard', earnedAt } - }); + if (didClaimCertificate) { + isNewCompletion = true; - if (courseRow.certificate?.isDownloadable) { - trackServerEvent({ - eventType: SERVER_EVENTS.CERTIFICATE_ISSUED, - userId: profileId, - courseId, - props: { path: 'standard', earnedAt } - }); + scheduleCertificationCompletionWork({ + courseId, + profileId, + groupMemberId: progress.groupMemberId, + courseRow, + earnedAt + }); + + trackServerEvent({ + eventType: SERVER_EVENTS.COURSE_COMPLETED, + userId: profileId, + courseId, + props: { path: 'standard', earnedAt } + }); + + if (courseRow.certificate?.isDownloadable) { + trackServerEvent({ + eventType: SERVER_EVENTS.CERTIFICATE_ISSUED, + userId: profileId, + courseId, + props: { path: 'standard', earnedAt } + }); + } + } } } return { ...evaluation, certificateEarnedAt, isNewCompletion }; } +/** + * Free-plan orgs do not get certificates: no earned record, no completion email. + * Self-hosted deployments always have certificates enabled. + */ +async function orgHasCertificatesEnabled(orgId: string): Promise { + if (env.PUBLIC_IS_SELFHOSTED === 'true') return true; + + const activePlan = await getActiveOrganizationPlan(orgId); + + return Boolean(activePlan && activePlan.planName !== PLAN.BASIC); +} + /** * Server-side guard for PDF certificate download (must match dashboard eligibility). */ diff --git a/apps/api/src/services/exercise/utils.ts b/apps/api/src/services/exercise/utils.ts index 44f295ff..5b39dddd 100644 --- a/apps/api/src/services/exercise/utils.ts +++ b/apps/api/src/services/exercise/utils.ts @@ -12,11 +12,19 @@ import { updateQuestions } from '@cio/db/queries/exercise'; +import { + QUESTION_TYPE_IDS, + QUESTION_TYPE_KEY, + resolveTrueFalseCorrectValue, + syncTrueFalseOptions, + type TrueFalseAnswerOption +} from '@cio/question-types'; import type { DbOrTxClient } from '@cio/db/drizzle'; import type { TExerciseUpdate } from '@cio/utils/validation/exercise'; type CurrentQuestion = TNewQuestion & { options?: TNewOption[] }; type CurrentOption = TNewOption; +type ExerciseUpdateQuestion = NonNullable[number]; type ExerciseAnswer = Pick< TQuestionAnswer, @@ -68,6 +76,26 @@ function areSettingsEqual(left: unknown, right: unknown): boolean { return JSON.stringify(leftNormalized) === JSON.stringify(rightNormalized); } +const TRUE_FALSE_QUESTION_TYPE_ID = QUESTION_TYPE_IDS[QUESTION_TYPE_KEY.TRUE_FALSE]; + +function normalizeTrueFalseQuestion( + question: ExerciseUpdateQuestion, + currentQuestion?: CurrentQuestion +): ExerciseUpdateQuestion { + const questionTypeId = question.questionTypeId ?? currentQuestion?.questionTypeId; + if (questionTypeId !== TRUE_FALSE_QUESTION_TYPE_ID) return question; + + const settings = normalizeSettings(question.settings ?? currentQuestion?.settings); + const options = question.options ?? currentQuestion?.options ?? []; + const correctValue = resolveTrueFalseCorrectValue(settings, options as TrueFalseAnswerOption[]); + + return { + ...question, + settings: { ...settings, correctValue }, + options: question.options ? syncTrueFalseOptions(question.options, correctValue) : question.options + }; +} + /** * Extracts exercise-level fields from the update payload */ @@ -81,6 +109,8 @@ export function buildExerciseUpdateFields(data: TExerciseUpdate): Partial }> = []; - for (const q of questions) { + for (const rawQuestion of questions) { + const q = normalizeTrueFalseQuestion(rawQuestion); + if (q.deletedAt && q.id) { deletedIds.push(q.id); } else if (q.id) { @@ -230,13 +262,16 @@ export function computeExerciseDiff( incomingQuestions: NonNullable ) { const questionMap = new Map(currentQuestions.map((q) => [q.id, q])); + const normalizedIncomingQuestions = incomingQuestions.map((incoming) => + normalizeTrueFalseQuestion(incoming, incoming.id ? questionMap.get(incoming.id) : undefined) + ); const deletedQuestionIds: number[] = []; const questionUpdates: Array<{ id: number; data: Partial }> = []; const newQuestions: NonNullable = []; // Process incoming questions - for (const incoming of incomingQuestions) { + for (const incoming of normalizedIncomingQuestions) { if (incoming.deletedAt && incoming.id) { deletedQuestionIds.push(incoming.id); } else if (incoming.id) { @@ -260,7 +295,7 @@ export function computeExerciseDiff( const optionUpdates: Array<{ id: number; data: Partial }> = []; const optionCreates: TNewOption[] = []; - for (const incoming of incomingQuestions) { + for (const incoming of normalizedIncomingQuestions) { if (!incoming.id || incoming.deletedAt) continue; const current = questionMap.get(incoming.id); @@ -336,8 +371,9 @@ export async function createNewQuestionsWithOptions( newQuestions: NonNullable, txClient: DbOrTxClient ): Promise { + const normalizedQuestions = newQuestions.map((question) => normalizeTrueFalseQuestion(question)); const createdQuestions = await createQuestions( - newQuestions.map((q) => ({ + normalizedQuestions.map((q) => ({ exerciseId, title: q.question, questionTypeId: q.questionTypeId || 1, @@ -351,7 +387,7 @@ export async function createNewQuestionsWithOptions( const newOptions: TNewOption[] = []; createdQuestions.forEach((newQuestion, index) => { - const questionData = newQuestions[index]; + const questionData = normalizedQuestions[index]; if (newQuestion && questionData?.options && questionData.options.length > 0) { // Only include non-deleted options for new questions for (const opt of questionData.options) { diff --git a/apps/dashboard/src/lib/features/course/components/ceritficate/course-completion-modal.svelte b/apps/dashboard/src/lib/features/course/components/ceritficate/course-completion-modal.svelte index 71e86948..1488e839 100644 --- a/apps/dashboard/src/lib/features/course/components/ceritficate/course-completion-modal.svelte +++ b/apps/dashboard/src/lib/features/course/components/ceritficate/course-completion-modal.svelte @@ -11,6 +11,7 @@ import { courseCompletionModal, closeCourseCompletionModal } from '$features/course/store/course-completion-modal'; import { getContentRoute } from '$features/course/utils/content'; import { formatBlockerMessage } from '$features/course/utils/certificate-utils'; + import { isFreePlan } from '$lib/utils/store/org'; import { t } from '$lib/utils/functions/translations'; let open = $state(false); @@ -84,11 +85,15 @@ {:else if activeStep === 'eligible'} {$t('course.completion.modal.title')} - {$t('course.completion.modal.subtitle')} + + {$isFreePlan ? $t('course.completion.modal.subtitle_no_certificate') : $t('course.completion.modal.subtitle')} + - + {#if !$isFreePlan} + + {/if} {:else if activeStep === 'not-eligible'} diff --git a/apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte b/apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte index 9abec214..b53d8453 100644 --- a/apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte +++ b/apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte @@ -1,6 +1,7 @@ {#if isLoading} -{:else if $submissions?.length} +{:else if submissionGroups?.length}
- {#each $submissions as student, i (student.id)} + {#each submissionGroups as studentGroup, i (studentGroup.studentKey)} {/each}
-

- {$submissions[studentSelected].groupmember?.profile.fullname ?? ''}'s {$t( - 'course.navItem.lessons.exercises.all_exercises.analytics.individual.answers' - )} -

- {#if $submissions[studentSelected]} + {@const selectedGroup = getSelectedGroup()} + {#if selectedGroup} + {@const selectedAttemptIndex = getSelectedAttemptIndex(selectedGroup)} + {@const selectedAttempt = selectedGroup.attempts[selectedAttemptIndex]} + {@const selectedSubmission = selectedAttempt.submission} +
+
+

+ {$t('course.navItem.lessons.exercises.all_exercises.analytics.individual.answers_for', { + name: selectedGroup.studentName + })} +

+

+ {$t('course.navItem.lessons.exercises.all_exercises.analytics.individual.attempts_count', { + count: selectedGroup.attempts.length + })} +

+
+ +
+ selectAttempt(selectedGroup, value)} + > + + {$t('course.navItem.lessons.exercises.all_exercises.analytics.individual.attempt_label', { + number: selectedAttempt.attemptNumber + })} + - + {getStatusLabel(selectedSubmission)} + + + {#each selectedGroup.attempts as attempt, attemptIndex (attempt.submission.id)} + {@const attemptScore = getTotalScore(attempt.submission)} + + {$t('course.navItem.lessons.exercises.all_exercises.analytics.individual.attempt_label', { + number: attempt.attemptNumber + })} + - + {formatSubmittedAt(attempt.submission)} + - + {getStatusLabel(attempt.submission)} + - + {attemptScore}/{getTotalPossiblePoints()} + - + {getAttemptPassLabel(attempt.submission)} + + {/each} + + +
+
+ +
+ {getStatusLabel(selectedSubmission)} + + {getTotalScore(selectedSubmission)}/{getTotalPossiblePoints()} + {$t('course.navItem.lessons.exercises.all_exercises.view_mode.points')} + + + {getAttemptPassLabel(selectedSubmission)} + +
+ {#if hasSectionGroups}
{#each activeSections as section, sectionIndex (section.id)} @@ -124,7 +260,7 @@ contract={{ mode: 'review', question: toExerciseQuestionModel(q), - answer: getStudentAnswerForQuestion($submissions[studentSelected], q), + answer: getStudentAnswerForQuestion(selectedSubmission, q), labels: questionLabels, disabled: true }} @@ -135,7 +271,7 @@ {/each}

- {getSectionScore($submissions[studentSelected], sectionQuestions)}/{getSectionMaxPoints(sectionQuestions)} + {getSectionScore(selectedSubmission, sectionQuestions)}/{getSectionMaxPoints(sectionQuestions)} {$t('course.navItem.lessons.exercises.all_exercises.view_mode.points')}

@@ -148,7 +284,7 @@ contract={{ mode: 'review', question: toExerciseQuestionModel(q), - answer: getStudentAnswerForQuestion($submissions[studentSelected], q), + answer: getStudentAnswerForQuestion(selectedSubmission, q), labels: questionLabels, disabled: true }} diff --git a/apps/dashboard/src/lib/features/course/components/exercise/submissions/submissions.svelte b/apps/dashboard/src/lib/features/course/components/exercise/submissions/submissions.svelte index a1c95fd4..c8a985d9 100644 --- a/apps/dashboard/src/lib/features/course/components/exercise/submissions/submissions.svelte +++ b/apps/dashboard/src/lib/features/course/components/exercise/submissions/submissions.svelte @@ -8,6 +8,7 @@ import { t } from '$lib/utils/functions/translations'; import type { ExerciseSubmissions } from './types'; import type { SubmissionListItem } from '$features/course/utils/types'; + import { groupSubmissionsByStudentAndAttempt } from '$features/course/utils/exercise-progression-utils'; interface Props { exerciseId: string; @@ -18,6 +19,7 @@ type SubmissionTab = 'summary' | 'individual'; let currentTab = $state('summary'); + const submissionGroups = $derived(groupSubmissionsByStudentAndAttempt(submissionsData)); function normalizeSubmissions(items: SubmissionListItem[]): ExerciseSubmissions[] { return items.map( @@ -66,7 +68,7 @@ - + {:else} diff --git a/apps/dashboard/src/lib/features/course/components/exercise/submissions/summary.svelte b/apps/dashboard/src/lib/features/course/components/exercise/submissions/summary.svelte index e063da41..cbfcc8ee 100644 --- a/apps/dashboard/src/lib/features/course/components/exercise/submissions/summary.svelte +++ b/apps/dashboard/src/lib/features/course/components/exercise/submissions/summary.svelte @@ -40,9 +40,9 @@ (): ExerciseQuestionLabels => ({ 'submission.common.no_answer': $t('course.navItem.lessons.exercises.all_exercises.analytics.individual.no'), 'submission.common.other': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.other'), - 'submission.chart.responses': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.responses'), + 'submission.chart.responses': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.attempts'), 'submission.chart.no_data': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.no_responses'), - 'submission.list.responses': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.responses'), + 'submission.list.responses': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.attempts'), 'submission.list.no_responses': $t( 'course.navItem.lessons.exercises.all_exercises.analytics.summary.no_responses' ) diff --git a/apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte b/apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte index 6dbd8c97..23e032af 100644 --- a/apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte +++ b/apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte @@ -49,6 +49,7 @@ import { toExerciseQuestionModel } from './question-type-utils'; import { getExerciseQuestionLabels } from './question-labels'; import { getExerciseSectionDisplayTitle } from '$features/course/utils/exercise-section-utils'; + import { shouldCompleteExerciseFromSubmission } from '$features/course/utils/exercise-progression-utils'; import axios from 'axios'; import { IconButton } from '@cio/ui/custom/icon-button'; import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left'; @@ -80,7 +81,19 @@ const questionLabels = $derived(getExerciseQuestionLabels()); const sectionFallbackTitle = $derived($t('course.navItem.lessons.exercises.all_exercises.section.fallback_title')); - const submissionList = $derived(Array.isArray(mySubmissions) ? mySubmissions : []); + /** Submissions created in this session — the `mySubmissions` prop only refreshes on page load. */ + let localSubmissions = $state([]); + + const submissionList = $derived.by(() => { + const loadedSubmissions = Array.isArray(mySubmissions) ? mySubmissions : []; + const newSubmissions = localSubmissions.filter( + (localSubmission) => !loadedSubmissions.some((loadedSubmission) => loadedSubmission.id === localSubmission.id) + ); + return [...loadedSubmissions, ...newSubmissions]; + }); + + /** Attempt currently shown: -1 means "latest" (e.g. right after submitting). */ + const viewedAttemptIndex = $derived(selectedTryIndex >= 0 ? selectedTryIndex : submissionList.length - 1); function handleStart() { questionnaireMetaData.update((m) => ({ @@ -137,6 +150,8 @@ if (exerciseApi.success && submitResult?.data) { skipHydrateFromSubmissions = false; const sub = submitResult.data as unknown as SubmissionListItem & { gradingState?: string }; + localSubmissions = [...localSubmissions, sub]; + selectedTryIndex = submissionList.length - 1; if (sub.gradingState === 'completed' && sub.statusId === STATUS.GRADED && Array.isArray(sub.answers)) { applyMySubmission(sub); } else { @@ -150,14 +165,26 @@ exerciseId })); } - courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: true }); + const shouldMarkComplete = shouldCompleteExerciseFromSubmission({ + completionPolicy: $questionnaire.completionPolicy, + passThreshold: $questionnaire.passThreshold, + totalPossiblePoints: totalPossibleGrade, + submission: sub + }); - toggleConfetti(); - setTimeout(toggleConfetti, 3000); + courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: shouldMarkComplete }); + + if (shouldMarkComplete) { + toggleConfetti(); + setTimeout(toggleConfetti, 3000); + } const allContentItems = getOrderedNavigableContent(courseApi.course); const allComplete = - $isOrgStudent && allContentItems.length > 0 && allContentItems.every((item) => item.isComplete); + shouldMarkComplete && + $isOrgStudent && + allContentItems.length > 0 && + allContentItems.every((item) => item.isComplete); if (allComplete && courseApi.course?.id) { const requiredExerciseId = courseApi.course?.certificate?.requiredExerciseId ?? undefined; @@ -165,11 +192,13 @@ const certRes = await courseApi.getCertificationEvaluation(courseApi.course.id); if (certRes?.data) { - if (certRes.data.isNewCompletion) { - updateCourseCompletionModal(courseApi.course.id, 'eligible', certRes.data, requiredExerciseId); - } else { - updateCourseCompletionModal(courseApi.course.id, 'not-eligible', certRes.data, requiredExerciseId); - } + const hasCompletedCourse = Boolean(certRes.data.eligibleForCertificate || certRes.data.certificateEarnedAt); + updateCourseCompletionModal( + courseApi.course.id, + hasCompletedCourse ? 'eligible' : 'not-eligible', + certRes.data, + requiredExerciseId + ); } else { closeCourseCompletionModal(); } @@ -288,6 +317,12 @@ }, 0); } + function formatPercent(value: number) { + return new Intl.NumberFormat(undefined, { + maximumFractionDigits: 1 + }).format(value); + } + function getAutoSavedData() { isLoadingAutoSavedData = true; @@ -606,6 +641,24 @@ ); const canGoNextForSharedQuestion = $derived(hasAnswerValue(sharedCurrentAnswer)); const sectionQuestionTotalPoints = $derived(getTotalPossibleGrade(currentSectionQuestions)); + const passThresholdPercent = $derived(Number($questionnaire.passThreshold ?? 100)); + const gradedScorePercent = $derived.by(() => { + const finalGrade = Number($questionnaireMetaData.finalTotalGrade); + const totalGrade = Number($questionnaireMetaData.totalPossibleGrade); + if (!Number.isFinite(finalGrade) || !Number.isFinite(totalGrade) || totalGrade <= 0) return null; + + return (finalGrade / totalGrade) * 100; + }); + const shouldShowPassedCompletionResult = $derived( + ($questionnaire.completionPolicy ?? 'submitted') === 'passed' && + STATUS.GRADED === $questionnaireMetaData.status && + gradedScorePercent !== null + ); + const didCurrentAttemptPass = $derived( + shouldShowPassedCompletionResult && gradedScorePercent !== null && gradedScorePercent >= passThresholdPercent + ); + const gradedScorePercentLabel = $derived(gradedScorePercent === null ? '' : formatPercent(gradedScorePercent)); + const passThresholdPercentLabel = $derived(formatPercent(passThresholdPercent)); const sectionNavigationGroups = $derived( activeSections.map((section, sectionIndex) => { const sectionQuestions = getQuestionsForSection($questionnaire.questions, section.id); @@ -666,15 +719,15 @@ }); function goPrevTry() { - if (selectedTryIndex <= 0) return; - selectedTryIndex -= 1; + if (viewedAttemptIndex <= 0) return; + selectedTryIndex = viewedAttemptIndex - 1; const sub = submissionList[selectedTryIndex]; if (sub) applyMySubmission(sub); } function goNextTry() { - if (selectedTryIndex >= submissionList.length - 1) return; - selectedTryIndex += 1; + if (viewedAttemptIndex >= submissionList.length - 1) return; + selectedTryIndex = viewedAttemptIndex + 1; const sub = submissionList[selectedTryIndex]; if (sub) applyMySubmission(sub); } @@ -725,6 +778,7 @@ alreadyCheckedAutoSavedData = false; skipHydrateFromSubmissions = false; selectedTryIndex = -1; + localSubmissions = []; } prevExerciseId = exerciseId; }); @@ -842,6 +896,43 @@ {$questionnaireMetaData.totalPossibleGrade}

+ {#if shouldShowPassedCompletionResult} + +
+
+

+ {didCurrentAttemptPass + ? $t('course.navItem.lessons.exercises.all_exercises.view_mode.passed_completion_result_title') + : $t( + 'course.navItem.lessons.exercises.all_exercises.view_mode.not_passed_completion_result_title' + )} +

+

+ {didCurrentAttemptPass + ? $t( + 'course.navItem.lessons.exercises.all_exercises.view_mode.passed_completion_result_description', + { + scorePercent: gradedScorePercentLabel, + requiredPercent: passThresholdPercentLabel + } + ) + : $t( + 'course.navItem.lessons.exercises.all_exercises.view_mode.not_passed_completion_result_description', + { + scorePercent: gradedScorePercentLabel, + requiredPercent: passThresholdPercentLabel + } + )} +

+
+ {#if $questionnaire.allowMultipleAttempts && !didCurrentAttemptPass} + + {/if} +
+
+ {/if} {:else if isSelfPacedLikeCourse(courseApi.course?.type)} @@ -861,7 +952,7 @@ {#if submissionList.length > 1}
@@ -869,12 +960,12 @@ {$t('course.navItem.lessons.exercises.all_exercises.view_mode.attempt_counter', { - current: selectedTryIndex + 1, + current: viewedAttemptIndex + 1, total: submissionList.length })} = submissionList.length - 1} + disabled={viewedAttemptIndex >= submissionList.length - 1} onclick={goNextTry} tooltip={$t('course.navItem.lessons.exercises.all_exercises.view_mode.next_try')} > @@ -894,7 +985,7 @@ /> - {#if $questionnaire.allowMultipleAttempts} + {#if $questionnaire.allowMultipleAttempts && !shouldShowPassedCompletionResult}