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 <rotimi-best@users.noreply.github.com>

* 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 <rotimi-best@users.noreply.github.com>

* 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 <rotimi-best@users.noreply.github.com>

* feat(course): improve exercise progression grading UX

---------

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
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
@@ -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}
{series}
/>
</Chart.Container>
{:else}
<p class="ui:text-muted-foreground ui:text-sm">
{getSubmissionLabel(labels, 'submission.chart.no_data', 'No responses yet')}
</p>
{/if}
<SubmissionResponseBarChart
{chartData}
{chartConfig}
{series}
emptyLabel={getSubmissionLabel(labels, 'submission.chart.no_data', 'No responses yet')}
/>
@@ -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}
{series}
/>
</Chart.Container>
{:else}
<p class="ui:text-muted-foreground ui:text-sm">
{getSubmissionLabel(labels, 'submission.chart.no_data', 'No responses yet')}
</p>
{/if}
<SubmissionResponseBarChart
{chartData}
{chartConfig}
{series}
emptyLabel={getSubmissionLabel(labels, 'submission.chart.no_data', 'No responses yet')}
/>
@@ -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}
{series}
/>
</Chart.Container>
{:else}
<p class="ui:text-muted-foreground ui:text-sm">
{getSubmissionLabel(labels, 'submission.chart.no_data', 'No responses yet')}
</p>
{/if}
<SubmissionResponseBarChart
{chartData}
{chartConfig}
{series}
emptyLabel={getSubmissionLabel(labels, 'submission.chart.no_data', 'No responses yet')}
/>
File diff suppressed because one or more lines are too long