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,
type TCourseCertificationRow
} from '@cio/db/queries/course/course';
import { claimMemberCertificateEarned } from '@cio/db/queries/course/people';
import { getActiveOrganizationPlan } from '@cio/db/queries/organization';
import { ROLE } from '@cio/utils/constants';
import { PLAN } from '@cio/utils/plans';
import { env } from '@cio/core/config/env';
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
export type CertificationBlocker = {
@@ -230,8 +234,14 @@ export async function evaluateCourseCertification(
let isNewCompletion = false;
if (evaluation.eligibleForCertificate && !certificateEarnedAt && progress.groupMemberId) {
const certificatesEnabled = await orgHasCertificatesEnabled(courseRow.orgId);
if (certificatesEnabled) {
const earnedAt = new Date().toISOString();
const didClaimCertificate = await claimMemberCertificateEarned(progress.groupMemberId, earnedAt);
certificateEarnedAt = earnedAt;
if (didClaimCertificate) {
isNewCompletion = true;
scheduleCertificationCompletionWork({
@@ -258,10 +268,24 @@ export async function evaluateCourseCertification(
});
}
}
}
}
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).
*/
+41 -5
View File
@@ -12,11 +12,19 @@ import {
updateQuestions
} from '@cio/db/queries/exercise';
import {
QUESTION_TYPE_IDS,
QUESTION_TYPE_KEY,
resolveTrueFalseCorrectValue,
syncTrueFalseOptions,
type TrueFalseAnswerOption
} from '@cio/question-types';
import type { DbOrTxClient } from '@cio/db/drizzle';
import type { TExerciseUpdate } from '@cio/utils/validation/exercise';
type CurrentQuestion = TNewQuestion & { options?: TNewOption[] };
type CurrentOption = TNewOption;
type ExerciseUpdateQuestion = NonNullable<TExerciseUpdate['questions']>[number];
type ExerciseAnswer = Pick<
TQuestionAnswer,
@@ -68,6 +76,26 @@ function areSettingsEqual(left: unknown, right: unknown): boolean {
return JSON.stringify(leftNormalized) === JSON.stringify(rightNormalized);
}
const TRUE_FALSE_QUESTION_TYPE_ID = QUESTION_TYPE_IDS[QUESTION_TYPE_KEY.TRUE_FALSE];
function normalizeTrueFalseQuestion(
question: ExerciseUpdateQuestion,
currentQuestion?: CurrentQuestion
): ExerciseUpdateQuestion {
const questionTypeId = question.questionTypeId ?? currentQuestion?.questionTypeId;
if (questionTypeId !== TRUE_FALSE_QUESTION_TYPE_ID) return question;
const settings = normalizeSettings(question.settings ?? currentQuestion?.settings);
const options = question.options ?? currentQuestion?.options ?? [];
const correctValue = resolveTrueFalseCorrectValue(settings, options as TrueFalseAnswerOption[]);
return {
...question,
settings: { ...settings, correctValue },
options: question.options ? syncTrueFalseOptions(question.options, correctValue) : question.options
};
}
/**
* Extracts exercise-level fields from the update payload
*/
@@ -81,6 +109,8 @@ export function buildExerciseUpdateFields(data: TExerciseUpdate): Partial<TExerc
if (data.isUnlocked !== undefined) exerciseUpdate.isUnlocked = data.isUnlocked;
if (data.dueBy !== undefined) exerciseUpdate.dueBy = data.dueBy || null;
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.slug !== undefined) exerciseUpdate.slug = data.slug;
return exerciseUpdate;
@@ -95,7 +125,9 @@ export function categorizeQuestions(questions: NonNullable<TExerciseUpdate['ques
const newQuestions: typeof questions = [];
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) {
deletedIds.push(q.id);
} else if (q.id) {
@@ -230,13 +262,16 @@ export function computeExerciseDiff(
incomingQuestions: NonNullable<TExerciseUpdate['questions']>
) {
const questionMap = new Map(currentQuestions.map((q) => [q.id, q]));
const normalizedIncomingQuestions = incomingQuestions.map((incoming) =>
normalizeTrueFalseQuestion(incoming, incoming.id ? questionMap.get(incoming.id) : undefined)
);
const deletedQuestionIds: number[] = [];
const questionUpdates: Array<{ id: number; data: Partial<TNewQuestion> }> = [];
const newQuestions: NonNullable<TExerciseUpdate['questions']> = [];
// Process incoming questions
for (const incoming of incomingQuestions) {
for (const incoming of normalizedIncomingQuestions) {
if (incoming.deletedAt && incoming.id) {
deletedQuestionIds.push(incoming.id);
} else if (incoming.id) {
@@ -260,7 +295,7 @@ export function computeExerciseDiff(
const optionUpdates: Array<{ id: number; data: Partial<TNewOption> }> = [];
const optionCreates: TNewOption[] = [];
for (const incoming of incomingQuestions) {
for (const incoming of normalizedIncomingQuestions) {
if (!incoming.id || incoming.deletedAt) continue;
const current = questionMap.get(incoming.id);
@@ -336,8 +371,9 @@ export async function createNewQuestionsWithOptions(
newQuestions: NonNullable<TExerciseUpdate['questions']>,
txClient: DbOrTxClient
): Promise<void> {
const normalizedQuestions = newQuestions.map((question) => normalizeTrueFalseQuestion(question));
const createdQuestions = await createQuestions(
newQuestions.map((q) => ({
normalizedQuestions.map((q) => ({
exerciseId,
title: q.question,
questionTypeId: q.questionTypeId || 1,
@@ -351,7 +387,7 @@ export async function createNewQuestionsWithOptions(
const newOptions: TNewOption[] = [];
createdQuestions.forEach((newQuestion, index) => {
const questionData = newQuestions[index];
const questionData = normalizedQuestions[index];
if (newQuestion && questionData?.options && questionData.options.length > 0) {
// Only include non-deleted options for new questions
for (const opt of questionData.options) {
@@ -11,6 +11,7 @@
import { courseCompletionModal, closeCourseCompletionModal } from '$features/course/store/course-completion-modal';
import { getContentRoute } from '$features/course/utils/content';
import { formatBlockerMessage } from '$features/course/utils/certificate-utils';
import { isFreePlan } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
let open = $state(false);
@@ -84,11 +85,15 @@
{:else if activeStep === 'eligible'}
<Dialog.Header>
<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.Footer class="ui:gap-2">
<Button variant="outline" onclick={() => onOpenChange(false)}>{$t('course.completion.modal.later')}</Button>
{#if !$isFreePlan}
<Button onclick={goToCertificate}>{$t('course.completion.modal.view_certificate')}</Button>
{/if}
</Dialog.Footer>
{:else if activeStep === 'not-eligible'}
<Dialog.Header>
@@ -1,6 +1,7 @@
<script lang="ts">
import TrashIcon from '@lucide/svelte/icons/trash';
import GripVerticalIcon from '@lucide/svelte/icons/grip-vertical';
import InfoIcon from '@lucide/svelte/icons/info';
import { dndzone } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import { tick } from 'svelte';
@@ -24,6 +25,8 @@
} from './store';
import { QUESTION_TYPES } from '$features/ui/question/constants';
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 QuestionTypeSelect from './question-type-select.svelte';
@@ -35,11 +38,17 @@
import { uploadImage } from '$lib/utils/services/upload';
import {
getExerciseEditorQuestionTypeLabel,
getQuestionTypeKey,
getQuestionTypeOptionById,
questionTypeSupportsOptions,
toExerciseQuestionModel
} 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 SectionEditor from './section-editor.svelte';
import type { Question } from '$features/course/types';
@@ -207,6 +216,7 @@
if (nextQuestionTypeKey === QUESTION_TYPE_KEY.TRUE_FALSE) {
const correctValue = (current.settings as { correctValue?: boolean })?.correctValue ?? true;
nextSettings = { ...nextSettings, correctValue };
nextOptions = makeTrueFalseOptions(correctValue);
} else if (questionTypeSupportsOptions(nextQuestionTypeKey)) {
const hasActiveOptions = nextOptions.some((option) => !option.deletedAt);
@@ -263,12 +273,21 @@
})
: (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];
next[idx] = {
...current,
title: nextQuestion.title,
settings: nextQuestion.settings ?? {},
options: mappedOptions,
settings: nextSettings,
options: nextOptions,
isDirty: true
};
@@ -331,6 +350,13 @@
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(() => {
if (reorderQuestions && !previousReorderQuestions) {
syncReorderQuestionItems();
@@ -357,6 +383,33 @@
<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)}
<QuestionContainer
elementId={getQuestionElementId(question.id)}
@@ -366,7 +419,7 @@
bind:points={question.points}
hasError={!!errors[question.id]}
errorMsg={getQuestionErrorMsg(errors, question, 'points')}
pointsHint={requiresPositivePointsForAutoGrade && Number(question.points) === 0
pointsHint={requiresPositivePointsForAutoGrade
? $t('course.navItem.lessons.exercises.all_exercises.points_required_auto_grade')
: null}
onPointsChange={() => {
@@ -386,6 +439,8 @@
</div>
{/if}
{@render gradingBadge(question)}
<div class="mt-2 flex flex-col">
<ExerciseQuestion.QuestionRenderer
showContainer={false}
@@ -13,9 +13,11 @@
type Props = {
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 slug = $state($questionnaire.slug ?? '');
@@ -28,13 +30,21 @@
async function saveSettings() {
if (!courseApi.course?.id) return;
const canSave = await onBeforeSave();
if (!canSave) return;
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, {
allowMultipleAttempts: !!$questionnaire.allowMultipleAttempts,
completionPolicy: $questionnaire.completionPolicy ?? 'submitted',
passThreshold: $questionnaire.passThreshold ?? 100,
slug: isPublicCourse && slug ? slug : undefined
allowMultipleAttempts,
completionPolicy,
passThreshold,
slug: slugPayload
});
saving = false;
@@ -92,7 +102,25 @@
</Select.Root>
{#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
type="number"
label={$t('course.navItem.lessons.exercises.all_exercises.settings_pass_threshold')}
@@ -1,6 +1,6 @@
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 { QuestionnaireMetaData } from './store';
import type { TExerciseUpdate } from '@cio/utils/validation/exercise';
@@ -153,15 +153,37 @@ export function transformQuestionsToApiFormat(
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),
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: getQuestionSettings(q),
options: questionTypeSupportsOptions(getQuestionTypeKey(q)) ? formatOptions(q.options) : [],
settings: { ...(settings ?? {}), correctValue },
options: syncTrueFalseOptions(formattedOptions, correctValue),
...(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 { ExerciseQuestion } from '@cio/ui';
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 { t } from '$lib/utils/functions/translations';
import type { ExerciseSubmissions } from './types';
import type { Question } from '$features/course/types';
import type { AnswerData } from '@cio/question-types';
import { toExerciseQuestionModel } from '../question-type-utils';
import { getExerciseQuestionLabels } from '../question-labels';
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 {
isLoading?: boolean;
submissionGroups?: ExerciseSubmissionStudentGroup[];
}
let { isLoading = $bindable(false) }: Props = $props();
let { isLoading = $bindable(false), submissionGroups = [] }: Props = $props();
let studentSelected = $state(0);
let selectedAttemptByStudentKey = $state<Record<string, number>>({});
const questionLabels = $derived(getExerciseQuestionLabels());
const sectionFallbackTitle = $derived($t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'));
const activeSections = $derived(
@@ -32,13 +39,13 @@
return Number.isNaN(numericId) ? null : numericId;
}
function getStudentAnswerForQuestion(student: ExerciseSubmissions, question: Question): AnswerData | undefined {
if (!student?.answers) return undefined;
function getStudentAnswerForQuestion(submission: SubmissionListItem, question: Question): AnswerData | undefined {
if (!submission?.answers) return undefined;
const questionId = toNumericQuestionId(question);
if (questionId === null) return undefined;
const submittedAnswer = student.answers.find((answer) => answer.questionId === questionId);
const submittedAnswer = submission.answers.find((answer) => answer.questionId === questionId);
if (
!submittedAnswer?.answerData ||
typeof submittedAnswer.answerData !== 'object' ||
@@ -49,12 +56,12 @@
return submittedAnswer.answerData;
}
function getSectionScore(student: ExerciseSubmissions, questions: Question[]) {
function getSectionScore(submission: SubmissionListItem, questions: Question[]) {
return questions.reduce((total, question) => {
const questionId = toNumericQuestionId(question);
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);
}, 0);
}
@@ -62,13 +69,83 @@
function getSectionMaxPoints(questions: Question[]) {
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>
{#if isLoading}
<Spinner />
{:else if $submissions?.length}
{:else if submissionGroups?.length}
<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">
<div
class={`flex h-12 w-12 items-center justify-center rounded-full ${
@@ -76,24 +153,83 @@
}`}
>
<UserAvatar
src={student.groupmember?.profile.avatarUrl}
src={studentGroup.studentAvatarUrl}
alt={$t('course.navItem.lessons.exercises.all_exercises.analytics.individual.student_avatar')}
class="m-1 size-10"
/>
</div>
<p class="line-clamp-2 w-20 leading-4 whitespace-pre-wrap">
{student.groupmember?.profile.fullname ?? ''}
{studentGroup.studentName}
</p>
</button>
{/each}
</div>
<p class="mb-2 font-medium">
{$submissions[studentSelected].groupmember?.profile.fullname ?? ''}'s {$t(
'course.navItem.lessons.exercises.all_exercises.analytics.individual.answers'
)}
{@const selectedGroup = getSelectedGroup()}
{#if selectedGroup}
{@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>
{#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}
<div class="space-y-8">
{#each activeSections as section, sectionIndex (section.id)}
@@ -124,7 +260,7 @@
contract={{
mode: 'review',
question: toExerciseQuestionModel(q),
answer: getStudentAnswerForQuestion($submissions[studentSelected], q),
answer: getStudentAnswerForQuestion(selectedSubmission, q),
labels: questionLabels,
disabled: true
}}
@@ -135,7 +271,7 @@
{/each}
<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')}
</p>
</section>
@@ -148,7 +284,7 @@
contract={{
mode: 'review',
question: toExerciseQuestionModel(q),
answer: getStudentAnswerForQuestion($submissions[studentSelected], q),
answer: getStudentAnswerForQuestion(selectedSubmission, q),
labels: questionLabels,
disabled: true
}}
@@ -8,6 +8,7 @@
import { t } from '$lib/utils/functions/translations';
import type { ExerciseSubmissions } from './types';
import type { SubmissionListItem } from '$features/course/utils/types';
import { groupSubmissionsByStudentAndAttempt } from '$features/course/utils/exercise-progression-utils';
interface Props {
exerciseId: string;
@@ -18,6 +19,7 @@
type SubmissionTab = 'summary' | 'individual';
let currentTab = $state<SubmissionTab>('summary');
const submissionGroups = $derived(groupSubmissionsByStudentAndAttempt(submissionsData));
function normalizeSubmissions(items: SubmissionListItem[]): ExerciseSubmissions[] {
return items.map(
@@ -66,7 +68,7 @@
</Tabs.Content>
<Tabs.Content value="individual" class="pt-2">
<Individual isLoading={false} />
<Individual isLoading={false} {submissionGroups} />
</Tabs.Content>
</Tabs.Root>
{:else}
@@ -40,9 +40,9 @@
(): ExerciseQuestionLabels => ({
'submission.common.no_answer': $t('course.navItem.lessons.exercises.all_exercises.analytics.individual.no'),
'submission.common.other': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.other'),
'submission.chart.responses': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.responses'),
'submission.chart.responses': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.attempts'),
'submission.chart.no_data': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.no_responses'),
'submission.list.responses': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.responses'),
'submission.list.responses': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.attempts'),
'submission.list.no_responses': $t(
'course.navItem.lessons.exercises.all_exercises.analytics.summary.no_responses'
)
@@ -49,6 +49,7 @@
import { toExerciseQuestionModel } from './question-type-utils';
import { getExerciseQuestionLabels } from './question-labels';
import { getExerciseSectionDisplayTitle } from '$features/course/utils/exercise-section-utils';
import { shouldCompleteExerciseFromSubmission } from '$features/course/utils/exercise-progression-utils';
import axios from 'axios';
import { IconButton } from '@cio/ui/custom/icon-button';
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
@@ -80,7 +81,19 @@
const questionLabels = $derived(getExerciseQuestionLabels());
const sectionFallbackTitle = $derived($t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'));
const submissionList = $derived(Array.isArray(mySubmissions) ? mySubmissions : []);
/** Submissions created in this session — the `mySubmissions` prop only refreshes on page load. */
let localSubmissions = $state<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() {
questionnaireMetaData.update((m) => ({
@@ -137,6 +150,8 @@
if (exerciseApi.success && submitResult?.data) {
skipHydrateFromSubmissions = false;
const sub = submitResult.data as unknown as SubmissionListItem & { gradingState?: string };
localSubmissions = [...localSubmissions, sub];
selectedTryIndex = submissionList.length - 1;
if (sub.gradingState === 'completed' && sub.statusId === STATUS.GRADED && Array.isArray(sub.answers)) {
applyMySubmission(sub);
} else {
@@ -150,14 +165,26 @@
exerciseId
}));
}
courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: true });
const shouldMarkComplete = shouldCompleteExerciseFromSubmission({
completionPolicy: $questionnaire.completionPolicy,
passThreshold: $questionnaire.passThreshold,
totalPossiblePoints: totalPossibleGrade,
submission: sub
});
courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: shouldMarkComplete });
if (shouldMarkComplete) {
toggleConfetti();
setTimeout(toggleConfetti, 3000);
}
const allContentItems = getOrderedNavigableContent(courseApi.course);
const allComplete =
$isOrgStudent && allContentItems.length > 0 && allContentItems.every((item) => item.isComplete);
shouldMarkComplete &&
$isOrgStudent &&
allContentItems.length > 0 &&
allContentItems.every((item) => item.isComplete);
if (allComplete && courseApi.course?.id) {
const requiredExerciseId = courseApi.course?.certificate?.requiredExerciseId ?? undefined;
@@ -165,11 +192,13 @@
const certRes = await courseApi.getCertificationEvaluation(courseApi.course.id);
if (certRes?.data) {
if (certRes.data.isNewCompletion) {
updateCourseCompletionModal(courseApi.course.id, 'eligible', certRes.data, requiredExerciseId);
} else {
updateCourseCompletionModal(courseApi.course.id, 'not-eligible', certRes.data, requiredExerciseId);
}
const hasCompletedCourse = Boolean(certRes.data.eligibleForCertificate || certRes.data.certificateEarnedAt);
updateCourseCompletionModal(
courseApi.course.id,
hasCompletedCourse ? 'eligible' : 'not-eligible',
certRes.data,
requiredExerciseId
);
} else {
closeCourseCompletionModal();
}
@@ -288,6 +317,12 @@
}, 0);
}
function formatPercent(value: number) {
return new Intl.NumberFormat(undefined, {
maximumFractionDigits: 1
}).format(value);
}
function getAutoSavedData() {
isLoadingAutoSavedData = true;
@@ -606,6 +641,24 @@
);
const canGoNextForSharedQuestion = $derived(hasAnswerValue(sharedCurrentAnswer));
const sectionQuestionTotalPoints = $derived(getTotalPossibleGrade(currentSectionQuestions));
const passThresholdPercent = $derived(Number($questionnaire.passThreshold ?? 100));
const gradedScorePercent = $derived.by(() => {
const finalGrade = Number($questionnaireMetaData.finalTotalGrade);
const totalGrade = Number($questionnaireMetaData.totalPossibleGrade);
if (!Number.isFinite(finalGrade) || !Number.isFinite(totalGrade) || totalGrade <= 0) return null;
return (finalGrade / totalGrade) * 100;
});
const shouldShowPassedCompletionResult = $derived(
($questionnaire.completionPolicy ?? 'submitted') === 'passed' &&
STATUS.GRADED === $questionnaireMetaData.status &&
gradedScorePercent !== null
);
const didCurrentAttemptPass = $derived(
shouldShowPassedCompletionResult && gradedScorePercent !== null && gradedScorePercent >= passThresholdPercent
);
const gradedScorePercentLabel = $derived(gradedScorePercent === null ? '' : formatPercent(gradedScorePercent));
const passThresholdPercentLabel = $derived(formatPercent(passThresholdPercent));
const sectionNavigationGroups = $derived(
activeSections.map((section, sectionIndex) => {
const sectionQuestions = getQuestionsForSection($questionnaire.questions, section.id);
@@ -666,15 +719,15 @@
});
function goPrevTry() {
if (selectedTryIndex <= 0) return;
selectedTryIndex -= 1;
if (viewedAttemptIndex <= 0) return;
selectedTryIndex = viewedAttemptIndex - 1;
const sub = submissionList[selectedTryIndex];
if (sub) applyMySubmission(sub);
}
function goNextTry() {
if (selectedTryIndex >= submissionList.length - 1) return;
selectedTryIndex += 1;
if (viewedAttemptIndex >= submissionList.length - 1) return;
selectedTryIndex = viewedAttemptIndex + 1;
const sub = submissionList[selectedTryIndex];
if (sub) applyMySubmission(sub);
}
@@ -725,6 +778,7 @@
alreadyCheckedAutoSavedData = false;
skipHydrateFromSubmissions = false;
selectedTryIndex = -1;
localSubmissions = [];
}
prevExerciseId = exerciseId;
});
@@ -842,6 +896,43 @@
<span class="text-xl">{$questionnaireMetaData.totalPossibleGrade}</span>
</p>
</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>
{:else if isSelfPacedLikeCourse(courseApi.course?.type)}
@@ -861,7 +952,7 @@
{#if submissionList.length > 1}
<div class="mb-4 flex flex-wrap items-center gap-2">
<IconButton
disabled={selectedTryIndex <= 0}
disabled={viewedAttemptIndex <= 0}
onclick={goPrevTry}
tooltip={$t('course.navItem.lessons.exercises.all_exercises.view_mode.previous_try')}
>
@@ -869,12 +960,12 @@
</IconButton>
<span class="ui:text-muted-foreground text-sm">
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.attempt_counter', {
current: selectedTryIndex + 1,
current: viewedAttemptIndex + 1,
total: submissionList.length
})}
</span>
<IconButton
disabled={selectedTryIndex >= submissionList.length - 1}
disabled={viewedAttemptIndex >= submissionList.length - 1}
onclick={goNextTry}
tooltip={$t('course.navItem.lessons.exercises.all_exercises.view_mode.next_try')}
>
@@ -894,7 +985,7 @@
/>
<RoleBasedSecurity allowedRoles={[3]}>
{#if $questionnaire.allowMultipleAttempts}
{#if $questionnaire.allowMultipleAttempts && !shouldShowPassedCompletionResult}
<div class="mt-4">
<Button type="button" variant="secondary" onclick={tryAgain}>
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.try_again')}
@@ -144,11 +144,13 @@
const certRes = await courseApi.getCertificationEvaluation(courseId);
if (certRes?.data) {
if (certRes.data.isNewCompletion) {
updateCourseCompletionModal(courseId, 'eligible', certRes.data, requiredExerciseId);
} else {
updateCourseCompletionModal(courseId, 'not-eligible', certRes.data, requiredExerciseId);
}
const hasCompletedCourse = Boolean(certRes.data.eligibleForCertificate || certRes.data.certificateEarnedAt);
updateCourseCompletionModal(
courseId,
hasCompletedCourse ? 'eligible' : 'not-eligible',
certRes.data,
requiredExerciseId
);
} else {
closeCourseCompletionModal();
}
@@ -1,7 +1,9 @@
<script lang="ts">
import { InputField } from '@cio/ui/custom/input-field';
import TrashIcon from '@lucide/svelte/icons/trash';
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
import { IconButton } from '@cio/ui/custom/icon-button';
import * as Tooltip from '@cio/ui/base/tooltip';
import { t } from '$lib/utils/functions/translations';
interface Props {
@@ -11,7 +13,7 @@
points?: any;
hasError?: boolean;
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;
onPointsChange?: any;
elementId?: string;
@@ -34,6 +36,10 @@
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(() => {
if (ref && scrollToQuestion) {
ref.scrollIntoView({
@@ -65,15 +71,34 @@
</p>
<InputField
placeholder={$t('course.navItem.lessons.exercises.new_exercise_modal.points')}
bind:value={points}
bind:value={
() => pointsValue,
(nextValue) => {
pointsValue = nextValue;
points = nextValue;
}
}
type="number"
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>
{#if errorMsg}
<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>
{/if}
@@ -59,9 +59,17 @@
} from '$features/course/components/exercise/question-type-utils';
import type { SubmissionListItem } from '$features/course/utils/types';
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 * as Dialog from '@cio/ui/base/dialog';
import type { ExerciseSectionState } from '$features/course/components/exercise/store';
import {
getActiveExerciseQuestions,
getManualGradedExerciseQuestions,
getTotalPossibleExercisePoints
} from '$features/course/utils/exercise-progression-utils';
interface Props {
exerciseId?: string;
@@ -100,6 +108,26 @@
let selectedTab = $state<ExerciseTab>('questions');
// eslint-disable-next-line svelte/prefer-writable-derived -- must be writable: cleared before intentional navigations and bound to UnsavedChanges
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) {
return typeof id === 'string' && id.includes('-form');
@@ -167,6 +195,150 @@
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() {
if ($isOrgStudent || !courseApi.course?.id) return;
@@ -226,6 +398,9 @@
}
}
const passedPolicyCanSave = await runPassedPolicySaveGuard();
if (!passedPolicyCanSave) return;
isSaving = true;
reset();
@@ -236,13 +411,20 @@
shouldIncludeDeleted: true
});
const allowMultipleAttempts = !!$questionnaire.allowMultipleAttempts;
const completionPolicy = $questionnaire.completionPolicy ?? 'submitted';
const passThreshold = $questionnaire.passThreshold ?? 100;
await exerciseApi.update(courseApi.course?.id, exerciseId, {
title: $questionnaire.title ?? '',
description: $questionnaire.description ?? '',
dueBy: $questionnaire.dueBy ?? '',
questions: questions,
sections: shouldSyncSections ? sectionsPayload : undefined,
sectionDisplayMode: $questionnaire.sectionDisplayMode
sectionDisplayMode: $questionnaire.sectionDisplayMode,
allowMultipleAttempts,
completionPolicy,
passThreshold
});
// Check if there are validation errors from the API
@@ -575,7 +757,11 @@
{/if}
</UnderlineTabs.Content>
<UnderlineTabs.Content value="settings">
<ExerciseSettingsTab {exerciseId} />
<ExerciseSettingsTab
{exerciseId}
onBeforeSave={runPassedPolicySaveGuard}
totalSubmissions={submissions.length}
/>
</UnderlineTabs.Content>
<UnderlineTabs.Content value="submissions">
<Submissions bind:exerciseId {submissions} />
@@ -610,6 +796,107 @@
</form>
</Dialog.Content>
</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}
</div>
{/snippet}
@@ -21,15 +21,18 @@
import { settings } from '$features/course/utils/settings-store';
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 { Course } from '../utils/types';
import { ContentType } from '@cio/utils/constants/content';
import { t } from '$lib/utils/functions/translations';
import { isObject } from '$lib/utils/functions/isObject';
import { snackbar } from '$features/ui/snackbar/store';
import { generateSlug } from '@cio/utils/functions';
import { DEFAULT_COMPLIANCE_SETTINGS } from '../utils/compliance-utils';
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 { uploadImage } from '$lib/utils/services/upload';
import { copyToClipboard } from '$lib/utils/functions/formatYoutubeVideo';
@@ -115,6 +118,49 @@
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() {
if (!courseApi.course) return;
@@ -822,6 +868,30 @@
{$settings.isPublished ? $t('course.navItem.settings.published') : $t('course.navItem.settings.unpublished')}
</Label>
</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.Separator />
@@ -215,7 +215,7 @@
<div class="flex items-center overflow-x-scroll">
{#each sections as { id, title, items }, idx (id)}
<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 }}
>
<div class="mb-2 flex items-center">
@@ -235,8 +235,8 @@
{#each items as item (item.id)}
<div
class="{item.isEarly
? 'border-none'
: 'border border-red-700'} mx-0 my-2 w-full rounded-md bg-white px-3 py-3 dark:bg-neutral-800"
? 'ui:border-border'
: '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 }}
>
<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",
"successful_integration": "Integration lykkedes",
"successful_deletion": "Integration slettet",
"update_successful": "Opdateringen lykkedes"
"update_successful": "Opdateringen lykkedes",
"unlocked_all_content": "Alle kursusartikler er låst op"
}
},
"people": {
@@ -320,7 +321,8 @@
"points_required_auto_grade": "Angiv en positiv pointværdi for hvert spørgsmål, før du gemmer.",
"success": "Gemt",
"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": {
"info": {
@@ -843,6 +845,12 @@
"title": "Velkomstmail",
"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..."
},
"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": {
@@ -1410,8 +1418,8 @@
"finish": "Afslut",
"next": "Næste",
"check": "Tjek",
"auto_graded_badge": "Auto-grade",
"manual_grading_required_badge": "Manual grading required",
"auto_graded_badge": "Automatisk bedømmelse",
"manual_grading_required_badge": "Manuel bedømmelse påkrævet",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": {
"submissions": "Indsendelser",
@@ -1419,14 +1427,25 @@
"heading": "Individuel",
"answers": "Individuelle 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": {
"heading": "Resumé",
"question_chart": "Spørgsmålsskemaer",
"responses": "Svar",
"no_responses": "Ingen svar endnu",
"other": "Andet"
"other": "Andet",
"attempts": "Forsøg"
},
"loading_submissions": "Indlæser indsendelser...",
"empty_title": "Ingen indlæg endnu",
@@ -1449,6 +1468,10 @@
"status_pending": "Status: Afventer gennemgang",
"status_graded": "Status: Gennemgang afsluttet",
"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_edit_hint_prefix": "Klik på ",
"empty_edit_hint_suffix": " knappen for at tilføje.",
@@ -1846,6 +1869,30 @@
"sending": "Underretter elever...",
"sent": "Meddelte {count} elever",
"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": {
@@ -2336,7 +2383,8 @@
"later": "Luk",
"checking": "Tjekker berettigelse...",
"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": {
@@ -288,7 +288,8 @@
"saved": "Erfolgreich gespeichert",
"successful_integration": "Integration erfolgreich",
"successful_deletion": "Integration gelöscht",
"update_successful": "Update erfolgreich"
"update_successful": "Update erfolgreich",
"unlocked_all_content": "Alle Kursgegenstände freigeschaltet"
}
},
"people": {
@@ -320,7 +321,8 @@
"points_required_auto_grade": "Legen Sie für jede Frage einen positiven Punktwert fest, bevor Sie speichern.",
"success": "Erfolgreich gespeichert",
"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": {
"info": {
@@ -843,6 +845,12 @@
"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.",
"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": {
@@ -1410,8 +1418,8 @@
"finish": "Fertig",
"next": "Als nächstes",
"check": "Überprüfen",
"auto_graded_badge": "Auto-grade",
"manual_grading_required_badge": "Manual grading required",
"auto_graded_badge": "Automatische Bewertung",
"manual_grading_required_badge": "Manuelle Bewertung erforderlich",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": {
"submissions": "Einsendungen",
@@ -1419,14 +1427,25 @@
"heading": "Individuell",
"answers": "Individuelle Antworten",
"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": {
"heading": "Zusammenfassung",
"question_chart": "Fragendiagramme",
"responses": "Antworten",
"no_responses": "Noch keine Antworten",
"other": "Andere"
"other": "Andere",
"attempts": "Versuche"
},
"loading_submissions": "Einsendungen werden geladen...",
"empty_title": "Noch keine Einsendungen",
@@ -1449,6 +1468,10 @@
"status_pending": "Status: Ausstehende Überprüfung",
"status_graded": "Status: Überprüfung abgeschlossen",
"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_edit_hint_prefix": "Klicken Sie auf ",
"empty_edit_hint_suffix": " Schaltfläche zum Hinzufügen.",
@@ -1846,6 +1869,30 @@
"sending": "Studierende benachrichtigen…",
"sent": "{count} Studenten benachrichtigt",
"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": {
@@ -2336,7 +2383,8 @@
"later": "Schließen",
"checking": "Berechtigung prüfen…",
"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": {
@@ -2038,6 +2038,7 @@
"modal": {
"title": "Course requirements complete!",
"subtitle": "Congratulations — you can view and download your certificate anytime.",
"subtitle_no_certificate": "Congratulations — you have completed this course.",
"view_certificate": "View certificate",
"later": "Close",
"checking": "Checking eligibility…",
@@ -2478,6 +2479,10 @@
"settings_completion_submitted": "Submitted — any submission counts",
"settings_completion_passed": "Passed — minimum score required",
"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",
"submissions": "Submissions",
"save": "Save",
@@ -2512,7 +2517,27 @@
"check": "Check",
"auto_graded_badge": "Auto-grade",
"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.",
"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": {
"title": "Title",
"heading": "Description",
@@ -2605,6 +2630,10 @@
"status_pending": "Status: Pending Review",
"status_graded": "Status: Review Completed",
"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...",
"instructor_feedback": "Instructor feedback",
"back": "Back",
@@ -2616,14 +2645,25 @@
"heading": "Summary",
"question_chart": "Question Charts",
"responses": "Responses",
"attempts": "Attempts",
"no_responses": "No responses yet",
"other": "Other"
},
"individual": {
"heading": "Individual",
"answers": "Individual Answers",
"answers_for": "{name}'s answers",
"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",
"loading_submissions": "Loading submissions...",
@@ -3545,6 +3585,12 @@
"determines": "This determines if your course displays on your landing page",
"published": "Published",
"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",
"course_type_desc": "Update the course type for a more personalized experience.",
"course_type_learn_more": "Learn more",
@@ -4094,6 +4140,7 @@
},
"exercise": {
"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",
"submission_updated": "Submission updated to",
"error_fetching": "Fetching exercises failed, please reload the page"
@@ -4125,6 +4172,7 @@
},
"course_settings": {
"success": {
"unlocked_all_content": "All course items unlocked",
"download": "Download Complete",
"saved": "Saved successfully",
"successful_integration": "Integration successful",
@@ -288,7 +288,8 @@
"saved": "Guardado exitosamente",
"successful_integration": "Integración exitosa",
"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": {
@@ -320,7 +321,8 @@
"points_required_auto_grade": "Asigne un valor de puntos positivo a cada pregunta antes de guardar.",
"success": "Guardado exitosamente",
"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": {
"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.",
"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": {
"title": "Analítica"
@@ -1410,8 +1418,8 @@
"finish": "terminar",
"next": "Siguiente",
"check": "comprobar",
"auto_graded_badge": "Auto-grade",
"manual_grading_required_badge": "Manual grading required",
"auto_graded_badge": "Calificación automática",
"manual_grading_required_badge": "Se requiere calificación manual",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": {
"submissions": "Envíos",
@@ -1419,14 +1427,25 @@
"heading": "individuo",
"answers": "Respuestas individuales",
"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": {
"heading": "Resumen",
"question_chart": "Cuadros de preguntas",
"responses": "Respuestas",
"no_responses": "Aún no hay respuestas",
"other": "Otro"
"other": "Otro",
"attempts": "Intentos"
},
"loading_submissions": "Cargando envíos...",
"empty_title": "Aún no hay presentaciones",
@@ -1449,6 +1468,10 @@
"status_pending": "Estado: Pendiente de revisión",
"status_graded": "Estado: Revisión completada",
"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_edit_hint_prefix": "Haga clic en ",
"empty_edit_hint_suffix": " botón para agregar.",
@@ -1846,6 +1869,30 @@
"sending": "Notificar a los estudiantes...",
"sent": "{count} estudiantes notificados",
"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": {
@@ -2336,7 +2383,8 @@
"later": "Cerrar",
"checking": "Comprobando la elegibilidad...",
"not_eligible_title": "Casi allí",
"retake_exercise": "Rehacer ejercicio"
"retake_exercise": "Rehacer ejercicio",
"subtitle_no_certificate": "Felicitaciones, ha completado este curso."
}
},
"certificates": {
@@ -288,7 +288,8 @@
"saved": "Enregistré avec succès",
"successful_integration": "Intégration réussie",
"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": {
@@ -320,7 +321,8 @@
"points_required_auto_grade": "Définissez un nombre de points positif pour chaque question avant d'enregistrer.",
"success": "Enregistré avec succès",
"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": {
"info": {
@@ -843,6 +845,12 @@
"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.",
"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": {
@@ -1410,8 +1418,8 @@
"finish": "Terminer",
"next": "Suivant",
"check": "Vérifier",
"auto_graded_badge": "Auto-grade",
"manual_grading_required_badge": "Manual grading required",
"auto_graded_badge": "Notation automatique",
"manual_grading_required_badge": "Notation manuelle requise",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": {
"submissions": "Soumissions",
@@ -1419,14 +1427,25 @@
"heading": "Individuel",
"answers": "Réponses individuelles",
"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": {
"heading": "Résumé",
"question_chart": "Tableaux de questions",
"responses": "Réponses",
"no_responses": "Pas encore de réponses",
"other": "Autre"
"other": "Autre",
"attempts": "Tentatives"
},
"loading_submissions": "Chargement des soumissions...",
"empty_title": "Aucune soumission pour l'instant",
@@ -1449,6 +1468,10 @@
"status_pending": "Statut : En attente d'examen",
"status_graded": "Statut : Examen terminé",
"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_edit_hint_prefix": "Cliquez sur le ",
"empty_edit_hint_suffix": " bouton pour ajouter.",
@@ -1846,6 +1869,30 @@
"sending": "Informer les étudiants…",
"sent": "{count} étudiants notifiés",
"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": {
@@ -2336,7 +2383,8 @@
"later": "Fermer",
"checking": "Vérification de l’éligibilité…",
"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": {
@@ -288,7 +288,8 @@
"saved": "सफलतापूर्वक सहेजा गया",
"successful_integration": "एकीकरण सफल",
"successful_deletion": "एकीकरण हटा दिया गया",
"update_successful": "अद्यतन सफल"
"update_successful": "अद्यतन सफल",
"unlocked_all_content": "सभी पाठ्यक्रम आइटम अनलॉक हो गए"
}
},
"people": {
@@ -320,7 +321,8 @@
"points_required_auto_grade": "सहेजने से पहले प्रत्येक प्रश्न के लिए धनात्मक अंक निर्धारित करें।",
"success": "सफलतापूर्वक सहेजा गया",
"submission_updated": "सबमिशन को अद्यतन किया गया",
"error_fetching": "फ़ेचिंग अभ्यास विफल रहा, कृपया पृष्ठ पुनः लोड करें"
"error_fetching": "फ़ेचिंग अभ्यास विफल रहा, कृपया पृष्ठ पुनः लोड करें",
"passed_requires_points": "पास होकर पूर्ण करने के लिए कम से कम एक अंक उपलब्ध होना चाहिए।"
},
"poll": {
"info": {
@@ -843,6 +845,12 @@
"title": "आपका स्वागत है ईमेल",
"description": "नामांकन के बाद छात्रों को प्राप्त होने वाले ईमेल को अनुकूलित करें। एक व्यक्तिगत नोट, निर्देश, या एक लाइव सत्र लिंक जोड़ें।",
"placeholder": "नामांकन के समय विद्यार्थियों को मिलने वाला स्वागत संदेश लिखें..."
},
"locked_content": {
"title": "{count} पाठ्यक्रम आइटम लॉक हैं",
"description": "नामांकित छात्र तब तक लॉक किए गए पाठ या अभ्यास नहीं ले पाएंगे जब तक आप उन्हें अनलॉक नहीं करते।",
"description_live": "लॉक किए गए पाठ और अभ्यास छात्रों से तब तक छिपे रहते हैं जब तक आप उन्हें अनलॉक नहीं करते - यदि आप सप्ताह दर सप्ताह सामग्री जारी करते हैं तो उपयोगी है।",
"unlock_all": "सबको अनलॉक करें"
}
},
"analytics": {
@@ -1410,8 +1418,8 @@
"finish": "ख़त्म करो",
"next": "अगला",
"check": "जांचें",
"auto_graded_badge": "Auto-grade",
"manual_grading_required_badge": "Manual grading required",
"auto_graded_badge": "स्वचालित ग्रेडिंग",
"manual_grading_required_badge": "मैन्युअल ग्रेडिंग आवश्यक",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": {
"submissions": "प्रस्तुतियाँ",
@@ -1419,14 +1427,25 @@
"heading": "व्यक्तिगत",
"answers": "व्यक्तिगत उत्तर",
"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": {
"heading": "सारांश",
"question_chart": "प्रश्न चार्ट",
"responses": "प्रतिक्रियाएँ",
"no_responses": "अभी तक कोई प्रतिक्रिया नहीं",
"other": "अन्य"
"other": "अन्य",
"attempts": "प्रयास"
},
"loading_submissions": "सबमिशन लोड हो रहा है...",
"empty_title": "अभी तक कोई सबमिशन नहीं",
@@ -1449,6 +1468,10 @@
"status_pending": "स्थिति: समीक्षा लंबित",
"status_graded": "स्थिति: समीक्षा पूरी हुई",
"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_edit_hint_prefix": "क्लिक करें ",
"empty_edit_hint_suffix": " जोड़ने के लिए बटन.",
@@ -1846,6 +1869,30 @@
"sending": "छात्रों को सूचित किया जा रहा है...",
"sent": "{count} छात्रों को सूचित किया गया",
"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": {
@@ -2336,7 +2383,8 @@
"later": "बंद करें",
"checking": "पात्रता की जाँच की जा रही है...",
"not_eligible_title": "लगभग वहाँ",
"retake_exercise": "पुनः व्यायाम करें"
"retake_exercise": "पुनः व्यायाम करें",
"subtitle_no_certificate": "बधाई हो - आपने यह पाठ्यक्रम पूरा कर लिया है।"
}
},
"certificates": {
@@ -288,7 +288,8 @@
"saved": "Zapisano pomyślnie",
"successful_integration": "Integracja udana",
"successful_deletion": "Integracja usunięta",
"update_successful": "Aktualizacja pomyślna"
"update_successful": "Aktualizacja pomyślna",
"unlocked_all_content": "Wszystkie elementy kursu odblokowane"
}
},
"people": {
@@ -320,7 +321,8 @@
"points_required_auto_grade": "Ustaw dodatnią liczbę punktów dla każdego pytania przed zapisaniem.",
"success": "Zapisano pomyślnie",
"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": {
"info": {
@@ -843,6 +845,12 @@
"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.",
"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": {
@@ -1410,8 +1418,8 @@
"finish": "Zakończ",
"next": "Następny",
"check": "Sprawdź",
"auto_graded_badge": "Auto-grade",
"manual_grading_required_badge": "Manual grading required",
"auto_graded_badge": "Ocena automatyczna",
"manual_grading_required_badge": "Wymagana ocena ręczna",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": {
"submissions": "Zgłoszenia",
@@ -1419,14 +1427,25 @@
"heading": "Indywidualny",
"answers": "Indywidualne 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": {
"heading": "Podsumowanie",
"question_chart": "Wykresy pytań",
"responses": "Odpowiedzi",
"no_responses": "Brak odpowiedzi",
"other": "Inne"
"other": "Inne",
"attempts": "Próby"
},
"loading_submissions": "Ładowanie zgłoszeń...",
"empty_title": "Nie ma jeszcze żadnych zgłoszeń",
@@ -1449,6 +1468,10 @@
"status_pending": "Stan: Oczekuje na sprawdzenie",
"status_graded": "Stan: Przegląd zakończony",
"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_edit_hint_prefix": "Kliknij ",
"empty_edit_hint_suffix": " przycisk, aby dodać.",
@@ -1846,6 +1869,30 @@
"sending": "Powiadamianie uczniów…",
"sent": "Powiadomiono {count} 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": {
@@ -2336,7 +2383,8 @@
"later": "Zamknij",
"checking": "Sprawdzam uprawnienia…",
"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": {
@@ -288,7 +288,8 @@
"saved": "Salvo com sucesso",
"successful_integration": "Integração bem-sucedida",
"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": {
@@ -320,7 +321,8 @@
"points_required_auto_grade": "Defina um valor de pontos positivo para cada pergunta antes de salvar.",
"success": "Salvo com sucesso",
"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": {
"info": {
@@ -843,6 +845,12 @@
"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.",
"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": {
@@ -1410,8 +1418,8 @@
"finish": "Concluir",
"next": "Próximo",
"check": "Verifique",
"auto_graded_badge": "Auto-grade",
"manual_grading_required_badge": "Manual grading required",
"auto_graded_badge": "Avaliação automática",
"manual_grading_required_badge": "Avaliação manual obrigatória",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": {
"submissions": "Envios",
@@ -1419,14 +1427,25 @@
"heading": "Individual",
"answers": "Respostas individuais",
"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": {
"heading": "Resumo",
"question_chart": "Gráficos de perguntas",
"responses": "Respostas",
"no_responses": "Ainda não há respostas",
"other": "Outro"
"other": "Outro",
"attempts": "Tentativas"
},
"loading_submissions": "Carregando envios...",
"empty_title": "Ainda não há envios",
@@ -1449,6 +1468,10 @@
"status_pending": "Status: revisão pendente",
"status_graded": "Status: revisão concluída",
"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_edit_hint_prefix": "Clique no ",
"empty_edit_hint_suffix": " botão para adicionar.",
@@ -1846,6 +1869,30 @@
"sending": "Notificando os alunos…",
"sent": "{count} alunos notificados",
"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": {
@@ -2336,7 +2383,8 @@
"later": "Fechar",
"checking": "Verificando elegibilidade…",
"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": {
@@ -288,7 +288,8 @@
"saved": "Сохранено успешно.",
"successful_integration": "Интеграция прошла успешно",
"successful_deletion": "Интеграция удалена",
"update_successful": "Обновление успешно выполнено"
"update_successful": "Обновление успешно выполнено",
"unlocked_all_content": "Все предметы курса разблокированы"
}
},
"people": {
@@ -320,7 +321,8 @@
"points_required_auto_grade": "Установите положительное количество баллов для каждого вопроса перед сохранением.",
"success": "Сохранено успешно",
"submission_updated": "Представление обновлено до",
"error_fetching": "Не удалось загрузить упражнения. Обновите страницу."
"error_fetching": "Не удалось загрузить упражнения. Обновите страницу.",
"passed_requires_points": "Для завершения по условию прохождения должен быть доступен хотя бы один балл."
},
"poll": {
"info": {
@@ -843,6 +845,12 @@
"title": "Приветственное письмо",
"description": "Настройте электронное письмо, которое студенты получат после регистрации. Добавьте личную заметку, инструкции или ссылку на сеанс в реальном времени.",
"placeholder": "Напишите приветственное сообщение, которое студенты получат при зачислении…"
},
"locked_content": {
"title": "Элементы курса ({count}) заблокированы",
"description": "Зачисленные учащиеся не смогут проходить заблокированные уроки или упражнения, пока вы их не разблокируете.",
"description_live": "Заблокированные уроки и упражнения остаются скрытыми от учащихся, пока вы их не разблокируете. Это полезно, если вы выпускаете контент еженедельно.",
"unlock_all": "Разблокировать все"
}
},
"analytics": {
@@ -1410,8 +1418,8 @@
"finish": "Готово",
"next": "Далее",
"check": "Проверить",
"auto_graded_badge": "Auto-grade",
"manual_grading_required_badge": "Manual grading required",
"auto_graded_badge": "Автоматическая оценка",
"manual_grading_required_badge": "Требуется ручная оценка",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": {
"submissions": "Материалы",
@@ -1419,14 +1427,25 @@
"heading": "Индивидуальный",
"answers": "Индивидуальные ответы",
"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": {
"heading": "Резюме",
"question_chart": "Таблицы вопросов",
"responses": "Ответы",
"no_responses": "Пока нет ответов",
"other": "Другое"
"other": "Другое",
"attempts": "Попытки"
},
"loading_submissions": "Загрузка материалов...",
"empty_title": "Пока нет заявок",
@@ -1449,6 +1468,10 @@
"status_pending": "Статус: Ожидается рассмотрение",
"status_graded": "Статус: Проверка завершена",
"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_edit_hint_prefix": "Нажмите кнопку ",
"empty_edit_hint_suffix": " кнопка для добавления.",
@@ -1846,6 +1869,30 @@
"sending": "Уведомление учащихся…",
"sent": "Уведомлено учащихся: {count}",
"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": {
@@ -2336,7 +2383,8 @@
"later": "Закрыть",
"checking": "Проверка пригодности…",
"not_eligible_title": "Почти там",
"retake_exercise": "Пересдать упражнение"
"retake_exercise": "Пересдать упражнение",
"subtitle_no_certificate": "Поздравляем — вы прошли этот курс."
}
},
"certificates": {
@@ -288,7 +288,8 @@
"saved": "Đã lưu thành công",
"successful_integration": "Tích hợp thành công",
"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": {
@@ -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.",
"success": "Đã lưu thành công",
"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": {
"info": {
@@ -843,6 +845,12 @@
"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.",
"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": {
@@ -1410,8 +1418,8 @@
"finish": "Kết thúc",
"next": "Tiếp theo",
"check": "Kiểm tra",
"auto_graded_badge": "Auto-grade",
"manual_grading_required_badge": "Manual grading required",
"auto_graded_badge": "Chấm tự động",
"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.",
"analytics": {
"submissions": "bài nộp",
@@ -1419,14 +1427,25 @@
"heading": "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",
"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": {
"heading": "Tóm tắt",
"question_chart": "Biểu đồ câu hỏi",
"responses": "Phản hồi",
"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...",
"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_graded": "Trạng thái: Đánh giá đã hoàn thành",
"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_edit_hint_prefix": "Bấm vào ",
"empty_edit_hint_suffix": " nút để thêm.",
@@ -1846,6 +1869,30 @@
"sending": "Thông báo cho học sinh…",
"sent": "Đã thông báo cho {count} 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": {
@@ -2336,7 +2383,8 @@
"later": "Đóng",
"checking": "Đang kiểm tra tính đủ điều kiện…",
"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": {
+1 -1
View File
@@ -17,7 +17,7 @@
"db:studio": "pnpm --filter @cio/db db:studio",
"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\"",
"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",
"docs:dev": "pnpm --filter @cio/docs dev",
"docs:build": "pnpm --filter @cio/docs build",
+13 -3
View File
@@ -41,7 +41,7 @@ export function buildTeacherSystemPrompt(context: AgentContext, options?: BuildT
## 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
@@ -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 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.
- 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
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.
- STAR: on \`settings.correctValue\` (1..max stars).
- 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'),
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(
z.object({
@@ -146,7 +152,7 @@ export const createExerciseSchema = {
})
)
.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 = {
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
.object({
courseId: z.string().describe('The course ID'),
@@ -207,7 +213,22 @@ export const updateExerciseSchema = {
.optional()
.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.'),
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(
(data) =>
@@ -218,7 +239,9 @@ export const updateExerciseSchema = {
data.order !== undefined ||
data.dueBy !== undefined ||
data.isUnlocked !== undefined ||
data.allowMultipleAttempts !== undefined,
data.allowMultipleAttempts !== undefined ||
data.completionPolicy !== undefined ||
data.passThreshold !== undefined,
{
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'),
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(
z.object({
label: z.string().min(1).describe('Option text'),
@@ -264,7 +293,7 @@ export const addQuestionsSchema = {
export const updateQuestionsSchema = {
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({
courseId: z.string().describe('The course ID'),
exerciseId: z.string().describe('The existing exercise ID'),
@@ -293,7 +322,7 @@ export const updateQuestionsSchema = {
.record(z.string(), z.unknown())
.optional()
.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
.array(
@@ -83,6 +83,12 @@ export const questionSchema = z.object({
),
points: z.number().min(0).default(1),
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() }))
});
@@ -118,7 +124,22 @@ export const updateExerciseParam = z
.optional()
.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.'),
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(
(data) =>
@@ -129,7 +150,9 @@ export const updateExerciseParam = z
data.order !== undefined ||
data.dueBy !== undefined ||
data.isUnlocked !== undefined ||
data.allowMultipleAttempts !== undefined,
data.allowMultipleAttempts !== undefined ||
data.completionPolicy !== undefined ||
data.passThreshold !== undefined,
{
message: 'Provide at least one field to update'
}
@@ -189,7 +212,7 @@ export const updateQuestionSettingsSchema = z
message: 'Provide at least one settings field'
})
.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({
+10 -3
View File
@@ -685,6 +685,7 @@ export function buildAgentTools(
questionTypeId: q.questionTypeId,
points: q.points,
order: q.order ?? i,
settings: q.settings,
options: q.options.map((o) => ({ label: o.label, isCorrect: o.isCorrect }))
}))
});
@@ -695,7 +696,7 @@ export function buildAgentTools(
update_exercise: tool({
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,
execute: async (args) => {
return executeAgentTool('update_exercise', { orgId, userId, courseId, args }, async () => {
@@ -717,7 +718,9 @@ export function buildAgentTools(
order: args.order,
dueBy: args.dueBy,
isUnlocked: args.isUnlocked,
allowMultipleAttempts: args.allowMultipleAttempts
allowMultipleAttempts: args.allowMultipleAttempts,
completionPolicy: args.completionPolicy,
passThreshold: args.passThreshold
});
return {
@@ -725,6 +728,8 @@ export function buildAgentTools(
title: updated.title,
description: updated.description ?? null,
dueBy: updated.dueBy ?? null,
completionPolicy: updated.completionPolicy ?? null,
passThreshold: updated.passThreshold ?? null,
updated: true
};
});
@@ -812,6 +817,7 @@ export function buildAgentTools(
questionTypeId: q.questionTypeId,
points: q.points,
order: q.order ?? nextOrder + i,
settings: q.settings,
...(args.exerciseSectionId !== undefined ? { exerciseSectionId: args.exerciseSectionId } : {}),
options: q.options.map((o) => ({ label: o.label, isCorrect: o.isCorrect }))
}));
@@ -823,6 +829,7 @@ export function buildAgentTools(
questionTypeId: eq.questionTypeId,
points: eq.points,
order: eq.order,
settings: eq.settings,
options: eq.options.map((o) => ({ id: Number(o.id), label: o.label || '', isCorrect: o.isCorrect }))
})),
...newQuestions
@@ -841,7 +848,7 @@ export function buildAgentTools(
update_questions: tool({
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,
execute: async (args) => {
return executeAgentTool('update_questions', { orgId, userId, courseId, args }, async () => {
+41 -5
View File
@@ -12,11 +12,19 @@ import {
updateQuestions
} from '@cio/db/queries/exercise';
import {
QUESTION_TYPE_IDS,
QUESTION_TYPE_KEY,
resolveTrueFalseCorrectValue,
syncTrueFalseOptions,
type TrueFalseAnswerOption
} from '@cio/question-types';
import type { DbOrTxClient } from '@cio/db/drizzle';
import type { TExerciseUpdate } from '@cio/utils/validation/exercise';
type CurrentQuestion = TNewQuestion & { options?: TNewOption[] };
type CurrentOption = TNewOption;
type ExerciseUpdateQuestion = NonNullable<TExerciseUpdate['questions']>[number];
type ExerciseAnswer = Pick<
TQuestionAnswer,
@@ -68,6 +76,26 @@ function areSettingsEqual(left: unknown, right: unknown): boolean {
return JSON.stringify(leftNormalized) === JSON.stringify(rightNormalized);
}
const TRUE_FALSE_QUESTION_TYPE_ID = QUESTION_TYPE_IDS[QUESTION_TYPE_KEY.TRUE_FALSE];
function normalizeTrueFalseQuestion(
question: ExerciseUpdateQuestion,
currentQuestion?: CurrentQuestion
): ExerciseUpdateQuestion {
const questionTypeId = question.questionTypeId ?? currentQuestion?.questionTypeId;
if (questionTypeId !== TRUE_FALSE_QUESTION_TYPE_ID) return question;
const settings = normalizeSettings(question.settings ?? currentQuestion?.settings);
const options = question.options ?? currentQuestion?.options ?? [];
const correctValue = resolveTrueFalseCorrectValue(settings, options as TrueFalseAnswerOption[]);
return {
...question,
settings: { ...settings, correctValue },
options: question.options ? syncTrueFalseOptions(question.options, correctValue) : question.options
};
}
/**
* Extracts exercise-level fields from the update payload
*/
@@ -81,6 +109,8 @@ export function buildExerciseUpdateFields(data: TExerciseUpdate): Partial<TExerc
if (data.isUnlocked !== undefined) exerciseUpdate.isUnlocked = data.isUnlocked;
if (data.dueBy !== undefined) exerciseUpdate.dueBy = data.dueBy || null;
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.slug !== undefined) exerciseUpdate.slug = data.slug;
return exerciseUpdate;
@@ -95,7 +125,9 @@ export function categorizeQuestions(questions: NonNullable<TExerciseUpdate['ques
const newQuestions: typeof questions = [];
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) {
deletedIds.push(q.id);
} else if (q.id) {
@@ -230,13 +262,16 @@ export function computeExerciseDiff(
incomingQuestions: NonNullable<TExerciseUpdate['questions']>
) {
const questionMap = new Map(currentQuestions.map((q) => [q.id, q]));
const normalizedIncomingQuestions = incomingQuestions.map((incoming) =>
normalizeTrueFalseQuestion(incoming, incoming.id ? questionMap.get(incoming.id) : undefined)
);
const deletedQuestionIds: number[] = [];
const questionUpdates: Array<{ id: number; data: Partial<TNewQuestion> }> = [];
const newQuestions: NonNullable<TExerciseUpdate['questions']> = [];
// Process incoming questions
for (const incoming of incomingQuestions) {
for (const incoming of normalizedIncomingQuestions) {
if (incoming.deletedAt && incoming.id) {
deletedQuestionIds.push(incoming.id);
} else if (incoming.id) {
@@ -260,7 +295,7 @@ export function computeExerciseDiff(
const optionUpdates: Array<{ id: number; data: Partial<TNewOption> }> = [];
const optionCreates: TNewOption[] = [];
for (const incoming of incomingQuestions) {
for (const incoming of normalizedIncomingQuestions) {
if (!incoming.id || incoming.deletedAt) continue;
const current = questionMap.get(incoming.id);
@@ -336,8 +371,9 @@ export async function createNewQuestionsWithOptions(
newQuestions: NonNullable<TExerciseUpdate['questions']>,
txClient: DbOrTxClient
): Promise<void> {
const normalizedQuestions = newQuestions.map((question) => normalizeTrueFalseQuestion(question));
const createdQuestions = await createQuestions(
newQuestions.map((q) => ({
normalizedQuestions.map((q) => ({
exerciseId,
title: q.question,
questionTypeId: q.questionTypeId || 1,
@@ -351,7 +387,7 @@ export async function createNewQuestionsWithOptions(
const newOptions: TNewOption[] = [];
createdQuestions.forEach((newQuestion, index) => {
const questionData = newQuestions[index];
const questionData = normalizedQuestions[index];
if (newQuestion && questionData?.options && questionData.options.length > 0) {
// Only include non-deleted options for new questions
for (const opt of questionData.options) {
+2 -1
View File
@@ -46,7 +46,8 @@ export async function createLesson(courseId: string, data: TLessonCreate): Promi
const lessonData = {
...data,
courseId,
slug
slug,
isUnlocked: data.isUnlocked ?? true
};
const [lesson] = await createLessons([lessonData]);
+1
View File
@@ -50,6 +50,7 @@
"dist"
],
"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",
"dev": "tsc --watch",
"db": "drizzle-kit",
+20 -1
View File
@@ -1,7 +1,7 @@
import * as schema from '@db/schema';
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 { 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.
*/
+1 -1
View File
@@ -996,7 +996,7 @@ export const lesson = pgTable(
callUrl: text('call_url'),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
order: bigint({ mode: 'number' }),
isUnlocked: boolean('is_unlocked').default(false),
isUnlocked: boolean('is_unlocked').default(true),
completionPolicy: varchar('completion_policy').default('manual').notNull(),
videoWatchThreshold: integer('video_watch_threshold').default(95),
videos: jsonb().default([]).$type<
+1 -1
View File
@@ -8,7 +8,7 @@
"dist"
],
"scripts": {
"build": "tsc && tsc-alias",
"build": "rimraf dist tsconfig.tsbuildinfo && tsc && tsc-alias",
"dev": "tsc --watch",
"format": "prettier --write \"src/**/*.ts\"",
"test": "vitest run",
+1
View File
@@ -18,3 +18,4 @@ export * from './renderer-registry';
export * from './textarea-validation';
export * from './star-rating-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 { supportsPartialCredit } from './question-type-capabilities';
import { getStarRatingMaxFromSettings, isValidStarRatingValue } from './star-rating-settings';
import { resolveTrueFalseCorrectValue } from './true-false-answer-key';
export type DbQuestionForScoring = {
id: number;
@@ -112,20 +113,7 @@ function scoreTrueFalse(
answer: Extract<AnswerData, { type: 'TRUE_FALSE' }>,
maxPoints: number
): number {
const opts = 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;
}
const correctIsTrue = resolveTrueFalseCorrectValue(question.settings, question.options);
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">
import * as Chart from '../../../../base/chart';
import type * as Chart from '../../../../base/chart';
import type { ExerciseQuestionRendererProps } from '@cio/question-types';
import SubmissionResponseBarChart from '../shared/submission-response-bar-chart.svelte';
import { getAnswerForQuestion, getSubmissionLabel, normalizeLabel, withChartColors } from '../submission-utils';
let {
@@ -75,19 +76,9 @@
});
</script>
{#if chartData.length > 0}
<Chart.Container config={chartConfig} class="ui:h-[280px] ui:w-full">
<Chart.BarChart
data={chartData}
xScale={Chart.scaleBand().padding(0.25)}
x="label"
axis="x"
tooltip={false}
<SubmissionResponseBarChart
{chartData}
{chartConfig}
{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">
import * as Chart from '../../../../base/chart';
import type * as Chart from '../../../../base/chart';
import type { ExerciseQuestionRendererProps } from '@cio/question-types';
import SubmissionResponseBarChart from '../shared/submission-response-bar-chart.svelte';
import { getAnswerForQuestion, getSubmissionLabel, normalizeLabel, withChartColors } from '../submission-utils';
let {
@@ -91,19 +92,9 @@
});
</script>
{#if chartData.length > 0}
<Chart.Container config={chartConfig} class="ui:h-[280px] ui:w-full">
<Chart.BarChart
data={chartData}
xScale={Chart.scaleBand().padding(0.25)}
x="label"
axis="x"
tooltip={false}
<SubmissionResponseBarChart
{chartData}
{chartConfig}
{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">
import {
getExerciseQuestionLabel,
resolveTrueFalseCorrectValue,
syncTrueFalseOptions,
type ExerciseQuestionModel,
type ExerciseQuestionRendererProps
} from '@cio/question-types';
@@ -18,14 +20,7 @@
if (value !== 'true' && value !== 'false') return;
const correctValue = value === 'true';
const opts = question.options ?? [];
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;
});
const options = syncTrueFalseOptions(question.options, correctValue);
patchQuestion({
settings: { ...(question.settings ?? {}), correctValue },
@@ -40,13 +35,9 @@
}
const selectedValue = $derived.by(() => {
const correctOpt = (question.options ?? []).find((o) => o.isCorrect);
if (correctOpt) {
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';
return String(
resolveTrueFalseCorrectValue(question.settings as Record<string, unknown> | undefined, question.options)
);
});
</script>
@@ -7,7 +7,7 @@
getExerciseQuestionLabel(labels, key, fallback);
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.false_label')
);
@@ -5,7 +5,9 @@
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(() => {
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 {
return (settings?.correctValue as boolean | undefined) !== false;
export function getTrueFalseCorrectIsTrue(
settings: Record<string, unknown> | undefined,
options: ExerciseQuestionOption[] | undefined
): boolean {
return resolveTrueFalseCorrectValue(settings, options);
}
@@ -1,7 +1,8 @@
<script lang="ts">
import * as Chart from '../../../../base/chart';
import type * as Chart from '../../../../base/chart';
import type { ExerciseQuestionRendererProps } from '@cio/question-types';
import SubmissionResponseBarChart from '../shared/submission-response-bar-chart.svelte';
import { getAnswerForQuestion, getSubmissionLabel, normalizeLabel, withChartColors } from '../submission-utils';
let {
@@ -94,19 +95,9 @@
});
</script>
{#if chartData.length > 0}
<Chart.Container config={chartConfig} class="ui:h-[280px] ui:w-full">
<Chart.BarChart
data={chartData}
xScale={Chart.scaleBand().padding(0.25)}
x="label"
axis="x"
tooltip={false}
<SubmissionResponseBarChart
{chartData}
{chartConfig}
{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": {
"build": "tsc && tsc-alias",
"build": "rimraf dist tsconfig.tsbuildinfo && tsc && tsc-alias",
"dev": "tsc --watch",
"format": "prettier --write ."
},
@@ -46,6 +46,10 @@ const QUESTION_VALIDATION_RULES: Record<number, Array<(question: QuestionRuleInp
return null;
},
(question) => {
if (typeof question.settings?.correctValue === 'boolean') {
return null;
}
const options = question.options || [];
if (options.length > 0 && !options.some((opt) => opt.isCorrect === true)) {
return 'Please mark an option as the correct answer';