44 KiB
Exercise Question Sections — Implementation Plan
This plan implements sections inside a single exercise. It does not change course sections. The existing exercise.sectionId field continues to mean the course section that contains an exercise; the new question grouping uses question.exerciseSectionId / DB question.exercise_section_id.
Delivery Phases
The work is broken into 4 sequential phases. Each phase is independently shippable and testable. Phases must be completed in order because later phases depend on earlier ones.
Phase 1: Database & Shared Types (Foundation)
Goal: Schema changes, shared types, and validation — no UI or API behavior changes yet.
Ticket 1.1: Drizzle Schema — exercise_section Table
Priority: P0 Estimate: 1–2 hours
What to do:
- Open
packages/db/src/schema.ts. - Add the
exerciseSectiontable definition above thequestiontable (becausequestionwill reference it):
export const exerciseSection = pgTable(
'exercise_section',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
exerciseId: uuid('exercise_id').notNull(),
title: varchar().notNull().default('Untitled Section'),
description: text(),
order: bigint({ mode: 'number' }).notNull().default(0),
colorTheme: varchar('color_theme').notNull().default('blue'),
afterBehavior: jsonb('after_behavior')
.notNull()
.default({ action: 'continue' }),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow()
},
(table) => [
foreignKey({
columns: [table.exerciseId],
foreignColumns: [exercise.id],
name: 'exercise_section_exercise_id_fkey'
}).onDelete('cascade'),
index('idx_exercise_section_exercise_id').on(table.exerciseId)
]
);
- Add
exerciseSectionIdcolumn to the existingquestiontable definition. Find thequestiontable object and add this column afterexerciseId:
exerciseSectionId: uuid('exercise_section_id'),
- Add a foreign key constraint in the
questiontable's constraint array:
foreignKey({
columns: [table.exerciseSectionId],
foreignColumns: [exerciseSection.id],
name: 'question_exercise_section_id_fkey'
}).onDelete('set null')
- Add
sectionDisplayModecolumn to the existingexercisetable definition:
sectionDisplayMode: varchar('section_display_mode').default('one_question'),
- Run
pnpm --filter @cio/db buildand fix any type errors.
Files to change:
packages/db/src/schema.ts
How to verify:
pnpm --filter @cio/db buildsucceeds with no errors.
Ticket 1.2: Database Migration Script
Priority: P0 Estimate: 1 hour
What to do:
- Generate a Drizzle migration from the schema changes in Ticket 1.1.
- The generated SQL should create the
exercise_sectiontable, addexercise_section_idtoquestion, addsection_display_modetoexercise, and create the index. - Review the generated migration carefully to ensure:
exercise_section_idonquestionis nullable (noNOT NULL).section_display_modeonexercisehas default'one_question'.color_themeonexercise_sectionhas default'blue'.- The
exercise_sectiontable hasON DELETE CASCADEforexercise_id. - The
question.exercise_section_idFK hasON DELETE SET NULL.
How to verify:
- Migration runs successfully against a local database.
- Existing data is not affected.
SELECT * FROM exercise_sectionreturns empty.SELECT exercise_section_id FROM question LIMIT 1returnsNULL.
Ticket 1.3: Shared TypeScript Types
Priority: P0 Estimate: 1 hour
What to do:
- Create
packages/question-types/src/exercise-section-types.ts:
import type { ExerciseQuestionModel } from './exercise-types';
export type ExerciseSectionAfterAction = 'continue' | 'go_to_section' | 'submit';
export interface ExerciseSectionAfterBehavior {
action: ExerciseSectionAfterAction;
exerciseSectionId?: string;
}
export type ExerciseSectionDisplayMode = 'one_question' | 'all_questions';
export type ExerciseSectionColorTheme = 'blue' | 'green' | 'amber' | 'rose' | 'violet' | 'slate';
export interface ExerciseSectionModel {
id?: string;
title: string;
description?: string | null;
order: number;
colorTheme: ExerciseSectionColorTheme;
afterBehavior: ExerciseSectionAfterBehavior;
questions: ExerciseQuestionModel[];
}
- Export from
packages/question-types/src/index.ts:
export * from './exercise-section-types';
- Run
pnpm --filter @cio/question-types build.
Files to create:
packages/question-types/src/exercise-section-types.ts
Files to change:
packages/question-types/src/index.ts
How to verify:
pnpm --filter @cio/question-types buildsucceeds.- Types are importable:
import { ExerciseSectionModel } from '@cio/question-types'resolves.
Ticket 1.4: Zod Validation Schemas for Sections
Priority: P0 Estimate: 1–2 hours
What to do:
- Create
packages/utils/src/validation/exercise/exercise-section.ts:
import { z } from 'zod';
export const ZExerciseSectionAfterBehavior = z.discriminatedUnion('action', [
z.object({ action: z.literal('continue') }),
z.object({ action: z.literal('go_to_section'), exerciseSectionId: z.string().uuid() }),
z.object({ action: z.literal('submit') })
]);
export const ZExerciseSection = z.object({
id: z.string().uuid().optional(),
title: z.string().min(1, 'Section title is required'),
description: z.string().optional().nullable(),
order: z.number().int().min(0),
colorTheme: z.enum(['blue', 'green', 'amber', 'rose', 'violet', 'slate']).default('blue'),
afterBehavior: ZExerciseSectionAfterBehavior.default({ action: 'continue' }),
questionIds: z.array(z.string()).optional()
});
export const ZExerciseSectionCreate = ZExerciseSection.omit({ id: true });
export const ZExerciseSectionUpdate = ZExerciseSection.partial().required({ id: true });
export type TExerciseSection = z.infer<typeof ZExerciseSection>;
- Update the existing exercise validation file to accept optional
sectionsandsectionDisplayMode. Find the existingZExerciseUpdateinpackages/utils/src/validation/exercise/exercise.tsand extend it:
import { ZExerciseSection } from './exercise-section';
// Add to ZExerciseUpdate:
sections: z.array(ZExerciseSection).optional(),
sectionDisplayMode: z.enum(['one_question', 'all_questions']).optional()
- Export from the validation index:
export * from './exercise-section';
Files to create:
packages/utils/src/validation/exercise/exercise-section.ts
Files to change:
packages/utils/src/validation/exercise/exercise.tspackages/utils/src/validation/exercise/index.ts(or wherever exercise validation is exported)
How to verify:
pnpm --filter @cio/utils buildsucceeds.ZExerciseSection.parse({ title: 'Test', order: 0 })succeeds.ZExerciseSection.parse({ title: '', order: 0 })fails with "Section title is required".
Ticket 1.5: Database Query Functions for Sections
Priority: P0 Estimate: 2–3 hours
What to do:
- Create
packages/db/src/queries/exercise/exercise-section.tswith these functions:
import { db } from '../../db';
import { exerciseSection, question } from '../../schema';
import { eq, inArray, sql } from 'drizzle-orm';
export async function getExerciseSectionsByExerciseId(exerciseId: string) {
return db
.select()
.from(exerciseSection)
.where(eq(exerciseSection.exerciseId, exerciseId))
.orderBy(exerciseSection.order);
}
export async function getExerciseSectionsByExerciseIds(exerciseIds: string[]) {
if (exerciseIds.length === 0) return [];
return db
.select()
.from(exerciseSection)
.where(inArray(exerciseSection.exerciseId, exerciseIds))
.orderBy(exerciseSection.order);
}
export async function createExerciseSections(
sections: Array<{
exerciseId: string;
title: string;
description?: string | null;
order: number;
colorTheme?: string;
afterBehavior?: { action: string; exerciseSectionId?: string };
}>
) {
if (sections.length === 0) return [];
return db.insert(exerciseSection).values(sections).returning();
}
export async function updateExerciseSection(
exerciseSectionId: string,
data: Partial<{
title: string;
description: string | null;
order: number;
colorTheme: string;
afterBehavior: { action: string; exerciseSectionId?: string };
}>
) {
return db
.update(exerciseSection)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(eq(exerciseSection.id, exerciseSectionId))
.returning();
}
export async function deleteExerciseSection(exerciseSectionId: string) {
return db.delete(exerciseSection).where(eq(exerciseSection.id, exerciseSectionId));
}
export async function moveQuestionToSection(questionId: number, exerciseSectionId: string | null) {
return db
.update(question)
.set({ exerciseSectionId })
.where(eq(question.id, questionId));
}
export async function bulkMoveQuestionsToSection(questionIds: number[], exerciseSectionId: string | null) {
if (questionIds.length === 0) return;
return db
.update(question)
.set({ exerciseSectionId })
.where(inArray(question.id, questionIds));
}
- Export from the queries index file (create or update
packages/db/src/queries/exercise/index.ts):
export * from './exercise-section';
Files to create:
packages/db/src/queries/exercise/exercise-section.ts
Files to change:
packages/db/src/queries/exercise/index.tspackages/db/src/queries/index.ts(if needed for top-level re-export)
How to verify:
pnpm --filter @cio/db buildsucceeds.- Functions are importable from
@db/queries.
Phase 2: API Layer
Goal: API endpoints accept and return sections. No dashboard changes yet — testable via API calls.
Ticket 2.1: Exercise Fetch — Return Sections
Priority: P0 Estimate: 2–3 hours
What to do:
- Open the exercise fetch service/query (likely
packages/db/src/queries/exercise/exercise.tsorapps/api/src/services/exercise/exercise.ts). - After fetching an exercise and its questions, also fetch sections:
const sections = await getExerciseSectionsByExerciseId(exerciseId);
- Group questions by
exerciseSectionId:- Questions with an
exerciseSectionIdgo into their respective section'squestionsarray. - Questions without an
exerciseSectionIdgo into the top-levelquestionsarray.
- Questions with an
- Return the response shaped as:
{
...exercise,
sectionDisplayMode: exercise.sectionDisplayMode,
sections: sections.map((section) => ({
id: section.id,
title: section.title,
description: section.description,
order: section.order,
colorTheme: section.colorTheme,
afterBehavior: section.afterBehavior,
questions: questionsInSection // sorted by order
})),
questions: unsectionedQuestions // sorted by order
}
- For backward compatibility, if there are no sections,
sectionsis[]and all questions are inquestions.
Files to change:
- Exercise fetch query/service file
- Exercise route handler (response shape)
How to verify:
GET /course/:courseId/exercise/:exerciseIdreturnssections: []for existing exercises.- After manually inserting a section and assigning questions to it, the endpoint returns the section with nested questions.
Ticket 2.2: Exercise Update — Accept Sections
Priority: P0 Estimate: 3–4 hours
What to do:
- Open the exercise update service (likely
apps/api/src/services/exercise/exercise.ts). - Accept
sectionsandsectionDisplayModein the update payload. - Implement section upsert logic:
For each section in the payload:
- If section has `id` and exists in DB → update title, description, colorTheme, order, afterBehavior
- If section has no `id` → create new section
- Sections in DB that are NOT in the payload → delete them
For each section:
- Get `questionIds` from the section payload
- Update each question's `exerciseSectionId` to this section's ID
- Update each question's `order` based on its position in `questionIds`
-
Validation rules:
- If
sectionsis provided and non-empty, every non-deleted question must be assigned to a section. - If any section has
afterBehavior.action === 'go_to_section', the referencedexerciseSectionIdmust exist in the same exercise. - If
sectionsis empty or omitted, don't touch existing sections.
- If
-
Update
sectionDisplayModeon the exercise if provided.
Files to change:
apps/api/src/services/exercise/exercise.tsapps/api/src/routes/course/exercise.ts(accept new fields in validator)
How to verify:
PUT /course/:courseId/exercise/:exerciseIdwithsections: [{ title: 'Part A', order: 0, questionIds: ['q1', 'q2'] }]creates a section and assigns questions.- Sending the same request with an updated title updates the section.
- Sending a request without the section deletes it.
sectionDisplayModepersists correctly.- Invalid
go_to_sectionreference returns a 400 error.
Ticket 2.3: Submission Fetch — Section-Grouped Response
Priority: P1 Estimate: 2–3 hours
What to do:
- Open the submission fetch service (used by the grading/submissions views).
- When returning submission data for an exercise that has sections:
- Fetch sections for the exercise.
- Group question answers by section.
- Compute per-section subtotals (sum of points awarded) and max points (sum of question points).
- Return a
sectionsarray with grouped answers alongside the existing flat data.
- For exercises without sections, return the existing flat response (backward compatible).
Response shape (when sections exist):
{
// existing fields...
sections: [
{
exerciseSectionId: 'uuid',
sectionTitle: 'Part A',
subtotal: 15,
maxPoints: 20,
answers: [/* answers for questions in this section */]
},
// ...
],
totalScore: 35,
maxScore: 50
}
Files to change:
- Submission fetch service/query
- Submission route handler
How to verify:
- Submission fetch for sectionless exercise returns existing shape unchanged.
- Submission fetch for sectioned exercise returns
sectionswith correct groupings and subtotals.
Ticket 2.4: Clone Service — Support Sections
Priority: P1 Estimate: 2–3 hours
What to do:
- Open
apps/api/src/services/course/clone.ts. - In
cloneExercises, after creating new exercises:
// 4a. Fetch sections for old exercises
const oldExerciseIds = oldExercises.map((ex) => ex.id);
const oldSections = await getExerciseSectionsByExerciseIds(oldExerciseIds);
let exerciseSectionIdMap = new Map<string, string>();
if (oldSections.length > 0) {
// 4b. Create new sections with mapped exercise IDs
const newSections = await createExerciseSections(
oldSections.map((section) => ({
title: section.title,
description: section.description,
order: section.order,
colorTheme: section.colorTheme,
afterBehavior: section.afterBehavior,
exerciseId: exerciseIdMap.get(section.exerciseId)!
}))
);
// 4c. Build section ID map
oldSections.forEach((oldSection, index) => {
exerciseSectionIdMap.set(oldSection.id, newSections[index].id);
});
// 4d. Remap afterBehavior.exerciseSectionId references
for (const newSection of newSections) {
const behavior = newSection.afterBehavior as any;
if (behavior?.action === 'go_to_section' && behavior.exerciseSectionId) {
const remappedId = exerciseSectionIdMap.get(behavior.exerciseSectionId);
if (remappedId) {
await updateExerciseSection(newSection.id, {
afterBehavior: { action: 'go_to_section', exerciseSectionId: remappedId }
});
}
}
}
}
- When inserting cloned questions, include
exerciseSectionId:
exerciseSectionId: question.exerciseSectionId ? exerciseSectionIdMap.get(question.exerciseSectionId) ?? null : null
- Also copy
sectionDisplayModewhen creating the new exercise.
Files to change:
apps/api/src/services/course/clone.ts
How to verify:
- Clone a course with sectioned exercises.
- Verify cloned exercise has same number of sections with correct titles.
- Verify cloned section color themes are preserved.
- Verify questions are assigned to correct cloned sections.
- Verify afterBehavior
exerciseSectionIdreferences point to the NEW section IDs (not old ones). - Verify
sectionDisplayModeis preserved.
Phase 3: Dashboard — Edit Mode
Goal: Instructors can create, edit, reorder, and delete sections. Questions can be moved between sections.
Ticket 3.1: Extend Exercise Store with Section State
Priority: P0 Estimate: 2–3 hours
What to do:
- Open
apps/dashboard/src/lib/features/course/components/exercise/store.ts. - Add the
ExerciseSectionStateinterface and extend thequestionnairewritable:
import type {
ExerciseSectionAfterBehavior,
ExerciseSectionColorTheme,
ExerciseSectionDisplayMode
} from '@cio/question-types';
export interface ExerciseSectionState {
id: string;
title: string;
description: string | null;
order: number;
colorTheme: ExerciseSectionColorTheme;
afterBehavior: ExerciseSectionAfterBehavior;
isDirty?: boolean;
deletedAt?: string;
}
Add sections and sectionDisplayMode to the questionnaire writable's type and default value (see PRD Section 7.1).
-
Add helper functions (see PRD Section 7.4):
handleAddSection()handleRemoveSection(exerciseSectionId, moveQuestionsToExerciseSectionId?)handleMoveQuestionToSection(questionId, exerciseSectionId)handleUpdateSectionAfterBehavior(exerciseSectionId, afterBehavior)handleUpdateSectionColorTheme(exerciseSectionId, colorTheme)getQuestionsForSection(questions, exerciseSectionId)hasSections(sections)handleReorderSections(orderedSectionIds)
-
Update
handleAddQuestion()to accept an optionalexerciseSectionIdparameter. When sections exist, the new question must have anexerciseSectionId. -
Extend
Questiontype inapps/dashboard/src/lib/features/course/types.tsto includeexerciseSectionId. -
Extend the metadata store to include
currentSectionIndexandsectionPhase.
Files to change:
apps/dashboard/src/lib/features/course/components/exercise/store.tsapps/dashboard/src/lib/features/course/types.ts
How to verify:
- Import and call
handleAddSection()— store updates with a new section. getQuestionsForSection()returns filtered questions.handleMoveQuestionToSection()updates the question'sexerciseSectionId.pnpm --filter @cio/dashboard buildsucceeds.
Ticket 3.2: Section Editor UI Component
Priority: P0 Estimate: 2–3 hours
What to do:
- Create
apps/dashboard/src/lib/features/course/components/exercise/section-editor.svelte. This is the edit-mode section card that wraps existing question editor markup.
interface Props {
exerciseSectionId: string;
title: string;
sectionNumber: number;
totalSections: number;
colorTheme: ExerciseSectionColorTheme;
questionCount: number;
requiredQuestionCount: number;
onTitleChange?: (title: string) => void;
onColorThemeChange?: (colorTheme: ExerciseSectionColorTheme) => void;
onAddQuestion?: () => void;
onDelete?: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
canMoveUp?: boolean;
canMoveDown?: boolean;
labels?: Record<string, string>;
}
-
Render three distinct zones:
- Header: secondary background, drag handle on the far left, selected color dot, editable title input that blends into the background, small color swatches, question count badge, and remove button.
- Content: primary white background with padding, question items stacked vertically, each in a bordered sub-card, plus a centered "Add question" button below the list.
- Footer: top border, muted required-question count on the left, small up/down arrow icon buttons on the right.
-
The color swatches use the supported
ExerciseSectionColorThemevalues. The swatch click updates the section state and marks it dirty. -
Compute
requiredQuestionCountfrom active questions whererequired !== false. Until optional questions are implemented, this equals the active question count. -
Use icon buttons for drag/reorder/remove affordances. Use existing UI button/input primitives and
ui:prefixes for UI theme color classes. -
If section descriptions remain in v1, expose description editing in an expandable section settings area instead of placing it in the compact header.
Files to create:
apps/dashboard/src/lib/features/course/components/exercise/section-editor.svelte
Files to change:
apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte
How to verify:
- Header, content, and footer zones render with the expected backgrounds and borders.
- Typing in the title fires the title callback.
- Selecting a color swatch updates the section color dot and store state.
- Clicking the centered "Add question" button creates a question assigned to the section.
- Up/down arrow buttons reorder sections and respect disabled states at the first/last section.
pnpm --filter @cio/dashboard buildsucceeds.
Ticket 3.3: Section After-Behavior Dropdown
Priority: P0 Estimate: 1–2 hours
What to do:
- Create
packages/ui/src/custom/exercise-question/section-after-behavior.svelte:
interface Props {
sectionIndex: number;
allSections: Array<{ id: string; title: string; order: number }>;
afterBehavior: ExerciseSectionAfterBehavior;
onChange: (behavior: ExerciseSectionAfterBehavior) => void;
labels: {
afterSectionPrefix: string;
continue: string;
goToSection: string;
submit: string;
};
}
-
Render a compact routing settings row near each section card, outside the section editor footer:
- Label: "After section {N}" (left side).
- A
<Select>dropdown (right side) with options:- "Continue to next section" →
{ action: 'continue' } - "Go to section: [Section Title]" → one entry per OTHER section →
{ action: 'go_to_section', exerciseSectionId: '...' } - "Submit exercise" →
{ action: 'submit' }
- "Continue to next section" →
- The currently selected option is shown in the trigger.
-
Style it as a secondary settings row. Do not place it in the section editor footer; that footer is reserved for required-question count and section reorder controls.
-
Export from
packages/ui/src/custom/exercise-question/index.ts.
Files to create:
packages/ui/src/custom/exercise-question/section-after-behavior.svelte
Files to change:
packages/ui/src/custom/exercise-question/index.ts
How to verify:
- Dropdown renders all options.
- Selecting an option fires
onChangewith the correct payload. - "Go to section" shows other section titles (not the current one).
pnpm --filter @cio/ui buildsucceeds.
Ticket 3.4: Edit Mode — Section-Grouped Editor
Priority: P0 Estimate: 4–6 hours
What to do:
This is the largest ticket. It modifies edit-mode.svelte to render questions inside section groups when sections exist.
-
Open
apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte. -
Add a conditional: if
hasSections($questionnaire.sections), render the section-grouped editor. Otherwise, render the existing flat editor (no changes to the current code path). -
For the section-grouped editor:
{#each activeSections as section, sectionIndex}
<SectionEditor
exerciseSectionId={section.id}
title={section.title}
sectionNumber={sectionIndex + 1}
totalSections={activeSections.length}
colorTheme={section.colorTheme}
questionCount={getQuestionsForSection($questionnaire.questions, section.id).length}
requiredQuestionCount={getRequiredQuestionCount(section.id)}
onTitleChange={(title) => updateSection(section.id, { title })}
onColorThemeChange={(colorTheme) => handleUpdateSectionColorTheme(section.id, colorTheme)}
onAddQuestion={() => handleAddQuestion(section.id)}
onDelete={() => initDeleteSection(section.id)}
onMoveUp={() => moveSectionUp(section.id)}
onMoveDown={() => moveSectionDown(section.id)}
canMoveUp={sectionIndex > 0}
canMoveDown={sectionIndex < activeSections.length - 1}
>
{#each getQuestionsForSection($questionnaire.questions, section.id) as question}
{#if !question.deletedAt}
<!-- Reuse existing QuestionContainer + QuestionRenderer markup inside a bordered sub-card -->
<QuestionContainer ...>
...existing question editing UI...
</QuestionContainer>
{/if}
{/each}
</SectionEditor>
{#if sectionRoutingEnabled}
<SectionAfterBehavior
sectionIndex={sectionIndex}
allSections={activeSections}
afterBehavior={section.afterBehavior}
onChange={(behavior) => handleUpdateSectionAfterBehavior(section.id, behavior)}
labels={sectionAfterBehaviorLabels}
/>
{/if}
{/each}
- Add an "Add Section" button at the bottom of the page (or in the header actions area alongside "Add Question"):
<Button variant="outline" onclick={handleAddSection}>
{$t('course.navItem.lessons.exercises.all_exercises.section.add')}
</Button>
- Implement section deletion with the delete modal (ask user whether to move questions or delete them):
Create apps/dashboard/src/lib/features/course/components/exercise/section-delete-modal.svelte:
<Dialog.Root>
<!-- "What should happen to the questions in this section?" -->
<!-- Option A: Move to [select another section] -->
<!-- Option B: Delete questions -->
</Dialog.Root>
-
When the first section is added to a sectionless exercise, auto-create "Section 1" and move all existing questions into it.
-
When the last section is deleted, move all remaining questions to sectionless (set
exerciseSectionIdtonull).
Files to create:
apps/dashboard/src/lib/features/course/components/exercise/section-delete-modal.svelte
Files to change:
apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte
How to verify:
- Adding a section to a sectionless exercise creates "Section 1" with all existing questions.
- Adding a second section creates an empty "Section 2".
- Questions render inside their respective section cards.
- Section title and color theme edits are reflected in the store.
- Section card header, content, and footer match the three-zone UI.
- After-behavior dropdown works and persists selection.
- Deleting a section with "move questions" moves them.
- Deleting a section with "delete questions" marks them as deleted.
- Adding a question to a section assigns it to that section.
pnpm --filter @cio/dashboard buildsucceeds.
Ticket 3.5: Save Flow — Include Sections in API Payload
Priority: P0 Estimate: 2–3 hours
What to do:
- Open
apps/dashboard/src/lib/features/course/pages/exercise.svelte. - In the
handleSavefunction, include sections in the update payload:
const activeSections = $questionnaire.sections.filter((s) => !s.deletedAt);
const sectionsPayload = activeSections.map((section) => ({
id: isUUID(section.id) ? section.id : undefined,
title: section.title,
description: section.description,
order: section.order,
colorTheme: section.colorTheme,
afterBehavior: section.afterBehavior,
questionIds: getQuestionsForSection($questionnaire.questions, section.id)
.map((q) => q.id)
}));
await exerciseApi.update(courseApi.course?.id, exerciseId, {
title: $questionnaire.title ?? '',
description: $questionnaire.description ?? '',
dueBy: $questionnaire.dueBy ?? '',
questions: questions,
sections: sectionsPayload.length > 0 ? sectionsPayload : undefined,
sectionDisplayMode: $questionnaire.sectionDisplayMode
});
-
Update the exercise API class to include
sectionsandsectionDisplayModein the request type. -
On successful save, update the store with the API response (which returns sections with server-generated IDs for new sections).
Files to change:
apps/dashboard/src/lib/features/course/pages/exercise.svelteapps/dashboard/src/lib/features/course/api/exercise.svelte.ts(if needed)
How to verify:
- Save an exercise with sections — sections are persisted.
- Reload the page — sections appear correctly with questions in the right sections.
- Save an exercise without sections — no
sectionsfield sent, no regression.
Ticket 3.6: Exercise Fetch — Populate Sections in Store
Priority: P0 Estimate: 1–2 hours
What to do:
- Find where the exercise data is fetched and populated into the questionnaire store (likely in the exercise page's
loadfunction or an$effectthat callsexerciseApi.get). - When the API response includes
sections, populate$questionnaire.sections:
questionnaire.set({
...existingData,
sections: response.sections.map((section) => ({
id: section.id,
title: section.title,
description: section.description,
order: section.order,
colorTheme: section.colorTheme,
afterBehavior: section.afterBehavior
})),
sectionDisplayMode: response.sectionDisplayMode || 'one_question',
questions: allQuestions.map((q) => ({ ...q, exerciseSectionId: q.exerciseSectionId ?? null }))
});
- Ensure
exerciseSectionIdis set on each question from the API response.
Files to change:
- Exercise page load/fetch logic
- Exercise store population logic
How to verify:
- Navigate to an exercise with sections — store has
sectionspopulated. - Questions have correct
exerciseSectionIdvalues. - Navigate to a sectionless exercise — store has
sections: [].
Ticket 3.7: Section Display Mode Setting
Priority: P1 Estimate: 1 hour
What to do:
- Add a setting in the exercise page (within the "Questions" tab, possibly in the header actions dropdown or a settings panel) that lets the instructor choose the section display mode.
- Use a
ToggleGrouporSelectwith two options:- "One question at a time" (
one_question) - "All questions at once" (
all_questions)
- "One question at a time" (
- This setting is only shown when the exercise has sections.
- Bind to
$questionnaire.sectionDisplayMode.
Files to change:
apps/dashboard/src/lib/features/course/pages/exercise.svelte(or a settings sub-component)
How to verify:
- Setting appears only when sections exist.
- Changing the setting updates the store.
- Saving persists the setting.
- Reloading shows the correct setting.
Ticket 3.8: Preview Mode — Section-Grouped
Priority: P1 Estimate: 2–3 hours
What to do:
- Open
apps/dashboard/src/lib/features/course/components/exercise/preview.svelte. - Create or reuse a read-only
SectionHeadercomponent for take/preview/view modes. This is separate from the edit-modeSectionEditor. - When sections exist, render questions grouped under section headers:
{#if hasSections(activeSections)}
{#each activeSections as section, sectionIndex}
<SectionHeader
title={section.title}
description={section.description}
sectionNumber={sectionIndex + 1}
totalSections={activeSections.length}
colorTheme={section.colorTheme}
questionCount={sectionQuestions.length}
totalPoints={sectionPoints}
mode="preview"
/>
{#each getQuestionsForSection(questions, section.id) as question, qIndex}
<ExerciseQuestion.QuestionRenderer ... />
{/each}
{/each}
{:else}
<!-- existing flat rendering -->
{/if}
- Questions are numbered within their section: "1.", "2." etc., resetting per section.
Files to change:
apps/dashboard/src/lib/features/course/components/exercise/preview.sveltepackages/ui/src/custom/exercise-question/section-header.svelte(if not already created by take-mode work)packages/ui/src/custom/exercise-question/index.ts(if creating the shared header)
How to verify:
- Preview of sectioned exercise shows section headers and grouped questions.
- Preview of sectionless exercise works as before.
Phase 4: Dashboard — Take Mode & Grading
Goal: Students experience section-aware navigation. Teachers see section-grouped grading.
Ticket 4.1: Section Navigation Sidebar Component
Priority: P1 Estimate: 2–3 hours
What to do:
- Create
packages/ui/src/custom/exercise-question/section-navigation-sidebar.svelte:
interface SectionNavQuestion {
key: string;
label: string;
isAnswered: boolean;
isCurrent: boolean;
}
interface SectionNavGroup {
id: string;
title: string;
questions: SectionNavQuestion[];
isCurrent: boolean;
}
interface Props {
sections: SectionNavGroup[];
}
-
Render a compact sidebar showing:
- Section titles as group headers.
- Question pills/badges within each section (e.g., "Q1", "Q2").
- Answered questions have a filled style; unanswered have an outlined style.
- Current question is highlighted.
-
Export from
packages/ui/src/custom/exercise-question/index.ts.
Files to create:
packages/ui/src/custom/exercise-question/section-navigation-sidebar.svelte
Files to change:
packages/ui/src/custom/exercise-question/index.ts
How to verify:
- Component renders section groups with question indicators.
- Visual states (answered, current) display correctly.
pnpm --filter @cio/ui buildsucceeds.
Ticket 4.2: Section Overview Screen Component
Priority: P0 Estimate: 1–2 hours
What to do:
- Create
apps/dashboard/src/lib/features/course/components/exercise/section-overview.svelte:
interface Props {
sectionTitle: string;
sectionDescription?: string | null;
sectionNumber: number;
totalSections: number;
questionCount: number;
totalPoints: number;
onBegin: () => void;
labels: {
beginSection: string;
questions: string;
points: string;
sectionOf: string;
};
}
- Render:
- Section title prominently.
- Section description (if any).
- "N questions · M points"
- "Begin Section" button.
- Pattern similar to the existing exercise start screen in
view-mode.svelte.
Files to create:
apps/dashboard/src/lib/features/course/components/exercise/section-overview.svelte
How to verify:
- Component renders with correct data.
- "Begin Section" button fires callback.
Ticket 4.3: View Mode — Section-Aware Take Flow
Priority: P0 Estimate: 5–7 hours
This is the most complex ticket. It modifies the student take flow to navigate through sections.
What to do:
-
Open
apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte. -
Add section-aware state derived from the metadata store:
const activeSections = $derived(
$questionnaire.sections.filter((s) => !s.deletedAt).sort((a, b) => a.order - b.order)
);
const hasSectionedExercise = $derived(activeSections.length > 0);
const currentSection = $derived(
hasSectionedExercise ? activeSections[$questionnaireMetaData.currentSectionIndex] : null
);
const currentSectionQuestions = $derived(
currentSection
? getQuestionsForSection($questionnaire.questions, currentSection.id)
: $questionnaire.questions
);
const sectionPhase = $derived($questionnaireMetaData.sectionPhase);
- Implement the section state machine:
if hasSectionedExercise:
if sectionPhase === 'overview':
→ render SectionOverview for currentSection
→ "Begin Section" → set sectionPhase to 'questions', reset question index
if sectionPhase === 'questions':
if sectionDisplayMode === 'one_question':
→ render current question (same as existing one-at-a-time flow)
→ on last question answered → evaluate afterBehavior
if sectionDisplayMode === 'all_questions':
→ render all questions in currentSection
→ "Complete Section" button → evaluate afterBehavior
evaluateAfterBehavior(currentSection.afterBehavior):
if action === 'continue':
→ if more sections, advance currentSectionIndex, set sectionPhase to 'overview'
→ if no more sections, submit exercise
if action === 'go_to_section':
→ find target section by exerciseSectionId
→ set currentSectionIndex to target's index, set sectionPhase to 'overview'
if action === 'submit':
→ submit exercise
else:
→ existing flat flow (no changes)
-
Add the
SectionNavigationSidebarto the take mode layout (positioned on the side or as a collapsible panel). -
Update progress calculation:
function getProgressValue() {
if (hasSectionedExercise) {
const totalQuestions = $questionnaire.questions.filter((q) => !q.deletedAt).length;
const answeredCount = Object.keys($questionnaireMetaData.answers).length;
return Math.round((answeredCount / totalQuestions) * 100) || 0;
}
// existing calculation
}
- Update autosave to include
currentSectionIndexandsectionPhase.
Files to change:
apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte
How to verify:
- Student sees section overview before starting each section.
- In
one_questionmode: questions navigate one at a time within a section, then post-section behavior fires. - In
all_questionsmode: all section questions shown, "Complete Section" triggers post-section behavior. - "Continue to next section" advances correctly.
- "Go to section X" jumps to the correct section.
- "Submit exercise" submits immediately.
- Progress bar reflects total questions answered across all sections.
- Navigation sidebar shows correct section/question state.
- Autosave preserves section + question position.
- Sectionless exercises work exactly as before.
Ticket 4.4: Grading — Section-Grouped Individual View
Priority: P1 Estimate: 2–3 hours
What to do:
-
Open
apps/dashboard/src/lib/features/course/components/exercise/submissions/individual.svelte. -
When the exercise has sections, group the question rendering:
{#if hasSections}
{#each activeSections as section}
<div class="section-group mb-6">
<h3 class="text-lg font-semibold mb-2">{section.title}</h3>
{#each getQuestionsForSection($questionnaire.questions, section.id) as q, i}
<div class="pb-4">
<ExerciseQuestion.QuestionRenderer
contract={{
mode: 'view',
question: { ...toExerciseQuestionModel(q), title: `${i + 1}. ${q.title}` },
answer: getStudentAnswerForQuestion($submissions[studentSelected], q),
labels: questionLabels,
disabled: true
}}
/>
</div>
{/each}
<div class="border-t pt-2 text-sm font-medium">
Section subtotal: {sectionScore}/{sectionMaxPoints}
</div>
</div>
{/each}
<div class="border-t-2 pt-3 text-base font-bold">
Total: {totalScore}/{totalMaxPoints}
</div>
{:else}
<!-- existing flat rendering -->
{/if}
Files to change:
apps/dashboard/src/lib/features/course/components/exercise/submissions/individual.svelte
How to verify:
- Sectioned exercise submissions show questions grouped by section with subtotals.
- Sectionless exercise submissions work as before.
Ticket 4.5: Grading — Section-Grouped Summary View
Priority: P1 Estimate: 2–3 hours
What to do:
-
Open
apps/dashboard/src/lib/features/course/components/exercise/submissions/summary.svelte. -
When sections exist, group the chart data by section:
{#if hasSections}
{#each activeSections as section}
<div class="section-summary mb-8">
<h3 class="text-lg font-semibold mb-3">{section.title}</h3>
{#each getSectionTransformedQuestions(section.id) as q}
<!-- existing chart rendering per question -->
{/each}
</div>
{/each}
{:else}
<!-- existing flat rendering -->
{/if}
Files to change:
apps/dashboard/src/lib/features/course/components/exercise/submissions/summary.svelte
How to verify:
- Sectioned exercise summary shows charts grouped by section.
- Sectionless exercise summary works as before.
Ticket 4.6: Translation Keys
Priority: P0 Estimate: 30 minutes
What to do:
- Open
apps/dashboard/src/lib/utils/translations/en.json. - Add all section-related translation keys under
course.navItem.lessons.exercises.all_exercises(see PRD Section 9 for the full list).
Files to change:
apps/dashboard/src/lib/utils/translations/en.json
How to verify:
- All section-related UI text uses translation keys.
- No hardcoded strings in section components.
Dependency Graph
Ticket 1.1 (Schema)
├── Ticket 1.2 (Migration)
├── Ticket 1.3 (Types)
│ └── Ticket 1.4 (Validation)
└── Ticket 1.5 (Queries)
├── Ticket 2.1 (API Fetch)
├── Ticket 2.2 (API Update)
│ └── Ticket 3.5 (Save Flow)
├── Ticket 2.3 (API Submission)
└── Ticket 2.4 (Clone)
Ticket 1.3 (Types)
├── Ticket 3.1 (Store)
│ ├── Ticket 3.4 (Edit Mode)
│ │ └── Ticket 3.5 (Save Flow)
│ ├── Ticket 3.6 (Fetch → Store)
│ └── Ticket 4.3 (View Mode)
├── Ticket 3.2 (Section Editor)
│ └── Ticket 3.4 (Edit Mode)
└── Ticket 3.3 (After-Behavior)
└── Ticket 3.4 (Edit Mode)
Ticket 4.6 (Translations) — can be done any time, needed before any UI ticket ships
Ticket 4.1 (Nav Sidebar) → Ticket 4.3 (View Mode)
Ticket 4.2 (Section Overview) → Ticket 4.3 (View Mode)
Ticket 4.4 (Grading Individual) — depends on Ticket 2.3
Ticket 4.5 (Grading Summary) — depends on Ticket 2.3
Ticket 3.7 (Display Mode Setting) — depends on Ticket 3.1
Ticket 3.8 (Preview) — depends on Ticket 3.1
Suggested Work Order
For a single developer working sequentially:
- Ticket 4.6 — Translations (quick, unblocks all UI work)
- Ticket 1.1 — Schema
- Ticket 1.2 — Migration
- Ticket 1.3 — Types
- Ticket 1.4 — Validation
- Ticket 1.5 — Queries
- Ticket 2.1 — API Fetch
- Ticket 2.2 — API Update
- Ticket 3.1 — Store
- Ticket 3.2 — Section Editor
- Ticket 3.3 — After-Behavior
- Ticket 3.6 — Fetch → Store
- Ticket 3.4 — Edit Mode
- Ticket 3.5 — Save Flow
- Ticket 3.7 — Display Mode Setting
- Ticket 3.8 — Preview
- Ticket 4.2 — Section Overview
- Ticket 4.1 — Nav Sidebar
- Ticket 4.3 — View Mode (Take)
- Ticket 2.3 — API Submission Grouped
- Ticket 4.4 — Grading Individual
- Ticket 4.5 — Grading Summary
- Ticket 2.4 — Clone
Estimated total: 40–55 hours of development time.
Shared Package File Contract
packages/question-types/src/
| File | Status | Description |
|---|---|---|
exercise-section-types.ts |
New | Section model, color theme type, after-behavior types, display mode type |
index.ts |
Modified | Add section types export |
packages/ui/src/custom/exercise-question/
| File | Status | Description |
|---|---|---|
section-header.svelte |
New | Read-only section header for take/preview/view modes |
section-after-behavior.svelte |
New | After-section behavior dropdown |
section-navigation-sidebar.svelte |
New | Section-grouped question navigation |
question-navigation.svelte |
Modified | Add section-aware props |
question-list.svelte |
Modified | Support section-grouped rendering |
index.ts |
Modified | Export new components |
apps/dashboard/src/lib/features/course/components/exercise/
| File | Status | Description |
|---|---|---|
section-editor.svelte |
New | Three-zone section editing card: header, content, footer |
section-overview.svelte |
New | Section overview screen for student take mode |
section-delete-modal.svelte |
New | Section deletion confirmation |