From d71ca63c1f41b324decab86da678cc9f7728df0a Mon Sep 17 00:00:00 2001 From: Eilif Hjermann Lindblad Date: Wed, 22 Apr 2026 13:53:29 +0200 Subject: [PATCH] feat: update stop tasks for option-based submission (#52) * first commit for nyhetskvartalet * updated to use database instead of mock. Added tests * fixed mistake * add canvas-confetti * add celebration component * make stopsuccesspage * fixed another mistake * fixed tests and api error * tiny fix in test * display next stop * fix: add missing failure translations and next stop button * wire stop submission for failed/completed task flows * use task options for news stop * use headline in metadata json * lint --------- Co-authored-by: kristoffer --- package-lock.json | 19 ++ package.json | 2 + src/locales/en.ts | 20 +- src/locales/nb.ts | 20 +- src/map/pages/StopFailurePage.vue | 48 ++++ src/map/pages/StopSuccessPage.vue | 75 ++++++ src/map/routes.ts | 40 +++- src/pupil-portal/routes.ts | 4 +- src/shared/components/AppCelebration.vue | 46 ++++ src/stops/api/stops.api.ts | 84 ++++++- src/stops/components/StopTaskView.vue | 54 +++-- src/stops/components/TaskCard.vue | 46 +++- .../components/tasks/SourceComparisonCard.vue | 223 ++++++++++-------- src/stops/model/__tests__/stop.store.spec.ts | 120 +++++++--- src/stops/model/stop.store.ts | 96 +++++++- src/stops/pages/StopPage.vue | 38 ++- .../model/teacherPlaytestStop.store.ts | 10 +- .../pages/TeacherPlaytestStopPage.vue | 4 +- 18 files changed, 738 insertions(+), 211 deletions(-) create mode 100644 src/map/pages/StopFailurePage.vue create mode 100644 src/map/pages/StopSuccessPage.vue create mode 100644 src/shared/components/AppCelebration.vue diff --git a/package-lock.json b/package-lock.json index 310211de..545f299e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@tailwindcss/vite": "^4.2.2", + "canvas-confetti": "^1.9.4", "lucide-vue-next": "^1.0.0", "pinia": "^3.0.4", "tailwindcss": "^4.2.2", @@ -20,6 +21,7 @@ "devDependencies": { "@playwright/test": "^1.58.2", "@tsconfig/node24": "^24.0.4", + "@types/canvas-confetti": "^1.9.0", "@types/jsdom": "^28.0.1", "@types/node": "^24.12.0", "@types/three": "^0.184.0", @@ -1620,6 +1622,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/canvas-confetti": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz", + "integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -2613,6 +2622,16 @@ "node": ">=8" } }, + "node_modules/canvas-confetti": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz", + "integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==", + "license": "ISC", + "funding": { + "type": "donate", + "url": "https://www.paypal.me/kirilvatev" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", diff --git a/package.json b/package.json index da2c8941..2a39d6fc 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@tailwindcss/vite": "^4.2.2", + "canvas-confetti": "^1.9.4", "lucide-vue-next": "^1.0.0", "pinia": "^3.0.4", "tailwindcss": "^4.2.2", @@ -32,6 +33,7 @@ "devDependencies": { "@playwright/test": "^1.58.2", "@tsconfig/node24": "^24.0.4", + "@types/canvas-confetti": "^1.9.0", "@types/jsdom": "^28.0.1", "@types/node": "^24.12.0", "@types/three": "^0.184.0", diff --git a/src/locales/en.ts b/src/locales/en.ts index 48792981..2f716f96 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -124,13 +124,14 @@ export const en = { stop: { loading: "Loading tasks…", finish: "Finish stop", - backToMap: "Back to map", + submitting: "Checking answers…", answerAllFirst: "Answer all tasks first", selected: "Selected", selectThis: "Select this", - answerKey: "Answer key", correct: "Correct", - incorrect: "Incorrect", + incorrect: "Not quite right", + backToMap: "Back to map", + answerKey: "Answer key", explanation: "Explanation", noTasks: "No published tasks are available for this stop yet.", teacherPlaytestBadge: "Teacher playtest", @@ -197,6 +198,19 @@ export const en = { toggleMusicMute: "Toggle music mute", toggleSfxMute: "Toggle sound effects mute", }, + success: { + stop_completed_title: "You did it!", + stop_completed_message: "Congratulations on completing this stop!", + return_to_map: "Back to map", + next_stop: "Next Stop", + next_stop_label: "Next Stop", + view_recap: "View Map Recap", + }, + failure: { + stop_failed_title: "Oops!", + stop_failed_message: "It didn't work this time.", + try_again: "Try again", + }, avatarEditor: { detectiveName: "Detective {name}", fallbackDisplayName: "Unknown", diff --git a/src/locales/nb.ts b/src/locales/nb.ts index b7d13d10..80cbb2bd 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -124,13 +124,14 @@ export const nb = { stop: { loading: "Laster oppgaver…", finish: "Fullfør stopp", - backToMap: "Tilbake til kartet", + submitting: "Sjekker svar…", answerAllFirst: "Svar på alle oppgavene først", selected: "Valgt", selectThis: "Velg denne", - answerKey: "Fasit", correct: "Riktig", - incorrect: "Feil", + incorrect: "Ikke helt riktig", + backToMap: "Tilbake til kartet", + answerKey: "Fasit", explanation: "Forklaring", noTasks: "Ingen publiserte oppgaver for dette stoppet ennå.", teacherPlaytestBadge: "Lærerspilltest", @@ -197,6 +198,19 @@ export const nb = { toggleMusicMute: "Slå av eller på musikk", toggleSfxMute: "Slå av eller på lydeffekter", }, + success: { + stop_completed_title: "Du klarte det!", + stop_completed_message: "Gratulerer med å ha fullført dette stoppestedet!", + return_to_map: "Tilbake til kartet", + next_stop: "Neste stopp", + next_stop_label: "Neste stoppested", + view_recap: "Se kart-oppsummering", + }, + failure: { + stop_failed_title: "Oi da!", + stop_failed_message: "Det gikk ikke denne gangen.", + try_again: "Prøv på nytt", + }, avatarEditor: { detectiveName: "Detektiv {name}", fallbackDisplayName: "Ukjent", diff --git a/src/map/pages/StopFailurePage.vue b/src/map/pages/StopFailurePage.vue new file mode 100644 index 00000000..a4ad6277 --- /dev/null +++ b/src/map/pages/StopFailurePage.vue @@ -0,0 +1,48 @@ + + + diff --git a/src/map/pages/StopSuccessPage.vue b/src/map/pages/StopSuccessPage.vue new file mode 100644 index 00000000..e41e6daa --- /dev/null +++ b/src/map/pages/StopSuccessPage.vue @@ -0,0 +1,75 @@ + + + diff --git a/src/map/routes.ts b/src/map/routes.ts index 74caa563..fa8858be 100644 --- a/src/map/routes.ts +++ b/src/map/routes.ts @@ -1,17 +1,31 @@ import type { RouteRecordRaw } from 'vue-router' - + import MapPage from '@/map/pages/MapPage.vue' +import StopSuccessPage from '@/map/pages/StopSuccessPage.vue' +import StopFailurePage from '@/map/pages/StopFailurePage.vue' import StopPage from '@/stops/pages/StopPage.vue' -export const pupilMapRoutes: RouteRecordRaw[] = [ - { - path: 'map', - name: 'pupil-map', - component: MapPage, - }, - { - path: 'map/:slug', - name: 'pupil-stop', - component: StopPage, - }, -] +export function createMapRoutes(prefix: string): RouteRecordRaw[] { + return [ + { + path: 'map', + name: `${prefix}-map`, + component: MapPage, + }, + { + path: 'map/:slug', + name: `${prefix}-stop`, + component: StopPage, + }, + { + path: 'map/:slug/success', + name: `${prefix}-stop-success`, + component: StopSuccessPage, + }, + { + path: 'map/:slug/failure', + name: `${prefix}-stop-failure`, + component: StopFailurePage, + }, + ]; +} diff --git a/src/pupil-portal/routes.ts b/src/pupil-portal/routes.ts index 90da34c5..8a943de2 100644 --- a/src/pupil-portal/routes.ts +++ b/src/pupil-portal/routes.ts @@ -3,7 +3,7 @@ import type { RouteRecordRaw } from 'vue-router' import { medalsRoutes } from '@/medals/routes' import { helpRoutes } from '@/help/routes' import { leaderboardRoutes } from '@/leaderboard/routes' -import { pupilMapRoutes } from '@/map/routes' +import { createMapRoutes } from '@/map/routes' import { mysteryRoutes } from '@/mystery/routes' import { notebookRoutes } from '@/notebook/routes' import { profileRoutes } from '@/profile/routes' @@ -28,7 +28,7 @@ export function createPupilPortalChildRoutes(): RouteRecordRaw[] { ...avatarRoutes, ...helpRoutes, ...leaderboardRoutes, - ...pupilMapRoutes, + ...createMapRoutes('pupil'), ...notebookRoutes, ...mysteryRoutes, ...medalsRoutes, diff --git a/src/shared/components/AppCelebration.vue b/src/shared/components/AppCelebration.vue new file mode 100644 index 00000000..6f134f04 --- /dev/null +++ b/src/shared/components/AppCelebration.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/src/stops/api/stops.api.ts b/src/stops/api/stops.api.ts index d265d7e5..a6635f80 100644 --- a/src/stops/api/stops.api.ts +++ b/src/stops/api/stops.api.ts @@ -1,13 +1,21 @@ const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? '').replace(/\/$/, '') const CLASSROOMS_BASE = `${API_BASE_URL}/api/v1/classrooms` -async function fetchJson(url: string, authorizationHeader: string): Promise { -const response = await fetch(url, { -headers: { Authorization: authorizationHeader }, -credentials: 'include', -}) - -if (!response.ok) { +async function fetchJson( + url: string, + authorizationHeader: string, + init: RequestInit = {}, +): Promise { + const response = await fetch(url, { + ...init, + headers: { + Authorization: authorizationHeader, + ...init.headers, + }, + credentials: 'include', + }) + + if (!response.ok) { const fallback = `Request failed with status ${response.status}` const contentType = response.headers.get('content-type') ?? '' if (contentType.includes('application/json')) { @@ -81,6 +89,38 @@ export interface TaskSummaryResponse { itemCount: number } +export interface SubmitStopTaskAnswerPayload { + taskId: number + itemAnswers: Array<{ + itemId: number + selectedOptionId: number + }> +} + +export interface SubmitStopAnswersPayload { + stopId: number + taskAnswers: SubmitStopTaskAnswerPayload[] +} + +export interface TaskSubmissionResultResponse { + taskId: number + title: LocalizedText + passed: boolean + score: number + maxScore: number + feedbackText: LocalizedOptionalText | null +} + +export interface StopSubmissionResponse { + stopId: number + passed: boolean + score: number + maxScore: number + stopStatus: string + completedAt: string | null + taskResults: TaskSubmissionResultResponse[] +} + // Mirrors backend TaskType enum exactly export type TaskType = | 'NEWS_COMPARISON' @@ -94,8 +134,16 @@ export type TaskType = // ---- Metadata shapes per task type ---- // Used in TaskItem.metadataJson for NEWS_COMPARISON tasks -export interface NewsComparisonItemMeta { - domain: string +export interface NewsComparisonArticleMeta { + optionOrder: number + domain: string | null + headline: LocalizedText | null + body: LocalizedText | null + mediaUrl: string | null +} + +export interface NewsComparisonTaskMeta { + articles: NewsComparisonArticleMeta[] } // ---- API functions ---- @@ -120,4 +168,20 @@ export function requestTask( `${CLASSROOMS_BASE}/${classroomId}/tasks/${taskId}`, authorizationHeader, ) -} \ No newline at end of file +} + +export function requestSubmitStopAnswers( + classroomId: number, + payload: SubmitStopAnswersPayload, + authorizationHeader: string, +): Promise { + return fetchJson( + `${CLASSROOMS_BASE}/${classroomId}/tasks/submit-stop`, + authorizationHeader, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + ) +} diff --git a/src/stops/components/StopTaskView.vue b/src/stops/components/StopTaskView.vue index 03f4b27a..59d7b132 100644 --- a/src/stops/components/StopTaskView.vue +++ b/src/stops/components/StopTaskView.vue @@ -3,10 +3,10 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import TaskCard from '@/stops/components/TaskCard.vue' import type { MapStopOverview } from '@/map/model/map.types' -import type { TaskResponse } from '@/stops/api/stops.api' +import type { TaskResponse, TaskSubmissionResultResponse } from '@/stops/api/stops.api' const props = defineProps<{ - answers: Map + answers: Map> badgeLabel?: string | null canFinish: boolean errorMessage: string | null @@ -14,12 +14,15 @@ const props = defineProps<{ showAnswers: boolean status: 'idle' | 'loading' | 'loaded' | 'error' stopOverview: MapStopOverview | null + submitErrorMessage?: string | null + taskResults?: Map tasks: TaskResponse[] }>() const emit = defineEmits<{ finish: [] - select: [taskId: number, itemId: number] + retry: [taskId: number] + select: [taskId: number, itemId: number, optionId: number] }>() const { t } = useI18n() @@ -66,28 +69,39 @@ const hasTasks = computed(() => props.status === 'loaded' && props.tasks.length

{{ errorMessage }}

-
- {{ t('stop.noTasks') }} -
- diff --git a/src/stops/components/TaskCard.vue b/src/stops/components/TaskCard.vue index 9fb1173e..71a56c0f 100644 --- a/src/stops/components/TaskCard.vue +++ b/src/stops/components/TaskCard.vue @@ -1,17 +1,19 @@ diff --git a/src/stops/components/tasks/SourceComparisonCard.vue b/src/stops/components/tasks/SourceComparisonCard.vue index 0dfde9e0..1754afb4 100644 --- a/src/stops/components/tasks/SourceComparisonCard.vue +++ b/src/stops/components/tasks/SourceComparisonCard.vue @@ -1,151 +1,178 @@ diff --git a/src/stops/model/__tests__/stop.store.spec.ts b/src/stops/model/__tests__/stop.store.spec.ts index 2c9f55b5..950227a1 100644 --- a/src/stops/model/__tests__/stop.store.spec.ts +++ b/src/stops/model/__tests__/stop.store.spec.ts @@ -26,7 +26,7 @@ const taskSummary = { difficultyLevel: 1, taskType: 'NEWS_COMPARISON', published: true, - itemCount: 2, + itemCount: 1, updatedAt: null, } @@ -47,18 +47,26 @@ const fullTask = { { id: 1, itemOrder: 1, - promptText: { nb: 'Artikkel A', en: 'Article A' }, + promptText: { nb: 'Hvilken artikkel er troverdig?', en: 'Which article is trustworthy?' }, mediaUrl: null, - metadataJson: '{"domain":"forskning.no"}', - options: [], - }, - { - id: 2, - itemOrder: 2, - promptText: { nb: 'Artikkel B', en: 'Article B' }, - mediaUrl: null, - metadataJson: '{"domain":"klikk.no"}', - options: [], + metadataJson: + '{"articles":[{"optionOrder":1,"domain":"forskning.no","headline":{"nb":"Artikkel A","en":"Article A"},"body":{"nb":"Brødtekst A","en":"Body A"},"mediaUrl":null},{"optionOrder":2,"domain":"klikk.no","headline":{"nb":"Artikkel B","en":"Article B"},"body":{"nb":"Brødtekst B","en":"Body B"},"mediaUrl":null}]}', + options: [ + { + id: 11, + optionOrder: 1, + optionText: { nb: 'Artikkel A', en: 'Article A' }, + isCorrect: null, + explanationText: null, + }, + { + id: 12, + optionOrder: 2, + optionText: { nb: 'Artikkel B', en: 'Article B' }, + isCorrect: null, + explanationText: null, + }, + ], }, ], } @@ -131,7 +139,7 @@ describe('useStopStore', () => { expect(stopStore.status).toBe('loaded') expect(stopStore.tasks).toHaveLength(1) expect(stopStore.tasks[0]?.id).toBe(10) - expect(stopStore.tasks[0]?.items).toHaveLength(2) + expect(stopStore.tasks[0]?.items).toHaveLength(1) }) it('fetches map overview first when it has not been loaded yet', async () => { @@ -152,37 +160,30 @@ describe('useStopStore', () => { expect(fetchMock).toHaveBeenCalledTimes(3) }) - it('sets status to error when not authenticated', async () => { + it('omits unpublished tasks from the loaded list', async () => { + seedAuthStore() + seedMapStore() + + const unpublishedSummary = { ...taskSummary, id: 99, published: false } + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(jsonResponse([unpublishedSummary]))) + const stopStore = useStopStore() await stopStore.loadTasksForStop('nyhetskvartalet') - expect(stopStore.status).toBe('error') - expect(stopStore.errorMessage).toBe('Not authenticated.') + expect(stopStore.tasks).toHaveLength(0) + expect(vi.mocked(fetch)).toHaveBeenCalledOnce() }) - it('sets status to error when the current session is not a pupil session', async () => { - const authStore = useAuthStore() - authStore.accessToken = 'access-token' - authStore.user = { - subject: 'teacher-1', - portalType: 'TEACHER', - email: 'teacher@example.com', - username: null, - authorities: ['ROLE_TEACHER'], - pupilProfile: null, - teacherProfile: null, - } - + it('sets status to error when not authenticated', async () => { const stopStore = useStopStore() await stopStore.loadTasksForStop('nyhetskvartalet') expect(stopStore.status).toBe('error') - expect(stopStore.errorMessage).toBe('Stop access is only available for pupil sessions.') + expect(stopStore.errorMessage).toBe('Not authenticated.') }) it('sets status to error when the slug is not found after fetching map overview', async () => { seedAuthStore() - vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(jsonResponse([]))) const stopStore = useStopStore() @@ -197,7 +198,9 @@ describe('useStopStore', () => { seedMapStore() vi.stubGlobal( 'fetch', - vi.fn().mockResolvedValueOnce(jsonResponse({ detail: 'Forbidden' }, { status: 403 })), + vi.fn().mockResolvedValueOnce( + jsonResponse({ detail: 'Forbidden' }, { status: 403 }), + ), ) const stopStore = useStopStore() @@ -207,14 +210,57 @@ describe('useStopStore', () => { expect(stopStore.errorMessage).toBe('Forbidden') }) - it('records the selected itemId for a task', () => { + it('clears previous tasks when a new load starts', async () => { + seedAuthStore() + seedMapStore() + + vi.stubGlobal( + 'fetch', + vi.fn() + .mockResolvedValueOnce(jsonResponse([taskSummary])) + .mockResolvedValueOnce(jsonResponse(fullTask)), + ) + + const stopStore = useStopStore() + await stopStore.loadTasksForStop('nyhetskvartalet') + expect(stopStore.tasks).toHaveLength(1) + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce(jsonResponse({ detail: 'Forbidden' }, { status: 403 })), + ) + await stopStore.loadTasksForStop('nyhetskvartalet') + + expect(stopStore.tasks).toHaveLength(0) + }) + + it('records the selected option for a task item', () => { + const stopStore = useStopStore() + stopStore.setAnswer(10, 1, 12) + + expect(stopStore.answers.get(10)?.get(1)).toBe(12) + }) + + it('replaces a previous answer for the same task item', () => { + const stopStore = useStopStore() + stopStore.setAnswer(10, 1, 11) + stopStore.setAnswer(10, 1, 12) + + expect(stopStore.answers.get(10)?.get(1)).toBe(12) + expect(stopStore.answers.size).toBe(1) + }) + + it('tracks answers for multiple tasks independently', () => { const stopStore = useStopStore() - stopStore.setAnswer(10, 2) + stopStore.setAnswer(10, 1, 11) + stopStore.setAnswer(11, 2, 24) - expect(stopStore.answers.get(10)).toBe(2) + expect(stopStore.answers.get(10)?.get(1)).toBe(11) + expect(stopStore.answers.get(11)?.get(2)).toBe(24) + expect(stopStore.answers.size).toBe(2) }) - it('reset clears tasks, answers, and error state', async () => { + it('clears tasks, answers, and error state on reset', async () => { seedAuthStore() seedMapStore() @@ -227,7 +273,7 @@ describe('useStopStore', () => { const stopStore = useStopStore() await stopStore.loadTasksForStop('nyhetskvartalet') - stopStore.setAnswer(1, 1) + stopStore.setAnswer(1, 1, 11) stopStore.reset() diff --git a/src/stops/model/stop.store.ts b/src/stops/model/stop.store.ts index 6b69e2b4..57ad5e60 100644 --- a/src/stops/model/stop.store.ts +++ b/src/stops/model/stop.store.ts @@ -2,10 +2,15 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { useAuthStore } from '@/auth/model/auth.store' import { useMapStore } from '@/map/model/map.store' -import { requestTask, requestTasksForStop } from '@/stops/api/stops.api' -import type { TaskResponse } from '@/stops/api/stops.api' +import { requestSubmitStopAnswers, requestTask, requestTasksForStop } from '@/stops/api/stops.api' +import type { + StopSubmissionResponse, + TaskResponse, + TaskSubmissionResultResponse, +} from '@/stops/api/stops.api' export type StopLoadStatus = 'idle' | 'loading' | 'loaded' | 'error' +export type StopSubmitStatus = 'idle' | 'submitting' | 'submitted' | 'error' export const useStopStore = defineStore('stop', () => { const authStore = useAuthStore() @@ -14,8 +19,11 @@ export const useStopStore = defineStore('stop', () => { const tasks = ref([]) const status = ref('idle') const errorMessage = ref(null) - // answers: taskId → selected itemId (local until submission) - const answers = ref>(new Map()) + const submitStatus = ref('idle') + const submitErrorMessage = ref(null) + const submissionResult = ref(null) + // answers: taskId -> (itemId -> selected optionId) + const answers = ref>>(new Map()) async function requireSession() { const classroomId = authStore.user?.pupilProfile?.currentClassroomId ?? null @@ -33,6 +41,9 @@ export const useStopStore = defineStore('stop', () => { status.value = 'loading' errorMessage.value = null tasks.value = [] + submissionResult.value = null + submitStatus.value = 'idle' + submitErrorMessage.value = null try { const session = await requireSession() @@ -64,14 +75,81 @@ export const useStopStore = defineStore('stop', () => { } } - function setAnswer(taskId: number, itemId: number): void { - answers.value = new Map(answers.value).set(taskId, itemId) + function setAnswer(taskId: number, itemId: number, optionId: number): void { + const nextAnswers = new Map(answers.value) + const taskAnswers = new Map(nextAnswers.get(taskId) ?? []) + taskAnswers.set(itemId, optionId) + nextAnswers.set(taskId, taskAnswers) + answers.value = nextAnswers + clearTaskResult(taskId) + } + + function getTaskResult(taskId: number): TaskSubmissionResultResponse | null { + return submissionResult.value?.taskResults.find((result) => result.taskId === taskId) ?? null + } + + function clearTaskResult(taskId: number): void { + if (!submissionResult.value) return + + submissionResult.value = { + ...submissionResult.value, + taskResults: submissionResult.value.taskResults.filter((result) => result.taskId !== taskId), + } + + if (submissionResult.value.taskResults.length === 0) { + submissionResult.value = null + submitStatus.value = 'idle' + } + } + + async function submitStop(stopId: number): Promise { + const session = await requireSession() + + submitStatus.value = 'submitting' + submitErrorMessage.value = null + + try { + const taskAnswers = tasks.value.map((task) => { + const taskAnswerSelections = answers.value.get(task.id) + return { + taskId: task.id, + itemAnswers: task.items.map((item) => { + const selectedOptionId = taskAnswerSelections?.get(item.id) + if (selectedOptionId == null) { + throw new Error('Answer every task before submitting the stop.') + } + return { + itemId: item.id, + selectedOptionId, + } + }), + } + }) + + const result = await requestSubmitStopAnswers( + session.classroomId, + { stopId, taskAnswers }, + session.authorizationHeader, + ) + + submissionResult.value = result + submitStatus.value = 'submitted' + return result + } catch (error) { + submitErrorMessage.value = + error instanceof Error ? error.message : 'Failed to submit stop answers.' + submitStatus.value = 'error' + throw error + } } function reset(): void { tasks.value = [] status.value = 'idle' errorMessage.value = null + submitStatus.value = 'idle' + submitErrorMessage.value = null + submissionResult.value = null answers.value = new Map() } @@ -79,9 +157,15 @@ export const useStopStore = defineStore('stop', () => { tasks, status, errorMessage, + submitStatus, + submitErrorMessage, + submissionResult, answers, loadTasksForStop, setAnswer, + getTaskResult, + clearTaskResult, + submitStop, reset, } }) diff --git a/src/stops/pages/StopPage.vue b/src/stops/pages/StopPage.vue index bceabd4d..0958cc31 100644 --- a/src/stops/pages/StopPage.vue +++ b/src/stops/pages/StopPage.vue @@ -15,16 +15,37 @@ const mapStore = useMapStore() const slug = computed(() => route.params.slug as string) const stopOverview = computed(() => mapStore.stops.find((stop) => stop.slug === slug.value) ?? null) const answers = computed(() => stopStore.answers) +const taskResults = computed( + () => new Map(stopStore.submissionResult?.taskResults.map((result) => [result.taskId, result]) ?? []), +) + const allAnswered = computed( - () => stopStore.tasks.length > 0 && stopStore.tasks.every((task) => stopStore.answers.has(task.id)), + () => + stopStore.tasks.length > 0 && + stopStore.tasks.every((task) => + task.items.every((item) => stopStore.answers.get(task.id)?.has(item.id) ?? false), + ), ) -function handleSelect(taskId: number, itemId: number): void { - stopStore.setAnswer(taskId, itemId) +const isSubmitting = computed(() => stopStore.submitStatus === 'submitting') + +function handleSelect(taskId: number, itemId: number, optionId: number): void { + stopStore.setAnswer(taskId, itemId, optionId) } -function handleFinish(): void { - void router.push({ name: 'pupil-map' }) +function handleRetry(taskId: number): void { + stopStore.clearTaskResult(taskId) +} + +async function handleFinish(): Promise { + if (!stopOverview.value) return + + const result = await stopStore.submitStop(stopOverview.value.stopId) + await mapStore.fetchMapOverview() + + if (result.passed) { + await router.push({ name: 'pupil-stop-success', params: { slug: slug.value } }) + } } onMounted(() => { @@ -39,14 +60,17 @@ onUnmounted(() => { diff --git a/src/teacher-playtest/model/teacherPlaytestStop.store.ts b/src/teacher-playtest/model/teacherPlaytestStop.store.ts index 03c167c2..8de61818 100644 --- a/src/teacher-playtest/model/teacherPlaytestStop.store.ts +++ b/src/teacher-playtest/model/teacherPlaytestStop.store.ts @@ -17,7 +17,7 @@ export const useTeacherPlaytestStopStore = defineStore('teacherPlaytestStop', () const tasks = ref([]) const status = ref('idle') const errorMessage = ref(null) - const answers = ref>(new Map()) + const answers = ref>>(new Map()) function reset() { tasks.value = [] @@ -26,8 +26,12 @@ export const useTeacherPlaytestStopStore = defineStore('teacherPlaytestStop', () answers.value = new Map() } - function setAnswer(taskId: number, itemId: number): void { - answers.value = new Map(answers.value).set(taskId, itemId) + function setAnswer(taskId: number, itemId: number, optionId: number): void { + const nextAnswers = new Map(answers.value) + const taskAnswers = new Map(nextAnswers.get(taskId) ?? []) + taskAnswers.set(itemId, optionId) + nextAnswers.set(taskId, taskAnswers) + answers.value = nextAnswers } function requireTeacherSession() { diff --git a/src/teacher-playtest/pages/TeacherPlaytestStopPage.vue b/src/teacher-playtest/pages/TeacherPlaytestStopPage.vue index 77c71251..930e662a 100644 --- a/src/teacher-playtest/pages/TeacherPlaytestStopPage.vue +++ b/src/teacher-playtest/pages/TeacherPlaytestStopPage.vue @@ -17,8 +17,8 @@ const slug = computed(() => String(route.params.slug ?? '')) const stopOverview = computed(() => mapStore.stops.find((stop) => stop.slug === slug.value) ?? null) const answers = computed(() => stopStore.answers) -function handleSelect(taskId: number, itemId: number): void { - stopStore.setAnswer(taskId, itemId) +function handleSelect(taskId: number, itemId: number, optionId: number): void { + stopStore.setAnswer(taskId, itemId, optionId) } function handleFinish(): void {