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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* feat(course): improve exercise progression grading UX

---------

Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
Rotimi Best
2026-07-15 03:26:56 +01:00
committed by GitHub
co-authored by Rotimi Best
parent fdc19aac41
commit bfafc685e0
53 changed files with 2026 additions and 285 deletions
@@ -15,7 +15,11 @@ import {
getCourseProgress as getCourseProgressQuery, getCourseProgress as getCourseProgressQuery,
type TCourseCertificationRow type TCourseCertificationRow
} from '@cio/db/queries/course/course'; } 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 { 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'; import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
export type CertificationBlocker = { export type CertificationBlocker = {
@@ -230,8 +234,14 @@ export async function evaluateCourseCertification(
let isNewCompletion = false; let isNewCompletion = false;
if (evaluation.eligibleForCertificate && !certificateEarnedAt && progress.groupMemberId) { if (evaluation.eligibleForCertificate && !certificateEarnedAt && progress.groupMemberId) {
const certificatesEnabled = await orgHasCertificatesEnabled(courseRow.orgId);
if (certificatesEnabled) {
const earnedAt = new Date().toISOString(); const earnedAt = new Date().toISOString();
const didClaimCertificate = await claimMemberCertificateEarned(progress.groupMemberId, earnedAt);
certificateEarnedAt = earnedAt; certificateEarnedAt = earnedAt;
if (didClaimCertificate) {
isNewCompletion = true; isNewCompletion = true;
scheduleCertificationCompletionWork({ scheduleCertificationCompletionWork({
@@ -258,10 +268,24 @@ export async function evaluateCourseCertification(
}); });
} }
} }
}
}
return { ...evaluation, certificateEarnedAt, isNewCompletion }; 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<boolean> {
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). * Server-side guard for PDF certificate download (must match dashboard eligibility).
*/ */
+41 -5
View File
@@ -12,11 +12,19 @@ import {
updateQuestions updateQuestions
} from '@cio/db/queries/exercise'; } 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 { DbOrTxClient } from '@cio/db/drizzle';
import type { TExerciseUpdate } from '@cio/utils/validation/exercise'; import type { TExerciseUpdate } from '@cio/utils/validation/exercise';
type CurrentQuestion = TNewQuestion & { options?: TNewOption[] }; type CurrentQuestion = TNewQuestion & { options?: TNewOption[] };
type CurrentOption = TNewOption; type CurrentOption = TNewOption;
type ExerciseUpdateQuestion = NonNullable<TExerciseUpdate['questions']>[number];
type ExerciseAnswer = Pick< type ExerciseAnswer = Pick<
TQuestionAnswer, TQuestionAnswer,
@@ -68,6 +76,26 @@ function areSettingsEqual(left: unknown, right: unknown): boolean {
return JSON.stringify(leftNormalized) === JSON.stringify(rightNormalized); 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 * Extracts exercise-level fields from the update payload
*/ */
@@ -81,6 +109,8 @@ export function buildExerciseUpdateFields(data: TExerciseUpdate): Partial<TExerc
if (data.isUnlocked !== undefined) exerciseUpdate.isUnlocked = data.isUnlocked; if (data.isUnlocked !== undefined) exerciseUpdate.isUnlocked = data.isUnlocked;
if (data.dueBy !== undefined) exerciseUpdate.dueBy = data.dueBy || null; if (data.dueBy !== undefined) exerciseUpdate.dueBy = data.dueBy || null;
if (data.allowMultipleAttempts !== undefined) exerciseUpdate.allowMultipleAttempts = data.allowMultipleAttempts; if (data.allowMultipleAttempts !== undefined) exerciseUpdate.allowMultipleAttempts = data.allowMultipleAttempts;
if (data.completionPolicy !== undefined) exerciseUpdate.completionPolicy = data.completionPolicy;
if (data.passThreshold !== undefined) exerciseUpdate.passThreshold = data.passThreshold;
if (data.sectionDisplayMode !== undefined) exerciseUpdate.sectionDisplayMode = data.sectionDisplayMode; if (data.sectionDisplayMode !== undefined) exerciseUpdate.sectionDisplayMode = data.sectionDisplayMode;
if (data.slug !== undefined) exerciseUpdate.slug = data.slug; if (data.slug !== undefined) exerciseUpdate.slug = data.slug;
return exerciseUpdate; return exerciseUpdate;
@@ -95,7 +125,9 @@ export function categorizeQuestions(questions: NonNullable<TExerciseUpdate['ques
const newQuestions: typeof questions = []; const newQuestions: typeof questions = [];
const questionUpdates: Array<{ id: number; data: Partial<TNewQuestion> }> = []; const questionUpdates: Array<{ id: number; data: Partial<TNewQuestion> }> = [];
for (const q of questions) { for (const rawQuestion of questions) {
const q = normalizeTrueFalseQuestion(rawQuestion);
if (q.deletedAt && q.id) { if (q.deletedAt && q.id) {
deletedIds.push(q.id); deletedIds.push(q.id);
} else if (q.id) { } else if (q.id) {
@@ -230,13 +262,16 @@ export function computeExerciseDiff(
incomingQuestions: NonNullable<TExerciseUpdate['questions']> incomingQuestions: NonNullable<TExerciseUpdate['questions']>
) { ) {
const questionMap = new Map(currentQuestions.map((q) => [q.id, q])); 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 deletedQuestionIds: number[] = [];
const questionUpdates: Array<{ id: number; data: Partial<TNewQuestion> }> = []; const questionUpdates: Array<{ id: number; data: Partial<TNewQuestion> }> = [];
const newQuestions: NonNullable<TExerciseUpdate['questions']> = []; const newQuestions: NonNullable<TExerciseUpdate['questions']> = [];
// Process incoming questions // Process incoming questions
for (const incoming of incomingQuestions) { for (const incoming of normalizedIncomingQuestions) {
if (incoming.deletedAt && incoming.id) { if (incoming.deletedAt && incoming.id) {
deletedQuestionIds.push(incoming.id); deletedQuestionIds.push(incoming.id);
} else if (incoming.id) { } else if (incoming.id) {
@@ -260,7 +295,7 @@ export function computeExerciseDiff(
const optionUpdates: Array<{ id: number; data: Partial<TNewOption> }> = []; const optionUpdates: Array<{ id: number; data: Partial<TNewOption> }> = [];
const optionCreates: TNewOption[] = []; const optionCreates: TNewOption[] = [];
for (const incoming of incomingQuestions) { for (const incoming of normalizedIncomingQuestions) {
if (!incoming.id || incoming.deletedAt) continue; if (!incoming.id || incoming.deletedAt) continue;
const current = questionMap.get(incoming.id); const current = questionMap.get(incoming.id);
@@ -336,8 +371,9 @@ export async function createNewQuestionsWithOptions(
newQuestions: NonNullable<TExerciseUpdate['questions']>, newQuestions: NonNullable<TExerciseUpdate['questions']>,
txClient: DbOrTxClient txClient: DbOrTxClient
): Promise<void> { ): Promise<void> {
const normalizedQuestions = newQuestions.map((question) => normalizeTrueFalseQuestion(question));
const createdQuestions = await createQuestions( const createdQuestions = await createQuestions(
newQuestions.map((q) => ({ normalizedQuestions.map((q) => ({
exerciseId, exerciseId,
title: q.question, title: q.question,
questionTypeId: q.questionTypeId || 1, questionTypeId: q.questionTypeId || 1,
@@ -351,7 +387,7 @@ export async function createNewQuestionsWithOptions(
const newOptions: TNewOption[] = []; const newOptions: TNewOption[] = [];
createdQuestions.forEach((newQuestion, index) => { createdQuestions.forEach((newQuestion, index) => {
const questionData = newQuestions[index]; const questionData = normalizedQuestions[index];
if (newQuestion && questionData?.options && questionData.options.length > 0) { if (newQuestion && questionData?.options && questionData.options.length > 0) {
// Only include non-deleted options for new questions // Only include non-deleted options for new questions
for (const opt of questionData.options) { for (const opt of questionData.options) {
@@ -11,6 +11,7 @@
import { courseCompletionModal, closeCourseCompletionModal } from '$features/course/store/course-completion-modal'; import { courseCompletionModal, closeCourseCompletionModal } from '$features/course/store/course-completion-modal';
import { getContentRoute } from '$features/course/utils/content'; import { getContentRoute } from '$features/course/utils/content';
import { formatBlockerMessage } from '$features/course/utils/certificate-utils'; import { formatBlockerMessage } from '$features/course/utils/certificate-utils';
import { isFreePlan } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
let open = $state(false); let open = $state(false);
@@ -84,11 +85,15 @@
{:else if activeStep === 'eligible'} {:else if activeStep === 'eligible'}
<Dialog.Header> <Dialog.Header>
<Dialog.Title>{$t('course.completion.modal.title')}</Dialog.Title> <Dialog.Title>{$t('course.completion.modal.title')}</Dialog.Title>
<Dialog.Description>{$t('course.completion.modal.subtitle')}</Dialog.Description> <Dialog.Description>
{$isFreePlan ? $t('course.completion.modal.subtitle_no_certificate') : $t('course.completion.modal.subtitle')}
</Dialog.Description>
</Dialog.Header> </Dialog.Header>
<Dialog.Footer class="ui:gap-2"> <Dialog.Footer class="ui:gap-2">
<Button variant="outline" onclick={() => onOpenChange(false)}>{$t('course.completion.modal.later')}</Button> <Button variant="outline" onclick={() => onOpenChange(false)}>{$t('course.completion.modal.later')}</Button>
{#if !$isFreePlan}
<Button onclick={goToCertificate}>{$t('course.completion.modal.view_certificate')}</Button> <Button onclick={goToCertificate}>{$t('course.completion.modal.view_certificate')}</Button>
{/if}
</Dialog.Footer> </Dialog.Footer>
{:else if activeStep === 'not-eligible'} {:else if activeStep === 'not-eligible'}
<Dialog.Header> <Dialog.Header>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import TrashIcon from '@lucide/svelte/icons/trash'; import TrashIcon from '@lucide/svelte/icons/trash';
import GripVerticalIcon from '@lucide/svelte/icons/grip-vertical'; import GripVerticalIcon from '@lucide/svelte/icons/grip-vertical';
import InfoIcon from '@lucide/svelte/icons/info';
import { dndzone } from 'svelte-dnd-action'; import { dndzone } from 'svelte-dnd-action';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import { tick } from 'svelte'; import { tick } from 'svelte';
@@ -24,6 +25,8 @@
} from './store'; } from './store';
import { QUESTION_TYPES } from '$features/ui/question/constants'; import { QUESTION_TYPES } from '$features/ui/question/constants';
import { ExerciseQuestion } from '@cio/ui'; import { ExerciseQuestion } from '@cio/ui';
import { Badge } from '@cio/ui/base/badge';
import * as Tooltip from '@cio/ui/base/tooltip';
import OrderModal from './order-modal.svelte'; import OrderModal from './order-modal.svelte';
import QuestionTypeSelect from './question-type-select.svelte'; import QuestionTypeSelect from './question-type-select.svelte';
@@ -35,11 +38,17 @@
import { uploadImage } from '$lib/utils/services/upload'; import { uploadImage } from '$lib/utils/services/upload';
import { import {
getExerciseEditorQuestionTypeLabel, getExerciseEditorQuestionTypeLabel,
getQuestionTypeKey,
getQuestionTypeOptionById, getQuestionTypeOptionById,
questionTypeSupportsOptions, questionTypeSupportsOptions,
toExerciseQuestionModel toExerciseQuestionModel
} from './question-type-utils'; } from './question-type-utils';
import { QUESTION_TYPE_KEY } from '@cio/question-types'; import {
QUESTION_TYPE_KEY,
isAutoGradableQuestionTypeId,
resolveTrueFalseCorrectValue,
syncTrueFalseOptions
} from '@cio/question-types';
import { getExerciseQuestionLabels } from './question-labels'; import { getExerciseQuestionLabels } from './question-labels';
import SectionEditor from './section-editor.svelte'; import SectionEditor from './section-editor.svelte';
import type { Question } from '$features/course/types'; import type { Question } from '$features/course/types';
@@ -207,6 +216,7 @@
if (nextQuestionTypeKey === QUESTION_TYPE_KEY.TRUE_FALSE) { if (nextQuestionTypeKey === QUESTION_TYPE_KEY.TRUE_FALSE) {
const correctValue = (current.settings as { correctValue?: boolean })?.correctValue ?? true; const correctValue = (current.settings as { correctValue?: boolean })?.correctValue ?? true;
nextSettings = { ...nextSettings, correctValue };
nextOptions = makeTrueFalseOptions(correctValue); nextOptions = makeTrueFalseOptions(correctValue);
} else if (questionTypeSupportsOptions(nextQuestionTypeKey)) { } else if (questionTypeSupportsOptions(nextQuestionTypeKey)) {
const hasActiveOptions = nextOptions.some((option) => !option.deletedAt); const hasActiveOptions = nextOptions.some((option) => !option.deletedAt);
@@ -263,12 +273,21 @@
}) })
: (current.options ?? []); : (current.options ?? []);
let nextSettings = nextQuestion.settings ?? {};
let nextOptions = mappedOptions;
if (getQuestionTypeKey(current) === QUESTION_TYPE_KEY.TRUE_FALSE) {
const correctValue = resolveTrueFalseCorrectValue(nextSettings, nextOptions);
nextSettings = { ...nextSettings, correctValue };
nextOptions = syncTrueFalseOptions(nextOptions, correctValue);
}
const next = [...q.questions]; const next = [...q.questions];
next[idx] = { next[idx] = {
...current, ...current,
title: nextQuestion.title, title: nextQuestion.title,
settings: nextQuestion.settings ?? {}, settings: nextSettings,
options: mappedOptions, options: nextOptions,
isDirty: true isDirty: true
}; };
@@ -331,6 +350,13 @@
return questions.filter((question) => toExerciseQuestionModel(question).required !== false).length; return questions.filter((question) => toExerciseQuestionModel(question).required !== false).length;
} }
function isQuestionAutoGradable(question: Question) {
const questionTypeId = Number(question.questionTypeId ?? question.questionType?.id);
if (!Number.isFinite(questionTypeId)) return false;
return isAutoGradableQuestionTypeId(questionTypeId);
}
$effect(() => { $effect(() => {
if (reorderQuestions && !previousReorderQuestions) { if (reorderQuestions && !previousReorderQuestions) {
syncReorderQuestionItems(); syncReorderQuestionItems();
@@ -357,6 +383,33 @@
<OrderModal /> <OrderModal />
{#snippet gradingBadge(question)}
{@const autoGradable = isQuestionAutoGradable(question)}
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<Badge
{...props}
variant="outline"
class="absolute top-0 right-4 z-10 translate-y-[-50%] gap-1 bg-white font-normal dark:bg-black"
>
{autoGradable
? $t('course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_auto_gradable')
: $t('course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_manual_grading')}
<InfoIcon class="h-3 w-3" aria-hidden="true" />
</Badge>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="top" sideOffset={6} class="max-w-xs">
{autoGradable
? $t('course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_auto_gradable_description')
: $t('course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_manual_grading_description')}
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
{/snippet}
{#snippet questionEditor(question, index)} {#snippet questionEditor(question, index)}
<QuestionContainer <QuestionContainer
elementId={getQuestionElementId(question.id)} elementId={getQuestionElementId(question.id)}
@@ -366,7 +419,7 @@
bind:points={question.points} bind:points={question.points}
hasError={!!errors[question.id]} hasError={!!errors[question.id]}
errorMsg={getQuestionErrorMsg(errors, question, 'points')} errorMsg={getQuestionErrorMsg(errors, question, 'points')}
pointsHint={requiresPositivePointsForAutoGrade && Number(question.points) === 0 pointsHint={requiresPositivePointsForAutoGrade
? $t('course.navItem.lessons.exercises.all_exercises.points_required_auto_grade') ? $t('course.navItem.lessons.exercises.all_exercises.points_required_auto_grade')
: null} : null}
onPointsChange={() => { onPointsChange={() => {
@@ -386,6 +439,8 @@
</div> </div>
{/if} {/if}
{@render gradingBadge(question)}
<div class="mt-2 flex flex-col"> <div class="mt-2 flex flex-col">
<ExerciseQuestion.QuestionRenderer <ExerciseQuestion.QuestionRenderer
showContainer={false} showContainer={false}
@@ -13,9 +13,11 @@
type Props = { type Props = {
exerciseId: string; exerciseId: string;
onBeforeSave?: () => Promise<boolean> | boolean;
totalSubmissions?: number;
}; };
let { exerciseId }: Props = $props(); let { exerciseId, onBeforeSave = () => true, totalSubmissions = 0 }: Props = $props();
let saving = $state(false); let saving = $state(false);
let slug = $state($questionnaire.slug ?? ''); let slug = $state($questionnaire.slug ?? '');
@@ -28,13 +30,21 @@
async function saveSettings() { async function saveSettings() {
if (!courseApi.course?.id) return; if (!courseApi.course?.id) return;
const canSave = await onBeforeSave();
if (!canSave) return;
saving = true; saving = true;
const allowMultipleAttempts = !!$questionnaire.allowMultipleAttempts;
const completionPolicy = $questionnaire.completionPolicy ?? 'submitted';
const passThreshold = $questionnaire.passThreshold ?? 100;
const slugPayload = isPublicCourse && slug ? slug : undefined;
await exerciseApi.update(courseApi.course.id, exerciseId, { await exerciseApi.update(courseApi.course.id, exerciseId, {
allowMultipleAttempts: !!$questionnaire.allowMultipleAttempts, allowMultipleAttempts,
completionPolicy: $questionnaire.completionPolicy ?? 'submitted', completionPolicy,
passThreshold: $questionnaire.passThreshold ?? 100, passThreshold,
slug: isPublicCourse && slug ? slug : undefined slug: slugPayload
}); });
saving = false; saving = false;
@@ -92,7 +102,25 @@
</Select.Root> </Select.Root>
{#if ($questionnaire.completionPolicy ?? 'submitted') === 'passed'} {#if ($questionnaire.completionPolicy ?? 'submitted') === 'passed'}
<div class="mt-4"> <div class="mt-4 space-y-3">
<div class="space-y-2 text-sm">
<p class="ui:text-muted-foreground">
{$t('course.navItem.lessons.exercises.all_exercises.settings_completion_passed_helper')}
</p>
<p class="text-amber-700 dark:text-amber-300">
{$t('course.navItem.lessons.exercises.all_exercises.settings_manual_grading_warning')}
</p>
{#if !$questionnaire.allowMultipleAttempts}
<p class="text-amber-700 dark:text-amber-300">
{$t('course.navItem.lessons.exercises.all_exercises.settings_single_attempt_warning')}
</p>
{/if}
{#if totalSubmissions > 0}
<p class="text-amber-700 dark:text-amber-300">
{$t('course.navItem.lessons.exercises.all_exercises.settings_existing_submissions_warning')}
</p>
{/if}
</div>
<InputField <InputField
type="number" type="number"
label={$t('course.navItem.lessons.exercises.all_exercises.settings_pass_threshold')} label={$t('course.navItem.lessons.exercises.all_exercises.settings_pass_threshold')}
@@ -1,6 +1,6 @@
import { getQuestionAnswerKey, getQuestionTypeKey, questionTypeSupportsOptions } from './question-type-utils'; import { getQuestionAnswerKey, getQuestionTypeKey, questionTypeSupportsOptions } from './question-type-utils';
import { QUESTION_TYPE_KEY } from '@cio/question-types'; import { QUESTION_TYPE_KEY, resolveTrueFalseCorrectValue, syncTrueFalseOptions } from '@cio/question-types';
import type { Question } from '$features/course/types'; import type { Question } from '$features/course/types';
import type { QuestionnaireMetaData } from './store'; import type { QuestionnaireMetaData } from './store';
import type { TExerciseUpdate } from '@cio/utils/validation/exercise'; import type { TExerciseUpdate } from '@cio/utils/validation/exercise';
@@ -153,15 +153,37 @@ export function transformQuestionsToApiFormat(
const filteredQuestions = shouldIncludeDeleted ? questions : questions.filter((q) => !q.deletedAt); const filteredQuestions = shouldIncludeDeleted ? questions : questions.filter((q) => !q.deletedAt);
return filteredQuestions.map((q, index) => ({ return filteredQuestions.map((q, index) => {
const questionTypeKey = getQuestionTypeKey(q);
const settings = getQuestionSettings(q);
const formattedOptions = questionTypeSupportsOptions(questionTypeKey) ? formatOptions(q.options) : [];
if (questionTypeKey === QUESTION_TYPE_KEY.TRUE_FALSE) {
const correctValue = resolveTrueFalseCorrectValue(settings, formattedOptions);
return {
id: normalizeId(q.id), id: normalizeId(q.id),
question: q.title, question: q.title,
points: q.points || 0, points: q.points || 0,
questionTypeId: q.questionTypeId, questionTypeId: q.questionTypeId,
exerciseSectionId: q.exerciseSectionId ?? null, exerciseSectionId: q.exerciseSectionId ?? null,
order: typeof (q as { order?: number }).order === 'number' ? (q as { order: number }).order : index + 1, order: typeof (q as { order?: number }).order === 'number' ? (q as { order: number }).order : index + 1,
settings: getQuestionSettings(q), settings: { ...(settings ?? {}), correctValue },
options: questionTypeSupportsOptions(getQuestionTypeKey(q)) ? formatOptions(q.options) : [], options: syncTrueFalseOptions(formattedOptions, correctValue),
...(shouldIncludeDeleted && q.deletedAt ? { deletedAt: q.deletedAt } : {}) ...(shouldIncludeDeleted && q.deletedAt ? { deletedAt: q.deletedAt } : {})
})); };
}
return {
id: normalizeId(q.id),
question: q.title,
points: q.points || 0,
questionTypeId: q.questionTypeId,
exerciseSectionId: q.exerciseSectionId ?? null,
order: typeof (q as { order?: number }).order === 'number' ? (q as { order: number }).order : index + 1,
settings,
options: formattedOptions,
...(shouldIncludeDeleted && q.deletedAt ? { deletedAt: q.deletedAt } : {})
};
});
} }
@@ -2,24 +2,31 @@
import { Spinner } from '@cio/ui/base/spinner'; import { Spinner } from '@cio/ui/base/spinner';
import { ExerciseQuestion } from '@cio/ui'; import { ExerciseQuestion } from '@cio/ui';
import { UserAvatar } from '@cio/ui/custom/user-avatar'; import { UserAvatar } from '@cio/ui/custom/user-avatar';
import { Badge } from '@cio/ui/base/badge';
import * as Select from '@cio/ui/base/select';
import { submissions } from './store';
import { getQuestionsForSection, questionnaire } from '../store'; import { getQuestionsForSection, questionnaire } from '../store';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import type { ExerciseSubmissions } from './types';
import type { Question } from '$features/course/types'; import type { Question } from '$features/course/types';
import type { AnswerData } from '@cio/question-types'; import type { AnswerData } from '@cio/question-types';
import { toExerciseQuestionModel } from '../question-type-utils'; import { toExerciseQuestionModel } from '../question-type-utils';
import { getExerciseQuestionLabels } from '../question-labels'; import { getExerciseQuestionLabels } from '../question-labels';
import { getExerciseSectionDisplayTitle } from '$features/course/utils/exercise-section-utils'; import { getExerciseSectionDisplayTitle } from '$features/course/utils/exercise-section-utils';
import {
shouldCompleteExerciseFromSubmission,
type ExerciseSubmissionStudentGroup
} from '$features/course/utils/exercise-progression-utils';
import type { SubmissionListItem } from '$features/course/utils/types';
interface Props { interface Props {
isLoading?: boolean; isLoading?: boolean;
submissionGroups?: ExerciseSubmissionStudentGroup[];
} }
let { isLoading = $bindable(false) }: Props = $props(); let { isLoading = $bindable(false), submissionGroups = [] }: Props = $props();
let studentSelected = $state(0); let studentSelected = $state(0);
let selectedAttemptByStudentKey = $state<Record<string, number>>({});
const questionLabels = $derived(getExerciseQuestionLabels()); const questionLabels = $derived(getExerciseQuestionLabels());
const sectionFallbackTitle = $derived($t('course.navItem.lessons.exercises.all_exercises.section.fallback_title')); const sectionFallbackTitle = $derived($t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'));
const activeSections = $derived( const activeSections = $derived(
@@ -32,13 +39,13 @@
return Number.isNaN(numericId) ? null : numericId; return Number.isNaN(numericId) ? null : numericId;
} }
function getStudentAnswerForQuestion(student: ExerciseSubmissions, question: Question): AnswerData | undefined { function getStudentAnswerForQuestion(submission: SubmissionListItem, question: Question): AnswerData | undefined {
if (!student?.answers) return undefined; if (!submission?.answers) return undefined;
const questionId = toNumericQuestionId(question); const questionId = toNumericQuestionId(question);
if (questionId === null) return undefined; if (questionId === null) return undefined;
const submittedAnswer = student.answers.find((answer) => answer.questionId === questionId); const submittedAnswer = submission.answers.find((answer) => answer.questionId === questionId);
if ( if (
!submittedAnswer?.answerData || !submittedAnswer?.answerData ||
typeof submittedAnswer.answerData !== 'object' || typeof submittedAnswer.answerData !== 'object' ||
@@ -49,12 +56,12 @@
return submittedAnswer.answerData; return submittedAnswer.answerData;
} }
function getSectionScore(student: ExerciseSubmissions, questions: Question[]) { function getSectionScore(submission: SubmissionListItem, questions: Question[]) {
return questions.reduce((total, question) => { return questions.reduce((total, question) => {
const questionId = toNumericQuestionId(question); const questionId = toNumericQuestionId(question);
if (questionId === null) return total; if (questionId === null) return total;
const answer = student.answers.find((item) => item.questionId === questionId); const answer = submission.answers.find((item) => item.questionId === questionId);
return total + (answer?.point ?? 0); return total + (answer?.point ?? 0);
}, 0); }, 0);
} }
@@ -62,13 +69,83 @@
function getSectionMaxPoints(questions: Question[]) { function getSectionMaxPoints(questions: Question[]) {
return questions.reduce((total, question) => total + Number(question.points ?? 0), 0); return questions.reduce((total, question) => total + Number(question.points ?? 0), 0);
} }
function getTotalScore(submission: SubmissionListItem) {
const submissionTotal = Number(
(submission as SubmissionListItem & { total?: number | string | null }).total ?? NaN
);
if (Number.isFinite(submissionTotal)) return submissionTotal;
return submission.answers.reduce((total, answer) => total + Number(answer.point ?? 0), 0);
}
function getTotalPossiblePoints() {
return getSectionMaxPoints($questionnaire.questions.filter((question) => !question.deletedAt));
}
function formatSubmittedAt(submission: SubmissionListItem) {
const createdAt = (submission as SubmissionListItem & { createdAt?: string | Date | null }).createdAt;
if (!createdAt) return t.get('course.navItem.lessons.exercises.all_exercises.analytics.individual.date_unknown');
return new Date(createdAt).toLocaleString();
}
function getStatusLabel(submission: SubmissionListItem & { gradingState?: string | null }) {
if (submission.gradingState === 'awaiting_manual') {
return t.get('course.navItem.lessons.exercises.all_exercises.analytics.individual.status_awaiting_manual');
}
if (submission.gradingState === 'completed' || submission.statusId === 3) {
return t.get('course.navItem.lessons.exercises.all_exercises.analytics.individual.status_completed');
}
return t.get('course.navItem.lessons.exercises.all_exercises.analytics.individual.status_pending');
}
function getAttemptPassLabel(submission: SubmissionListItem & { gradingState?: string | null }) {
return didAttemptCompleteExercise(submission)
? ($questionnaire.completionPolicy ?? 'submitted') === 'submitted'
? t.get('course.navItem.lessons.exercises.all_exercises.analytics.individual.counts_as_complete')
: t.get('course.navItem.lessons.exercises.all_exercises.analytics.individual.passed')
: t.get('course.navItem.lessons.exercises.all_exercises.analytics.individual.not_passed');
}
function didAttemptCompleteExercise(submission: SubmissionListItem & { gradingState?: string | null }) {
return shouldCompleteExerciseFromSubmission({
completionPolicy: $questionnaire.completionPolicy,
passThreshold: $questionnaire.passThreshold,
totalPossiblePoints: getTotalPossiblePoints(),
submission
});
}
function getSelectedGroup() {
return submissionGroups[studentSelected] ?? submissionGroups[0];
}
function getSelectedAttemptIndex(group: ExerciseSubmissionStudentGroup) {
const selectedAttemptIndex = selectedAttemptByStudentKey[group.studentKey] ?? group.attempts.length - 1;
if (selectedAttemptIndex < 0 || selectedAttemptIndex >= group.attempts.length) return group.attempts.length - 1;
return selectedAttemptIndex;
}
function selectAttempt(group: ExerciseSubmissionStudentGroup, value: string) {
const selectedAttemptIndex = Number(value);
if (Number.isNaN(selectedAttemptIndex)) return;
selectedAttemptByStudentKey = {
...selectedAttemptByStudentKey,
[group.studentKey]: selectedAttemptIndex
};
}
</script> </script>
{#if isLoading} {#if isLoading}
<Spinner /> <Spinner />
{:else if $submissions?.length} {:else if submissionGroups?.length}
<div class="mt-2 mb-5 flex w-full gap-1 overflow-auto"> <div class="mt-2 mb-5 flex w-full gap-1 overflow-auto">
{#each $submissions as student, i (student.id)} {#each submissionGroups as studentGroup, i (studentGroup.studentKey)}
<button onclick={() => (studentSelected = i)} class="flex w-20 flex-col items-center"> <button onclick={() => (studentSelected = i)} class="flex w-20 flex-col items-center">
<div <div
class={`flex h-12 w-12 items-center justify-center rounded-full ${ class={`flex h-12 w-12 items-center justify-center rounded-full ${
@@ -76,24 +153,83 @@
}`} }`}
> >
<UserAvatar <UserAvatar
src={student.groupmember?.profile.avatarUrl} src={studentGroup.studentAvatarUrl}
alt={$t('course.navItem.lessons.exercises.all_exercises.analytics.individual.student_avatar')} alt={$t('course.navItem.lessons.exercises.all_exercises.analytics.individual.student_avatar')}
class="m-1 size-10" class="m-1 size-10"
/> />
</div> </div>
<p class="line-clamp-2 w-20 leading-4 whitespace-pre-wrap"> <p class="line-clamp-2 w-20 leading-4 whitespace-pre-wrap">
{student.groupmember?.profile.fullname ?? ''} {studentGroup.studentName}
</p> </p>
</button> </button>
{/each} {/each}
</div> </div>
<p class="mb-2 font-medium"> {@const selectedGroup = getSelectedGroup()}
{$submissions[studentSelected].groupmember?.profile.fullname ?? ''}'s {$t( {#if selectedGroup}
'course.navItem.lessons.exercises.all_exercises.analytics.individual.answers' {@const selectedAttemptIndex = getSelectedAttemptIndex(selectedGroup)}
)} {@const selectedAttempt = selectedGroup.attempts[selectedAttemptIndex]}
{@const selectedSubmission = selectedAttempt.submission}
<div class="mb-4 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<p class="font-medium">
{$t('course.navItem.lessons.exercises.all_exercises.analytics.individual.answers_for', {
name: selectedGroup.studentName
})}
</p> </p>
{#if $submissions[studentSelected]} <p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.lessons.exercises.all_exercises.analytics.individual.attempts_count', {
count: selectedGroup.attempts.length
})}
</p>
</div>
<div class="w-full md:w-80">
<Select.Root
type="single"
value={String(selectedAttemptIndex)}
onValueChange={(value) => selectAttempt(selectedGroup, value)}
>
<Select.Trigger class="w-full">
{$t('course.navItem.lessons.exercises.all_exercises.analytics.individual.attempt_label', {
number: selectedAttempt.attemptNumber
})}
-
{getStatusLabel(selectedSubmission)}
</Select.Trigger>
<Select.Content>
{#each selectedGroup.attempts as attempt, attemptIndex (attempt.submission.id)}
{@const attemptScore = getTotalScore(attempt.submission)}
<Select.Item value={String(attemptIndex)}>
{$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)}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
<div class="mb-4 flex flex-wrap items-center gap-2">
<Badge variant="outline">{getStatusLabel(selectedSubmission)}</Badge>
<Badge variant="secondary">
{getTotalScore(selectedSubmission)}/{getTotalPossiblePoints()}
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.points')}
</Badge>
<Badge variant={didAttemptCompleteExercise(selectedSubmission) ? 'success' : 'warning'}>
{getAttemptPassLabel(selectedSubmission)}
</Badge>
</div>
{#if hasSectionGroups} {#if hasSectionGroups}
<div class="space-y-8"> <div class="space-y-8">
{#each activeSections as section, sectionIndex (section.id)} {#each activeSections as section, sectionIndex (section.id)}
@@ -124,7 +260,7 @@
contract={{ contract={{
mode: 'review', mode: 'review',
question: toExerciseQuestionModel(q), question: toExerciseQuestionModel(q),
answer: getStudentAnswerForQuestion($submissions[studentSelected], q), answer: getStudentAnswerForQuestion(selectedSubmission, q),
labels: questionLabels, labels: questionLabels,
disabled: true disabled: true
}} }}
@@ -135,7 +271,7 @@
{/each} {/each}
<p class="border-t pt-2 text-sm font-medium"> <p class="border-t pt-2 text-sm font-medium">
{getSectionScore($submissions[studentSelected], sectionQuestions)}/{getSectionMaxPoints(sectionQuestions)} {getSectionScore(selectedSubmission, sectionQuestions)}/{getSectionMaxPoints(sectionQuestions)}
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.points')} {$t('course.navItem.lessons.exercises.all_exercises.view_mode.points')}
</p> </p>
</section> </section>
@@ -148,7 +284,7 @@
contract={{ contract={{
mode: 'review', mode: 'review',
question: toExerciseQuestionModel(q), question: toExerciseQuestionModel(q),
answer: getStudentAnswerForQuestion($submissions[studentSelected], q), answer: getStudentAnswerForQuestion(selectedSubmission, q),
labels: questionLabels, labels: questionLabels,
disabled: true disabled: true
}} }}
@@ -8,6 +8,7 @@
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import type { ExerciseSubmissions } from './types'; import type { ExerciseSubmissions } from './types';
import type { SubmissionListItem } from '$features/course/utils/types'; import type { SubmissionListItem } from '$features/course/utils/types';
import { groupSubmissionsByStudentAndAttempt } from '$features/course/utils/exercise-progression-utils';
interface Props { interface Props {
exerciseId: string; exerciseId: string;
@@ -18,6 +19,7 @@
type SubmissionTab = 'summary' | 'individual'; type SubmissionTab = 'summary' | 'individual';
let currentTab = $state<SubmissionTab>('summary'); let currentTab = $state<SubmissionTab>('summary');
const submissionGroups = $derived(groupSubmissionsByStudentAndAttempt(submissionsData));
function normalizeSubmissions(items: SubmissionListItem[]): ExerciseSubmissions[] { function normalizeSubmissions(items: SubmissionListItem[]): ExerciseSubmissions[] {
return items.map( return items.map(
@@ -66,7 +68,7 @@
</Tabs.Content> </Tabs.Content>
<Tabs.Content value="individual" class="pt-2"> <Tabs.Content value="individual" class="pt-2">
<Individual isLoading={false} /> <Individual isLoading={false} {submissionGroups} />
</Tabs.Content> </Tabs.Content>
</Tabs.Root> </Tabs.Root>
{:else} {:else}
@@ -40,9 +40,9 @@
(): ExerciseQuestionLabels => ({ (): ExerciseQuestionLabels => ({
'submission.common.no_answer': $t('course.navItem.lessons.exercises.all_exercises.analytics.individual.no'), '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.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.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( 'submission.list.no_responses': $t(
'course.navItem.lessons.exercises.all_exercises.analytics.summary.no_responses' 'course.navItem.lessons.exercises.all_exercises.analytics.summary.no_responses'
) )
@@ -49,6 +49,7 @@
import { toExerciseQuestionModel } from './question-type-utils'; import { toExerciseQuestionModel } from './question-type-utils';
import { getExerciseQuestionLabels } from './question-labels'; import { getExerciseQuestionLabels } from './question-labels';
import { getExerciseSectionDisplayTitle } from '$features/course/utils/exercise-section-utils'; import { getExerciseSectionDisplayTitle } from '$features/course/utils/exercise-section-utils';
import { shouldCompleteExerciseFromSubmission } from '$features/course/utils/exercise-progression-utils';
import axios from 'axios'; import axios from 'axios';
import { IconButton } from '@cio/ui/custom/icon-button'; import { IconButton } from '@cio/ui/custom/icon-button';
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left'; import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
@@ -80,7 +81,19 @@
const questionLabels = $derived(getExerciseQuestionLabels()); const questionLabels = $derived(getExerciseQuestionLabels());
const sectionFallbackTitle = $derived($t('course.navItem.lessons.exercises.all_exercises.section.fallback_title')); 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<SubmissionListItem[]>([]);
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() { function handleStart() {
questionnaireMetaData.update((m) => ({ questionnaireMetaData.update((m) => ({
@@ -137,6 +150,8 @@
if (exerciseApi.success && submitResult?.data) { if (exerciseApi.success && submitResult?.data) {
skipHydrateFromSubmissions = false; skipHydrateFromSubmissions = false;
const sub = submitResult.data as unknown as SubmissionListItem & { gradingState?: string }; 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)) { if (sub.gradingState === 'completed' && sub.statusId === STATUS.GRADED && Array.isArray(sub.answers)) {
applyMySubmission(sub); applyMySubmission(sub);
} else { } else {
@@ -150,14 +165,26 @@
exerciseId exerciseId
})); }));
} }
courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: true }); const shouldMarkComplete = shouldCompleteExerciseFromSubmission({
completionPolicy: $questionnaire.completionPolicy,
passThreshold: $questionnaire.passThreshold,
totalPossiblePoints: totalPossibleGrade,
submission: sub
});
courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: shouldMarkComplete });
if (shouldMarkComplete) {
toggleConfetti(); toggleConfetti();
setTimeout(toggleConfetti, 3000); setTimeout(toggleConfetti, 3000);
}
const allContentItems = getOrderedNavigableContent(courseApi.course); const allContentItems = getOrderedNavigableContent(courseApi.course);
const allComplete = 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) { if (allComplete && courseApi.course?.id) {
const requiredExerciseId = courseApi.course?.certificate?.requiredExerciseId ?? undefined; const requiredExerciseId = courseApi.course?.certificate?.requiredExerciseId ?? undefined;
@@ -165,11 +192,13 @@
const certRes = await courseApi.getCertificationEvaluation(courseApi.course.id); const certRes = await courseApi.getCertificationEvaluation(courseApi.course.id);
if (certRes?.data) { if (certRes?.data) {
if (certRes.data.isNewCompletion) { const hasCompletedCourse = Boolean(certRes.data.eligibleForCertificate || certRes.data.certificateEarnedAt);
updateCourseCompletionModal(courseApi.course.id, 'eligible', certRes.data, requiredExerciseId); updateCourseCompletionModal(
} else { courseApi.course.id,
updateCourseCompletionModal(courseApi.course.id, 'not-eligible', certRes.data, requiredExerciseId); hasCompletedCourse ? 'eligible' : 'not-eligible',
} certRes.data,
requiredExerciseId
);
} else { } else {
closeCourseCompletionModal(); closeCourseCompletionModal();
} }
@@ -288,6 +317,12 @@
}, 0); }, 0);
} }
function formatPercent(value: number) {
return new Intl.NumberFormat(undefined, {
maximumFractionDigits: 1
}).format(value);
}
function getAutoSavedData() { function getAutoSavedData() {
isLoadingAutoSavedData = true; isLoadingAutoSavedData = true;
@@ -606,6 +641,24 @@
); );
const canGoNextForSharedQuestion = $derived(hasAnswerValue(sharedCurrentAnswer)); const canGoNextForSharedQuestion = $derived(hasAnswerValue(sharedCurrentAnswer));
const sectionQuestionTotalPoints = $derived(getTotalPossibleGrade(currentSectionQuestions)); 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( const sectionNavigationGroups = $derived(
activeSections.map((section, sectionIndex) => { activeSections.map((section, sectionIndex) => {
const sectionQuestions = getQuestionsForSection($questionnaire.questions, section.id); const sectionQuestions = getQuestionsForSection($questionnaire.questions, section.id);
@@ -666,15 +719,15 @@
}); });
function goPrevTry() { function goPrevTry() {
if (selectedTryIndex <= 0) return; if (viewedAttemptIndex <= 0) return;
selectedTryIndex -= 1; selectedTryIndex = viewedAttemptIndex - 1;
const sub = submissionList[selectedTryIndex]; const sub = submissionList[selectedTryIndex];
if (sub) applyMySubmission(sub); if (sub) applyMySubmission(sub);
} }
function goNextTry() { function goNextTry() {
if (selectedTryIndex >= submissionList.length - 1) return; if (viewedAttemptIndex >= submissionList.length - 1) return;
selectedTryIndex += 1; selectedTryIndex = viewedAttemptIndex + 1;
const sub = submissionList[selectedTryIndex]; const sub = submissionList[selectedTryIndex];
if (sub) applyMySubmission(sub); if (sub) applyMySubmission(sub);
} }
@@ -725,6 +778,7 @@
alreadyCheckedAutoSavedData = false; alreadyCheckedAutoSavedData = false;
skipHydrateFromSubmissions = false; skipHydrateFromSubmissions = false;
selectedTryIndex = -1; selectedTryIndex = -1;
localSubmissions = [];
} }
prevExerciseId = exerciseId; prevExerciseId = exerciseId;
}); });
@@ -842,6 +896,43 @@
<span class="text-xl">{$questionnaireMetaData.totalPossibleGrade}</span> <span class="text-xl">{$questionnaireMetaData.totalPossibleGrade}</span>
</p> </p>
</div> </div>
{#if shouldShowPassedCompletionResult}
<Alert.Root variant={didCurrentAttemptPass ? 'information' : 'warning'} class="h-fit! w-full! flex-1!">
<div class="col-start-2 flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 space-y-1">
<p class="font-medium">
{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'
)}
</p>
<p class="ui:text-muted-foreground text-sm">
{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
}
)}
</p>
</div>
{#if $questionnaire.allowMultipleAttempts && !didCurrentAttemptPass}
<Button type="button" variant="outline" onclick={tryAgain} class="shrink-0">
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.try_again')}
</Button>
{/if}
</div>
</Alert.Root>
{/if}
</div> </div>
</div> </div>
{:else if isSelfPacedLikeCourse(courseApi.course?.type)} {:else if isSelfPacedLikeCourse(courseApi.course?.type)}
@@ -861,7 +952,7 @@
{#if submissionList.length > 1} {#if submissionList.length > 1}
<div class="mb-4 flex flex-wrap items-center gap-2"> <div class="mb-4 flex flex-wrap items-center gap-2">
<IconButton <IconButton
disabled={selectedTryIndex <= 0} disabled={viewedAttemptIndex <= 0}
onclick={goPrevTry} onclick={goPrevTry}
tooltip={$t('course.navItem.lessons.exercises.all_exercises.view_mode.previous_try')} tooltip={$t('course.navItem.lessons.exercises.all_exercises.view_mode.previous_try')}
> >
@@ -869,12 +960,12 @@
</IconButton> </IconButton>
<span class="ui:text-muted-foreground text-sm"> <span class="ui:text-muted-foreground text-sm">
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.attempt_counter', { {$t('course.navItem.lessons.exercises.all_exercises.view_mode.attempt_counter', {
current: selectedTryIndex + 1, current: viewedAttemptIndex + 1,
total: submissionList.length total: submissionList.length
})} })}
</span> </span>
<IconButton <IconButton
disabled={selectedTryIndex >= submissionList.length - 1} disabled={viewedAttemptIndex >= submissionList.length - 1}
onclick={goNextTry} onclick={goNextTry}
tooltip={$t('course.navItem.lessons.exercises.all_exercises.view_mode.next_try')} tooltip={$t('course.navItem.lessons.exercises.all_exercises.view_mode.next_try')}
> >
@@ -894,7 +985,7 @@
/> />
<RoleBasedSecurity allowedRoles={[3]}> <RoleBasedSecurity allowedRoles={[3]}>
{#if $questionnaire.allowMultipleAttempts} {#if $questionnaire.allowMultipleAttempts && !shouldShowPassedCompletionResult}
<div class="mt-4"> <div class="mt-4">
<Button type="button" variant="secondary" onclick={tryAgain}> <Button type="button" variant="secondary" onclick={tryAgain}>
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.try_again')} {$t('course.navItem.lessons.exercises.all_exercises.view_mode.try_again')}
@@ -144,11 +144,13 @@
const certRes = await courseApi.getCertificationEvaluation(courseId); const certRes = await courseApi.getCertificationEvaluation(courseId);
if (certRes?.data) { if (certRes?.data) {
if (certRes.data.isNewCompletion) { const hasCompletedCourse = Boolean(certRes.data.eligibleForCertificate || certRes.data.certificateEarnedAt);
updateCourseCompletionModal(courseId, 'eligible', certRes.data, requiredExerciseId); updateCourseCompletionModal(
} else { courseId,
updateCourseCompletionModal(courseId, 'not-eligible', certRes.data, requiredExerciseId); hasCompletedCourse ? 'eligible' : 'not-eligible',
} certRes.data,
requiredExerciseId
);
} else { } else {
closeCourseCompletionModal(); closeCourseCompletionModal();
} }
@@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import { InputField } from '@cio/ui/custom/input-field'; import { InputField } from '@cio/ui/custom/input-field';
import TrashIcon from '@lucide/svelte/icons/trash'; import TrashIcon from '@lucide/svelte/icons/trash';
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
import { IconButton } from '@cio/ui/custom/icon-button'; import { IconButton } from '@cio/ui/custom/icon-button';
import * as Tooltip from '@cio/ui/base/tooltip';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
interface Props { interface Props {
@@ -11,7 +13,7 @@
points?: any; points?: any;
hasError?: boolean; hasError?: boolean;
errorMsg?: string | null; errorMsg?: string | null;
/** Extra hint under points row (e.g. auto-grade requires non-zero points) */ /** Hint shown next to the points input while points is 0 (e.g. auto-grade requires non-zero points) */
pointsHint?: string | null; pointsHint?: string | null;
onPointsChange?: any; onPointsChange?: any;
elementId?: string; elementId?: string;
@@ -34,6 +36,10 @@
let ref: HTMLDivElement | undefined = $state(); let ref: HTMLDivElement | undefined = $state();
// `points` is often bound to a plain store object property, which is not deeply
// reactive — a local reactive owner keeps the zero-points warning live while typing.
let pointsValue = $state(points);
$effect(() => { $effect(() => {
if (ref && scrollToQuestion) { if (ref && scrollToQuestion) {
ref.scrollIntoView({ ref.scrollIntoView({
@@ -65,15 +71,34 @@
</p> </p>
<InputField <InputField
placeholder={$t('course.navItem.lessons.exercises.new_exercise_modal.points')} placeholder={$t('course.navItem.lessons.exercises.new_exercise_modal.points')}
bind:value={points} bind:value={
() => pointsValue,
(nextValue) => {
pointsValue = nextValue;
points = nextValue;
}
}
type="number" type="number"
onchange={onPointsChange} onchange={onPointsChange}
/> />
{#if Number(pointsValue) === 0}
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger class="ml-2 shrink-0">
<TriangleAlertIcon size={16} class="text-amber-500 dark:text-amber-400" />
</Tooltip.Trigger>
<Tooltip.Content side="top" sideOffset={4}>
{$t('course.navItem.lessons.exercises.all_exercises.zero_points_warning')}
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
{/if}
</div> </div>
{#if errorMsg} {#if errorMsg}
<p class="text-xs text-red-500">{errorMsg}</p> <p class="text-xs text-red-500">{errorMsg}</p>
{:else if pointsHint} {:else if pointsHint && Number(pointsValue) === 0}
<p class="ui:text-muted-foreground max-w-[min(100%,12rem)] text-xs">{pointsHint}</p> <p class="ui:text-muted-foreground max-w-[min(100%,12rem)] text-xs">{pointsHint}</p>
{/if} {/if}
@@ -59,9 +59,17 @@
} from '$features/course/components/exercise/question-type-utils'; } from '$features/course/components/exercise/question-type-utils';
import type { SubmissionListItem } from '$features/course/utils/types'; import type { SubmissionListItem } from '$features/course/utils/types';
import { Badge } from '@cio/ui/base/badge'; import { Badge } from '@cio/ui/base/badge';
import * as Alert from '@cio/ui/base/alert';
import * as RadioGroup from '@cio/ui/base/radio-group';
import InfoIcon from '@lucide/svelte/icons/info';
import { isAutoGradableQuestionType } from '@cio/question-types'; import { isAutoGradableQuestionType } from '@cio/question-types';
import * as Dialog from '@cio/ui/base/dialog'; import * as Dialog from '@cio/ui/base/dialog';
import type { ExerciseSectionState } from '$features/course/components/exercise/store'; import type { ExerciseSectionState } from '$features/course/components/exercise/store';
import {
getActiveExerciseQuestions,
getManualGradedExerciseQuestions,
getTotalPossibleExercisePoints
} from '$features/course/utils/exercise-progression-utils';
interface Props { interface Props {
exerciseId?: string; exerciseId?: string;
@@ -100,6 +108,26 @@
let selectedTab = $state<ExerciseTab>('questions'); let selectedTab = $state<ExerciseTab>('questions');
// eslint-disable-next-line svelte/prefer-writable-derived -- must be writable: cleared before intentional navigations and bound to UnsavedChanges // eslint-disable-next-line svelte/prefer-writable-derived -- must be writable: cleared before intentional navigations and bound to UnsavedChanges
let hasUnsavedChanges = $state(false); let hasUnsavedChanges = $state(false);
type PassedPolicySaveAction = 'cancel' | 'save';
type PassedPolicyAttemptChoice = 'retry' | 'single';
type PassedPolicyDialogState = {
open: boolean;
manualQuestions: ReturnType<typeof getManualGradedExerciseQuestions>;
hasManualWarning: boolean;
hasSingleAttemptWarning: boolean;
resolve: ((action: PassedPolicySaveAction) => void) | null;
};
let passedPolicyDialog = $state<PassedPolicyDialogState>({
open: false,
manualQuestions: [],
hasManualWarning: false,
hasSingleAttemptWarning: false,
resolve: null
});
let acknowledgedPassedPolicyReviewSignature = $state<string | null>(null);
let passedPolicyAttemptChoice = $state<PassedPolicyAttemptChoice>('retry');
const passedPolicyReviewStoragePrefix = 'classroomio:passed-policy-review';
function isTemporaryId(id: string | number | undefined) { function isTemporaryId(id: string | number | undefined) {
return typeof id === 'string' && id.includes('-form'); return typeof id === 'string' && id.includes('-form');
@@ -167,6 +195,150 @@
return sectionErrors; return sectionErrors;
} }
function getQuestionDisplayTitle(
question: ReturnType<typeof getManualGradedExerciseQuestions>[number],
index: number
) {
return (
question.title?.trim() ||
t.get('course.navItem.lessons.exercises.all_exercises.guard.question_fallback', {
number: index + 1
})
);
}
function setPassedPolicyPointValidationErrors() {
const activeQuestions = getActiveExerciseQuestions($questionnaire.questions ?? []);
const pointErrors: ExerciseEditorErrors = {};
for (const question of activeQuestions) {
pointErrors[question.id] = {
points: t.get('course.navItem.lessons.exercises.all_exercises.guard.zero_points_field')
};
}
questionnaireValidation.set(pointErrors);
snackbar.error('snackbar.exercise.passed_requires_points');
}
function getPassedPolicyReviewStorageKey() {
return `${passedPolicyReviewStoragePrefix}:${courseApi.course?.id ?? 'course'}:${exerciseId}`;
}
function getPassedPolicyReviewSignature(params: {
manualQuestions: ReturnType<typeof getManualGradedExerciseQuestions>;
hasManualWarning: boolean;
hasSingleAttemptWarning: boolean;
}) {
const manualQuestionProfile = params.manualQuestions
.map((question) => String(getQuestionTypeId(question)))
.sort()
.join(',');
return [
'v1',
`manual:${params.hasManualWarning ? manualQuestionProfile : 'none'}`,
`single_attempt:${params.hasSingleAttemptWarning ? 'yes' : 'no'}`
].join('|');
}
function hasAcknowledgedPassedPolicyReview(signature: string) {
if (acknowledgedPassedPolicyReviewSignature === signature) return true;
try {
const storedSignatures = localStorage.getItem(getPassedPolicyReviewStorageKey())?.split('\n') ?? [];
return storedSignatures.includes(signature);
} catch {
return false;
}
}
function acknowledgePassedPolicyReview(signature: string) {
acknowledgedPassedPolicyReviewSignature = signature;
try {
const storedSignatures = localStorage.getItem(getPassedPolicyReviewStorageKey())?.split('\n') ?? [];
const nextSignatures = new Set([...storedSignatures, signature].filter(Boolean));
localStorage.setItem(getPassedPolicyReviewStorageKey(), [...nextSignatures].join('\n'));
} catch {
// localStorage may be full or unavailable
}
}
function openPassedPolicyDialog(params: {
manualQuestions: ReturnType<typeof getManualGradedExerciseQuestions>;
hasManualWarning: boolean;
hasSingleAttemptWarning: boolean;
}) {
passedPolicyAttemptChoice = 'retry';
return new Promise<PassedPolicySaveAction>((resolve) => {
passedPolicyDialog = {
...params,
open: true,
resolve
};
});
}
function closePassedPolicyDialog(action: PassedPolicySaveAction) {
const resolveDialog = passedPolicyDialog.resolve;
passedPolicyDialog = {
open: false,
manualQuestions: [],
hasManualWarning: false,
hasSingleAttemptWarning: false,
resolve: null
};
resolveDialog?.(action);
}
async function runPassedPolicySaveGuard() {
if (($questionnaire.completionPolicy ?? 'submitted') !== 'passed') return true;
const totalPossiblePoints = getTotalPossibleExercisePoints($questionnaire.questions ?? []);
if (totalPossiblePoints <= 0) {
setPassedPolicyPointValidationErrors();
return false;
}
const manualQuestions = getManualGradedExerciseQuestions($questionnaire.questions ?? []);
const hasManualWarning = manualQuestions.length > 0;
const hasSingleAttemptWarning = !$questionnaire.allowMultipleAttempts;
if (!hasManualWarning && !hasSingleAttemptWarning) return true;
const passedPolicyReviewSignature = getPassedPolicyReviewSignature({
manualQuestions,
hasManualWarning,
hasSingleAttemptWarning
});
if (hasAcknowledgedPassedPolicyReview(passedPolicyReviewSignature)) {
return true;
}
const action = await openPassedPolicyDialog({
manualQuestions,
hasManualWarning,
hasSingleAttemptWarning
});
if (action === 'cancel') return false;
if (hasSingleAttemptWarning && passedPolicyAttemptChoice === 'retry') {
questionnaire.update((state) => ({ ...state, allowMultipleAttempts: true }));
const retryEnabledSignature = getPassedPolicyReviewSignature({
manualQuestions,
hasManualWarning,
hasSingleAttemptWarning: false
});
acknowledgePassedPolicyReview(retryEnabledSignature);
}
acknowledgePassedPolicyReview(passedPolicyReviewSignature);
return true;
}
async function handleSave() { async function handleSave() {
if ($isOrgStudent || !courseApi.course?.id) return; if ($isOrgStudent || !courseApi.course?.id) return;
@@ -226,6 +398,9 @@
} }
} }
const passedPolicyCanSave = await runPassedPolicySaveGuard();
if (!passedPolicyCanSave) return;
isSaving = true; isSaving = true;
reset(); reset();
@@ -236,13 +411,20 @@
shouldIncludeDeleted: true shouldIncludeDeleted: true
}); });
const allowMultipleAttempts = !!$questionnaire.allowMultipleAttempts;
const completionPolicy = $questionnaire.completionPolicy ?? 'submitted';
const passThreshold = $questionnaire.passThreshold ?? 100;
await exerciseApi.update(courseApi.course?.id, exerciseId, { await exerciseApi.update(courseApi.course?.id, exerciseId, {
title: $questionnaire.title ?? '', title: $questionnaire.title ?? '',
description: $questionnaire.description ?? '', description: $questionnaire.description ?? '',
dueBy: $questionnaire.dueBy ?? '', dueBy: $questionnaire.dueBy ?? '',
questions: questions, questions: questions,
sections: shouldSyncSections ? sectionsPayload : undefined, sections: shouldSyncSections ? sectionsPayload : undefined,
sectionDisplayMode: $questionnaire.sectionDisplayMode sectionDisplayMode: $questionnaire.sectionDisplayMode,
allowMultipleAttempts,
completionPolicy,
passThreshold
}); });
// Check if there are validation errors from the API // Check if there are validation errors from the API
@@ -575,7 +757,11 @@
{/if} {/if}
</UnderlineTabs.Content> </UnderlineTabs.Content>
<UnderlineTabs.Content value="settings"> <UnderlineTabs.Content value="settings">
<ExerciseSettingsTab {exerciseId} /> <ExerciseSettingsTab
{exerciseId}
onBeforeSave={runPassedPolicySaveGuard}
totalSubmissions={submissions.length}
/>
</UnderlineTabs.Content> </UnderlineTabs.Content>
<UnderlineTabs.Content value="submissions"> <UnderlineTabs.Content value="submissions">
<Submissions bind:exerciseId {submissions} /> <Submissions bind:exerciseId {submissions} />
@@ -610,6 +796,107 @@
</form> </form>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
<Dialog.Root
open={passedPolicyDialog.open}
onOpenChange={(isOpen) => {
if (!isOpen && passedPolicyDialog.open) closePassedPolicyDialog('cancel');
}}
>
<Dialog.Content class="max-h-[90dvh] w-[min(92vw,36rem)] overflow-y-auto">
<Dialog.Header>
<Dialog.Title>
{passedPolicyDialog.hasSingleAttemptWarning
? $t('course.navItem.lessons.exercises.all_exercises.guard.attempts_title')
: $t('course.navItem.lessons.exercises.all_exercises.guard.title')}
</Dialog.Title>
<Dialog.Description>
{$t('course.navItem.lessons.exercises.all_exercises.guard.description')}
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
{#if passedPolicyDialog.hasSingleAttemptWarning}
<RadioGroup.Root
value={passedPolicyAttemptChoice}
onValueChange={(value) => {
if (value === 'retry' || value === 'single') {
passedPolicyAttemptChoice = value;
}
}}
class="gap-3"
>
<label
for="passed-policy-retry"
class="flex cursor-pointer items-start gap-3 rounded-md border p-3 {passedPolicyAttemptChoice ===
'retry'
? 'ui:border-primary'
: ''}"
>
<RadioGroup.Item value="retry" id="passed-policy-retry" class="mt-0.5" />
<div class="flex flex-col gap-1">
<span class="flex flex-wrap items-center gap-2 text-sm font-medium">
{$t('course.navItem.lessons.exercises.all_exercises.guard.retry_option')}
<Badge variant="secondary">
{$t('course.navItem.lessons.exercises.all_exercises.guard.recommended')}
</Badge>
</span>
<span class="ui:text-muted-foreground text-sm">
{$t('course.navItem.lessons.exercises.all_exercises.guard.retry_option_description')}
</span>
</div>
</label>
<label
for="passed-policy-single"
class="flex cursor-pointer items-start gap-3 rounded-md border p-3 {passedPolicyAttemptChoice ===
'single'
? 'ui:border-primary'
: ''}"
>
<RadioGroup.Item value="single" id="passed-policy-single" class="mt-0.5" />
<div class="flex flex-col gap-1">
<span class="text-sm font-medium">
{$t('course.navItem.lessons.exercises.all_exercises.guard.single_attempt_option')}
</span>
<span class="ui:text-muted-foreground text-sm">
{$t('course.navItem.lessons.exercises.all_exercises.guard.single_attempt_option_description')}
</span>
</div>
</label>
</RadioGroup.Root>
{/if}
{#if passedPolicyDialog.hasManualWarning}
<Alert.Root variant="information">
<InfoIcon />
<Alert.Title>
{$t('course.navItem.lessons.exercises.all_exercises.guard.manual_notice_title')}
</Alert.Title>
<Alert.Description>
{$t('course.navItem.lessons.exercises.all_exercises.guard.manual_notice_description')}
<ul class="list-disc pl-5">
{#each passedPolicyDialog.manualQuestions as question, index (question.id)}
<li>{getQuestionDisplayTitle(question, index)}</li>
{/each}
</ul>
</Alert.Description>
</Alert.Root>
{/if}
</div>
<div class="mt-6 flex justify-end gap-2">
<Button variant="outline" type="button" onclick={() => closePassedPolicyDialog('cancel')}>
{$t('course.navItem.lessons.exercises.all_exercises.guard.cancel')}
</Button>
<Button type="button" onclick={() => closePassedPolicyDialog('save')}>
{passedPolicyDialog.hasSingleAttemptWarning
? $t('course.navItem.lessons.exercises.all_exercises.guard.save_continue')
: $t('course.navItem.lessons.exercises.all_exercises.guard.save_anyway')}
</Button>
</div>
</Dialog.Content>
</Dialog.Root>
{/if} {/if}
</div> </div>
{/snippet} {/snippet}
@@ -21,15 +21,18 @@
import { settings } from '$features/course/utils/settings-store'; import { settings } from '$features/course/utils/settings-store';
import Copy from '@lucide/svelte/icons/copy'; import Copy from '@lucide/svelte/icons/copy';
import * as Alert from '@cio/ui/base/alert';
import LockOpenIcon from '@lucide/svelte/icons/lock-open';
import type { TCourseType } from '@cio/db/types'; import type { TCourseType } from '@cio/db/types';
import type { Course } from '../utils/types'; import type { Course } from '../utils/types';
import { ContentType } from '@cio/utils/constants/content';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { isObject } from '$lib/utils/functions/isObject'; import { isObject } from '$lib/utils/functions/isObject';
import { snackbar } from '$features/ui/snackbar/store'; import { snackbar } from '$features/ui/snackbar/store';
import { generateSlug } from '@cio/utils/functions'; import { generateSlug } from '@cio/utils/functions';
import { DEFAULT_COMPLIANCE_SETTINGS } from '../utils/compliance-utils'; import { DEFAULT_COMPLIANCE_SETTINGS } from '../utils/compliance-utils';
import { DeleteModal } from '$features/ui'; import { DeleteModal } from '$features/ui';
import { courseApi } from '$features/course/api'; import { contentApi, courseApi } from '$features/course/api';
import { tagApi } from '$features/tag/api'; import { tagApi } from '$features/tag/api';
import { uploadImage } from '$lib/utils/services/upload'; import { uploadImage } from '$lib/utils/services/upload';
import { copyToClipboard } from '$lib/utils/functions/formatYoutubeVideo'; import { copyToClipboard } from '$lib/utils/functions/formatYoutubeVideo';
@@ -115,6 +118,49 @@
hasUnsavedChanges = true; hasUnsavedChanges = true;
}; };
let isUnlockingAll = $state(false);
type LockedContentItem = { id: string; type: ContentType.Lesson | ContentType.Exercise };
function collectLockedContentItems(course: Course | null): LockedContentItem[] {
const content = course?.content;
if (!content) return [];
const items = content.grouped ? content.sections.flatMap((section) => section.items) : content.items;
return items
.filter((item) => (item.isUnlocked ?? true) === false)
.map((item) => ({
id: item.id,
type: item.type === ContentType.Exercise ? ContentType.Exercise : ContentType.Lesson
}));
}
const lockedContentItems = $derived(collectLockedContentItems(courseApi.course));
const isLiveClassCourse = $derived(courseApi.course?.type === 'LIVE_CLASS');
const showLockedContentNotice = $derived($settings.isPublished && lockedContentItems.length > 0);
async function handleUnlockAllContent() {
const courseId = courseApi.course?.id;
if (!courseId || lockedContentItems.length === 0 || isUnlockingAll) return;
isUnlockingAll = true;
const itemsToUnlock = [...lockedContentItems];
const didUnlock = await contentApi.updateContent(
courseId,
itemsToUnlock.map((item) => ({ id: item.id, type: item.type, isUnlocked: true }))
);
if (didUnlock) {
for (const item of itemsToUnlock) {
courseApi.updateContentItem(item.id, item.type, { isUnlocked: true });
}
snackbar.success('snackbar.course_settings.success.unlocked_all_content');
}
isUnlockingAll = false;
}
async function handleDeleteCourse() { async function handleDeleteCourse() {
if (!courseApi.course) return; if (!courseApi.course) return;
@@ -822,6 +868,30 @@
{$settings.isPublished ? $t('course.navItem.settings.published') : $t('course.navItem.settings.unpublished')} {$settings.isPublished ? $t('course.navItem.settings.published') : $t('course.navItem.settings.unpublished')}
</Label> </Label>
</Field.Field> </Field.Field>
{#if showLockedContentNotice}
<Alert.Root variant={isLiveClassCourse ? 'information' : 'warning'}>
<LockOpenIcon />
<Alert.Title>
{$t('course.navItem.settings.locked_content.title', { count: lockedContentItems.length })}
</Alert.Title>
<Alert.Description>
{isLiveClassCourse
? $t('course.navItem.settings.locked_content.description_live')
: $t('course.navItem.settings.locked_content.description')}
<Button
variant="outline"
size="sm"
class="mt-2 w-fit"
loading={isUnlockingAll}
disabled={isUnlockingAll}
onclick={handleUnlockAllContent}
>
{$t('course.navItem.settings.locked_content.unlock_all')}
</Button>
</Alert.Description>
</Alert.Root>
{/if}
</Field.Set> </Field.Set>
<Field.Separator /> <Field.Separator />
@@ -215,7 +215,7 @@
<div class="flex items-center overflow-x-scroll"> <div class="flex items-center overflow-x-scroll">
{#each sections as { id, title, items }, idx (id)} {#each sections as { id, title, items }, idx (id)}
<div <div
class="section ui:bg-muted mr-3 h-80 overflow-hidden rounded-md p-3" class="section ui:bg-muted ui:border-border mr-3 h-80 overflow-hidden rounded-md border p-3"
animate:flip={{ duration: flipDurationMs }} animate:flip={{ duration: flipDurationMs }}
> >
<div class="mb-2 flex items-center"> <div class="mb-2 flex items-center">
@@ -235,8 +235,8 @@
{#each items as item (item.id)} {#each items as item (item.id)}
<div <div
class="{item.isEarly class="{item.isEarly
? 'border-none' ? 'ui:border-border'
: 'border border-red-700'} mx-0 my-2 w-full rounded-md bg-white px-3 py-3 dark:bg-neutral-800" : 'border-red-700'} ui:bg-card mx-0 my-2 w-full rounded-md border px-3 py-3 shadow-sm"
animate:flip={{ duration: flipDurationMs }} animate:flip={{ duration: flipDurationMs }}
> >
<a <a
@@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest';
import type { Question } from '$features/course/types';
import type { SubmissionListItem } from '$features/course/utils/types';
import {
getManualGradedExerciseQuestions,
getTotalPossibleExercisePoints,
groupSubmissionsByStudentAndAttempt,
shouldCompleteExerciseFromSubmission
} from './exercise-progression-utils';
function makeQuestion(overrides: Partial<Question>): Question {
return {
id: overrides.id ?? 1,
title: overrides.title ?? 'Question',
points: overrides.points ?? 1,
questionTypeId: overrides.questionTypeId ?? 1,
questionType: overrides.questionType,
options: [],
...overrides
} as Question;
}
function makeSubmission(overrides: Partial<SubmissionListItem> & { id: string }): SubmissionListItem {
return {
id: overrides.id,
statusId: overrides.statusId ?? 1,
gradingState: overrides.gradingState,
total: overrides.total ?? 0,
createdAt: overrides.createdAt ?? '2026-01-01T00:00:00.000Z',
groupmember: overrides.groupmember ?? {
profile: {
id: 'student-1',
fullname: 'Student One',
avatarUrl: ''
}
},
answers: overrides.answers ?? []
} as unknown as SubmissionListItem;
}
describe('exercise progression helpers', () => {
it('detects manual-graded questions from question type ids', () => {
const questions = [
makeQuestion({ id: 1, questionTypeId: 1 }),
makeQuestion({ id: 2, questionTypeId: 3 }),
makeQuestion({ id: 3, questionTypeId: 8 })
];
expect(getManualGradedExerciseQuestions(questions).map((question) => question.id)).toEqual([2, 3]);
});
it('calculates total possible points from active questions', () => {
const questions = [
makeQuestion({ id: 1, points: 3 }),
makeQuestion({ id: 2, points: 2 }),
makeQuestion({ id: 3, points: 20, deletedAt: '2026-01-01' })
];
expect(getTotalPossibleExercisePoints(questions)).toBe(5);
});
it('decides when a submission completes a passed-policy exercise', () => {
const passingSubmission = makeSubmission({
id: 'passing',
gradingState: 'completed',
total: 8
});
const failingSubmission = makeSubmission({
id: 'failing',
gradingState: 'completed',
total: 6
});
const awaitingManualSubmission = makeSubmission({
id: 'manual',
gradingState: 'awaiting_manual',
total: 10
});
expect(
shouldCompleteExerciseFromSubmission({
completionPolicy: 'passed',
passThreshold: 70,
totalPossiblePoints: 10,
submission: passingSubmission
})
).toBe(true);
expect(
shouldCompleteExerciseFromSubmission({
completionPolicy: 'passed',
passThreshold: 70,
totalPossiblePoints: 10,
submission: failingSubmission
})
).toBe(false);
expect(
shouldCompleteExerciseFromSubmission({
completionPolicy: 'passed',
passThreshold: 70,
totalPossiblePoints: 10,
submission: awaitingManualSubmission
})
).toBe(false);
});
it('treats submitted-policy submissions as complete regardless of grading state', () => {
const submission = makeSubmission({
id: 'submitted',
gradingState: 'awaiting_manual',
total: 0
});
expect(
shouldCompleteExerciseFromSubmission({
completionPolicy: 'submitted',
passThreshold: 100,
totalPossiblePoints: 0,
submission
})
).toBe(true);
});
it('groups submissions by student and orders attempts by submission date', () => {
const olderAttempt = makeSubmission({
id: 'attempt-1',
total: 4,
createdAt: '2026-01-01T00:00:00.000Z'
});
const newerAttempt = makeSubmission({
id: 'attempt-2',
total: 9,
createdAt: '2026-01-02T00:00:00.000Z'
});
const groups = groupSubmissionsByStudentAndAttempt([newerAttempt, olderAttempt]);
expect(groups).toHaveLength(1);
expect(groups[0].attempts.map((attempt) => attempt.submission.id)).toEqual(['attempt-1', 'attempt-2']);
expect(groups[0].attempts.map((attempt) => attempt.attemptNumber)).toEqual([1, 2]);
});
});
@@ -0,0 +1,106 @@
import { isAutoGradableQuestionTypeId } from '@cio/question-types';
import type { Question } from '$features/course/types';
import type { SubmissionListItem } from '$features/course/utils/types';
export type ExerciseCompletionPolicy = 'submitted' | 'passed';
export type ExerciseAttempt = {
attemptNumber: number;
submission: SubmissionListItem;
};
export type ExerciseSubmissionStudentGroup = {
studentKey: string;
studentName: string;
studentAvatarUrl: string;
attempts: ExerciseAttempt[];
};
export function getActiveExerciseQuestions(questions: Question[]): Question[] {
return questions.filter((question) => !question.deletedAt);
}
export function getTotalPossibleExercisePoints(questions: Question[]): number {
return getActiveExerciseQuestions(questions).reduce((total, question) => total + Number(question.points ?? 0), 0);
}
export function getManualGradedExerciseQuestions(questions: Question[]): Question[] {
return getActiveExerciseQuestions(questions).filter((question) => {
const questionTypeId = Number(question.questionTypeId ?? question.questionType?.id);
if (!Number.isFinite(questionTypeId)) return true;
return !isAutoGradableQuestionTypeId(questionTypeId);
});
}
function getSubmissionScore(submission: SubmissionListItem): number {
const submissionTotal = Number((submission as SubmissionListItem & { total?: number | string | null }).total ?? NaN);
if (Number.isFinite(submissionTotal)) return submissionTotal;
return (submission.answers ?? []).reduce((total, answer) => total + Number(answer.point ?? 0), 0);
}
export function shouldCompleteExerciseFromSubmission(params: {
completionPolicy: ExerciseCompletionPolicy | null | undefined;
passThreshold: number | null | undefined;
totalPossiblePoints: number;
submission: SubmissionListItem & { gradingState?: string | null };
}): boolean {
if ((params.completionPolicy ?? 'submitted') === 'submitted') return true;
if (params.submission.gradingState !== 'completed') return false;
if (params.totalPossiblePoints <= 0) return false;
const threshold = Number(params.passThreshold ?? 100);
const scorePercent = (getSubmissionScore(params.submission) / params.totalPossiblePoints) * 100;
return scorePercent >= threshold;
}
function getSubmissionTimestamp(submission: SubmissionListItem): number {
const createdAt = (submission as SubmissionListItem & { createdAt?: string | Date | null }).createdAt;
const timestamp = createdAt ? new Date(createdAt).getTime() : NaN;
return Number.isFinite(timestamp) ? timestamp : 0;
}
function getStudentKey(submission: SubmissionListItem): string {
const profile = submission.groupmember?.profile;
const firstAnswerGroupMemberId = submission.answers?.find((answer) => answer.groupMemberId)?.groupMemberId;
return String(profile?.id ?? firstAnswerGroupMemberId ?? submission.id);
}
export function groupSubmissionsByStudentAndAttempt(
submissionItems: SubmissionListItem[]
): ExerciseSubmissionStudentGroup[] {
const groupByStudent = new Map<string, SubmissionListItem[]>();
for (const submission of submissionItems) {
const studentKey = getStudentKey(submission);
const studentSubmissions = groupByStudent.get(studentKey) ?? [];
studentSubmissions.push(submission);
groupByStudent.set(studentKey, studentSubmissions);
}
return [...groupByStudent.entries()]
.map(([studentKey, studentSubmissions]) => {
const orderedSubmissions = [...studentSubmissions].sort(
(leftSubmission, rightSubmission) =>
getSubmissionTimestamp(leftSubmission) - getSubmissionTimestamp(rightSubmission)
);
const latestSubmission = orderedSubmissions[orderedSubmissions.length - 1];
const profile = latestSubmission?.groupmember?.profile;
return {
studentKey,
studentName: profile?.fullname ?? '',
studentAvatarUrl: profile?.avatarUrl ?? '',
attempts: orderedSubmissions.map((submission, index) => ({
attemptNumber: index + 1,
submission
}))
};
})
.sort((leftGroup, rightGroup) => leftGroup.studentName.localeCompare(rightGroup.studentName));
}
@@ -288,7 +288,8 @@
"saved": "Gemt", "saved": "Gemt",
"successful_integration": "Integration lykkedes", "successful_integration": "Integration lykkedes",
"successful_deletion": "Integration slettet", "successful_deletion": "Integration slettet",
"update_successful": "Opdateringen lykkedes" "update_successful": "Opdateringen lykkedes",
"unlocked_all_content": "Alle kursusartikler er låst op"
} }
}, },
"people": { "people": {
@@ -320,7 +321,8 @@
"points_required_auto_grade": "Angiv en positiv pointværdi for hvert spørgsmål, før du gemmer.", "points_required_auto_grade": "Angiv en positiv pointværdi for hvert spørgsmål, før du gemmer.",
"success": "Gemt", "success": "Gemt",
"submission_updated": "Indsendelse opdateret til", "submission_updated": "Indsendelse opdateret til",
"error_fetching": "Hentning af øvelser mislykkedes. Genindlæs venligst siden" "error_fetching": "Hentning af øvelser mislykkedes. Genindlæs venligst siden",
"passed_requires_points": "Gennemførelse ved bestået kræver mindst ét tilgængeligt point."
}, },
"poll": { "poll": {
"info": { "info": {
@@ -843,6 +845,12 @@
"title": "Velkomstmail", "title": "Velkomstmail",
"description": "Tilpas den e-mail, som eleverne modtager efter tilmelding. Tilføj en personlig note, instruktioner eller et link til en livesession.", "description": "Tilpas den e-mail, som eleverne modtager efter tilmelding. Tilføj en personlig note, instruktioner eller et link til en livesession.",
"placeholder": "Skriv den velkomstbesked, eleverne får, når de tilmelder sig..." "placeholder": "Skriv den velkomstbesked, eleverne får, når de tilmelder sig..."
},
"locked_content": {
"title": "{count} kursuselement(er) er låst",
"description": "Tilmeldte elever vil ikke kunne tage låste lektioner eller øvelser, før du låser dem op.",
"description_live": "Låste lektioner og øvelser forbliver skjult for eleverne, indtil du låser dem op - nyttigt, hvis du udgiver indhold uge for uge.",
"unlock_all": "Lås alle op"
} }
}, },
"analytics": { "analytics": {
@@ -1410,8 +1418,8 @@
"finish": "Afslut", "finish": "Afslut",
"next": "Næste", "next": "Næste",
"check": "Tjek", "check": "Tjek",
"auto_graded_badge": "Auto-grade", "auto_graded_badge": "Automatisk bedømmelse",
"manual_grading_required_badge": "Manual grading required", "manual_grading_required_badge": "Manuel bedømmelse påkrævet",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.", "points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": { "analytics": {
"submissions": "Indsendelser", "submissions": "Indsendelser",
@@ -1419,14 +1427,25 @@
"heading": "Individuel", "heading": "Individuel",
"answers": "Individuelle svar", "answers": "Individuelle svar",
"no": "Der blev ikke givet noget svar", "no": "Der blev ikke givet noget svar",
"student_avatar": "Student" "student_avatar": "Student",
"answers_for": "{name}s svar",
"attempts_count": "{count} forsøg",
"attempt_label": "Forsøg {number}",
"date_unknown": "Dato ukendt",
"status_awaiting_manual": "Afventer manuel bedømmelse",
"status_completed": "Bedømt",
"status_pending": "Afventer",
"counts_as_complete": "Tæller som gennemført",
"passed": "Bestået",
"not_passed": "Ikke bestået"
}, },
"summary": { "summary": {
"heading": "Resumé", "heading": "Resumé",
"question_chart": "Spørgsmålsskemaer", "question_chart": "Spørgsmålsskemaer",
"responses": "Svar", "responses": "Svar",
"no_responses": "Ingen svar endnu", "no_responses": "Ingen svar endnu",
"other": "Andet" "other": "Andet",
"attempts": "Forsøg"
}, },
"loading_submissions": "Indlæser indsendelser...", "loading_submissions": "Indlæser indsendelser...",
"empty_title": "Ingen indlæg endnu", "empty_title": "Ingen indlæg endnu",
@@ -1449,6 +1468,10 @@
"status_pending": "Status: Afventer gennemgang", "status_pending": "Status: Afventer gennemgang",
"status_graded": "Status: Gennemgang afsluttet", "status_graded": "Status: Gennemgang afsluttet",
"status_submitted": "Status: Indsendt", "status_submitted": "Status: Indsendt",
"passed_completion_result_title": "Bestået",
"passed_completion_result_description": "Du scorede {scorePercent} %. Denne øvelse krævede {requiredPercent} % for at tælle som gennemført.",
"not_passed_completion_result_title": "Ikke bestået endnu",
"not_passed_completion_result_description": "Du scorede {scorePercent} %. Denne øvelse kræver {requiredPercent} % for at tælle som gennemført.",
"empty_description": "Klik på knappen Rediger for at tilføje.", "empty_description": "Klik på knappen Rediger for at tilføje.",
"empty_edit_hint_prefix": "Klik på ", "empty_edit_hint_prefix": "Klik på ",
"empty_edit_hint_suffix": " knappen for at tilføje.", "empty_edit_hint_suffix": " knappen for at tilføje.",
@@ -1846,6 +1869,30 @@
"sending": "Underretter elever...", "sending": "Underretter elever...",
"sent": "Meddelte {count} elever", "sent": "Meddelte {count} elever",
"failed": "Eleverne kunne ikke underrettes" "failed": "Eleverne kunne ikke underrettes"
},
"zero_points_warning": "Sæt point over 0, ellers vil eleverne ikke blive bedømt for dette spørgsmål.",
"settings_completion_passed_helper": "Studerende skal nå beståelsesscoren, før denne øvelse tæller som gennemført.",
"settings_manual_grading_warning": "Manuelt bedømte afleveringer skal bedømmes, før de kan tælle som bestået.",
"settings_single_attempt_warning": "Med ét forsøg skal studerende, der ikke består, have handling fra underviseren, før de kan fortsætte.",
"settings_existing_submissions_warning": "Ændring af beståelseskriterier kan påvirke eksisterende gennemførelse og progression.",
"question_auto_graded_badge": "Automatisk bedømt",
"question_manual_grading_badge": "Manuel bedømmelse",
"guard": {
"title": "Gennemgå før du gemmer",
"attempts_title": "Hvad sker der, hvis en elev fejler?",
"description": "Denne øvelse tæller kun som gennemført, når en elev består.",
"retry_option": "Lad dem prøve igen, indtil de passerer",
"retry_option_description": "Studerende kan genindsende så mange gange som nødvendigt.",
"single_attempt_option": "Kun ét forsøg",
"single_attempt_option_description": "En studerende, der ikke består, forbliver blokeret, indtil du manuelt ændrer deres afleveringsstatus.",
"recommended": "Anbefales",
"manual_notice_title": "Nogle spørgsmål kræver manuel karakter",
"manual_notice_description": "En elevs beståelse er ikke endelig, før du bedømmer disse spørgsmål:",
"cancel": "Annuller",
"save_anyway": "Gem alligevel",
"save_continue": "Gem og fortsæt",
"question_fallback": "Spørgsmål {number}",
"zero_points_field": "Bestået afslutning kræver mindst et tilgængeligt point."
} }
}, },
"new_exercise_modal": { "new_exercise_modal": {
@@ -2336,7 +2383,8 @@
"later": "Luk", "later": "Luk",
"checking": "Tjekker berettigelse...", "checking": "Tjekker berettigelse...",
"not_eligible_title": "Næsten der", "not_eligible_title": "Næsten der",
"retake_exercise": "Gentag øvelsen" "retake_exercise": "Gentag øvelsen",
"subtitle_no_certificate": "Tillykke - du har gennemført dette kursus."
} }
}, },
"certificates": { "certificates": {
@@ -288,7 +288,8 @@
"saved": "Erfolgreich gespeichert", "saved": "Erfolgreich gespeichert",
"successful_integration": "Integration erfolgreich", "successful_integration": "Integration erfolgreich",
"successful_deletion": "Integration gelöscht", "successful_deletion": "Integration gelöscht",
"update_successful": "Update erfolgreich" "update_successful": "Update erfolgreich",
"unlocked_all_content": "Alle Kursgegenstände freigeschaltet"
} }
}, },
"people": { "people": {
@@ -320,7 +321,8 @@
"points_required_auto_grade": "Legen Sie für jede Frage einen positiven Punktwert fest, bevor Sie speichern.", "points_required_auto_grade": "Legen Sie für jede Frage einen positiven Punktwert fest, bevor Sie speichern.",
"success": "Erfolgreich gespeichert", "success": "Erfolgreich gespeichert",
"submission_updated": "Einreichung aktualisiert auf", "submission_updated": "Einreichung aktualisiert auf",
"error_fetching": "Das Abrufen der Übungen ist fehlgeschlagen. Bitte laden Sie die Seite neu" "error_fetching": "Das Abrufen der Übungen ist fehlgeschlagen. Bitte laden Sie die Seite neu",
"passed_requires_points": "Für den Abschluss durch Bestehen muss mindestens ein Punkt verfügbar sein."
}, },
"poll": { "poll": {
"info": { "info": {
@@ -843,6 +845,12 @@
"title": "Willkommens-E-Mail", "title": "Willkommens-E-Mail",
"description": "Passen Sie die E-Mail an, die Studierende nach der Anmeldung erhalten. Fügen Sie eine persönliche Notiz, Anweisungen oder einen Link zur Live-Sitzung hinzu.", "description": "Passen Sie die E-Mail an, die Studierende nach der Anmeldung erhalten. Fügen Sie eine persönliche Notiz, Anweisungen oder einen Link zur Live-Sitzung hinzu.",
"placeholder": "Schreiben Sie die Willkommensnachricht, die Studierende bei der Einschreibung erhalten …" "placeholder": "Schreiben Sie die Willkommensnachricht, die Studierende bei der Einschreibung erhalten …"
},
"locked_content": {
"title": "{count} Kurselemente sind gesperrt",
"description": "Eingeschriebene Schüler können erst dann an gesperrten Lektionen oder Übungen teilnehmen, wenn Sie sie entsperren.",
"description_live": "Gesperrte Lektionen und Übungen bleiben den Schülern verborgen, bis Sie sie entsperren – nützlich, wenn Sie Inhalte Woche für Woche veröffentlichen.",
"unlock_all": "Schalte alle frei"
} }
}, },
"analytics": { "analytics": {
@@ -1410,8 +1418,8 @@
"finish": "Fertig", "finish": "Fertig",
"next": "Als nächstes", "next": "Als nächstes",
"check": "Überprüfen", "check": "Überprüfen",
"auto_graded_badge": "Auto-grade", "auto_graded_badge": "Automatische Bewertung",
"manual_grading_required_badge": "Manual grading required", "manual_grading_required_badge": "Manuelle Bewertung erforderlich",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.", "points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": { "analytics": {
"submissions": "Einsendungen", "submissions": "Einsendungen",
@@ -1419,14 +1427,25 @@
"heading": "Individuell", "heading": "Individuell",
"answers": "Individuelle Antworten", "answers": "Individuelle Antworten",
"no": "Es wurde keine Antwort gegeben", "no": "Es wurde keine Antwort gegeben",
"student_avatar": "Student" "student_avatar": "Student",
"answers_for": "{name}s Antworten",
"attempts_count": "{count} Versuche",
"attempt_label": "Versuch {number}",
"date_unknown": "Datum unbekannt",
"status_awaiting_manual": "Wartet auf manuelle Bewertung",
"status_completed": "Bewertet",
"status_pending": "Ausstehend",
"counts_as_complete": "Zählt als abgeschlossen",
"passed": "Bestanden",
"not_passed": "Nicht bestanden"
}, },
"summary": { "summary": {
"heading": "Zusammenfassung", "heading": "Zusammenfassung",
"question_chart": "Fragendiagramme", "question_chart": "Fragendiagramme",
"responses": "Antworten", "responses": "Antworten",
"no_responses": "Noch keine Antworten", "no_responses": "Noch keine Antworten",
"other": "Andere" "other": "Andere",
"attempts": "Versuche"
}, },
"loading_submissions": "Einsendungen werden geladen...", "loading_submissions": "Einsendungen werden geladen...",
"empty_title": "Noch keine Einsendungen", "empty_title": "Noch keine Einsendungen",
@@ -1449,6 +1468,10 @@
"status_pending": "Status: Ausstehende Überprüfung", "status_pending": "Status: Ausstehende Überprüfung",
"status_graded": "Status: Überprüfung abgeschlossen", "status_graded": "Status: Überprüfung abgeschlossen",
"status_submitted": "Status: Eingereicht", "status_submitted": "Status: Eingereicht",
"passed_completion_result_title": "Bestanden",
"passed_completion_result_description": "Du hast {scorePercent} % erzielt. Für diese Übung waren {requiredPercent} % erforderlich, damit sie als abgeschlossen zählt.",
"not_passed_completion_result_title": "Noch nicht bestanden",
"not_passed_completion_result_description": "Du hast {scorePercent} % erzielt. Für diese Übung sind {requiredPercent} % erforderlich, damit sie als abgeschlossen zählt.",
"empty_description": "Klicken Sie zum Hinzufügen auf die Schaltfläche Bearbeiten.", "empty_description": "Klicken Sie zum Hinzufügen auf die Schaltfläche Bearbeiten.",
"empty_edit_hint_prefix": "Klicken Sie auf ", "empty_edit_hint_prefix": "Klicken Sie auf ",
"empty_edit_hint_suffix": " Schaltfläche zum Hinzufügen.", "empty_edit_hint_suffix": " Schaltfläche zum Hinzufügen.",
@@ -1846,6 +1869,30 @@
"sending": "Studierende benachrichtigen…", "sending": "Studierende benachrichtigen…",
"sent": "{count} Studenten benachrichtigt", "sent": "{count} Studenten benachrichtigt",
"failed": "Die Schüler konnten nicht benachrichtigt werden" "failed": "Die Schüler konnten nicht benachrichtigt werden"
},
"zero_points_warning": "Legen Sie Punkte über 0 fest, andernfalls erhalten die Schüler für diese Frage keine Bewertung.",
"settings_completion_passed_helper": "Studierende müssen die Bestehenspunktzahl erreichen, bevor diese Übung als abgeschlossen zählt.",
"settings_manual_grading_warning": "Manuell bewertete Einreichungen müssen bewertet werden, bevor sie als bestanden zählen können.",
"settings_single_attempt_warning": "Bei nur einem Versuch benötigen nicht bestandene Studierende eine Aktion der Lehrkraft, bevor sie fortfahren können.",
"settings_existing_submissions_warning": "Das Ändern der Bestehenskriterien kann bestehende Abschlüsse und den Fortschritt beeinflussen.",
"question_auto_graded_badge": "Automatisch bewertet",
"question_manual_grading_badge": "Manuelle Bewertung",
"guard": {
"title": "Vor dem Speichern überprüfen",
"attempts_title": "Was passiert, wenn ein Student durchfällt?",
"description": "Diese Übung gilt erst dann als abgeschlossen, wenn ein Schüler bestanden hat.",
"retry_option": "Lassen Sie sie es erneut versuchen, bis sie bestehen",
"retry_option_description": "Studierende können ihre Unterlagen so oft wie nötig erneut einreichen.",
"single_attempt_option": "Nur ein Versuch",
"single_attempt_option_description": "Ein Student, der durchfällt, bleibt blockiert, bis Sie seinen Abgabestatus manuell ändern.",
"recommended": "Empfohlen",
"manual_notice_title": "Einige Fragen erfordern eine manuelle Bewertung",
"manual_notice_description": "Der Pass eines Schülers ist erst dann endgültig, wenn Sie die folgenden Fragen benotet haben:",
"cancel": "Abbrechen",
"save_anyway": "Sparen Sie trotzdem",
"save_continue": "Speichern und fortfahren",
"question_fallback": "Frage {number}",
"zero_points_field": "Für den bestandenen Abschluss ist mindestens ein Punkt erforderlich."
} }
}, },
"new_exercise_modal": { "new_exercise_modal": {
@@ -2336,7 +2383,8 @@
"later": "Schließen", "later": "Schließen",
"checking": "Berechtigung prüfen…", "checking": "Berechtigung prüfen…",
"not_eligible_title": "Fast da", "not_eligible_title": "Fast da",
"retake_exercise": "Wiederholen Sie die Übung" "retake_exercise": "Wiederholen Sie die Übung",
"subtitle_no_certificate": "Herzlichen Glückwunsch – Sie haben diesen Kurs abgeschlossen."
} }
}, },
"certificates": { "certificates": {
@@ -2038,6 +2038,7 @@
"modal": { "modal": {
"title": "Course requirements complete!", "title": "Course requirements complete!",
"subtitle": "Congratulations — you can view and download your certificate anytime.", "subtitle": "Congratulations — you can view and download your certificate anytime.",
"subtitle_no_certificate": "Congratulations — you have completed this course.",
"view_certificate": "View certificate", "view_certificate": "View certificate",
"later": "Close", "later": "Close",
"checking": "Checking eligibility…", "checking": "Checking eligibility…",
@@ -2478,6 +2479,10 @@
"settings_completion_submitted": "Submitted — any submission counts", "settings_completion_submitted": "Submitted — any submission counts",
"settings_completion_passed": "Passed — minimum score required", "settings_completion_passed": "Passed — minimum score required",
"settings_pass_threshold": "Pass threshold (%)", "settings_pass_threshold": "Pass threshold (%)",
"settings_completion_passed_helper": "Students must reach the passing score before this exercise counts as complete.",
"settings_manual_grading_warning": "Manually graded submissions must be graded before they can count as passed.",
"settings_single_attempt_warning": "With one attempt, failed students need instructor action before they can continue.",
"settings_existing_submissions_warning": "Changing pass criteria can affect existing completion and progression.",
"settings_save": "Save settings", "settings_save": "Save settings",
"submissions": "Submissions", "submissions": "Submissions",
"save": "Save", "save": "Save",
@@ -2512,7 +2517,27 @@
"check": "Check", "check": "Check",
"auto_graded_badge": "Auto-grade", "auto_graded_badge": "Auto-grade",
"manual_grading_required_badge": "Manual grading required", "manual_grading_required_badge": "Manual grading required",
"question_auto_graded_badge": "Auto graded",
"question_manual_grading_badge": "Manual grading",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.", "points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"zero_points_warning": "Set points above 0, otherwise students won't be graded for this question.",
"guard": {
"title": "Review before saving",
"attempts_title": "What happens if a student fails?",
"description": "This exercise only counts as complete when a student passes.",
"retry_option": "Let them retry until they pass",
"retry_option_description": "Students can resubmit as many times as needed.",
"single_attempt_option": "One attempt only",
"single_attempt_option_description": "A student who fails stays blocked until you manually change their submission status.",
"recommended": "Recommended",
"manual_notice_title": "Some questions need manual grading",
"manual_notice_description": "A student's pass isn't final until you grade these questions:",
"cancel": "Cancel",
"save_anyway": "Save anyway",
"save_continue": "Save and continue",
"question_fallback": "Question {number}",
"zero_points_field": "Passed completion needs at least one point available."
},
"description": { "description": {
"title": "Title", "title": "Title",
"heading": "Description", "heading": "Description",
@@ -2605,6 +2630,10 @@
"status_pending": "Status: Pending Review", "status_pending": "Status: Pending Review",
"status_graded": "Status: Review Completed", "status_graded": "Status: Review Completed",
"status_submitted": "Status: Submitted", "status_submitted": "Status: Submitted",
"passed_completion_result_title": "Passed",
"passed_completion_result_description": "You scored {scorePercent}%. This exercise required {requiredPercent}% to count as complete.",
"not_passed_completion_result_title": "Not passed yet",
"not_passed_completion_result_description": "You scored {scorePercent}%. This exercise requires {requiredPercent}% to count as complete.",
"checking_submission": "Checking your submission...", "checking_submission": "Checking your submission...",
"instructor_feedback": "Instructor feedback", "instructor_feedback": "Instructor feedback",
"back": "Back", "back": "Back",
@@ -2616,14 +2645,25 @@
"heading": "Summary", "heading": "Summary",
"question_chart": "Question Charts", "question_chart": "Question Charts",
"responses": "Responses", "responses": "Responses",
"attempts": "Attempts",
"no_responses": "No responses yet", "no_responses": "No responses yet",
"other": "Other" "other": "Other"
}, },
"individual": { "individual": {
"heading": "Individual", "heading": "Individual",
"answers": "Individual Answers", "answers": "Individual Answers",
"answers_for": "{name}'s answers",
"no": "No answer was provided", "no": "No answer was provided",
"student_avatar": "Student" "student_avatar": "Student",
"attempts_count": "{count} attempts",
"attempt_label": "Attempt {number}",
"date_unknown": "Date unknown",
"status_awaiting_manual": "Awaiting manual grading",
"status_completed": "Graded",
"status_pending": "Pending",
"counts_as_complete": "Counts as complete",
"passed": "Passed",
"not_passed": "Not passed"
}, },
"submissions": "Submissions", "submissions": "Submissions",
"loading_submissions": "Loading submissions...", "loading_submissions": "Loading submissions...",
@@ -3545,6 +3585,12 @@
"determines": "This determines if your course displays on your landing page", "determines": "This determines if your course displays on your landing page",
"published": "Published", "published": "Published",
"unpublished": "Unpublished", "unpublished": "Unpublished",
"locked_content": {
"title": "{count} course item(s) are locked",
"description": "Enrolled students won't be able to take locked lessons or exercises until you unlock them.",
"description_live": "Locked lessons and exercises stay hidden from students until you unlock them — useful if you release content week by week.",
"unlock_all": "Unlock all"
},
"type": "Course type", "type": "Course type",
"course_type_desc": "Update the course type for a more personalized experience.", "course_type_desc": "Update the course type for a more personalized experience.",
"course_type_learn_more": "Learn more", "course_type_learn_more": "Learn more",
@@ -4094,6 +4140,7 @@
}, },
"exercise": { "exercise": {
"points_required_auto_grade": "Set a positive point value for every question before saving.", "points_required_auto_grade": "Set a positive point value for every question before saving.",
"passed_requires_points": "Passed completion needs at least one point available.",
"success": "Saved Successfully", "success": "Saved Successfully",
"submission_updated": "Submission updated to", "submission_updated": "Submission updated to",
"error_fetching": "Fetching exercises failed, please reload the page" "error_fetching": "Fetching exercises failed, please reload the page"
@@ -4125,6 +4172,7 @@
}, },
"course_settings": { "course_settings": {
"success": { "success": {
"unlocked_all_content": "All course items unlocked",
"download": "Download Complete", "download": "Download Complete",
"saved": "Saved successfully", "saved": "Saved successfully",
"successful_integration": "Integration successful", "successful_integration": "Integration successful",
@@ -288,7 +288,8 @@
"saved": "Guardado exitosamente", "saved": "Guardado exitosamente",
"successful_integration": "Integración exitosa", "successful_integration": "Integración exitosa",
"successful_deletion": "Integración eliminada", "successful_deletion": "Integración eliminada",
"update_successful": "Actualización exitosa" "update_successful": "Actualización exitosa",
"unlocked_all_content": "Todos los elementos del curso desbloqueados"
} }
}, },
"people": { "people": {
@@ -320,7 +321,8 @@
"points_required_auto_grade": "Asigne un valor de puntos positivo a cada pregunta antes de guardar.", "points_required_auto_grade": "Asigne un valor de puntos positivo a cada pregunta antes de guardar.",
"success": "Guardado exitosamente", "success": "Guardado exitosamente",
"submission_updated": "Envío actualizado a", "submission_updated": "Envío actualizado a",
"error_fetching": "No se han podido recuperar los ejercicios. Vuelva a cargar la página." "error_fetching": "No se han podido recuperar los ejercicios. Vuelva a cargar la página.",
"passed_requires_points": "La finalización por aprobado necesita al menos un punto disponible."
}, },
"poll": { "poll": {
"info": { "info": {
@@ -843,7 +845,13 @@
"description": "Personalice el correo electrónico que reciben los estudiantes después de inscribirse. Agregue una nota personal, instrucciones o un enlace de sesión en vivo.", "description": "Personalice el correo electrónico que reciben los estudiantes después de inscribirse. Agregue una nota personal, instrucciones o un enlace de sesión en vivo.",
"placeholder": "Escribe el mensaje de bienvenida que reciben los estudiantes cuando se matriculan..." "placeholder": "Escribe el mensaje de bienvenida que reciben los estudiantes cuando se matriculan..."
}, },
"course_type_learn_more": "Más información" "course_type_learn_more": "Más información",
"locked_content": {
"title": "{count} elementos del curso están bloqueados",
"description": "Los estudiantes inscritos no podrán tomar lecciones o ejercicios bloqueados hasta que los desbloquees.",
"description_live": "Las lecciones y ejercicios bloqueados permanecen ocultos para los estudiantes hasta que los desbloqueas, lo que resulta útil si publicas contenido semana tras semana.",
"unlock_all": "Desbloquear todo"
}
}, },
"analytics": { "analytics": {
"title": "Analítica" "title": "Analítica"
@@ -1410,8 +1418,8 @@
"finish": "terminar", "finish": "terminar",
"next": "Siguiente", "next": "Siguiente",
"check": "comprobar", "check": "comprobar",
"auto_graded_badge": "Auto-grade", "auto_graded_badge": "Calificación automática",
"manual_grading_required_badge": "Manual grading required", "manual_grading_required_badge": "Se requiere calificación manual",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.", "points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": { "analytics": {
"submissions": "Envíos", "submissions": "Envíos",
@@ -1419,14 +1427,25 @@
"heading": "individuo", "heading": "individuo",
"answers": "Respuestas individuales", "answers": "Respuestas individuales",
"no": "No se proporcionó respuesta", "no": "No se proporcionó respuesta",
"student_avatar": "estudiante" "student_avatar": "estudiante",
"answers_for": "Respuestas de {name}",
"attempts_count": "{count} intentos",
"attempt_label": "Intento {number}",
"date_unknown": "Fecha desconocida",
"status_awaiting_manual": "En espera de calificación manual",
"status_completed": "Calificado",
"status_pending": "Pendiente",
"counts_as_complete": "Cuenta como completado",
"passed": "Aprobado",
"not_passed": "No aprobado"
}, },
"summary": { "summary": {
"heading": "Resumen", "heading": "Resumen",
"question_chart": "Cuadros de preguntas", "question_chart": "Cuadros de preguntas",
"responses": "Respuestas", "responses": "Respuestas",
"no_responses": "Aún no hay respuestas", "no_responses": "Aún no hay respuestas",
"other": "Otro" "other": "Otro",
"attempts": "Intentos"
}, },
"loading_submissions": "Cargando envíos...", "loading_submissions": "Cargando envíos...",
"empty_title": "Aún no hay presentaciones", "empty_title": "Aún no hay presentaciones",
@@ -1449,6 +1468,10 @@
"status_pending": "Estado: Pendiente de revisión", "status_pending": "Estado: Pendiente de revisión",
"status_graded": "Estado: Revisión completada", "status_graded": "Estado: Revisión completada",
"status_submitted": "Estado: Enviado", "status_submitted": "Estado: Enviado",
"passed_completion_result_title": "Aprobado",
"passed_completion_result_description": "Obtuviste {scorePercent} %. Este ejercicio requería {requiredPercent} % para contar como completado.",
"not_passed_completion_result_title": "Aún no aprobado",
"not_passed_completion_result_description": "Obtuviste {scorePercent} %. Este ejercicio requiere {requiredPercent} % para contar como completado.",
"empty_description": "Haga clic en el botón Editar para agregar.", "empty_description": "Haga clic en el botón Editar para agregar.",
"empty_edit_hint_prefix": "Haga clic en ", "empty_edit_hint_prefix": "Haga clic en ",
"empty_edit_hint_suffix": " botón para agregar.", "empty_edit_hint_suffix": " botón para agregar.",
@@ -1846,6 +1869,30 @@
"sending": "Notificar a los estudiantes...", "sending": "Notificar a los estudiantes...",
"sent": "{count} estudiantes notificados", "sent": "{count} estudiantes notificados",
"failed": "No se pudo notificar a los estudiantes" "failed": "No se pudo notificar a los estudiantes"
},
"zero_points_warning": "Establezca puntos por encima de 0; de lo contrario, los estudiantes no serán calificados para esta pregunta.",
"settings_completion_passed_helper": "Los estudiantes deben alcanzar la puntuación de aprobado antes de que este ejercicio cuente como completado.",
"settings_manual_grading_warning": "Los envíos con calificación manual deben calificarse antes de que puedan contar como aprobados.",
"settings_single_attempt_warning": "Con un solo intento, los estudiantes que no aprueben necesitarán una acción del instructor antes de poder continuar.",
"settings_existing_submissions_warning": "Cambiar los criterios de aprobación puede afectar la finalización y el progreso existentes.",
"question_auto_graded_badge": "Calificación automática",
"question_manual_grading_badge": "Calificación manual",
"guard": {
"title": "Revisar antes de guardar",
"attempts_title": "¿Qué pasa si un estudiante fracasa?",
"description": "Este ejercicio solo cuenta como completo cuando un estudiante aprueba.",
"retry_option": "Que vuelvan a intentarlo hasta que pasen.",
"retry_option_description": "Los estudiantes pueden volver a enviar tantas veces como sea necesario.",
"single_attempt_option": "un solo intento",
"single_attempt_option_description": "Un estudiante que no aprueba permanece bloqueado hasta que usted cambia manualmente su estado de envío.",
"recommended": "Recomendado",
"manual_notice_title": "Algunas preguntas necesitan calificación manual",
"manual_notice_description": "El pase de un estudiante no es definitivo hasta que califique estas preguntas:",
"cancel": "Cancelar",
"save_anyway": "Guardar de todos modos",
"save_continue": "Guardar y continuar",
"question_fallback": "Pregunta {number}",
"zero_points_field": "La finalización aprobada necesita al menos un punto disponible."
} }
}, },
"new_exercise_modal": { "new_exercise_modal": {
@@ -2336,7 +2383,8 @@
"later": "Cerrar", "later": "Cerrar",
"checking": "Comprobando la elegibilidad...", "checking": "Comprobando la elegibilidad...",
"not_eligible_title": "Casi allí", "not_eligible_title": "Casi allí",
"retake_exercise": "Rehacer ejercicio" "retake_exercise": "Rehacer ejercicio",
"subtitle_no_certificate": "Felicitaciones, ha completado este curso."
} }
}, },
"certificates": { "certificates": {
@@ -288,7 +288,8 @@
"saved": "Enregistré avec succès", "saved": "Enregistré avec succès",
"successful_integration": "Intégration réussie", "successful_integration": "Intégration réussie",
"successful_deletion": "Intégration supprimée", "successful_deletion": "Intégration supprimée",
"update_successful": "Mise à jour réussie" "update_successful": "Mise à jour réussie",
"unlocked_all_content": "Tous les éléments de cours débloqués"
} }
}, },
"people": { "people": {
@@ -320,7 +321,8 @@
"points_required_auto_grade": "Définissez un nombre de points positif pour chaque question avant d'enregistrer.", "points_required_auto_grade": "Définissez un nombre de points positif pour chaque question avant d'enregistrer.",
"success": "Enregistré avec succès", "success": "Enregistré avec succès",
"submission_updated": "Soumission mise à jour pour", "submission_updated": "Soumission mise à jour pour",
"error_fetching": "La récupération des exercices a échoué, veuillez recharger la page" "error_fetching": "La récupération des exercices a échoué, veuillez recharger la page",
"passed_requires_points": "La validation par réussite nécessite au moins un point disponible."
}, },
"poll": { "poll": {
"info": { "info": {
@@ -843,6 +845,12 @@
"title": "E-mail de bienvenue", "title": "E-mail de bienvenue",
"description": "Personnalisez l'e-mail que les étudiants reçoivent après leur inscription. Ajoutez une note personnelle, des instructions ou un lien de session en direct.", "description": "Personnalisez l'e-mail que les étudiants reçoivent après leur inscription. Ajoutez une note personnelle, des instructions ou un lien de session en direct.",
"placeholder": "Rédigez le message de bienvenue que les étudiants reçoivent lors de leur inscription…" "placeholder": "Rédigez le message de bienvenue que les étudiants reçoivent lors de leur inscription…"
},
"locked_content": {
"title": "{count} éléments de cours sont verrouillés",
"description": "Les étudiants inscrits ne pourront pas suivre de leçons ou d'exercices verrouillés tant que vous ne les aurez pas déverrouillés.",
"description_live": "Les leçons et les exercices verrouillés restent cachés aux étudiants jusqu'à ce que vous les déverrouilliez, ce qui est utile si vous publiez du contenu semaine après semaine.",
"unlock_all": "Débloquez tout"
} }
}, },
"analytics": { "analytics": {
@@ -1410,8 +1418,8 @@
"finish": "Terminer", "finish": "Terminer",
"next": "Suivant", "next": "Suivant",
"check": "Vérifier", "check": "Vérifier",
"auto_graded_badge": "Auto-grade", "auto_graded_badge": "Notation automatique",
"manual_grading_required_badge": "Manual grading required", "manual_grading_required_badge": "Notation manuelle requise",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.", "points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": { "analytics": {
"submissions": "Soumissions", "submissions": "Soumissions",
@@ -1419,14 +1427,25 @@
"heading": "Individuel", "heading": "Individuel",
"answers": "Réponses individuelles", "answers": "Réponses individuelles",
"no": "Aucune réponse n'a été fournie", "no": "Aucune réponse n'a été fournie",
"student_avatar": "Étudiant" "student_avatar": "Étudiant",
"answers_for": "Réponses de {name}",
"attempts_count": "{count} tentatives",
"attempt_label": "Tentative {number}",
"date_unknown": "Date inconnue",
"status_awaiting_manual": "En attente de notation manuelle",
"status_completed": "Noté",
"status_pending": "En attente",
"counts_as_complete": "Compte comme terminé",
"passed": "Réussi",
"not_passed": "Non réussi"
}, },
"summary": { "summary": {
"heading": "Résumé", "heading": "Résumé",
"question_chart": "Tableaux de questions", "question_chart": "Tableaux de questions",
"responses": "Réponses", "responses": "Réponses",
"no_responses": "Pas encore de réponses", "no_responses": "Pas encore de réponses",
"other": "Autre" "other": "Autre",
"attempts": "Tentatives"
}, },
"loading_submissions": "Chargement des soumissions...", "loading_submissions": "Chargement des soumissions...",
"empty_title": "Aucune soumission pour l'instant", "empty_title": "Aucune soumission pour l'instant",
@@ -1449,6 +1468,10 @@
"status_pending": "Statut : En attente d'examen", "status_pending": "Statut : En attente d'examen",
"status_graded": "Statut : Examen terminé", "status_graded": "Statut : Examen terminé",
"status_submitted": "Statut : Soumis", "status_submitted": "Statut : Soumis",
"passed_completion_result_title": "Réussi",
"passed_completion_result_description": "Vous avez obtenu {scorePercent} %. Cet exercice exigeait {requiredPercent} % pour être considéré comme terminé.",
"not_passed_completion_result_title": "Pas encore réussi",
"not_passed_completion_result_description": "Vous avez obtenu {scorePercent} %. Cet exercice exige {requiredPercent} % pour être considéré comme terminé.",
"empty_description": "Cliquez sur le bouton Modifier pour ajouter.", "empty_description": "Cliquez sur le bouton Modifier pour ajouter.",
"empty_edit_hint_prefix": "Cliquez sur le ", "empty_edit_hint_prefix": "Cliquez sur le ",
"empty_edit_hint_suffix": " bouton pour ajouter.", "empty_edit_hint_suffix": " bouton pour ajouter.",
@@ -1846,6 +1869,30 @@
"sending": "Informer les étudiants…", "sending": "Informer les étudiants…",
"sent": "{count} étudiants notifiés", "sent": "{count} étudiants notifiés",
"failed": "Impossible d'avertir les étudiants" "failed": "Impossible d'avertir les étudiants"
},
"zero_points_warning": "Fixez des points au-dessus de 0, sinon les étudiants ne seront pas notés pour cette question.",
"settings_completion_passed_helper": "Les étudiants doivent atteindre la note de passage avant que cet exercice soit considéré comme terminé.",
"settings_manual_grading_warning": "Les soumissions notées manuellement doivent être corrigées avant de pouvoir compter comme réussies.",
"settings_single_attempt_warning": "Avec une seule tentative, les étudiants en échec ont besoin d’une action de l’enseignant avant de pouvoir continuer.",
"settings_existing_submissions_warning": "Modifier les critères de réussite peut affecter les validations et la progression existantes.",
"question_auto_graded_badge": "Notation automatique",
"question_manual_grading_badge": "Notation manuelle",
"guard": {
"title": "Vérifier avant d'enregistrer",
"attempts_title": "Que se passe-t-il si un élève échoue ?",
"description": "Cet exercice n’est considéré comme terminé que lorsqu’un élève réussit.",
"retry_option": "Laissez-les réessayer jusqu'à ce qu'ils réussissent",
"retry_option_description": "Les étudiants peuvent soumettre à nouveau autant de fois que nécessaire.",
"single_attempt_option": "Une seule tentative",
"single_attempt_option_description": "Un étudiant qui échoue reste bloqué jusqu'à ce que vous modifiiez manuellement son statut de soumission.",
"recommended": "Recommandé",
"manual_notice_title": "Certaines questions nécessitent une notation manuelle",
"manual_notice_description": "La réussite d'un étudiant n'est définitive que lorsque vous avez noté ces questions :",
"cancel": "Annuler",
"save_anyway": "Enregistrer quand même",
"save_continue": "Enregistrer et continuer",
"question_fallback": "Question {number}",
"zero_points_field": "La réussite nécessite au moins un point disponible."
} }
}, },
"new_exercise_modal": { "new_exercise_modal": {
@@ -2336,7 +2383,8 @@
"later": "Fermer", "later": "Fermer",
"checking": "Vérification de l’éligibilité…", "checking": "Vérification de l’éligibilité…",
"not_eligible_title": "Presque là", "not_eligible_title": "Presque là",
"retake_exercise": "Reprendre l'exercice" "retake_exercise": "Reprendre l'exercice",
"subtitle_no_certificate": "Félicitations, vous avez terminé ce cours."
} }
}, },
"certificates": { "certificates": {
@@ -288,7 +288,8 @@
"saved": "सफलतापूर्वक सहेजा गया", "saved": "सफलतापूर्वक सहेजा गया",
"successful_integration": "एकीकरण सफल", "successful_integration": "एकीकरण सफल",
"successful_deletion": "एकीकरण हटा दिया गया", "successful_deletion": "एकीकरण हटा दिया गया",
"update_successful": "अद्यतन सफल" "update_successful": "अद्यतन सफल",
"unlocked_all_content": "सभी पाठ्यक्रम आइटम अनलॉक हो गए"
} }
}, },
"people": { "people": {
@@ -320,7 +321,8 @@
"points_required_auto_grade": "सहेजने से पहले प्रत्येक प्रश्न के लिए धनात्मक अंक निर्धारित करें।", "points_required_auto_grade": "सहेजने से पहले प्रत्येक प्रश्न के लिए धनात्मक अंक निर्धारित करें।",
"success": "सफलतापूर्वक सहेजा गया", "success": "सफलतापूर्वक सहेजा गया",
"submission_updated": "सबमिशन को अद्यतन किया गया", "submission_updated": "सबमिशन को अद्यतन किया गया",
"error_fetching": "फ़ेचिंग अभ्यास विफल रहा, कृपया पृष्ठ पुनः लोड करें" "error_fetching": "फ़ेचिंग अभ्यास विफल रहा, कृपया पृष्ठ पुनः लोड करें",
"passed_requires_points": "पास होकर पूर्ण करने के लिए कम से कम एक अंक उपलब्ध होना चाहिए।"
}, },
"poll": { "poll": {
"info": { "info": {
@@ -843,6 +845,12 @@
"title": "आपका स्वागत है ईमेल", "title": "आपका स्वागत है ईमेल",
"description": "नामांकन के बाद छात्रों को प्राप्त होने वाले ईमेल को अनुकूलित करें। एक व्यक्तिगत नोट, निर्देश, या एक लाइव सत्र लिंक जोड़ें।", "description": "नामांकन के बाद छात्रों को प्राप्त होने वाले ईमेल को अनुकूलित करें। एक व्यक्तिगत नोट, निर्देश, या एक लाइव सत्र लिंक जोड़ें।",
"placeholder": "नामांकन के समय विद्यार्थियों को मिलने वाला स्वागत संदेश लिखें..." "placeholder": "नामांकन के समय विद्यार्थियों को मिलने वाला स्वागत संदेश लिखें..."
},
"locked_content": {
"title": "{count} पाठ्यक्रम आइटम लॉक हैं",
"description": "नामांकित छात्र तब तक लॉक किए गए पाठ या अभ्यास नहीं ले पाएंगे जब तक आप उन्हें अनलॉक नहीं करते।",
"description_live": "लॉक किए गए पाठ और अभ्यास छात्रों से तब तक छिपे रहते हैं जब तक आप उन्हें अनलॉक नहीं करते - यदि आप सप्ताह दर सप्ताह सामग्री जारी करते हैं तो उपयोगी है।",
"unlock_all": "सबको अनलॉक करें"
} }
}, },
"analytics": { "analytics": {
@@ -1410,8 +1418,8 @@
"finish": "ख़त्म करो", "finish": "ख़त्म करो",
"next": "अगला", "next": "अगला",
"check": "जांचें", "check": "जांचें",
"auto_graded_badge": "Auto-grade", "auto_graded_badge": "स्वचालित ग्रेडिंग",
"manual_grading_required_badge": "Manual grading required", "manual_grading_required_badge": "मैन्युअल ग्रेडिंग आवश्यक",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.", "points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": { "analytics": {
"submissions": "प्रस्तुतियाँ", "submissions": "प्रस्तुतियाँ",
@@ -1419,14 +1427,25 @@
"heading": "व्यक्तिगत", "heading": "व्यक्तिगत",
"answers": "व्यक्तिगत उत्तर", "answers": "व्यक्तिगत उत्तर",
"no": "कोई उत्तर नहीं दिया गया", "no": "कोई उत्तर नहीं दिया गया",
"student_avatar": "छात्र" "student_avatar": "छात्र",
"answers_for": "{name} के उत्तर",
"attempts_count": "{count} प्रयास",
"attempt_label": "प्रयास {number}",
"date_unknown": "तारीख अज्ञात",
"status_awaiting_manual": "मैन्युअल ग्रेडिंग की प्रतीक्षा में",
"status_completed": "ग्रेड किया गया",
"status_pending": "लंबित",
"counts_as_complete": "पूर्ण माना जाएगा",
"passed": "पास",
"not_passed": "पास नहीं हुआ"
}, },
"summary": { "summary": {
"heading": "सारांश", "heading": "सारांश",
"question_chart": "प्रश्न चार्ट", "question_chart": "प्रश्न चार्ट",
"responses": "प्रतिक्रियाएँ", "responses": "प्रतिक्रियाएँ",
"no_responses": "अभी तक कोई प्रतिक्रिया नहीं", "no_responses": "अभी तक कोई प्रतिक्रिया नहीं",
"other": "अन्य" "other": "अन्य",
"attempts": "प्रयास"
}, },
"loading_submissions": "सबमिशन लोड हो रहा है...", "loading_submissions": "सबमिशन लोड हो रहा है...",
"empty_title": "अभी तक कोई सबमिशन नहीं", "empty_title": "अभी तक कोई सबमिशन नहीं",
@@ -1449,6 +1468,10 @@
"status_pending": "स्थिति: समीक्षा लंबित", "status_pending": "स्थिति: समीक्षा लंबित",
"status_graded": "स्थिति: समीक्षा पूरी हुई", "status_graded": "स्थिति: समीक्षा पूरी हुई",
"status_submitted": "स्थिति: सबमिट किया गया", "status_submitted": "स्थिति: सबमिट किया गया",
"passed_completion_result_title": "उत्तीर्ण",
"passed_completion_result_description": "आपने {scorePercent}% स्कोर किया। इस अभ्यास को पूर्ण मानने के लिए {requiredPercent}% आवश्यक था।",
"not_passed_completion_result_title": "अभी उत्तीर्ण नहीं",
"not_passed_completion_result_description": "आपने {scorePercent}% स्कोर किया। इस अभ्यास को पूर्ण मानने के लिए {requiredPercent}% आवश्यक है।",
"empty_description": "जोड़ने के लिए संपादन बटन पर क्लिक करें।", "empty_description": "जोड़ने के लिए संपादन बटन पर क्लिक करें।",
"empty_edit_hint_prefix": "क्लिक करें ", "empty_edit_hint_prefix": "क्लिक करें ",
"empty_edit_hint_suffix": " जोड़ने के लिए बटन.", "empty_edit_hint_suffix": " जोड़ने के लिए बटन.",
@@ -1846,6 +1869,30 @@
"sending": "छात्रों को सूचित किया जा रहा है...", "sending": "छात्रों को सूचित किया जा रहा है...",
"sent": "{count} छात्रों को सूचित किया गया", "sent": "{count} छात्रों को सूचित किया गया",
"failed": "छात्रों को सूचित नहीं किया जा सका" "failed": "छात्रों को सूचित नहीं किया जा सका"
},
"zero_points_warning": "0 से ऊपर अंक निर्धारित करें, अन्यथा छात्रों को इस प्रश्न के लिए ग्रेड नहीं दिया जाएगा।",
"settings_completion_passed_helper": "इस अभ्यास को पूर्ण मानने से पहले छात्रों को पासिंग स्कोर तक पहुँचना होगा।",
"settings_manual_grading_warning": "मैन्युअल रूप से जाँचे गए सबमिशन को पास माना जा सके, उससे पहले उन्हें ग्रेड करना होगा।",
"settings_single_attempt_warning": "एक ही प्रयास में असफल छात्रों को आगे बढ़ने से पहले प्रशिक्षक की कार्रवाई की आवश्यकता होगी।",
"settings_existing_submissions_warning": "पास मानदंड बदलने से मौजूदा पूर्णता और प्रगति प्रभावित हो सकती है।",
"question_auto_graded_badge": "स्वचालित रूप से ग्रेड किया गया",
"question_manual_grading_badge": "मैन्युअल ग्रेडिंग",
"guard": {
"title": "सहेजने से पहले समीक्षा करें",
"attempts_title": "यदि कोई छात्र फेल हो जाए तो क्या होगा?",
"description": "यह अभ्यास तभी पूरा माना जाता है जब कोई छात्र उत्तीर्ण हो जाता है।",
"retry_option": "जब तक वे सफल नहीं हो जाते, उन्हें पुनः प्रयास करने दें",
"retry_option_description": "छात्र आवश्यकतानुसार कई बार पुनः सबमिट कर सकते हैं।",
"single_attempt_option": "केवल एक प्रयास",
"single_attempt_option_description": "जो छात्र असफल होता है वह तब तक अवरुद्ध रहता है जब तक कि आप मैन्युअल रूप से उसकी सबमिशन स्थिति नहीं बदलते।",
"recommended": "अनुशंसित",
"manual_notice_title": "कुछ प्रश्नों के लिए मैन्युअल ग्रेडिंग की आवश्यकता होती है",
"manual_notice_description": "किसी छात्र का पास तब तक अंतिम नहीं है जब तक आप इन प्रश्नों को ग्रेड नहीं देते:",
"cancel": "रद्द करें",
"save_anyway": "फिर भी बचा लो",
"save_continue": "सहेजें और जारी रखें",
"question_fallback": "प्रश्न {number}",
"zero_points_field": "उत्तीर्ण पूर्णता के लिए कम से कम एक अंक उपलब्ध होना आवश्यक है।"
} }
}, },
"new_exercise_modal": { "new_exercise_modal": {
@@ -2336,7 +2383,8 @@
"later": "बंद करें", "later": "बंद करें",
"checking": "पात्रता की जाँच की जा रही है...", "checking": "पात्रता की जाँच की जा रही है...",
"not_eligible_title": "लगभग वहाँ", "not_eligible_title": "लगभग वहाँ",
"retake_exercise": "पुनः व्यायाम करें" "retake_exercise": "पुनः व्यायाम करें",
"subtitle_no_certificate": "बधाई हो - आपने यह पाठ्यक्रम पूरा कर लिया है।"
} }
}, },
"certificates": { "certificates": {
@@ -288,7 +288,8 @@
"saved": "Zapisano pomyślnie", "saved": "Zapisano pomyślnie",
"successful_integration": "Integracja udana", "successful_integration": "Integracja udana",
"successful_deletion": "Integracja usunięta", "successful_deletion": "Integracja usunięta",
"update_successful": "Aktualizacja pomyślna" "update_successful": "Aktualizacja pomyślna",
"unlocked_all_content": "Wszystkie elementy kursu odblokowane"
} }
}, },
"people": { "people": {
@@ -320,7 +321,8 @@
"points_required_auto_grade": "Ustaw dodatnią liczbę punktów dla każdego pytania przed zapisaniem.", "points_required_auto_grade": "Ustaw dodatnią liczbę punktów dla każdego pytania przed zapisaniem.",
"success": "Zapisano pomyślnie", "success": "Zapisano pomyślnie",
"submission_updated": "Zgłoszenie zaktualizowane do", "submission_updated": "Zgłoszenie zaktualizowane do",
"error_fetching": "Pobieranie ćwiczeń nie powiodło się. Załaduj stronę ponownie" "error_fetching": "Pobieranie ćwiczeń nie powiodło się. Załaduj stronę ponownie",
"passed_requires_points": "Ukończenie przez zaliczenie wymaga co najmniej jednego dostępnego punktu."
}, },
"poll": { "poll": {
"info": { "info": {
@@ -843,6 +845,12 @@
"title": "Witamy e-mail", "title": "Witamy e-mail",
"description": "Dostosuj wiadomość e-mail otrzymywaną przez uczniów po zarejestrowaniu się. Dodaj osobistą notatkę, instrukcje lub link do sesji na żywo.", "description": "Dostosuj wiadomość e-mail otrzymywaną przez uczniów po zarejestrowaniu się. Dodaj osobistą notatkę, instrukcje lub link do sesji na żywo.",
"placeholder": "Napisz wiadomość powitalną, jaką otrzymają uczniowie po zarejestrowaniu się…" "placeholder": "Napisz wiadomość powitalną, jaką otrzymają uczniowie po zarejestrowaniu się…"
},
"locked_content": {
"title": "Elementy kursu: {count} są zablokowane",
"description": "Zarejestrowani uczniowie nie będą mogli brać udziału w zablokowanych lekcjach ani ćwiczeniach, dopóki ich nie odblokujesz.",
"description_live": "Zablokowane lekcje i ćwiczenia pozostają ukryte przed uczniami, dopóki ich nie odblokujesz — przydatne, jeśli publikujesz treści tydzień po tygodniu.",
"unlock_all": "Odblokuj wszystko"
} }
}, },
"analytics": { "analytics": {
@@ -1410,8 +1418,8 @@
"finish": "Zakończ", "finish": "Zakończ",
"next": "Następny", "next": "Następny",
"check": "Sprawdź", "check": "Sprawdź",
"auto_graded_badge": "Auto-grade", "auto_graded_badge": "Ocena automatyczna",
"manual_grading_required_badge": "Manual grading required", "manual_grading_required_badge": "Wymagana ocena ręczna",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.", "points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": { "analytics": {
"submissions": "Zgłoszenia", "submissions": "Zgłoszenia",
@@ -1419,14 +1427,25 @@
"heading": "Indywidualny", "heading": "Indywidualny",
"answers": "Indywidualne odpowiedzi", "answers": "Indywidualne odpowiedzi",
"no": "Nie udzielono żadnej odpowiedzi", "no": "Nie udzielono żadnej odpowiedzi",
"student_avatar": "Studenta" "student_avatar": "Studenta",
"answers_for": "Odpowiedzi: {name}",
"attempts_count": "{count} próby",
"attempt_label": "Próba {number}",
"date_unknown": "Data nieznana",
"status_awaiting_manual": "Oczekuje na ocenę ręczną",
"status_completed": "Ocenione",
"status_pending": "Oczekujące",
"counts_as_complete": "Liczy się jako ukończone",
"passed": "Zaliczone",
"not_passed": "Niezaliczone"
}, },
"summary": { "summary": {
"heading": "Podsumowanie", "heading": "Podsumowanie",
"question_chart": "Wykresy pytań", "question_chart": "Wykresy pytań",
"responses": "Odpowiedzi", "responses": "Odpowiedzi",
"no_responses": "Brak odpowiedzi", "no_responses": "Brak odpowiedzi",
"other": "Inne" "other": "Inne",
"attempts": "Próby"
}, },
"loading_submissions": "Ładowanie zgłoszeń...", "loading_submissions": "Ładowanie zgłoszeń...",
"empty_title": "Nie ma jeszcze żadnych zgłoszeń", "empty_title": "Nie ma jeszcze żadnych zgłoszeń",
@@ -1449,6 +1468,10 @@
"status_pending": "Stan: Oczekuje na sprawdzenie", "status_pending": "Stan: Oczekuje na sprawdzenie",
"status_graded": "Stan: Przegląd zakończony", "status_graded": "Stan: Przegląd zakończony",
"status_submitted": "Stan: przesłany", "status_submitted": "Stan: przesłany",
"passed_completion_result_title": "Zaliczono",
"passed_completion_result_description": "Uzyskano {scorePercent}%. Aby to ćwiczenie liczyło się jako ukończone, wymagane było {requiredPercent}%.",
"not_passed_completion_result_title": "Jeszcze nie zaliczono",
"not_passed_completion_result_description": "Uzyskano {scorePercent}%. Aby to ćwiczenie liczyło się jako ukończone, wymagane jest {requiredPercent}%.",
"empty_description": "Kliknij przycisk Edytuj, aby dodać.", "empty_description": "Kliknij przycisk Edytuj, aby dodać.",
"empty_edit_hint_prefix": "Kliknij ", "empty_edit_hint_prefix": "Kliknij ",
"empty_edit_hint_suffix": " przycisk, aby dodać.", "empty_edit_hint_suffix": " przycisk, aby dodać.",
@@ -1846,6 +1869,30 @@
"sending": "Powiadamianie uczniów…", "sending": "Powiadamianie uczniów…",
"sent": "Powiadomiono {count} uczniów", "sent": "Powiadomiono {count} uczniów",
"failed": "Nie udało się powiadomić uczniów" "failed": "Nie udało się powiadomić uczniów"
},
"zero_points_warning": "Ustaw punkty powyżej 0, w przeciwnym razie uczniowie nie będą oceniani za to pytanie.",
"settings_completion_passed_helper": "Uczniowie muszą osiągnąć wynik zaliczający, zanim to ćwiczenie zostanie uznane za ukończone.",
"settings_manual_grading_warning": "Zgłoszenia oceniane ręcznie muszą zostać ocenione, zanim będą mogły liczyć się jako zaliczone.",
"settings_single_attempt_warning": "Przy jednej próbie uczniowie, którzy nie zaliczą, będą potrzebować działania instruktora, zanim będą mogli kontynuować.",
"settings_existing_submissions_warning": "Zmiana kryteriów zaliczenia może wpłynąć na istniejące ukończenia i postęp.",
"question_auto_graded_badge": "Oceniane automatycznie",
"question_manual_grading_badge": "Ocena ręczna",
"guard": {
"title": "Przejrzyj przed zapisaniem",
"attempts_title": "Co się stanie, jeśli uczeń nie zaliczy egzaminu?",
"description": "To ćwiczenie uważa się za ukończone dopiero wtedy, gdy uczeń zaliczy.",
"retry_option": "Pozwól im próbować ponownie, aż zaliczą",
"retry_option_description": "Uczniowie mogą przesyłać ponownie dowolną liczbę razy.",
"single_attempt_option": "Tylko jedna próba",
"single_attempt_option_description": "Uczeń, który nie zdał egzaminu, pozostaje zablokowany do czasu ręcznej zmiany statusu jego przesłania.",
"recommended": "Zalecane",
"manual_notice_title": "Niektóre pytania wymagają ręcznej oceny",
"manual_notice_description": "Legitymacja studencka nie jest ostateczna, dopóki nie ocenisz tych pytań:",
"cancel": "Anuluj",
"save_anyway": "Mimo to zapisz",
"save_continue": "Zapisz i kontynuuj",
"question_fallback": "Pytanie {number}",
"zero_points_field": "Zaliczenie pozytywne wymaga posiadania co najmniej jednego punktu."
} }
}, },
"new_exercise_modal": { "new_exercise_modal": {
@@ -2336,7 +2383,8 @@
"later": "Zamknij", "later": "Zamknij",
"checking": "Sprawdzam uprawnienia…", "checking": "Sprawdzam uprawnienia…",
"not_eligible_title": "Prawie tam", "not_eligible_title": "Prawie tam",
"retake_exercise": "Powtórz ćwiczenie" "retake_exercise": "Powtórz ćwiczenie",
"subtitle_no_certificate": "Gratulacje — ukończyłeś ten kurs."
} }
}, },
"certificates": { "certificates": {
@@ -288,7 +288,8 @@
"saved": "Salvo com sucesso", "saved": "Salvo com sucesso",
"successful_integration": "Integração bem-sucedida", "successful_integration": "Integração bem-sucedida",
"successful_deletion": "Integração excluída", "successful_deletion": "Integração excluída",
"update_successful": "Atualização bem-sucedida" "update_successful": "Atualização bem-sucedida",
"unlocked_all_content": "Todos os itens do curso desbloqueados"
} }
}, },
"people": { "people": {
@@ -320,7 +321,8 @@
"points_required_auto_grade": "Defina um valor de pontos positivo para cada pergunta antes de salvar.", "points_required_auto_grade": "Defina um valor de pontos positivo para cada pergunta antes de salvar.",
"success": "Salvo com sucesso", "success": "Salvo com sucesso",
"submission_updated": "Envio atualizado para", "submission_updated": "Envio atualizado para",
"error_fetching": "Falha ao buscar exercícios. Atualize a página" "error_fetching": "Falha ao buscar exercícios. Atualize a página",
"passed_requires_points": "A conclusão por aprovação precisa de pelo menos um ponto disponível."
}, },
"poll": { "poll": {
"info": { "info": {
@@ -843,6 +845,12 @@
"title": "E-mail de boas-vindas", "title": "E-mail de boas-vindas",
"description": "Personalize o e-mail que os alunos recebem após a inscrição. Adicione uma nota pessoal, instruções ou um link de sessão ao vivo.", "description": "Personalize o e-mail que os alunos recebem após a inscrição. Adicione uma nota pessoal, instruções ou um link de sessão ao vivo.",
"placeholder": "Escreva a mensagem de boas-vindas que os alunos recebem quando se matriculam…" "placeholder": "Escreva a mensagem de boas-vindas que os alunos recebem quando se matriculam…"
},
"locked_content": {
"title": "{count} itens do curso estão bloqueados",
"description": "Os alunos inscritos não poderão fazer aulas ou exercícios bloqueados até que você os desbloqueie.",
"description_live": "Aulas e exercícios bloqueados permanecem ocultos aos alunos até que você os desbloqueie – o que é útil se você liberar conteúdo semana após semana.",
"unlock_all": "Desbloquear tudo"
} }
}, },
"analytics": { "analytics": {
@@ -1410,8 +1418,8 @@
"finish": "Concluir", "finish": "Concluir",
"next": "Próximo", "next": "Próximo",
"check": "Verifique", "check": "Verifique",
"auto_graded_badge": "Auto-grade", "auto_graded_badge": "Avaliação automática",
"manual_grading_required_badge": "Manual grading required", "manual_grading_required_badge": "Avaliação manual obrigatória",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.", "points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": { "analytics": {
"submissions": "Envios", "submissions": "Envios",
@@ -1419,14 +1427,25 @@
"heading": "Individual", "heading": "Individual",
"answers": "Respostas individuais", "answers": "Respostas individuais",
"no": "Nenhuma resposta foi fornecida", "no": "Nenhuma resposta foi fornecida",
"student_avatar": "Estudante" "student_avatar": "Estudante",
"answers_for": "Respostas de {name}",
"attempts_count": "{count} tentativas",
"attempt_label": "Tentativa {number}",
"date_unknown": "Data desconhecida",
"status_awaiting_manual": "Aguardando avaliação manual",
"status_completed": "Avaliado",
"status_pending": "Pendente",
"counts_as_complete": "Conta como concluído",
"passed": "Aprovado",
"not_passed": "Não aprovado"
}, },
"summary": { "summary": {
"heading": "Resumo", "heading": "Resumo",
"question_chart": "Gráficos de perguntas", "question_chart": "Gráficos de perguntas",
"responses": "Respostas", "responses": "Respostas",
"no_responses": "Ainda não há respostas", "no_responses": "Ainda não há respostas",
"other": "Outro" "other": "Outro",
"attempts": "Tentativas"
}, },
"loading_submissions": "Carregando envios...", "loading_submissions": "Carregando envios...",
"empty_title": "Ainda não há envios", "empty_title": "Ainda não há envios",
@@ -1449,6 +1468,10 @@
"status_pending": "Status: revisão pendente", "status_pending": "Status: revisão pendente",
"status_graded": "Status: revisão concluída", "status_graded": "Status: revisão concluída",
"status_submitted": "Status: Enviado", "status_submitted": "Status: Enviado",
"passed_completion_result_title": "Aprovado",
"passed_completion_result_description": "Você obteve {scorePercent}%. Este exercício exigia {requiredPercent}% para contar como concluído.",
"not_passed_completion_result_title": "Ainda não aprovado",
"not_passed_completion_result_description": "Você obteve {scorePercent}%. Este exercício exige {requiredPercent}% para contar como concluído.",
"empty_description": "Clique no botão Editar para adicionar.", "empty_description": "Clique no botão Editar para adicionar.",
"empty_edit_hint_prefix": "Clique no ", "empty_edit_hint_prefix": "Clique no ",
"empty_edit_hint_suffix": " botão para adicionar.", "empty_edit_hint_suffix": " botão para adicionar.",
@@ -1846,6 +1869,30 @@
"sending": "Notificando os alunos…", "sending": "Notificando os alunos…",
"sent": "{count} alunos notificados", "sent": "{count} alunos notificados",
"failed": "Não foi possível notificar os alunos" "failed": "Não foi possível notificar os alunos"
},
"zero_points_warning": "Defina pontos acima de 0, caso contrário os alunos não serão avaliados nesta questão.",
"settings_completion_passed_helper": "Os estudantes devem atingir a pontuação de aprovação antes que este exercício conte como concluído.",
"settings_manual_grading_warning": "Envios avaliados manualmente precisam ser avaliados antes de poderem contar como aprovados.",
"settings_single_attempt_warning": "Com uma tentativa, estudantes reprovados precisam de ação do instrutor antes de continuar.",
"settings_existing_submissions_warning": "Alterar os critérios de aprovação pode afetar conclusões e progressões existentes.",
"question_auto_graded_badge": "Avaliação automática",
"question_manual_grading_badge": "Avaliação manual",
"guard": {
"title": "Revise antes de salvar",
"attempts_title": "O que acontece se um aluno for reprovado?",
"description": "Este exercício só conta como concluído quando o aluno é aprovado.",
"retry_option": "Deixe-os tentar novamente até passarem",
"retry_option_description": "Os alunos poderão reenviar quantas vezes forem necessárias.",
"single_attempt_option": "Uma tentativa apenas",
"single_attempt_option_description": "Um aluno reprovado permanece bloqueado até que você altere manualmente o status do envio.",
"recommended": "Recomendado",
"manual_notice_title": "Algumas perguntas precisam de avaliação manual",
"manual_notice_description": "A aprovação de um aluno não é definitiva até que você avalie estas questões:",
"cancel": "Cancelar",
"save_anyway": "Salve mesmo assim",
"save_continue": "Salvar e continuar",
"question_fallback": "Pergunta {number}",
"zero_points_field": "A conclusão aprovada precisa de pelo menos um ponto disponível."
} }
}, },
"new_exercise_modal": { "new_exercise_modal": {
@@ -2336,7 +2383,8 @@
"later": "Fechar", "later": "Fechar",
"checking": "Verificando elegibilidade…", "checking": "Verificando elegibilidade…",
"not_eligible_title": "Quase lá", "not_eligible_title": "Quase lá",
"retake_exercise": "Refazer o exercício" "retake_exercise": "Refazer o exercício",
"subtitle_no_certificate": "Parabéns – você concluiu este curso."
} }
}, },
"certificates": { "certificates": {
@@ -288,7 +288,8 @@
"saved": "Сохранено успешно.", "saved": "Сохранено успешно.",
"successful_integration": "Интеграция прошла успешно", "successful_integration": "Интеграция прошла успешно",
"successful_deletion": "Интеграция удалена", "successful_deletion": "Интеграция удалена",
"update_successful": "Обновление успешно выполнено" "update_successful": "Обновление успешно выполнено",
"unlocked_all_content": "Все предметы курса разблокированы"
} }
}, },
"people": { "people": {
@@ -320,7 +321,8 @@
"points_required_auto_grade": "Установите положительное количество баллов для каждого вопроса перед сохранением.", "points_required_auto_grade": "Установите положительное количество баллов для каждого вопроса перед сохранением.",
"success": "Сохранено успешно", "success": "Сохранено успешно",
"submission_updated": "Представление обновлено до", "submission_updated": "Представление обновлено до",
"error_fetching": "Не удалось загрузить упражнения. Обновите страницу." "error_fetching": "Не удалось загрузить упражнения. Обновите страницу.",
"passed_requires_points": "Для завершения по условию прохождения должен быть доступен хотя бы один балл."
}, },
"poll": { "poll": {
"info": { "info": {
@@ -843,6 +845,12 @@
"title": "Приветственное письмо", "title": "Приветственное письмо",
"description": "Настройте электронное письмо, которое студенты получат после регистрации. Добавьте личную заметку, инструкции или ссылку на сеанс в реальном времени.", "description": "Настройте электронное письмо, которое студенты получат после регистрации. Добавьте личную заметку, инструкции или ссылку на сеанс в реальном времени.",
"placeholder": "Напишите приветственное сообщение, которое студенты получат при зачислении…" "placeholder": "Напишите приветственное сообщение, которое студенты получат при зачислении…"
},
"locked_content": {
"title": "Элементы курса ({count}) заблокированы",
"description": "Зачисленные учащиеся не смогут проходить заблокированные уроки или упражнения, пока вы их не разблокируете.",
"description_live": "Заблокированные уроки и упражнения остаются скрытыми от учащихся, пока вы их не разблокируете. Это полезно, если вы выпускаете контент еженедельно.",
"unlock_all": "Разблокировать все"
} }
}, },
"analytics": { "analytics": {
@@ -1410,8 +1418,8 @@
"finish": "Готово", "finish": "Готово",
"next": "Далее", "next": "Далее",
"check": "Проверить", "check": "Проверить",
"auto_graded_badge": "Auto-grade", "auto_graded_badge": "Автоматическая оценка",
"manual_grading_required_badge": "Manual grading required", "manual_grading_required_badge": "Требуется ручная оценка",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.", "points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": { "analytics": {
"submissions": "Материалы", "submissions": "Материалы",
@@ -1419,14 +1427,25 @@
"heading": "Индивидуальный", "heading": "Индивидуальный",
"answers": "Индивидуальные ответы", "answers": "Индивидуальные ответы",
"no": "Ответ не был предоставлен", "no": "Ответ не был предоставлен",
"student_avatar": "Студент" "student_avatar": "Студент",
"answers_for": "Ответы {name}",
"attempts_count": "{count} попыток",
"attempt_label": "Попытка {number}",
"date_unknown": "Дата неизвестна",
"status_awaiting_manual": "Ожидает ручной оценки",
"status_completed": "Оценено",
"status_pending": "В ожидании",
"counts_as_complete": "Считается завершенным",
"passed": "Пройдено",
"not_passed": "Не пройдено"
}, },
"summary": { "summary": {
"heading": "Резюме", "heading": "Резюме",
"question_chart": "Таблицы вопросов", "question_chart": "Таблицы вопросов",
"responses": "Ответы", "responses": "Ответы",
"no_responses": "Пока нет ответов", "no_responses": "Пока нет ответов",
"other": "Другое" "other": "Другое",
"attempts": "Попытки"
}, },
"loading_submissions": "Загрузка материалов...", "loading_submissions": "Загрузка материалов...",
"empty_title": "Пока нет заявок", "empty_title": "Пока нет заявок",
@@ -1449,6 +1468,10 @@
"status_pending": "Статус: Ожидается рассмотрение", "status_pending": "Статус: Ожидается рассмотрение",
"status_graded": "Статус: Проверка завершена", "status_graded": "Статус: Проверка завершена",
"status_submitted": "Статус: Отправлено", "status_submitted": "Статус: Отправлено",
"passed_completion_result_title": "Пройдено",
"passed_completion_result_description": "Вы набрали {scorePercent}%. Для зачета этого упражнения как выполненного требовалось {requiredPercent}%.",
"not_passed_completion_result_title": "Пока не пройдено",
"not_passed_completion_result_description": "Вы набрали {scorePercent}%. Чтобы это упражнение считалось выполненным, требуется {requiredPercent}%.",
"empty_description": "Нажмите кнопку «Изменить», чтобы добавить.", "empty_description": "Нажмите кнопку «Изменить», чтобы добавить.",
"empty_edit_hint_prefix": "Нажмите кнопку ", "empty_edit_hint_prefix": "Нажмите кнопку ",
"empty_edit_hint_suffix": " кнопка для добавления.", "empty_edit_hint_suffix": " кнопка для добавления.",
@@ -1846,6 +1869,30 @@
"sending": "Уведомление учащихся…", "sending": "Уведомление учащихся…",
"sent": "Уведомлено учащихся: {count}", "sent": "Уведомлено учащихся: {count}",
"failed": "Не удалось уведомить студентов" "failed": "Не удалось уведомить студентов"
},
"zero_points_warning": "Установите балл выше 0, иначе учащиеся не будут оценены по этому вопросу.",
"settings_completion_passed_helper": "Студенты должны набрать проходной балл, прежде чем это упражнение будет считаться завершенным.",
"settings_manual_grading_warning": "Отправки с ручной оценкой должны быть оценены, прежде чем они смогут считаться пройденными.",
"settings_single_attempt_warning": "При одной попытке студентам, не прошедшим задание, потребуется действие преподавателя, прежде чем они смогут продолжить.",
"settings_existing_submissions_warning": "Изменение критериев прохождения может повлиять на существующие завершения и прогресс.",
"question_auto_graded_badge": "Оценивается автоматически",
"question_manual_grading_badge": "Ручная оценка",
"guard": {
"title": "Проверьте перед сохранением",
"attempts_title": "Что произойдет, если студент не справится?",
"description": "Это упражнение считается выполненным только в том случае, если учащийся сдал его.",
"retry_option": "Пусть повторяют, пока не пройдут",
"retry_option_description": "Студенты могут отправлять повторно столько раз, сколько необходимо.",
"single_attempt_option": "Только одна попытка",
"single_attempt_option_description": "Учащийся, который не сдал экзамен, остается заблокированным, пока вы вручную не измените статус его отправки.",
"recommended": "Рекомендуется",
"manual_notice_title": "Некоторые вопросы требуют ручной оценки",
"manual_notice_description": "Студенческий пропуск не является окончательным, пока вы не оцените следующие вопросы:",
"cancel": "Отмена",
"save_anyway": "Все равно сохранить",
"save_continue": "Сохранить и продолжить",
"question_fallback": "Вопрос {number}",
"zero_points_field": "Для успешного завершения требуется хотя бы одна доступная точка."
} }
}, },
"new_exercise_modal": { "new_exercise_modal": {
@@ -2336,7 +2383,8 @@
"later": "Закрыть", "later": "Закрыть",
"checking": "Проверка пригодности…", "checking": "Проверка пригодности…",
"not_eligible_title": "Почти там", "not_eligible_title": "Почти там",
"retake_exercise": "Пересдать упражнение" "retake_exercise": "Пересдать упражнение",
"subtitle_no_certificate": "Поздравляем — вы прошли этот курс."
} }
}, },
"certificates": { "certificates": {
@@ -288,7 +288,8 @@
"saved": "Đã lưu thành công", "saved": "Đã lưu thành công",
"successful_integration": "Tích hợp thành công", "successful_integration": "Tích hợp thành công",
"successful_deletion": "Đã xóa tích hợp", "successful_deletion": "Đã xóa tích hợp",
"update_successful": "Cập nhật thành công" "update_successful": "Cập nhật thành công",
"unlocked_all_content": "Tất cả các mục khóa học đã được mở khóa"
} }
}, },
"people": { "people": {
@@ -320,7 +321,8 @@
"points_required_auto_grade": "Đặt điểm dương cho mỗi câu hỏi trước khi lưu.", "points_required_auto_grade": "Đặt điểm dương cho mỗi câu hỏi trước khi lưu.",
"success": "Đã lưu thành công", "success": "Đã lưu thành công",
"submission_updated": "Đã cập nhật bài nộp vào", "submission_updated": "Đã cập nhật bài nộp vào",
"error_fetching": "Tìm nạp bài tập không thành công, vui lòng tải lại trang" "error_fetching": "Tìm nạp bài tập không thành công, vui lòng tải lại trang",
"passed_requires_points": "Hoàn thành theo điều kiện đạt yêu cầu cần có ít nhất một điểm khả dụng."
}, },
"poll": { "poll": {
"info": { "info": {
@@ -843,6 +845,12 @@
"title": "Email chào mừng", "title": "Email chào mừng",
"description": "Tùy chỉnh email sinh viên nhận được sau khi đăng ký. Thêm ghi chú cá nhân, hướng dẫn hoặc liên kết phiên trực tiếp.", "description": "Tùy chỉnh email sinh viên nhận được sau khi đăng ký. Thêm ghi chú cá nhân, hướng dẫn hoặc liên kết phiên trực tiếp.",
"placeholder": "Viết lời chào mừng mà học viên nhận được khi đăng ký…" "placeholder": "Viết lời chào mừng mà học viên nhận được khi đăng ký…"
},
"locked_content": {
"title": "{count} mục khóa học đã bị khóa",
"description": "Học viên đã đăng ký sẽ không thể học các bài học hoặc bài tập bị khóa cho đến khi bạn mở khóa chúng.",
"description_live": "Các bài học và bài tập bị khóa sẽ được ẩn khỏi học sinh cho đến khi bạn mở khóa chúng — hữu ích nếu bạn phát hành nội dung hàng tuần.",
"unlock_all": "Mở khóa tất cả"
} }
}, },
"analytics": { "analytics": {
@@ -1410,8 +1418,8 @@
"finish": "Kết thúc", "finish": "Kết thúc",
"next": "Tiếp theo", "next": "Tiếp theo",
"check": "Kiểm tra", "check": "Kiểm tra",
"auto_graded_badge": "Auto-grade", "auto_graded_badge": "Chấm tự động",
"manual_grading_required_badge": "Manual grading required", "manual_grading_required_badge": "Cần chấm thủ công",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.", "points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": { "analytics": {
"submissions": "bài nộp", "submissions": "bài nộp",
@@ -1419,14 +1427,25 @@
"heading": "Cá nhân", "heading": "Cá nhân",
"answers": "Câu trả lời cá nhân", "answers": "Câu trả lời cá nhân",
"no": "Không có câu trả lời nào được đưa ra", "no": "Không có câu trả lời nào được đưa ra",
"student_avatar": "sinh viên" "student_avatar": "sinh viên",
"answers_for": "Câu trả lời của {name}",
"attempts_count": "{count} lần làm",
"attempt_label": "Lần làm {number}",
"date_unknown": "Không rõ ngày",
"status_awaiting_manual": "Đang chờ chấm thủ công",
"status_completed": "Đã chấm",
"status_pending": "Đang chờ",
"counts_as_complete": "Được tính là hoàn thành",
"passed": "Đạt",
"not_passed": "Không đạt"
}, },
"summary": { "summary": {
"heading": "Tóm tắt", "heading": "Tóm tắt",
"question_chart": "Biểu đồ câu hỏi", "question_chart": "Biểu đồ câu hỏi",
"responses": "Phản hồi", "responses": "Phản hồi",
"no_responses": "Chưa có phản hồi nào", "no_responses": "Chưa có phản hồi nào",
"other": "Khác" "other": "Khác",
"attempts": "Lần làm"
}, },
"loading_submissions": "Đang tải nội dung gửi...", "loading_submissions": "Đang tải nội dung gửi...",
"empty_title": "Chưa có bài nộp nào", "empty_title": "Chưa có bài nộp nào",
@@ -1449,6 +1468,10 @@
"status_pending": "Trạng thái: Đang chờ xem xét", "status_pending": "Trạng thái: Đang chờ xem xét",
"status_graded": "Trạng thái: Đánh giá đã hoàn thành", "status_graded": "Trạng thái: Đánh giá đã hoàn thành",
"status_submitted": "Trạng thái: Đã gửi", "status_submitted": "Trạng thái: Đã gửi",
"passed_completion_result_title": "Đã đạt",
"passed_completion_result_description": "Bạn đạt {scorePercent}%. Bài tập này yêu cầu {requiredPercent}% để được tính là hoàn thành.",
"not_passed_completion_result_title": "Chưa đạt",
"not_passed_completion_result_description": "Bạn đạt {scorePercent}%. Bài tập này yêu cầu {requiredPercent}% để được tính là hoàn thành.",
"empty_description": "Bấm vào nút Chỉnh sửa để thêm.", "empty_description": "Bấm vào nút Chỉnh sửa để thêm.",
"empty_edit_hint_prefix": "Bấm vào ", "empty_edit_hint_prefix": "Bấm vào ",
"empty_edit_hint_suffix": " nút để thêm.", "empty_edit_hint_suffix": " nút để thêm.",
@@ -1846,6 +1869,30 @@
"sending": "Thông báo cho học sinh…", "sending": "Thông báo cho học sinh…",
"sent": "Đã thông báo cho {count} sinh viên", "sent": "Đã thông báo cho {count} sinh viên",
"failed": "Không thể thông báo cho sinh viên" "failed": "Không thể thông báo cho sinh viên"
},
"zero_points_warning": "Đặt điểm trên 0, nếu không học sinh sẽ không được chấm điểm cho câu hỏi này.",
"settings_completion_passed_helper": "Học viên phải đạt điểm qua trước khi bài tập này được tính là hoàn thành.",
"settings_manual_grading_warning": "Bài nộp chấm thủ công phải được chấm trước khi có thể tính là đạt.",
"settings_single_attempt_warning": "Với một lần làm, học viên không đạt cần giảng viên can thiệp trước khi có thể tiếp tục.",
"settings_existing_submissions_warning": "Việc thay đổi tiêu chí đạt có thể ảnh hưởng đến trạng thái hoàn thành và tiến trình hiện có.",
"question_auto_graded_badge": "Chấm tự động",
"question_manual_grading_badge": "Chấm thủ công",
"guard": {
"title": "Xem lại trước khi lưu",
"attempts_title": "Điều gì xảy ra nếu một học sinh thất bại?",
"description": "Bài tập này chỉ được tính là hoàn thành khi một học sinh vượt qua.",
"retry_option": "Hãy để họ thử lại cho đến khi họ vượt qua",
"retry_option_description": "Học viên có thể nộp lại nhiều lần nếu cần.",
"single_attempt_option": "Chỉ một lần thử",
"single_attempt_option_description": "Học sinh không đạt sẽ bị chặn cho đến khi bạn thay đổi trạng thái gửi bài của họ theo cách thủ công.",
"recommended": "Được đề xuất",
"manual_notice_title": "Một số câu hỏi cần chấm điểm thủ công",
"manual_notice_description": "Điểm đậu của học sinh không phải là điểm cuối cùng cho đến khi bạn chấm điểm những câu hỏi sau:",
"cancel": "Hủy bỏ",
"save_anyway": "Vẫn lưu",
"save_continue": "Lưu và tiếp tục",
"question_fallback": "Câu hỏi {number}",
"zero_points_field": "Hoàn thành vượt qua cần có ít nhất một điểm."
} }
}, },
"new_exercise_modal": { "new_exercise_modal": {
@@ -2336,7 +2383,8 @@
"later": "Đóng", "later": "Đóng",
"checking": "Đang kiểm tra tính đủ điều kiện…", "checking": "Đang kiểm tra tính đủ điều kiện…",
"not_eligible_title": "Gần như ở đó", "not_eligible_title": "Gần như ở đó",
"retake_exercise": "Làm lại bài tập" "retake_exercise": "Làm lại bài tập",
"subtitle_no_certificate": "Xin chúc mừng - bạn đã hoàn thành khóa học này."
} }
}, },
"certificates": { "certificates": {
+1 -1
View File
@@ -17,7 +17,7 @@
"db:studio": "pnpm --filter @cio/db db:studio", "db:studio": "pnpm --filter @cio/db db:studio",
"db:backfill-profile-email-verification": "pnpm --filter @cio/db db:backfill-profile-email-verification", "db:backfill-profile-email-verification": "pnpm --filter @cio/db db:backfill-profile-email-verification",
"dashboard:dev": "pnpm concurrently -n dashboard,ui -c blue,yellow \"pnpm --filter=@cio/dashboard run dev\" \"pnpm --filter=@cio/ui run dev\"", "dashboard:dev": "pnpm concurrently -n dashboard,ui -c blue,yellow \"pnpm --filter=@cio/dashboard run dev\" \"pnpm --filter=@cio/ui run dev\"",
"api:dev": "pnpm concurrently -n api,jobs -c blue,yellow \"pnpm --filter=@cio/api run dev\" \"pnpm --filter=@cio/jobs-worker run dev\"", "api:dev": "pnpm turbo run build --filter=@cio/api^... --filter=@cio/jobs-worker^... && pnpm concurrently -n api,jobs -c blue,yellow \"pnpm --filter=@cio/api run dev\" \"pnpm --filter=@cio/jobs-worker run dev\"",
"jobs:dev": "pnpm --filter=@cio/jobs-worker run dev", "jobs:dev": "pnpm --filter=@cio/jobs-worker run dev",
"docs:dev": "pnpm --filter @cio/docs dev", "docs:dev": "pnpm --filter @cio/docs dev",
"docs:build": "pnpm --filter @cio/docs build", "docs:build": "pnpm --filter @cio/docs build",
+13 -3
View File
@@ -41,7 +41,7 @@ export function buildTeacherSystemPrompt(context: AgentContext, options?: BuildT
## Your Capabilities ## Your Capabilities
You have access to specific tools listed below. Use them to read course content, read a lesson's uploaded video transcript via get_lesson_transcript (the video's spoken content is not in the lesson body — use this whenever a question is about what the video says or explains), create sections and lessons, update existing course-outline section metadata via update_section, update existing lesson metadata via update_lesson, write lesson content, create exercises with questions, create exercise question blocks via create_exercise_section, edit exercise-level metadata (title, description, linked lesson, course section placement, order, due date, lock state, allow-multiple-attempts) via update_exercise, edit each existing in-exercise question block heading and intro via update_exercise_section (ids from get_exercise_details sections array — not course section ids), add or update questions inside an existing exercise (use add_questions with exerciseSectionId from that sections array when the exercise groups questions into blocks), update course landing-page copy/settings, check go-live readiness, and publish the course when it is ready. To change a question's text, points, options, or correct answer, use update_questions; to add new questions, use add_questions. You have access to specific tools listed below. Use them to read course content, read a lesson's uploaded video transcript via get_lesson_transcript (the video's spoken content is not in the lesson body — use this whenever a question is about what the video says or explains), create sections and lessons, update existing course-outline section metadata via update_section, update existing lesson metadata via update_lesson, write lesson content, create exercises with questions, create exercise question blocks via create_exercise_section, edit exercise-level metadata (title, description, linked lesson, course section placement, order, due date, lock state, allow-multiple-attempts, completion policy, passing threshold) via update_exercise, edit each existing in-exercise question block heading and intro via update_exercise_section (ids from get_exercise_details sections array — not course section ids), add or update questions inside an existing exercise (use add_questions with exerciseSectionId from that sections array when the exercise groups questions into blocks), update course landing-page copy/settings, check go-live readiness, and publish the course when it is ready. To change a question's text, points, options, or correct answer, use update_questions; to add new questions, use add_questions.
## Question Types ## Question Types
@@ -215,15 +215,25 @@ If a lesson has \`hasExercise: true\`, the linked exercise takes the next order
- If the teacher asks to change a section name or order, first call get_course_structure, then use update_section with the existing section ID. Do NOT create a replacement section. - If the teacher asks to change a section name or order, first call get_course_structure, then use update_section with the existing section ID. Do NOT create a replacement section.
- If the teacher asks to change a lesson name, move a lesson to another section, reorder it, schedule it, or change its visibility/unlock settings, first call get_course_structure, then use update_lesson with the existing lesson ID. Do NOT create a replacement lesson. - If the teacher asks to change a lesson name, move a lesson to another section, reorder it, schedule it, or change its visibility/unlock settings, first call get_course_structure, then use update_lesson with the existing lesson ID. Do NOT create a replacement lesson.
- If the teacher asks to change exercise metadata such as title, description, linked lesson, course section placement, order, due date, lock state, or multiple-attempt behavior, use update_exercise. Do NOT create a replacement exercise. - If the teacher asks to change exercise metadata such as title, description, linked lesson, course section placement, order, due date, lock state, multiple-attempt behavior, completion policy, or passing threshold, use update_exercise. Do NOT create a replacement exercise.
- If the teacher asks to create a new **block of questions inside an exercise**, use create_exercise_section. If they ask to rename or add intro text for an existing block, call get_exercise_details first, then use update_exercise_section with the exercise id and the section id from the sections array. Do NOT use update_section for that — update_section only changes course outline sections. - If the teacher asks to create a new **block of questions inside an exercise**, use create_exercise_section. If they ask to rename or add intro text for an existing block, call get_exercise_details first, then use update_exercise_section with the exercise id and the section id from the sections array. Do NOT use update_section for that — update_section only changes course outline sections.
- Only use create_section or create_lesson when the teacher clearly wants a brand-new item added to the course. - Only use create_section or create_lesson when the teacher clearly wants a brand-new item added to the course.
## Exercise Completion Gates
Exercises have two independent controls that affect student completion:
- \`completionPolicy: "submitted"\` — submitting an attempt marks the exercise complete.
- \`completionPolicy: "passed"\` plus \`passThreshold: <0-100>\` — the student must score at least that percentage before the exercise is complete.
When the teacher says students must "pass", "hit a score", "meet a threshold", "get 70%", "score at least X%", or similar, use \`update_exercise\` with \`completionPolicy: "passed"\` and the requested \`passThreshold\`. If the teacher says students must complete/pass an exercise **before progressing to later course items**, also make sure the course uses sequential progression by calling \`update_course_landing_page\` with \`metadata.progressionMode: "sequential"\` when it is not already sequential. Sequential progression decides whether later items are locked; the exercise completion policy decides whether this exercise counts as complete after any submission or only after a passing score.
## Editing Existing Questions ## Editing Existing Questions
To change an existing question (its text, points, order, in-exercise section, correct answer, or options), use update_questions — never use add_questions for edits, as that creates duplicates. Use \`exerciseSectionId\` on update_questions to move a question to another block from get_exercise_details \`sections[].id\`, or null to unassign. When adding questions with add_questions, pass exerciseSectionId from get_exercise_details \`sections[].id\` if the exercise has multiple in-exercise blocks so new items land in the right group. Where the correct answer lives depends on the question type: To change an existing question (its text, points, order, in-exercise section, correct answer, or options), use update_questions — never use add_questions for edits, as that creates duplicates. Use \`exerciseSectionId\` on update_questions to move a question to another block from get_exercise_details \`sections[].id\`, or null to unassign. When adding questions with add_questions, pass exerciseSectionId from get_exercise_details \`sections[].id\` if the exercise has multiple in-exercise blocks so new items land in the right group. Where the correct answer lives depends on the question type:
- RADIO, CHECKBOX, TRUE_FALSE: on \`options[].isCorrect\`. Include an option's \`id\` to edit it; omit \`id\` to add a new option. The system automatically balances which position holds the correct answer across an exercise, so you don't need to vary it yourself — but DO keep the correct option from being an obvious tell: don't always make it the longest or most detailed choice, vary True/False answers, and avoid leaning on "all of the above"-style options. - RADIO, CHECKBOX: on \`options[].isCorrect\`. Include an option's \`id\` to edit it; omit \`id\` to add a new option. The system automatically balances which position holds the correct answer across an exercise, so you don't need to vary it yourself — but DO keep the correct option from being an obvious tell: don't always make it the longest or most detailed choice, and avoid leaning on "all of the above"-style options.
- TRUE_FALSE: on \`settings.correctValue\` as a boolean. Use \`true\` when True is correct and \`false\` when False is correct. Keep exactly two options labeled True and False; their \`isCorrect\` values are synchronized from \`settings.correctValue\`.
- NUMERIC: see the dedicated NUMERIC block below — \`settings.correctValue\` is required and \`settings.tolerance\` should almost always be set. \`options\` MUST be empty/absent. - NUMERIC: see the dedicated NUMERIC block below — \`settings.correctValue\` is required and \`settings.tolerance\` should almost always be set. \`options\` MUST be empty/absent.
- STAR: on \`settings.correctValue\` (1..max stars). - STAR: on \`settings.correctValue\` (1..max stars).
- WORD_BANK: on \`settings.correctAnswers\` (array, one per \`___\` blank) and \`settings.template\`. - WORD_BANK: on \`settings.correctAnswers\` (array, one per \`___\` blank) and \`settings.template\`.
+35 -6
View File
@@ -138,6 +138,12 @@ export const createExerciseSchema = {
), ),
points: z.number().min(0).default(1).describe('Points for this question'), points: z.number().min(0).default(1).describe('Points for this question'),
order: z.number().int().min(0).describe('Display order'), order: z.number().int().min(0).describe('Display order'),
settings: z
.record(z.string(), z.unknown())
.optional()
.describe(
'Per-type correct-answer storage. TRUE_FALSE: { correctValue: boolean }. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }.'
),
options: z options: z
.array( .array(
z.object({ z.object({
@@ -146,7 +152,7 @@ export const createExerciseSchema = {
}) })
) )
.describe( .describe(
'Answer options. RADIO and CHECKBOX questions MUST have at least 4 options with plausible distractors (not obvious filler). RADIO needs exactly one correct option; CHECKBOX needs at least 2 correct and at least 1 incorrect. TRUE_FALSE uses exactly 2 options. Leave empty for NUMERIC/STAR/WORD_BANK.' 'Answer options. RADIO and CHECKBOX questions MUST have at least 4 options with plausible distractors (not obvious filler). RADIO needs exactly one correct option; CHECKBOX needs at least 2 correct and at least 1 incorrect. TRUE_FALSE uses exactly 2 options labeled True and False; set the correct answer in settings.correctValue. Leave empty for NUMERIC/STAR/WORD_BANK.'
) )
}) })
) )
@@ -189,7 +195,7 @@ export const updateExerciseSectionSchema = {
export const updateExerciseSchema = { export const updateExerciseSchema = {
description: description:
"Update an existing exercise's metadata (title, description, linked lesson, section, order, due date, lock state, allow-multiple-attempts). Use this to edit the exercise itself — not its questions. To change questions, use update_questions; to add questions, use add_questions.", 'Update an existing exercise\'s metadata (title, description, linked lesson, section, order, due date, lock state, allow-multiple-attempts, completion policy, passing threshold). Use this to edit the exercise itself — not its questions. To require students to pass before progress/completion, set completionPolicy to "passed" and passThreshold to the required percentage. To only require submission, set completionPolicy to "submitted". To change questions, use update_questions; to add questions, use add_questions.',
parameters: z parameters: z
.object({ .object({
courseId: z.string().describe('The course ID'), courseId: z.string().describe('The course ID'),
@@ -207,7 +213,22 @@ export const updateExerciseSchema = {
.optional() .optional()
.describe('Due date in ISO 8601 format (e.g. 2026-05-01T23:59:00Z). Omit to keep unchanged.'), .describe('Due date in ISO 8601 format (e.g. 2026-05-01T23:59:00Z). Omit to keep unchanged.'),
isUnlocked: z.boolean().optional().describe('Whether the exercise is unlocked for students.'), isUnlocked: z.boolean().optional().describe('Whether the exercise is unlocked for students.'),
allowMultipleAttempts: z.boolean().optional().describe('Whether students can re-take the exercise.') allowMultipleAttempts: z.boolean().optional().describe('Whether students can re-take the exercise.'),
completionPolicy: z
.enum(['submitted', 'passed'])
.optional()
.describe(
'"submitted" means any submitted attempt completes the exercise. "passed" means the student must meet or exceed passThreshold before the exercise is considered complete.'
),
passThreshold: z
.number()
.int()
.min(0)
.max(100)
.optional()
.describe(
'Passing score percentage from 0 to 100. Use with completionPolicy "passed" when the teacher asks students to pass a threshold before progressing.'
)
}) })
.refine( .refine(
(data) => (data) =>
@@ -218,7 +239,9 @@ export const updateExerciseSchema = {
data.order !== undefined || data.order !== undefined ||
data.dueBy !== undefined || data.dueBy !== undefined ||
data.isUnlocked !== undefined || data.isUnlocked !== undefined ||
data.allowMultipleAttempts !== undefined, data.allowMultipleAttempts !== undefined ||
data.completionPolicy !== undefined ||
data.passThreshold !== undefined,
{ {
message: 'Provide at least one field to update' message: 'Provide at least one field to update'
} }
@@ -251,6 +274,12 @@ export const addQuestionsSchema = {
), ),
points: z.number().min(0).default(1).describe('Points for this question'), points: z.number().min(0).default(1).describe('Points for this question'),
order: z.number().int().min(0).describe('Display order'), order: z.number().int().min(0).describe('Display order'),
settings: z
.record(z.string(), z.unknown())
.optional()
.describe(
'Per-type correct-answer storage. TRUE_FALSE: { correctValue: boolean }. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }.'
),
options: z.array( options: z.array(
z.object({ z.object({
label: z.string().min(1).describe('Option text'), label: z.string().min(1).describe('Option text'),
@@ -264,7 +293,7 @@ export const addQuestionsSchema = {
export const updateQuestionsSchema = { export const updateQuestionsSchema = {
description: description:
'Update existing questions in an exercise. Pass only the fields you want to change; `id` identifies the question. Optional `exerciseSectionId` (from get_exercise_details sections) moves the question to another in-exercise block; use null to clear section assignment. For NUMERIC the correct answer is `settings.correctValue` (a number), not an option — do NOT add options to NUMERIC questions. For STAR use `settings.correctValue` (1..max). For WORD_BANK use `settings.correctAnswers` and `settings.template`. RADIO/CHECKBOX/TRUE_FALSE use `options[].isCorrect`: include an option `id` to edit it, omit `id` to add a new one. `settings` is shallow-merged with the existing settings. Omit `options` entirely to leave existing options unchanged — if you pass an options array, any existing option not listed in it will be removed.', 'Update existing questions in an exercise. Pass only the fields you want to change; `id` identifies the question. Optional `exerciseSectionId` (from get_exercise_details sections) moves the question to another in-exercise block; use null to clear section assignment. For TRUE_FALSE use `settings.correctValue` (boolean) and keep exactly two options labeled True and False. For NUMERIC the correct answer is `settings.correctValue` (a number), not an option — do NOT add options to NUMERIC questions. For STAR use `settings.correctValue` (1..max). For WORD_BANK use `settings.correctAnswers` and `settings.template`. RADIO/CHECKBOX use `options[].isCorrect`: include an option `id` to edit it, omit `id` to add a new one. `settings` is shallow-merged with the existing settings. Omit `options` entirely to leave existing options unchanged — if you pass an options array, any existing option not listed in it will be removed.',
parameters: z.object({ parameters: z.object({
courseId: z.string().describe('The course ID'), courseId: z.string().describe('The course ID'),
exerciseId: z.string().describe('The existing exercise ID'), exerciseId: z.string().describe('The existing exercise ID'),
@@ -293,7 +322,7 @@ export const updateQuestionsSchema = {
.record(z.string(), z.unknown()) .record(z.string(), z.unknown())
.optional() .optional()
.describe( .describe(
'Per-type correct-answer storage. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }. Shallow-merged with existing settings.' 'Per-type correct-answer storage. TRUE_FALSE: { correctValue: boolean }. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }. Shallow-merged with existing settings.'
), ),
options: z options: z
.array( .array(
@@ -83,6 +83,12 @@ export const questionSchema = z.object({
), ),
points: z.number().min(0).default(1), points: z.number().min(0).default(1),
order: z.number().int().min(0), order: z.number().int().min(0),
settings: z
.record(z.string(), z.unknown())
.optional()
.describe(
'Per-type correct-answer storage. TRUE_FALSE: { correctValue: boolean }. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }.'
),
options: z.array(z.object({ label: z.string().min(1), isCorrect: z.boolean() })) options: z.array(z.object({ label: z.string().min(1), isCorrect: z.boolean() }))
}); });
@@ -118,7 +124,22 @@ export const updateExerciseParam = z
.optional() .optional()
.describe('Due date in ISO 8601 format (e.g. 2026-05-01T23:59:00Z). Omit to keep unchanged.'), .describe('Due date in ISO 8601 format (e.g. 2026-05-01T23:59:00Z). Omit to keep unchanged.'),
isUnlocked: z.boolean().optional().describe('Whether the exercise is unlocked for students.'), isUnlocked: z.boolean().optional().describe('Whether the exercise is unlocked for students.'),
allowMultipleAttempts: z.boolean().optional().describe('Whether students can re-take the exercise.') allowMultipleAttempts: z.boolean().optional().describe('Whether students can re-take the exercise.'),
completionPolicy: z
.enum(['submitted', 'passed'])
.optional()
.describe(
'"submitted" means any submitted attempt completes the exercise. "passed" means the student must meet or exceed passThreshold before the exercise is considered complete.'
),
passThreshold: z
.number()
.int()
.min(0)
.max(100)
.optional()
.describe(
'Passing score percentage from 0 to 100. Use with completionPolicy "passed" when the teacher asks students to pass a threshold before progressing.'
)
}) })
.refine( .refine(
(data) => (data) =>
@@ -129,7 +150,9 @@ export const updateExerciseParam = z
data.order !== undefined || data.order !== undefined ||
data.dueBy !== undefined || data.dueBy !== undefined ||
data.isUnlocked !== undefined || data.isUnlocked !== undefined ||
data.allowMultipleAttempts !== undefined, data.allowMultipleAttempts !== undefined ||
data.completionPolicy !== undefined ||
data.passThreshold !== undefined,
{ {
message: 'Provide at least one field to update' message: 'Provide at least one field to update'
} }
@@ -189,7 +212,7 @@ export const updateQuestionSettingsSchema = z
message: 'Provide at least one settings field' message: 'Provide at least one settings field'
}) })
.describe( .describe(
'Per-type correct-answer storage. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }.' 'Per-type correct-answer storage. TRUE_FALSE: { correctValue: boolean }. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }.'
); );
export const updateOptionSchema = z.object({ export const updateOptionSchema = z.object({
+10 -3
View File
@@ -685,6 +685,7 @@ export function buildAgentTools(
questionTypeId: q.questionTypeId, questionTypeId: q.questionTypeId,
points: q.points, points: q.points,
order: q.order ?? i, order: q.order ?? i,
settings: q.settings,
options: q.options.map((o) => ({ label: o.label, isCorrect: o.isCorrect })) options: q.options.map((o) => ({ label: o.label, isCorrect: o.isCorrect }))
})) }))
}); });
@@ -695,7 +696,7 @@ export function buildAgentTools(
update_exercise: tool({ update_exercise: tool({
description: description:
"Update an existing exercise's metadata (title, description, linked lesson, section, order, due date, lock state, allow-multiple-attempts). Use this for editing the exercise itself — not for changing its questions. To edit questions, use update_questions; to add questions, use add_questions.", 'Update an existing exercise\'s metadata (title, description, linked lesson, section, order, due date, lock state, allow-multiple-attempts, completion policy, passing threshold). Use this for editing the exercise itself — not for changing its questions. To require students to pass before progress/completion, set completionPolicy to "passed" and passThreshold to the required percentage. To edit questions, use update_questions; to add questions, use add_questions.',
inputSchema: updateExerciseParam, inputSchema: updateExerciseParam,
execute: async (args) => { execute: async (args) => {
return executeAgentTool('update_exercise', { orgId, userId, courseId, args }, async () => { return executeAgentTool('update_exercise', { orgId, userId, courseId, args }, async () => {
@@ -717,7 +718,9 @@ export function buildAgentTools(
order: args.order, order: args.order,
dueBy: args.dueBy, dueBy: args.dueBy,
isUnlocked: args.isUnlocked, isUnlocked: args.isUnlocked,
allowMultipleAttempts: args.allowMultipleAttempts allowMultipleAttempts: args.allowMultipleAttempts,
completionPolicy: args.completionPolicy,
passThreshold: args.passThreshold
}); });
return { return {
@@ -725,6 +728,8 @@ export function buildAgentTools(
title: updated.title, title: updated.title,
description: updated.description ?? null, description: updated.description ?? null,
dueBy: updated.dueBy ?? null, dueBy: updated.dueBy ?? null,
completionPolicy: updated.completionPolicy ?? null,
passThreshold: updated.passThreshold ?? null,
updated: true updated: true
}; };
}); });
@@ -812,6 +817,7 @@ export function buildAgentTools(
questionTypeId: q.questionTypeId, questionTypeId: q.questionTypeId,
points: q.points, points: q.points,
order: q.order ?? nextOrder + i, order: q.order ?? nextOrder + i,
settings: q.settings,
...(args.exerciseSectionId !== undefined ? { exerciseSectionId: args.exerciseSectionId } : {}), ...(args.exerciseSectionId !== undefined ? { exerciseSectionId: args.exerciseSectionId } : {}),
options: q.options.map((o) => ({ label: o.label, isCorrect: o.isCorrect })) options: q.options.map((o) => ({ label: o.label, isCorrect: o.isCorrect }))
})); }));
@@ -823,6 +829,7 @@ export function buildAgentTools(
questionTypeId: eq.questionTypeId, questionTypeId: eq.questionTypeId,
points: eq.points, points: eq.points,
order: eq.order, order: eq.order,
settings: eq.settings,
options: eq.options.map((o) => ({ id: Number(o.id), label: o.label || '', isCorrect: o.isCorrect })) options: eq.options.map((o) => ({ id: Number(o.id), label: o.label || '', isCorrect: o.isCorrect }))
})), })),
...newQuestions ...newQuestions
@@ -841,7 +848,7 @@ export function buildAgentTools(
update_questions: tool({ update_questions: tool({
description: description:
'Update existing questions in an exercise. Pass only fields you want to change; `id` identifies the question. Optional `exerciseSectionId` moves the question to another in-exercise block (use get_exercise_details section ids), or null to unassign. For NUMERIC, the correct answer is `settings.correctValue` (number) — do NOT add options to NUMERIC questions. For STAR use `settings.correctValue`. For WORD_BANK use `settings.correctAnswers` and `settings.template`. RADIO/CHECKBOX/TRUE_FALSE use `options[].isCorrect` (include option `id` to edit, omit `id` to add). `settings` is shallow-merged with existing settings. Omit `options` entirely to leave existing options untouched.', 'Update existing questions in an exercise. Pass only fields you want to change; `id` identifies the question. Optional `exerciseSectionId` moves the question to another in-exercise block (use get_exercise_details section ids), or null to unassign. For TRUE_FALSE use `settings.correctValue` (boolean) and keep exactly two options labeled True and False. For NUMERIC, the correct answer is `settings.correctValue` (number) — do NOT add options to NUMERIC questions. For STAR use `settings.correctValue`. For WORD_BANK use `settings.correctAnswers` and `settings.template`. RADIO/CHECKBOX use `options[].isCorrect` (include option `id` to edit, omit `id` to add). `settings` is shallow-merged with existing settings. Omit `options` entirely to leave existing options untouched.',
inputSchema: updateQuestionsParam, inputSchema: updateQuestionsParam,
execute: async (args) => { execute: async (args) => {
return executeAgentTool('update_questions', { orgId, userId, courseId, args }, async () => { return executeAgentTool('update_questions', { orgId, userId, courseId, args }, async () => {
+41 -5
View File
@@ -12,11 +12,19 @@ import {
updateQuestions updateQuestions
} from '@cio/db/queries/exercise'; } 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 { DbOrTxClient } from '@cio/db/drizzle';
import type { TExerciseUpdate } from '@cio/utils/validation/exercise'; import type { TExerciseUpdate } from '@cio/utils/validation/exercise';
type CurrentQuestion = TNewQuestion & { options?: TNewOption[] }; type CurrentQuestion = TNewQuestion & { options?: TNewOption[] };
type CurrentOption = TNewOption; type CurrentOption = TNewOption;
type ExerciseUpdateQuestion = NonNullable<TExerciseUpdate['questions']>[number];
type ExerciseAnswer = Pick< type ExerciseAnswer = Pick<
TQuestionAnswer, TQuestionAnswer,
@@ -68,6 +76,26 @@ function areSettingsEqual(left: unknown, right: unknown): boolean {
return JSON.stringify(leftNormalized) === JSON.stringify(rightNormalized); 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 * Extracts exercise-level fields from the update payload
*/ */
@@ -81,6 +109,8 @@ export function buildExerciseUpdateFields(data: TExerciseUpdate): Partial<TExerc
if (data.isUnlocked !== undefined) exerciseUpdate.isUnlocked = data.isUnlocked; if (data.isUnlocked !== undefined) exerciseUpdate.isUnlocked = data.isUnlocked;
if (data.dueBy !== undefined) exerciseUpdate.dueBy = data.dueBy || null; if (data.dueBy !== undefined) exerciseUpdate.dueBy = data.dueBy || null;
if (data.allowMultipleAttempts !== undefined) exerciseUpdate.allowMultipleAttempts = data.allowMultipleAttempts; if (data.allowMultipleAttempts !== undefined) exerciseUpdate.allowMultipleAttempts = data.allowMultipleAttempts;
if (data.completionPolicy !== undefined) exerciseUpdate.completionPolicy = data.completionPolicy;
if (data.passThreshold !== undefined) exerciseUpdate.passThreshold = data.passThreshold;
if (data.sectionDisplayMode !== undefined) exerciseUpdate.sectionDisplayMode = data.sectionDisplayMode; if (data.sectionDisplayMode !== undefined) exerciseUpdate.sectionDisplayMode = data.sectionDisplayMode;
if (data.slug !== undefined) exerciseUpdate.slug = data.slug; if (data.slug !== undefined) exerciseUpdate.slug = data.slug;
return exerciseUpdate; return exerciseUpdate;
@@ -95,7 +125,9 @@ export function categorizeQuestions(questions: NonNullable<TExerciseUpdate['ques
const newQuestions: typeof questions = []; const newQuestions: typeof questions = [];
const questionUpdates: Array<{ id: number; data: Partial<TNewQuestion> }> = []; const questionUpdates: Array<{ id: number; data: Partial<TNewQuestion> }> = [];
for (const q of questions) { for (const rawQuestion of questions) {
const q = normalizeTrueFalseQuestion(rawQuestion);
if (q.deletedAt && q.id) { if (q.deletedAt && q.id) {
deletedIds.push(q.id); deletedIds.push(q.id);
} else if (q.id) { } else if (q.id) {
@@ -230,13 +262,16 @@ export function computeExerciseDiff(
incomingQuestions: NonNullable<TExerciseUpdate['questions']> incomingQuestions: NonNullable<TExerciseUpdate['questions']>
) { ) {
const questionMap = new Map(currentQuestions.map((q) => [q.id, q])); 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 deletedQuestionIds: number[] = [];
const questionUpdates: Array<{ id: number; data: Partial<TNewQuestion> }> = []; const questionUpdates: Array<{ id: number; data: Partial<TNewQuestion> }> = [];
const newQuestions: NonNullable<TExerciseUpdate['questions']> = []; const newQuestions: NonNullable<TExerciseUpdate['questions']> = [];
// Process incoming questions // Process incoming questions
for (const incoming of incomingQuestions) { for (const incoming of normalizedIncomingQuestions) {
if (incoming.deletedAt && incoming.id) { if (incoming.deletedAt && incoming.id) {
deletedQuestionIds.push(incoming.id); deletedQuestionIds.push(incoming.id);
} else if (incoming.id) { } else if (incoming.id) {
@@ -260,7 +295,7 @@ export function computeExerciseDiff(
const optionUpdates: Array<{ id: number; data: Partial<TNewOption> }> = []; const optionUpdates: Array<{ id: number; data: Partial<TNewOption> }> = [];
const optionCreates: TNewOption[] = []; const optionCreates: TNewOption[] = [];
for (const incoming of incomingQuestions) { for (const incoming of normalizedIncomingQuestions) {
if (!incoming.id || incoming.deletedAt) continue; if (!incoming.id || incoming.deletedAt) continue;
const current = questionMap.get(incoming.id); const current = questionMap.get(incoming.id);
@@ -336,8 +371,9 @@ export async function createNewQuestionsWithOptions(
newQuestions: NonNullable<TExerciseUpdate['questions']>, newQuestions: NonNullable<TExerciseUpdate['questions']>,
txClient: DbOrTxClient txClient: DbOrTxClient
): Promise<void> { ): Promise<void> {
const normalizedQuestions = newQuestions.map((question) => normalizeTrueFalseQuestion(question));
const createdQuestions = await createQuestions( const createdQuestions = await createQuestions(
newQuestions.map((q) => ({ normalizedQuestions.map((q) => ({
exerciseId, exerciseId,
title: q.question, title: q.question,
questionTypeId: q.questionTypeId || 1, questionTypeId: q.questionTypeId || 1,
@@ -351,7 +387,7 @@ export async function createNewQuestionsWithOptions(
const newOptions: TNewOption[] = []; const newOptions: TNewOption[] = [];
createdQuestions.forEach((newQuestion, index) => { createdQuestions.forEach((newQuestion, index) => {
const questionData = newQuestions[index]; const questionData = normalizedQuestions[index];
if (newQuestion && questionData?.options && questionData.options.length > 0) { if (newQuestion && questionData?.options && questionData.options.length > 0) {
// Only include non-deleted options for new questions // Only include non-deleted options for new questions
for (const opt of questionData.options) { for (const opt of questionData.options) {
+2 -1
View File
@@ -46,7 +46,8 @@ export async function createLesson(courseId: string, data: TLessonCreate): Promi
const lessonData = { const lessonData = {
...data, ...data,
courseId, courseId,
slug slug,
isUnlocked: data.isUnlocked ?? true
}; };
const [lesson] = await createLessons([lessonData]); const [lesson] = await createLessons([lessonData]);
+1
View File
@@ -50,6 +50,7 @@
"dist" "dist"
], ],
"scripts": { "scripts": {
"prebuild": "pnpm --filter @cio/question-types run build && pnpm --filter @cio/utils run build && pnpm --filter @cio/email run build",
"build": "rimraf dist tsconfig.tsbuildinfo && tsc && tsc-alias", "build": "rimraf dist tsconfig.tsbuildinfo && tsc && tsc-alias",
"dev": "tsc --watch", "dev": "tsc --watch",
"db": "drizzle-kit", "db": "drizzle-kit",
+20 -1
View File
@@ -1,7 +1,7 @@
import * as schema from '@db/schema'; import * as schema from '@db/schema';
import { TGroupmember, TNewGroupmember } from '@db/types'; import { TGroupmember, TNewGroupmember } from '@db/types';
import { and, eq, or } from 'drizzle-orm'; import { and, eq, isNull, or } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants'; import { ROLE } from '@cio/utils/constants';
import { db } from '@db/drizzle'; import { db } from '@db/drizzle';
@@ -325,6 +325,25 @@ export async function setMemberCertificateEarned(memberId: string, earnedAt: str
} }
} }
/**
* Atomically claims the certificate for a member: only succeeds if it was not earned yet.
* Returns true when this caller won the claim, so concurrent evaluations issue the
* certificate (and downstream email) exactly once.
*/
export async function claimMemberCertificateEarned(memberId: string, earnedAt: string): Promise<boolean> {
try {
const updated = await db
.update(schema.groupmember)
.set({ certificateEarnedAt: earnedAt })
.where(and(eq(schema.groupmember.id, memberId), isNull(schema.groupmember.certificateEarnedAt)))
.returning({ id: schema.groupmember.id });
return updated.length > 0;
} catch (error) {
console.error('claimMemberCertificateEarned error:', error);
throw new Error(`Failed to claim certificate earned: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/** /**
* Records that the certification congratulations email was sent. * Records that the certification congratulations email was sent.
*/ */
+1 -1
View File
@@ -996,7 +996,7 @@ export const lesson = pgTable(
callUrl: text('call_url'), callUrl: text('call_url'),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations // You can use { mode: "bigint" } if numbers are exceeding js number limitations
order: bigint({ mode: 'number' }), order: bigint({ mode: 'number' }),
isUnlocked: boolean('is_unlocked').default(false), isUnlocked: boolean('is_unlocked').default(true),
completionPolicy: varchar('completion_policy').default('manual').notNull(), completionPolicy: varchar('completion_policy').default('manual').notNull(),
videoWatchThreshold: integer('video_watch_threshold').default(95), videoWatchThreshold: integer('video_watch_threshold').default(95),
videos: jsonb().default([]).$type< videos: jsonb().default([]).$type<
+1 -1
View File
@@ -8,7 +8,7 @@
"dist" "dist"
], ],
"scripts": { "scripts": {
"build": "tsc && tsc-alias", "build": "rimraf dist tsconfig.tsbuildinfo && tsc && tsc-alias",
"dev": "tsc --watch", "dev": "tsc --watch",
"format": "prettier --write \"src/**/*.ts\"", "format": "prettier --write \"src/**/*.ts\"",
"test": "vitest run", "test": "vitest run",
+1
View File
@@ -18,3 +18,4 @@ export * from './renderer-registry';
export * from './textarea-validation'; export * from './textarea-validation';
export * from './star-rating-settings'; export * from './star-rating-settings';
export * from './video-recording-settings'; export * from './video-recording-settings';
export * from './true-false-answer-key';
@@ -5,6 +5,7 @@ import { getQuestionTypeById } from './question-type-lookup';
import { QUESTION_TYPE_ID_TO_KEY } from './question-type-registry'; import { QUESTION_TYPE_ID_TO_KEY } from './question-type-registry';
import { supportsPartialCredit } from './question-type-capabilities'; import { supportsPartialCredit } from './question-type-capabilities';
import { getStarRatingMaxFromSettings, isValidStarRatingValue } from './star-rating-settings'; import { getStarRatingMaxFromSettings, isValidStarRatingValue } from './star-rating-settings';
import { resolveTrueFalseCorrectValue } from './true-false-answer-key';
export type DbQuestionForScoring = { export type DbQuestionForScoring = {
id: number; id: number;
@@ -112,20 +113,7 @@ function scoreTrueFalse(
answer: Extract<AnswerData, { type: 'TRUE_FALSE' }>, answer: Extract<AnswerData, { type: 'TRUE_FALSE' }>,
maxPoints: number maxPoints: number
): number { ): number {
const opts = question.options ?? []; const correctIsTrue = resolveTrueFalseCorrectValue(question.settings, question.options);
const trueOpt = opts.find((o) => normalizeText(o.label) === 'true');
const falseOpt = opts.find((o) => normalizeText(o.label) === 'false');
const correctFromSettings = question.settings?.correctValue;
let correctIsTrue: boolean;
if (typeof correctFromSettings === 'boolean') {
correctIsTrue = correctFromSettings;
} else if (trueOpt?.isCorrect) {
correctIsTrue = true;
} else if (falseOpt?.isCorrect) {
correctIsTrue = false;
} else {
correctIsTrue = true;
}
return answer.value === correctIsTrue ? maxPoints : 0; return answer.value === correctIsTrue ? maxPoints : 0;
} }
@@ -0,0 +1,59 @@
export type TrueFalseAnswerOption = {
label?: string | null;
value?: string | number | boolean | null;
isCorrect?: boolean;
deletedAt?: unknown;
};
function normalizeBooleanText(value: unknown): string {
return String(value ?? '')
.trim()
.toLowerCase();
}
export function getTrueFalseOptionValue(option: TrueFalseAnswerOption): boolean | undefined {
const normalizedValue = normalizeBooleanText(option.value);
if (normalizedValue === 'true' || normalizedValue === '1') return true;
if (normalizedValue === 'false' || normalizedValue === '0') return false;
const normalizedLabel = normalizeBooleanText(option.label);
if (normalizedLabel === 'true' || normalizedLabel === '1') return true;
if (normalizedLabel === 'false' || normalizedLabel === '0') return false;
return undefined;
}
export function resolveTrueFalseCorrectValue(
settings: Record<string, unknown> | undefined,
options: TrueFalseAnswerOption[] | undefined,
fallback = true
): boolean {
if (typeof settings?.correctValue === 'boolean') return settings.correctValue;
const correctOption = options?.find((option) => !option.deletedAt && option.isCorrect);
const correctValue = correctOption ? getTrueFalseOptionValue(correctOption) : undefined;
return typeof correctValue === 'boolean' ? correctValue : fallback;
}
export function syncTrueFalseOptions<T extends TrueFalseAnswerOption>(
options: T[] | undefined,
correctValue: boolean
): T[] {
return (options ?? []).map((option) => {
const optionValue = getTrueFalseOptionValue(option);
if (typeof optionValue !== 'boolean') return option;
return { ...option, isCorrect: optionValue === correctValue };
});
}
export function getCanonicalTrueFalseSettings(
settings: Record<string, unknown> | undefined,
options: TrueFalseAnswerOption[] | undefined
): Record<string, unknown> {
return {
...(settings ?? {}),
correctValue: resolveTrueFalseCorrectValue(settings, options)
};
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { resolveTrueFalseCorrectValue, syncTrueFalseOptions } from '../src';
describe('true/false answer key helpers', () => {
it('uses settings.correctValue as the canonical answer when present', () => {
const options = [
{ label: 'True', value: 'true', isCorrect: true },
{ label: 'False', value: 'false', isCorrect: false }
];
expect(resolveTrueFalseCorrectValue({ correctValue: false }, options)).toBe(false);
});
it('falls back to the correct option label for legacy questions', () => {
const options = [
{ label: 'True', value: 'fb6c9fa8-4fd9-4e71-b319-3e969656a935', isCorrect: false },
{ label: 'False', value: '2e191a1e-4aca-4bef-afae-dbd076d0b60b', isCorrect: true }
];
expect(resolveTrueFalseCorrectValue({}, options)).toBe(false);
});
it('syncs option correctness from the canonical boolean', () => {
const options = [
{ label: 'True', value: 'true', isCorrect: true },
{ label: 'False', value: 'false', isCorrect: false }
];
expect(syncTrueFalseOptions(options, false)).toEqual([
{ label: 'True', value: 'true', isCorrect: false },
{ label: 'False', value: 'false', isCorrect: true }
]);
});
});
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import * as Chart from '../../../../base/chart'; import type * as Chart from '../../../../base/chart';
import type { ExerciseQuestionRendererProps } from '@cio/question-types'; import type { ExerciseQuestionRendererProps } from '@cio/question-types';
import SubmissionResponseBarChart from '../shared/submission-response-bar-chart.svelte';
import { getAnswerForQuestion, getSubmissionLabel, normalizeLabel, withChartColors } from '../submission-utils'; import { getAnswerForQuestion, getSubmissionLabel, normalizeLabel, withChartColors } from '../submission-utils';
let { let {
@@ -75,19 +76,9 @@
}); });
</script> </script>
{#if chartData.length > 0} <SubmissionResponseBarChart
<Chart.Container config={chartConfig} class="ui:h-[280px] ui:w-full"> {chartData}
<Chart.BarChart {chartConfig}
data={chartData}
xScale={Chart.scaleBand().padding(0.25)}
x="label"
axis="x"
tooltip={false}
{series} {series}
emptyLabel={getSubmissionLabel(labels, 'submission.chart.no_data', 'No responses yet')}
/> />
</Chart.Container>
{:else}
<p class="ui:text-muted-foreground ui:text-sm">
{getSubmissionLabel(labels, 'submission.chart.no_data', 'No responses yet')}
</p>
{/if}
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import * as Chart from '../../../../base/chart'; import type * as Chart from '../../../../base/chart';
import type { ExerciseQuestionRendererProps } from '@cio/question-types'; import type { ExerciseQuestionRendererProps } from '@cio/question-types';
import SubmissionResponseBarChart from '../shared/submission-response-bar-chart.svelte';
import { getAnswerForQuestion, getSubmissionLabel, normalizeLabel, withChartColors } from '../submission-utils'; import { getAnswerForQuestion, getSubmissionLabel, normalizeLabel, withChartColors } from '../submission-utils';
let { let {
@@ -91,19 +92,9 @@
}); });
</script> </script>
{#if chartData.length > 0} <SubmissionResponseBarChart
<Chart.Container config={chartConfig} class="ui:h-[280px] ui:w-full"> {chartData}
<Chart.BarChart {chartConfig}
data={chartData}
xScale={Chart.scaleBand().padding(0.25)}
x="label"
axis="x"
tooltip={false}
{series} {series}
emptyLabel={getSubmissionLabel(labels, 'submission.chart.no_data', 'No responses yet')}
/> />
</Chart.Container>
{:else}
<p class="ui:text-muted-foreground ui:text-sm">
{getSubmissionLabel(labels, 'submission.chart.no_data', 'No responses yet')}
</p>
{/if}
@@ -0,0 +1,48 @@
<script lang="ts">
import * as Chart from '../../../../base/chart';
import type { ChartConfig } from '../../../../base/chart';
interface Row {
key: string;
label: string;
responses: number;
color?: string;
}
interface Props {
chartData: Row[];
chartConfig: ChartConfig;
emptyLabel: string;
series: Array<{ key: string; value: string; label: string; color: string }>;
}
let { chartData, chartConfig, emptyLabel, series }: Props = $props();
const ROW_HEIGHT_PX = 48;
const MIN_CHART_HEIGHT_PX = 120;
const AXIS_LABEL_MAX_LENGTH = 28;
const chartHeightPx = $derived(Math.max(MIN_CHART_HEIGHT_PX, chartData.length * ROW_HEIGHT_PX));
function truncateAxisLabel(label: string): string {
if (label.length <= AXIS_LABEL_MAX_LENGTH) return label;
return `${label.slice(0, AXIS_LABEL_MAX_LENGTH - 1).trimEnd()}…`;
}
</script>
{#if chartData.length > 0}
<Chart.Container config={chartConfig} class="ui:aspect-auto ui:w-full" style="height: {chartHeightPx}px">
<Chart.BarChart
data={chartData}
orientation="horizontal"
y="label"
axis="y"
bandPadding={0.25}
props={{ yAxis: { format: truncateAxisLabel } }}
{series}
/>
</Chart.Container>
{:else}
<p class="ui:text-muted-foreground ui:text-sm">{emptyLabel}</p>
{/if}
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { import {
getExerciseQuestionLabel, getExerciseQuestionLabel,
resolveTrueFalseCorrectValue,
syncTrueFalseOptions,
type ExerciseQuestionModel, type ExerciseQuestionModel,
type ExerciseQuestionRendererProps type ExerciseQuestionRendererProps
} from '@cio/question-types'; } from '@cio/question-types';
@@ -18,14 +20,7 @@
if (value !== 'true' && value !== 'false') return; if (value !== 'true' && value !== 'false') return;
const correctValue = value === 'true'; const correctValue = value === 'true';
const opts = question.options ?? []; const options = syncTrueFalseOptions(question.options, correctValue);
const options = opts.map((opt) => {
const v = String(opt.value ?? opt.label ?? '').toLowerCase();
const isTrue = v === 'true' || v === '1';
const isFalse = v === 'false' || v === '0';
const isCorrect = (isTrue && correctValue) || (isFalse && !correctValue);
return isTrue || isFalse ? { ...opt, isCorrect } : opt;
});
patchQuestion({ patchQuestion({
settings: { ...(question.settings ?? {}), correctValue }, settings: { ...(question.settings ?? {}), correctValue },
@@ -40,13 +35,9 @@
} }
const selectedValue = $derived.by(() => { const selectedValue = $derived.by(() => {
const correctOpt = (question.options ?? []).find((o) => o.isCorrect); return String(
if (correctOpt) { resolveTrueFalseCorrectValue(question.settings as Record<string, unknown> | undefined, question.options)
const v = String(correctOpt.value ?? correctOpt.label).toLowerCase(); );
return v === 'false' || v === '0' ? 'false' : 'true';
}
const fromSettings = question.settings?.correctValue as boolean | undefined;
return typeof fromSettings === 'boolean' ? String(fromSettings) : 'true';
}); });
</script> </script>
@@ -7,7 +7,7 @@
getExerciseQuestionLabel(labels, key, fallback); getExerciseQuestionLabel(labels, key, fallback);
const correctValue = $derived( const correctValue = $derived(
getTrueFalseCorrectIsTrue(question.settings as Record<string, unknown> | undefined) getTrueFalseCorrectIsTrue(question.settings as Record<string, unknown> | undefined, question.options)
? label('true_false.true_label') ? label('true_false.true_label')
: label('true_false.false_label') : label('true_false.false_label')
); );
@@ -5,7 +5,9 @@
let { question, answer = null, labels }: ExerciseQuestionRendererProps = $props(); let { question, answer = null, labels }: ExerciseQuestionRendererProps = $props();
const correctIsTrue = $derived(getTrueFalseCorrectIsTrue(question.settings as Record<string, unknown> | undefined)); const correctIsTrue = $derived(
getTrueFalseCorrectIsTrue(question.settings as Record<string, unknown> | undefined, question.options)
);
const studentValue = $derived.by(() => { const studentValue = $derived.by(() => {
if (answer?.type !== 'TRUE_FALSE') return ''; if (answer?.type !== 'TRUE_FALSE') return '';
@@ -1,6 +1,11 @@
import { resolveTrueFalseCorrectValue, type ExerciseQuestionOption } from '@cio/question-types';
/** /**
* Matches preview/review: undefined `correctValue` defaults to true. * Matches backend scoring: settings.correctValue wins, then option correctness, then true.
*/ */
export function getTrueFalseCorrectIsTrue(settings: Record<string, unknown> | undefined): boolean { export function getTrueFalseCorrectIsTrue(
return (settings?.correctValue as boolean | undefined) !== false; settings: Record<string, unknown> | undefined,
options: ExerciseQuestionOption[] | undefined
): boolean {
return resolveTrueFalseCorrectValue(settings, options);
} }
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import * as Chart from '../../../../base/chart'; import type * as Chart from '../../../../base/chart';
import type { ExerciseQuestionRendererProps } from '@cio/question-types'; import type { ExerciseQuestionRendererProps } from '@cio/question-types';
import SubmissionResponseBarChart from '../shared/submission-response-bar-chart.svelte';
import { getAnswerForQuestion, getSubmissionLabel, normalizeLabel, withChartColors } from '../submission-utils'; import { getAnswerForQuestion, getSubmissionLabel, normalizeLabel, withChartColors } from '../submission-utils';
let { let {
@@ -94,19 +95,9 @@
}); });
</script> </script>
{#if chartData.length > 0} <SubmissionResponseBarChart
<Chart.Container config={chartConfig} class="ui:h-[280px] ui:w-full"> {chartData}
<Chart.BarChart {chartConfig}
data={chartData}
xScale={Chart.scaleBand().padding(0.25)}
x="label"
axis="x"
tooltip={false}
{series} {series}
emptyLabel={getSubmissionLabel(labels, 'submission.chart.no_data', 'No responses yet')}
/> />
</Chart.Container>
{:else}
<p class="ui:text-muted-foreground ui:text-sm">
{getSubmissionLabel(labels, 'submission.chart.no_data', 'No responses yet')}
</p>
{/if}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -71,7 +71,7 @@
} }
}, },
"scripts": { "scripts": {
"build": "tsc && tsc-alias", "build": "rimraf dist tsconfig.tsbuildinfo && tsc && tsc-alias",
"dev": "tsc --watch", "dev": "tsc --watch",
"format": "prettier --write ." "format": "prettier --write ."
}, },
@@ -46,6 +46,10 @@ const QUESTION_VALIDATION_RULES: Record<number, Array<(question: QuestionRuleInp
return null; return null;
}, },
(question) => { (question) => {
if (typeof question.settings?.correctValue === 'boolean') {
return null;
}
const options = question.options || []; const options = question.options || [];
if (options.length > 0 && !options.some((opt) => opt.isCorrect === true)) { if (options.length > 0 && !options.some((opt) => opt.isCorrect === true)) {
return 'Please mark an option as the correct answer'; return 'Please mark an option as the correct answer';