From c0bd9f733506a73b1527ef97fde114d685e310e6 Mon Sep 17 00:00:00 2001 From: kristoffer Date: Mon, 20 Apr 2026 11:10:47 +0200 Subject: [PATCH 01/15] first commit for nyhetskvartalet --- package-lock.json | 35 +-- src/locales/en.ts | 8 + src/locales/nb.ts | 8 + src/map/routes.ts | 6 + src/stops/api/stops.api.ts | 113 ++++++++ src/stops/components/TaskCard.vue | 48 ++++ .../components/tasks/SourceComparisonCard.vue | 107 +++++++ src/stops/model/__tests__/stop.store.spec.ts | 260 ++++++++++++++++++ src/stops/model/stop.store.ts | 180 ++++++++++++ src/stops/pages/StopPage.vue | 132 +++++++++ 10 files changed, 876 insertions(+), 21 deletions(-) create mode 100644 src/stops/api/stops.api.ts create mode 100644 src/stops/components/TaskCard.vue create mode 100644 src/stops/components/tasks/SourceComparisonCard.vue create mode 100644 src/stops/model/__tests__/stop.store.spec.ts create mode 100644 src/stops/model/stop.store.ts create mode 100644 src/stops/pages/StopPage.vue diff --git a/package-lock.json b/package-lock.json index 310211de..9e7f1ac8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -262,6 +262,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -310,6 +311,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -321,27 +323,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1731,6 +1712,7 @@ "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.2", @@ -1770,6 +1752,7 @@ "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", @@ -2442,6 +2425,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3066,6 +3050,7 @@ "integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -3167,6 +3152,7 @@ "integrity": "sha512-f1J/tcbnrpgC8suPN5AtdJ5MQjuXbSU9pGRSSYAuF3SHoiYCOdEX6O22pLaRyLHXvDcOe+O5ENgc1owQ587agA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "natural-compare": "^1.4.0", @@ -4525,6 +4511,7 @@ "integrity": "sha512-DGFsuBX5MFZX9yiDdtKjTrYPq45CZ8Fft6qCltJITYZxfwYjVdGf/6wycGYTACloauwIPxUnYhBVeZbHvleGhw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "oxlint": "bin/oxlint" }, @@ -4716,6 +4703,7 @@ "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", "license": "MIT", + "peer": true, "dependencies": { "@vue/devtools-api": "^7.7.7" }, @@ -5404,6 +5392,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5519,6 +5508,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5650,6 +5640,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -5768,6 +5759,7 @@ "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.4", "@vitest/mocker": "4.1.4", @@ -5877,6 +5869,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.32", "@vue/compiler-sfc": "3.5.32", diff --git a/src/locales/en.ts b/src/locales/en.ts index 5ef4a1b4..057f59a0 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -101,6 +101,14 @@ export const en = { map: { lockedMessage: "Complete the previous stop first", }, + stop: { + loading: 'Loading tasks…', + finish: 'Finish stop', + answerAllFirst: 'Answer all tasks first', + selected: 'Selected', + selectThis: 'Select this', + unsupportedTaskType: 'Task type "{type}" is not yet supported.', + }, pupilHome: { title: "Pupil home page", defaultName: "Pupil", diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 9bb02245..3c98c594 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -101,6 +101,14 @@ export const nb = { map: { lockedMessage: "Fullfør det forrige stoppet først", }, + stop: { + loading: 'Laster oppgaver…', + finish: 'Fullfør stopp', + answerAllFirst: 'Svar på alle oppgavene først', + selected: 'Valgt', + selectThis: 'Velg denne', + unsupportedTaskType: 'Oppgavetype «{type}» støttes ikke ennå.', + }, pupilHome: { title: "Elevhjemmeside", defaultName: "Elev", diff --git a/src/map/routes.ts b/src/map/routes.ts index fed32324..c051c615 100644 --- a/src/map/routes.ts +++ b/src/map/routes.ts @@ -1,6 +1,7 @@ import type { RouteRecordRaw } from 'vue-router' import MapPage from '@/map/pages/MapPage.vue' +import StopPage from '@/stops/pages/StopPage.vue' export function createMapRoutes(prefix: string): RouteRecordRaw[] { return [ @@ -9,5 +10,10 @@ export function createMapRoutes(prefix: string): RouteRecordRaw[] { name: `${prefix}-map`, component: MapPage, }, + { + path: 'map/:slug', + name: `${prefix}-stop`, + component: StopPage, + }, ]; } diff --git a/src/stops/api/stops.api.ts b/src/stops/api/stops.api.ts new file mode 100644 index 00000000..33edfad1 --- /dev/null +++ b/src/stops/api/stops.api.ts @@ -0,0 +1,113 @@ +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) { + const text = await response.text().catch(() => '') + throw new Error(text || `Request failed with status ${response.status}`) + } + + return response.json() as Promise +} + +// ---- Response shapes (mirrors backend TaskResponses.kt) ---- + +export interface LocalizedText { + nb: string + en: string +} + +export interface LocalizedOptionalText { + nb: string | null + en: string | null +} + +export interface TaskOptionResponse { + id: number + optionOrder: number + optionText: LocalizedText + isCorrect: boolean | null + explanationText: LocalizedOptionalText | null +} + +export interface TaskItemResponse { + id: number + itemOrder: number + promptText: LocalizedText + mediaUrl: string | null + metadataJson: string | null + options: TaskOptionResponse[] +} + +export interface TaskResponse { + id: number + classroomId: number + stopId: number + createdByTeacherId: number + title: LocalizedText + introText: LocalizedOptionalText | null + difficultyLevel: number + taskType: TaskType + passingRule: string + passingScore: number | null + maxScore: number + published: boolean + items: TaskItemResponse[] +} + +export interface TaskSummaryResponse { + id: number + classroomId: number + stopId: number + title: LocalizedText + difficultyLevel: number + taskType: TaskType + published: boolean + itemCount: number +} + +// Mirrors backend TaskType enum exactly +export type TaskType = + | 'NEWS_COMPARISON' + | 'IMAGE_ASSESSMENT' + | 'EMAIL_REVIEW' + | 'PASSWORD_EVALUATION' + | 'SOCIAL_SCENARIO' + | 'FINAL_BOSS' + | 'GENERIC' + +// ---- Metadata shapes per task type ---- + +// Used in TaskItem.metadataJson for NEWS_COMPARISON tasks +export interface NewsComparisonItemMeta { + domain: string +} + +// ---- API functions ---- + +export function requestTasksForStop( + classroomId: number, + stopId: number, + authorizationHeader: string, +): Promise { + return fetchJson( + `${CLASSROOMS_BASE}/${classroomId}/tasks?stopId=${stopId}`, + authorizationHeader, + ) +} + +export function requestTask( + classroomId: number, + taskId: number, + authorizationHeader: string, +): Promise { + return fetchJson( + `${CLASSROOMS_BASE}/${classroomId}/tasks/${taskId}`, + authorizationHeader, + ) +} diff --git a/src/stops/components/TaskCard.vue b/src/stops/components/TaskCard.vue new file mode 100644 index 00000000..31b2cb99 --- /dev/null +++ b/src/stops/components/TaskCard.vue @@ -0,0 +1,48 @@ + + + diff --git a/src/stops/components/tasks/SourceComparisonCard.vue b/src/stops/components/tasks/SourceComparisonCard.vue new file mode 100644 index 00000000..83f99d60 --- /dev/null +++ b/src/stops/components/tasks/SourceComparisonCard.vue @@ -0,0 +1,107 @@ + + + diff --git a/src/stops/model/__tests__/stop.store.spec.ts b/src/stops/model/__tests__/stop.store.spec.ts new file mode 100644 index 00000000..3d0a55a3 --- /dev/null +++ b/src/stops/model/__tests__/stop.store.spec.ts @@ -0,0 +1,260 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +import { useStopStore } from '@/stops/model/stop.store' +import { useAuthStore } from '@/auth/model/auth.store' +import { useMapStore } from '@/map/model/map.store' + +function jsonResponse(body: unknown, init: ResponseInit = {}) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + ...init, + }) +} + +const CLASSROOM_ID = 2 +const PUPIL_USER_ID = 99 +const STOP_ID = 1 + +const taskSummary = { + id: 10, + classroomId: CLASSROOM_ID, + stopId: STOP_ID, + title: { nb: 'Hvilken kilde er troverdig?', en: 'Which source is trustworthy?' }, + difficultyLevel: 1, + taskType: 'NEWS_COMPARISON', + published: true, + itemCount: 2, + updatedAt: null, +} + +const fullTask = { + id: 10, + classroomId: CLASSROOM_ID, + stopId: STOP_ID, + createdByTeacherId: 5, + title: { nb: 'Hvilken kilde er troverdig?', en: 'Which source is trustworthy?' }, + introText: null, + difficultyLevel: 1, + taskType: 'NEWS_COMPARISON', + passingRule: 'ALL_CORRECT', + passingScore: 1, + maxScore: 1, + published: true, + items: [ + { + id: 1, + itemOrder: 1, + promptText: { nb: 'Artikkel A', en: 'Article A' }, + 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: [], + }, + ], +} + +function seedAuthStore() { + const authStore = useAuthStore() + authStore.accessToken = 'access-token' + authStore.user = { + subject: String(PUPIL_USER_ID), + portalType: 'PUPIL', + email: null, + username: 'pupil-one', + authorities: ['ROLE_PUPIL'], + pupilProfile: { + displayName: 'Pupil One', + totalXp: 0, + currentLevel: 1, + currentClassroomId: CLASSROOM_ID, + }, + teacherProfile: null, + } +} + +function seedMapStore() { + const mapStore = useMapStore() + mapStore.stops = [ + { + stopId: STOP_ID, + slug: 'nyhetskvartalet', + title: 'Nyhetskvartalet', + category: 'media', + displayOrder: 1, + isFinalBoss: false, + status: 'NOT_STARTED', + stars: 0, + medalAwarded: false, + }, + ] +} + +describe('useStopStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.restoreAllMocks() + }) + + describe('initial state', () => { + it('starts idle with no tasks and no answers', () => { + const stopStore = useStopStore() + + expect(stopStore.status).toBe('idle') + expect(stopStore.tasks).toEqual([]) + expect(stopStore.answers.size).toBe(0) + expect(stopStore.errorMessage).toBeNull() + }) + }) + + describe('loadTasksForStop', () => { + it('fetches summaries then full details for each published task', async () => { + seedAuthStore() + seedMapStore() + + // First call: task list. Second call: full task details. + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse([taskSummary])) + .mockResolvedValueOnce(jsonResponse(fullTask)) + vi.stubGlobal('fetch', fetchMock) + + const stopStore = useStopStore() + await stopStore.loadTasksForStop('nyhetskvartalet', false) + + expect(stopStore.status).toBe('loaded') + expect(stopStore.tasks).toHaveLength(1) + expect(stopStore.tasks[0]?.id).toBe(10) + expect(stopStore.tasks[0]?.items).toHaveLength(2) + }) + + 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', false) + + expect(stopStore.tasks).toHaveLength(0) + // fetch should not have been called a second time since nothing was published + expect(vi.mocked(fetch)).toHaveBeenCalledOnce() + }) + + it('sets status to error when not authenticated', async () => { + // No auth store seeded + + const stopStore = useStopStore() + await stopStore.loadTasksForStop('nyhetskvartalet', false) + + expect(stopStore.status).toBe('error') + expect(stopStore.errorMessage).toBe('Not authenticated.') + }) + + it('sets status to error when the slug is not in the map store', async () => { + seedAuthStore() + // Map store left empty — no stops + + const stopStore = useStopStore() + await stopStore.loadTasksForStop('nyhetskvartalet', false) + + expect(stopStore.status).toBe('error') + expect(stopStore.errorMessage).toContain('nyhetskvartalet') + }) + + it('sets status to error when the API call fails', async () => { + seedAuthStore() + seedMapStore() + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce( + jsonResponse({ detail: 'Forbidden' }, { status: 403 }), + ), + ) + + const stopStore = useStopStore() + await stopStore.loadTasksForStop('nyhetskvartalet', false) + + expect(stopStore.status).toBe('error') + expect(stopStore.errorMessage).toBe('Forbidden') + }) + + 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', false) + expect(stopStore.tasks).toHaveLength(1) + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce(jsonResponse({ detail: 'Forbidden' }, { status: 403 })), + ) + await stopStore.loadTasksForStop('nyhetskvartalet', false) + + // tasks are cleared at the start of the new load + expect(stopStore.tasks).toHaveLength(0) + }) + }) + + describe('setAnswer', () => { + it('records the selected itemId for a task', () => { + const stopStore = useStopStore() + stopStore.setAnswer(10, 2) + + expect(stopStore.answers.get(10)).toBe(2) + }) + + it('replaces a previous answer for the same task', () => { + const stopStore = useStopStore() + stopStore.setAnswer(10, 1) + stopStore.setAnswer(10, 2) + + expect(stopStore.answers.get(10)).toBe(2) + expect(stopStore.answers.size).toBe(1) + }) + + it('tracks answers for multiple tasks independently', () => { + const stopStore = useStopStore() + stopStore.setAnswer(10, 1) + stopStore.setAnswer(11, 4) + + expect(stopStore.answers.get(10)).toBe(1) + expect(stopStore.answers.get(11)).toBe(4) + expect(stopStore.answers.size).toBe(2) + }) + }) + + describe('reset', () => { + it('clears tasks, answers, and error state', async () => { + const stopStore = useStopStore() + await stopStore.loadTasksForStop('nyhetskvartalet', true) + stopStore.setAnswer(1, 1) + + stopStore.reset() + + expect(stopStore.tasks).toEqual([]) + expect(stopStore.answers.size).toBe(0) + expect(stopStore.status).toBe('idle') + expect(stopStore.errorMessage).toBeNull() + }) + }) +}) diff --git a/src/stops/model/stop.store.ts b/src/stops/model/stop.store.ts new file mode 100644 index 00000000..508c0a99 --- /dev/null +++ b/src/stops/model/stop.store.ts @@ -0,0 +1,180 @@ +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' + +export type StopLoadStatus = 'idle' | 'loading' | 'loaded' | 'error' + +// ---- Mock data ---- +// Used until tasks are seeded in the database. +// Remove this and the useMock path in loadTasksForStop once the backend is populated. + +const MOCK_TASKS: TaskResponse[] = [ + { + id: 1, + classroomId: 1, + stopId: 1, + createdByTeacherId: 1, + title: { nb: 'Hvilken kilde er troverdig?', en: 'Which source is trustworthy?' }, + introText: { + nb: 'Les de to artiklene og velg den som virker mest troverdig.', + en: 'Read both articles and select the one that appears most trustworthy.', + }, + difficultyLevel: 1, + taskType: 'NEWS_COMPARISON', + passingRule: 'ALL_CORRECT', + passingScore: 1, + maxScore: 1, + published: true, + items: [ + { + id: 1, + itemOrder: 1, + promptText: { + nb: 'HELT UTROLIG! Forskning beviser at sjokolade er sunt!\n\nEn ny, banebrekkende studie har ENDELIG bevist det vi alle har håpet på: sjokolade er like sunt som – eller kanskje til og med sunnere enn – grønnsaker! Legen som ledet forskningen ble selv overrasket over resultatene.\n\n"Vi kan ikke tro det vi ser. Sjokolade kurerer nesten alt," skal en navnløs forsker ha sagt.\n\nEksperter fra hele verden er nå i sjokk. Det anbefales å spise minst 200 gram sjokolade daglig for å holde seg frisk. Grønnsaker kan trygt droppes!\nDel denne artikkelen med alle du kjenner FØR myndighetene sensurerer den – Big Pharma vil ikke at du skal vite dette!', + en: 'UNBELIEVABLE! Research proves chocolate is healthy!\n\nA groundbreaking new study has FINALLY proven what we all hoped: chocolate is just as healthy as – or perhaps even healthier than – vegetables! The doctor who led the research was surprised by the results.\n\n"We cannot believe what we are seeing. Chocolate cures almost everything," an anonymous researcher allegedly said.\n\nExperts worldwide are now in shock. It is recommended to eat at least 200 grams of chocolate daily to stay healthy. Vegetables can safely be dropped!\nShare this article with everyone you know BEFORE the authorities censor it – Big Pharma does not want you to know this!', + }, + mediaUrl: null, + metadataJson: JSON.stringify({ domain: 'helserevolusjonen.no' }), + options: [], + }, + { + id: 2, + itemOrder: 2, + promptText: { + nb: 'Studie: Mørk sjokolade kan ha helsegevinster\n\nEn ny studie publisert i tidsskriftet Nutrients viser at flavonoider i mørk sjokolade (70 % kakao eller mer) kan bidra til lavere blodtrykk og bedre insulinfølsomhet hos noen voksne.\n\n"Effekten er reell, men begrenset. Dette er ikke en erstatning for et sunt kosthold med grønnsaker, frukt og fiber," sier ernæringsforsker Tone Berg ved UiO.\n\nStudien fulgte 200 deltakere over 12 uker og målte blodtrykk og betennelsesmarkører. Forskerne understreker at porsjonsstørrelse er avgjørende – rundt 20–30 gram per dag var mengden brukt i forsøket. Helsedirektoratet minner om at sjokolade fortsatt er kaloririk og ikke inngår i de generelle kostrådene.', + en: 'Study: Dark chocolate may have health benefits\n\nA new study published in the journal Nutrients shows that flavonoids in dark chocolate (70% cocoa or more) may contribute to lower blood pressure and better insulin sensitivity in some adults.\n\n"The effect is real but limited. This is not a replacement for a healthy diet with vegetables, fruit, and fibre," says nutritional researcher Tone Berg at UiO.\n\nThe study followed 200 participants over 12 weeks and measured blood pressure and inflammation markers. Researchers emphasise that portion size is decisive – around 20–30 grams per day was the amount used in the trial. The Directorate of Health notes that chocolate remains calorie-dense and is not part of general dietary guidelines.', + }, + mediaUrl: 'https://images.unsplash.com/photo-1481391319762-47dff72954d9?w=600&q=80', + metadataJson: JSON.stringify({ domain: 'forskning.no' }), + options: [], + }, + ], + }, + { + id: 2, + classroomId: 1, + stopId: 1, + createdByTeacherId: 1, + title: { nb: 'Kjenner du igjen klikkagn?', en: 'Can you spot clickbait?' }, + introText: { + nb: 'Velg artikkelen som bruker seriøst og nøytralt språk.', + en: 'Select the article that uses serious and neutral language.', + }, + difficultyLevel: 2, + taskType: 'NEWS_COMPARISON', + passingRule: 'ALL_CORRECT', + passingScore: 1, + maxScore: 1, + published: true, + items: [ + { + id: 3, + itemOrder: 1, + promptText: { + nb: 'LEGEN HAR IKKE FORTALT DEG DETTE! Én enkel ting kurerer alle sykdommer\n\nLegemiddelindustrien skjuler en hemmelighet som kan endre livet ditt. En ukjent doktor avslørte nylig at du ALDRI trenger å bli syk igjen. Klikk her for å finne ut hva Big Pharma ikke vil du skal vite.', + en: 'YOUR DOCTOR NEVER TOLD YOU THIS! One simple thing cures all diseases\n\nThe pharmaceutical industry is hiding a secret that could change your life. An unknown doctor recently revealed you NEVER have to get sick again. Click here to find out what Big Pharma does not want you to know.', + }, + mediaUrl: null, + metadataJson: JSON.stringify({ domain: 'helseavsloringer.net' }), + options: [], + }, + { + id: 4, + itemOrder: 2, + promptText: { + nb: 'Ny rapport: Regelmessig mosjon reduserer risiko for hjerte- og karsykdommer\n\nFolkehelseinstituttet presenterte i dag en ny rapport som viser at 150 minutter moderat fysisk aktivitet per uke er forbundet med 35 prosent lavere risiko for hjerteinfarkt hos voksne over 40 år. Rapporten bygger på data fra over 50 000 nordmenn fulgt over ti år.', + en: 'New report: Regular exercise reduces risk of cardiovascular disease\n\nThe Norwegian Institute of Public Health today presented a new report showing that 150 minutes of moderate physical activity per week is associated with a 35 percent lower risk of heart attack in adults over 40. The report is based on data from over 50,000 Norwegians followed over ten years.', + }, + mediaUrl: null, + metadataJson: JSON.stringify({ domain: 'fhi.no' }), + options: [], + }, + ], + }, +] + +// ---- Store ---- + +export const useStopStore = defineStore('stop', () => { + const authStore = useAuthStore() + const mapStore = useMapStore() + + const tasks = ref([]) + const status = ref('idle') + const errorMessage = ref(null) + // answers: taskId → selected itemId (local until submission) + const answers = ref>(new Map()) + + function requireSession() { + const header = authStore.authorizationHeader + if (!header) throw new Error('Not authenticated.') + + const classroomId = authStore.user?.pupilProfile?.currentClassroomId + if (classroomId == null) throw new Error('No active classroom found.') + + return { authorizationHeader: header, classroomId } + } + + async function loadTasksForStop(slug: string, useMock = false): Promise { + status.value = 'loading' + errorMessage.value = null + tasks.value = [] + + try { + if (useMock) { + await new Promise((r) => setTimeout(r, 300)) + tasks.value = MOCK_TASKS + status.value = 'loaded' + return + } + + const session = requireSession() + + // Resolve stopId from slug via the already-loaded map store + const stop = mapStore.stops.find((s) => s.slug === slug) + if (!stop) throw new Error(`Stop with slug "${slug}" not found.`) + + const summaries = await requestTasksForStop( + session.classroomId, + stop.stopId, + session.authorizationHeader, + ) + + const published = summaries.filter((s) => s.published) + + // Fetch full task details in parallel + tasks.value = await Promise.all( + published.map((s) => requestTask(session.classroomId, s.id, session.authorizationHeader)), + ) + + status.value = 'loaded' + } catch (error) { + errorMessage.value = error instanceof Error ? error.message : 'Failed to load tasks.' + status.value = 'error' + } + } + + function setAnswer(taskId: number, itemId: number): void { + answers.value = new Map(answers.value).set(taskId, itemId) + } + + function reset(): void { + tasks.value = [] + status.value = 'idle' + errorMessage.value = null + answers.value = new Map() + } + + return { + tasks, + status, + errorMessage, + answers, + loadTasksForStop, + setAnswer, + reset, + } +}) diff --git a/src/stops/pages/StopPage.vue b/src/stops/pages/StopPage.vue new file mode 100644 index 00000000..c8617bc7 --- /dev/null +++ b/src/stops/pages/StopPage.vue @@ -0,0 +1,132 @@ + + + From 0e4eaa925475e0ed98ba18942a6f7f865d6294b2 Mon Sep 17 00:00:00 2001 From: kristoffer Date: Mon, 20 Apr 2026 13:27:35 +0200 Subject: [PATCH 02/15] updated to use database instead of mock. Added tests --- package-lock.json | 35 +++--- src/stops/model/__tests__/stop.store.spec.ts | 16 +-- src/stops/model/stop.store.ts | 107 ++----------------- src/stops/pages/StopPage.vue | 4 +- 4 files changed, 36 insertions(+), 126 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9e7f1ac8..310211de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -262,7 +262,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -311,7 +310,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -323,6 +321,27 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1712,7 +1731,6 @@ "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.2", @@ -1752,7 +1770,6 @@ "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", @@ -2425,7 +2442,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3050,7 +3066,6 @@ "integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -3152,7 +3167,6 @@ "integrity": "sha512-f1J/tcbnrpgC8suPN5AtdJ5MQjuXbSU9pGRSSYAuF3SHoiYCOdEX6O22pLaRyLHXvDcOe+O5ENgc1owQ587agA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "natural-compare": "^1.4.0", @@ -4511,7 +4525,6 @@ "integrity": "sha512-DGFsuBX5MFZX9yiDdtKjTrYPq45CZ8Fft6qCltJITYZxfwYjVdGf/6wycGYTACloauwIPxUnYhBVeZbHvleGhw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "oxlint": "bin/oxlint" }, @@ -4703,7 +4716,6 @@ "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/devtools-api": "^7.7.7" }, @@ -5392,7 +5404,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5508,7 +5519,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5640,7 +5650,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -5759,7 +5768,6 @@ "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.1.4", "@vitest/mocker": "4.1.4", @@ -5869,7 +5877,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.32", "@vue/compiler-sfc": "3.5.32", diff --git a/src/stops/model/__tests__/stop.store.spec.ts b/src/stops/model/__tests__/stop.store.spec.ts index 3d0a55a3..d59a9e17 100644 --- a/src/stops/model/__tests__/stop.store.spec.ts +++ b/src/stops/model/__tests__/stop.store.spec.ts @@ -128,7 +128,7 @@ describe('useStopStore', () => { vi.stubGlobal('fetch', fetchMock) const stopStore = useStopStore() - await stopStore.loadTasksForStop('nyhetskvartalet', false) + await stopStore.loadTasksForStop('nyhetskvartalet') expect(stopStore.status).toBe('loaded') expect(stopStore.tasks).toHaveLength(1) @@ -144,7 +144,7 @@ describe('useStopStore', () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(jsonResponse([unpublishedSummary]))) const stopStore = useStopStore() - await stopStore.loadTasksForStop('nyhetskvartalet', false) + await stopStore.loadTasksForStop('nyhetskvartalet') expect(stopStore.tasks).toHaveLength(0) // fetch should not have been called a second time since nothing was published @@ -155,7 +155,7 @@ describe('useStopStore', () => { // No auth store seeded const stopStore = useStopStore() - await stopStore.loadTasksForStop('nyhetskvartalet', false) + await stopStore.loadTasksForStop('nyhetskvartalet') expect(stopStore.status).toBe('error') expect(stopStore.errorMessage).toBe('Not authenticated.') @@ -166,7 +166,7 @@ describe('useStopStore', () => { // Map store left empty — no stops const stopStore = useStopStore() - await stopStore.loadTasksForStop('nyhetskvartalet', false) + await stopStore.loadTasksForStop('nyhetskvartalet') expect(stopStore.status).toBe('error') expect(stopStore.errorMessage).toContain('nyhetskvartalet') @@ -183,7 +183,7 @@ describe('useStopStore', () => { ) const stopStore = useStopStore() - await stopStore.loadTasksForStop('nyhetskvartalet', false) + await stopStore.loadTasksForStop('nyhetskvartalet') expect(stopStore.status).toBe('error') expect(stopStore.errorMessage).toBe('Forbidden') @@ -201,14 +201,14 @@ describe('useStopStore', () => { ) const stopStore = useStopStore() - await stopStore.loadTasksForStop('nyhetskvartalet', false) + await stopStore.loadTasksForStop('nyhetskvartalet') expect(stopStore.tasks).toHaveLength(1) vi.stubGlobal( 'fetch', vi.fn().mockResolvedValueOnce(jsonResponse({ detail: 'Forbidden' }, { status: 403 })), ) - await stopStore.loadTasksForStop('nyhetskvartalet', false) + await stopStore.loadTasksForStop('nyhetskvartalet') // tasks are cleared at the start of the new load expect(stopStore.tasks).toHaveLength(0) @@ -246,7 +246,7 @@ describe('useStopStore', () => { describe('reset', () => { it('clears tasks, answers, and error state', async () => { const stopStore = useStopStore() - await stopStore.loadTasksForStop('nyhetskvartalet', true) + await stopStore.loadTasksForStop('nyhetskvartalet') stopStore.setAnswer(1, 1) stopStore.reset() diff --git a/src/stops/model/stop.store.ts b/src/stops/model/stop.store.ts index 508c0a99..03107bad 100644 --- a/src/stops/model/stop.store.ts +++ b/src/stops/model/stop.store.ts @@ -7,97 +7,6 @@ import type { TaskResponse } from '@/stops/api/stops.api' export type StopLoadStatus = 'idle' | 'loading' | 'loaded' | 'error' -// ---- Mock data ---- -// Used until tasks are seeded in the database. -// Remove this and the useMock path in loadTasksForStop once the backend is populated. - -const MOCK_TASKS: TaskResponse[] = [ - { - id: 1, - classroomId: 1, - stopId: 1, - createdByTeacherId: 1, - title: { nb: 'Hvilken kilde er troverdig?', en: 'Which source is trustworthy?' }, - introText: { - nb: 'Les de to artiklene og velg den som virker mest troverdig.', - en: 'Read both articles and select the one that appears most trustworthy.', - }, - difficultyLevel: 1, - taskType: 'NEWS_COMPARISON', - passingRule: 'ALL_CORRECT', - passingScore: 1, - maxScore: 1, - published: true, - items: [ - { - id: 1, - itemOrder: 1, - promptText: { - nb: 'HELT UTROLIG! Forskning beviser at sjokolade er sunt!\n\nEn ny, banebrekkende studie har ENDELIG bevist det vi alle har håpet på: sjokolade er like sunt som – eller kanskje til og med sunnere enn – grønnsaker! Legen som ledet forskningen ble selv overrasket over resultatene.\n\n"Vi kan ikke tro det vi ser. Sjokolade kurerer nesten alt," skal en navnløs forsker ha sagt.\n\nEksperter fra hele verden er nå i sjokk. Det anbefales å spise minst 200 gram sjokolade daglig for å holde seg frisk. Grønnsaker kan trygt droppes!\nDel denne artikkelen med alle du kjenner FØR myndighetene sensurerer den – Big Pharma vil ikke at du skal vite dette!', - en: 'UNBELIEVABLE! Research proves chocolate is healthy!\n\nA groundbreaking new study has FINALLY proven what we all hoped: chocolate is just as healthy as – or perhaps even healthier than – vegetables! The doctor who led the research was surprised by the results.\n\n"We cannot believe what we are seeing. Chocolate cures almost everything," an anonymous researcher allegedly said.\n\nExperts worldwide are now in shock. It is recommended to eat at least 200 grams of chocolate daily to stay healthy. Vegetables can safely be dropped!\nShare this article with everyone you know BEFORE the authorities censor it – Big Pharma does not want you to know this!', - }, - mediaUrl: null, - metadataJson: JSON.stringify({ domain: 'helserevolusjonen.no' }), - options: [], - }, - { - id: 2, - itemOrder: 2, - promptText: { - nb: 'Studie: Mørk sjokolade kan ha helsegevinster\n\nEn ny studie publisert i tidsskriftet Nutrients viser at flavonoider i mørk sjokolade (70 % kakao eller mer) kan bidra til lavere blodtrykk og bedre insulinfølsomhet hos noen voksne.\n\n"Effekten er reell, men begrenset. Dette er ikke en erstatning for et sunt kosthold med grønnsaker, frukt og fiber," sier ernæringsforsker Tone Berg ved UiO.\n\nStudien fulgte 200 deltakere over 12 uker og målte blodtrykk og betennelsesmarkører. Forskerne understreker at porsjonsstørrelse er avgjørende – rundt 20–30 gram per dag var mengden brukt i forsøket. Helsedirektoratet minner om at sjokolade fortsatt er kaloririk og ikke inngår i de generelle kostrådene.', - en: 'Study: Dark chocolate may have health benefits\n\nA new study published in the journal Nutrients shows that flavonoids in dark chocolate (70% cocoa or more) may contribute to lower blood pressure and better insulin sensitivity in some adults.\n\n"The effect is real but limited. This is not a replacement for a healthy diet with vegetables, fruit, and fibre," says nutritional researcher Tone Berg at UiO.\n\nThe study followed 200 participants over 12 weeks and measured blood pressure and inflammation markers. Researchers emphasise that portion size is decisive – around 20–30 grams per day was the amount used in the trial. The Directorate of Health notes that chocolate remains calorie-dense and is not part of general dietary guidelines.', - }, - mediaUrl: 'https://images.unsplash.com/photo-1481391319762-47dff72954d9?w=600&q=80', - metadataJson: JSON.stringify({ domain: 'forskning.no' }), - options: [], - }, - ], - }, - { - id: 2, - classroomId: 1, - stopId: 1, - createdByTeacherId: 1, - title: { nb: 'Kjenner du igjen klikkagn?', en: 'Can you spot clickbait?' }, - introText: { - nb: 'Velg artikkelen som bruker seriøst og nøytralt språk.', - en: 'Select the article that uses serious and neutral language.', - }, - difficultyLevel: 2, - taskType: 'NEWS_COMPARISON', - passingRule: 'ALL_CORRECT', - passingScore: 1, - maxScore: 1, - published: true, - items: [ - { - id: 3, - itemOrder: 1, - promptText: { - nb: 'LEGEN HAR IKKE FORTALT DEG DETTE! Én enkel ting kurerer alle sykdommer\n\nLegemiddelindustrien skjuler en hemmelighet som kan endre livet ditt. En ukjent doktor avslørte nylig at du ALDRI trenger å bli syk igjen. Klikk her for å finne ut hva Big Pharma ikke vil du skal vite.', - en: 'YOUR DOCTOR NEVER TOLD YOU THIS! One simple thing cures all diseases\n\nThe pharmaceutical industry is hiding a secret that could change your life. An unknown doctor recently revealed you NEVER have to get sick again. Click here to find out what Big Pharma does not want you to know.', - }, - mediaUrl: null, - metadataJson: JSON.stringify({ domain: 'helseavsloringer.net' }), - options: [], - }, - { - id: 4, - itemOrder: 2, - promptText: { - nb: 'Ny rapport: Regelmessig mosjon reduserer risiko for hjerte- og karsykdommer\n\nFolkehelseinstituttet presenterte i dag en ny rapport som viser at 150 minutter moderat fysisk aktivitet per uke er forbundet med 35 prosent lavere risiko for hjerteinfarkt hos voksne over 40 år. Rapporten bygger på data fra over 50 000 nordmenn fulgt over ti år.', - en: 'New report: Regular exercise reduces risk of cardiovascular disease\n\nThe Norwegian Institute of Public Health today presented a new report showing that 150 minutes of moderate physical activity per week is associated with a 35 percent lower risk of heart attack in adults over 40. The report is based on data from over 50,000 Norwegians followed over ten years.', - }, - mediaUrl: null, - metadataJson: JSON.stringify({ domain: 'fhi.no' }), - options: [], - }, - ], - }, -] - -// ---- Store ---- - export const useStopStore = defineStore('stop', () => { const authStore = useAuthStore() const mapStore = useMapStore() @@ -118,22 +27,18 @@ export const useStopStore = defineStore('stop', () => { return { authorizationHeader: header, classroomId } } - async function loadTasksForStop(slug: string, useMock = false): Promise { + async function loadTasksForStop(slug: string): Promise { status.value = 'loading' errorMessage.value = null tasks.value = [] try { - if (useMock) { - await new Promise((r) => setTimeout(r, 300)) - tasks.value = MOCK_TASKS - status.value = 'loaded' - return - } - const session = requireSession() - // Resolve stopId from slug via the already-loaded map store + if (mapStore.status !== 'loaded') { + await mapStore.fetchMapOverview() + } + const stop = mapStore.stops.find((s) => s.slug === slug) if (!stop) throw new Error(`Stop with slug "${slug}" not found.`) @@ -177,4 +82,4 @@ export const useStopStore = defineStore('stop', () => { setAnswer, reset, } -}) +}) \ No newline at end of file diff --git a/src/stops/pages/StopPage.vue b/src/stops/pages/StopPage.vue index c8617bc7..41f988f6 100644 --- a/src/stops/pages/StopPage.vue +++ b/src/stops/pages/StopPage.vue @@ -37,9 +37,7 @@ function handleFinish(): void { } onMounted(() => { - // Pass useMock=true while backend tasks are not yet seeded. - // Flip to false (or remove the argument) once real data exists. - stopStore.loadTasksForStop(slug.value, true) + stopStore.loadTasksForStop(slug.value) }) onUnmounted(() => { From 368a0a3c573549fee48f5f23c161e77144b4f0cb Mon Sep 17 00:00:00 2001 From: kristoffer Date: Mon, 20 Apr 2026 13:31:27 +0200 Subject: [PATCH 03/15] fixed mistake --- src/stops/pages/StopPage.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/src/stops/pages/StopPage.vue b/src/stops/pages/StopPage.vue index 41f988f6..49f556e3 100644 --- a/src/stops/pages/StopPage.vue +++ b/src/stops/pages/StopPage.vue @@ -13,7 +13,6 @@ const stopStore = useStopStore() const mapStore = useMapStore() const slug = computed(() => route.params.slug as string) -const lang = computed(() => (locale.value === 'nb' ? 'nb' : 'en')) // The stop overview from the map store (title, category, etc.) const stopOverview = computed(() => mapStore.stops.find((s) => s.slug === slug.value) ?? null) From 34c4d6b81ca06e642d8c45e00f7054240d2d3987 Mon Sep 17 00:00:00 2001 From: eilifhl Date: Mon, 20 Apr 2026 13:33:08 +0200 Subject: [PATCH 04/15] add canvas-confetti --- package-lock.json | 19 +++++++++++++++++++ package.json | 2 ++ 2 files changed, 21 insertions(+) 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", From ae32165b0a8bb015130a08111d1fd61affff7412 Mon Sep 17 00:00:00 2001 From: eilifhl Date: Mon, 20 Apr 2026 13:33:30 +0200 Subject: [PATCH 05/15] add celebration component --- src/shared/components/Celebration.vue | 46 +++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/shared/components/Celebration.vue diff --git a/src/shared/components/Celebration.vue b/src/shared/components/Celebration.vue new file mode 100644 index 00000000..17f39dc0 --- /dev/null +++ b/src/shared/components/Celebration.vue @@ -0,0 +1,46 @@ + + + + + From 4a7f38093881205caad7008a753a43e0a19b8b30 Mon Sep 17 00:00:00 2001 From: eilifhl Date: Mon, 20 Apr 2026 13:34:47 +0200 Subject: [PATCH 06/15] make stopsuccesspage --- src/locales/en.ts | 5 +++++ src/locales/nb.ts | 5 +++++ src/map/pages/StopSuccessPage.vue | 28 ++++++++++++++++++++++++++++ src/map/routes.ts | 11 +++++++++-- 4 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 src/map/pages/StopSuccessPage.vue diff --git a/src/locales/en.ts b/src/locales/en.ts index 1e97869d..4eae6a02 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -170,6 +170,11 @@ 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", + }, avatarEditor: { detectiveName: "Detective {name}", fallbackDisplayName: "Unknown", diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 5fc8f320..f946c4fe 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -170,6 +170,11 @@ 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", + }, avatarEditor: { detectiveName: "Detektiv {name}", fallbackDisplayName: "Ukjent", diff --git a/src/map/pages/StopSuccessPage.vue b/src/map/pages/StopSuccessPage.vue new file mode 100644 index 00000000..003b335c --- /dev/null +++ b/src/map/pages/StopSuccessPage.vue @@ -0,0 +1,28 @@ + + + diff --git a/src/map/routes.ts b/src/map/routes.ts index fed32324..6b7489e4 100644 --- a/src/map/routes.ts +++ b/src/map/routes.ts @@ -1,7 +1,8 @@ import type { RouteRecordRaw } from 'vue-router' - + import MapPage from '@/map/pages/MapPage.vue' - +import StopSuccessPage from '@/map/pages/StopSuccessPage.vue' + export function createMapRoutes(prefix: string): RouteRecordRaw[] { return [ { @@ -9,5 +10,11 @@ export function createMapRoutes(prefix: string): RouteRecordRaw[] { name: `${prefix}-map`, component: MapPage, }, + { + path: 'map/success', + name: `${prefix}-stop-success`, + component: StopSuccessPage, + }, ]; } + From 326a95ac22d74c2f4c4be7b994b82962e8682648 Mon Sep 17 00:00:00 2001 From: kristoffer Date: Mon, 20 Apr 2026 13:38:26 +0200 Subject: [PATCH 07/15] fixed another mistake --- src/stops/pages/StopPage.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stops/pages/StopPage.vue b/src/stops/pages/StopPage.vue index 49f556e3..4cecad1a 100644 --- a/src/stops/pages/StopPage.vue +++ b/src/stops/pages/StopPage.vue @@ -8,7 +8,7 @@ import TaskCard from '@/stops/components/TaskCard.vue' const route = useRoute() const router = useRouter() -const { t, locale } = useI18n() +const { t } = useI18n() const stopStore = useStopStore() const mapStore = useMapStore() From 2f0205553c5486caead1b55c860e2ff4d26ff1fd Mon Sep 17 00:00:00 2001 From: kristoffer Date: Mon, 20 Apr 2026 13:45:02 +0200 Subject: [PATCH 08/15] fixed tests and api error --- src/stops/api/stops.api.ts | 30 ++++--- src/stops/model/__tests__/stop.store.spec.ts | 91 ++++++++++++++------ 2 files changed, 83 insertions(+), 38 deletions(-) diff --git a/src/stops/api/stops.api.ts b/src/stops/api/stops.api.ts index 33edfad1..d265d7e5 100644 --- a/src/stops/api/stops.api.ts +++ b/src/stops/api/stops.api.ts @@ -2,14 +2,24 @@ 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) { +const response = await fetch(url, { +headers: { Authorization: authorizationHeader }, +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')) { + const body = await response.json().catch(() => null) as + | { message?: string; detail?: string; error?: string } + | null + if (body?.detail) throw new Error(body.detail) + if (body?.message) throw new Error(body.message) + if (body?.error) throw new Error(body.error) + } const text = await response.text().catch(() => '') - throw new Error(text || `Request failed with status ${response.status}`) + throw new Error(text || fallback) } return response.json() as Promise @@ -98,7 +108,7 @@ export function requestTasksForStop( return fetchJson( `${CLASSROOMS_BASE}/${classroomId}/tasks?stopId=${stopId}`, authorizationHeader, - ) +) } export function requestTask( @@ -109,5 +119,5 @@ export function requestTask( return fetchJson( `${CLASSROOMS_BASE}/${classroomId}/tasks/${taskId}`, authorizationHeader, - ) -} +) +} \ No newline at end of file diff --git a/src/stops/model/__tests__/stop.store.spec.ts b/src/stops/model/__tests__/stop.store.spec.ts index d59a9e17..84df612d 100644 --- a/src/stops/model/__tests__/stop.store.spec.ts +++ b/src/stops/model/__tests__/stop.store.spec.ts @@ -62,6 +62,18 @@ const fullTask = { ], } +const mapStopOverview = { + stopId: STOP_ID, + slug: 'nyhetskvartalet', + title: 'Nyhetskvartalet', + category: 'media', + displayOrder: 1, + isFinalBoss: false, + status: 'NOT_STARTED', + stars: 0, + medalAwarded: false, +} + function seedAuthStore() { const authStore = useAuthStore() authStore.accessToken = 'access-token' @@ -81,21 +93,11 @@ function seedAuthStore() { } } +// Seeds the map store as already loaded so loadTasksForStop skips fetchMapOverview function seedMapStore() { const mapStore = useMapStore() - mapStore.stops = [ - { - stopId: STOP_ID, - slug: 'nyhetskvartalet', - title: 'Nyhetskvartalet', - category: 'media', - displayOrder: 1, - isFinalBoss: false, - status: 'NOT_STARTED', - stars: 0, - medalAwarded: false, - }, - ] + mapStore.stops = [mapStopOverview] + mapStore.status = 'loaded' } describe('useStopStore', () => { @@ -136,6 +138,25 @@ describe('useStopStore', () => { expect(stopStore.tasks[0]?.items).toHaveLength(2) }) + it('fetches map overview first when it has not been loaded yet', async () => { + seedAuthStore() + // Map store left at default idle status — overview not loaded + + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse([mapStopOverview])) // fetchMapOverview + .mockResolvedValueOnce(jsonResponse([taskSummary])) // requestTasksForStop + .mockResolvedValueOnce(jsonResponse(fullTask)) // requestTask + vi.stubGlobal('fetch', fetchMock) + + const stopStore = useStopStore() + await stopStore.loadTasksForStop('nyhetskvartalet') + + expect(stopStore.status).toBe('loaded') + expect(stopStore.tasks).toHaveLength(1) + expect(fetchMock).toHaveBeenCalledTimes(3) + }) + it('omits unpublished tasks from the loaded list', async () => { seedAuthStore() seedMapStore() @@ -161,12 +182,17 @@ describe('useStopStore', () => { expect(stopStore.errorMessage).toBe('Not authenticated.') }) - it('sets status to error when the slug is not in the map store', async () => { + it('sets status to error when the slug is not found after fetching map overview', async () => { seedAuthStore() - // Map store left empty — no stops + // Map store idle — overview will be fetched, but returns no matching stop - const stopStore = useStopStore() - await stopStore.loadTasksForStop('nyhetskvartalet') + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce(jsonResponse([])), // fetchMapOverview returns empty list +) + +const stopStore = useStopStore() +await stopStore.loadTasksForStop('nyhetskvartalet') expect(stopStore.status).toBe('error') expect(stopStore.errorMessage).toContain('nyhetskvartalet') @@ -180,10 +206,10 @@ describe('useStopStore', () => { vi.fn().mockResolvedValueOnce( jsonResponse({ detail: 'Forbidden' }, { status: 403 }), ), - ) +) - const stopStore = useStopStore() - await stopStore.loadTasksForStop('nyhetskvartalet') +const stopStore = useStopStore() +await stopStore.loadTasksForStop('nyhetskvartalet') expect(stopStore.status).toBe('error') expect(stopStore.errorMessage).toBe('Forbidden') @@ -198,19 +224,18 @@ describe('useStopStore', () => { vi.fn() .mockResolvedValueOnce(jsonResponse([taskSummary])) .mockResolvedValueOnce(jsonResponse(fullTask)), - ) +) - const stopStore = useStopStore() - await stopStore.loadTasksForStop('nyhetskvartalet') +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') +await stopStore.loadTasksForStop('nyhetskvartalet') - // tasks are cleared at the start of the new load expect(stopStore.tasks).toHaveLength(0) }) }) @@ -245,8 +270,18 @@ describe('useStopStore', () => { describe('reset', () => { it('clears tasks, answers, and error state', async () => { - const stopStore = useStopStore() - await stopStore.loadTasksForStop('nyhetskvartalet') + seedAuthStore() + seedMapStore() + + vi.stubGlobal( + 'fetch', + vi.fn() + .mockResolvedValueOnce(jsonResponse([taskSummary])) + .mockResolvedValueOnce(jsonResponse(fullTask)), +) + +const stopStore = useStopStore() +await stopStore.loadTasksForStop('nyhetskvartalet') stopStore.setAnswer(1, 1) stopStore.reset() @@ -257,4 +292,4 @@ describe('useStopStore', () => { expect(stopStore.errorMessage).toBeNull() }) }) -}) +}) \ No newline at end of file From faaae2da81da5929a16073a36c5acdfc78c0bb79 Mon Sep 17 00:00:00 2001 From: kristoffer Date: Mon, 20 Apr 2026 13:50:03 +0200 Subject: [PATCH 09/15] tiny fix in test --- src/stops/model/__tests__/stop.store.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/stops/model/__tests__/stop.store.spec.ts b/src/stops/model/__tests__/stop.store.spec.ts index 84df612d..e06e6a62 100644 --- a/src/stops/model/__tests__/stop.store.spec.ts +++ b/src/stops/model/__tests__/stop.store.spec.ts @@ -4,6 +4,7 @@ import { createPinia, setActivePinia } from 'pinia' import { useStopStore } from '@/stops/model/stop.store' import { useAuthStore } from '@/auth/model/auth.store' import { useMapStore } from '@/map/model/map.store' +import type { MapStopOverview } from '@/map/model/map.types' function jsonResponse(body: unknown, init: ResponseInit = {}) { return new Response(JSON.stringify(body), { @@ -62,7 +63,7 @@ const fullTask = { ], } -const mapStopOverview = { +const mapStopOverview: MapStopOverview = { stopId: STOP_ID, slug: 'nyhetskvartalet', title: 'Nyhetskvartalet', From 5c89cf86c6187255b6a2dba9511d25164a1b33d5 Mon Sep 17 00:00:00 2001 From: eilifhl Date: Mon, 20 Apr 2026 14:01:31 +0200 Subject: [PATCH 10/15] display next stop --- src/locales/en.ts | 3 ++ src/locales/nb.ts | 3 ++ src/map/pages/StopSuccessPage.vue | 49 ++++++++++++++++++++++++++++--- src/map/routes.ts | 2 +- 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/locales/en.ts b/src/locales/en.ts index 4eae6a02..48553e66 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -174,6 +174,9 @@ export const en = { 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", }, avatarEditor: { detectiveName: "Detective {name}", diff --git a/src/locales/nb.ts b/src/locales/nb.ts index f946c4fe..ffd37b74 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -174,6 +174,9 @@ export const nb = { 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", }, avatarEditor: { detectiveName: "Detektiv {name}", diff --git a/src/map/pages/StopSuccessPage.vue b/src/map/pages/StopSuccessPage.vue index 003b335c..3b6511c2 100644 --- a/src/map/pages/StopSuccessPage.vue +++ b/src/map/pages/StopSuccessPage.vue @@ -4,15 +4,27 @@
🏆
-

+ +

{{ t('success.stop_completed_title') }}

-

- {{ t('success.stop_completed_message') }} + +

+ {{ completedStopName }}

+ +
+

+ {{ t('success.next_stop_label') }} +

+

+ {{ nextStop.title }} +

+
+ {{ t('success.return_to_map') }} @@ -21,8 +33,37 @@ diff --git a/src/map/routes.ts b/src/map/routes.ts index 6b7489e4..cf84a65f 100644 --- a/src/map/routes.ts +++ b/src/map/routes.ts @@ -11,7 +11,7 @@ export function createMapRoutes(prefix: string): RouteRecordRaw[] { component: MapPage, }, { - path: 'map/success', + path: 'map/success/:stopSlug', name: `${prefix}-stop-success`, component: StopSuccessPage, }, From cdf9ffdb3f27b433dc9048cc5420f899b5217f24 Mon Sep 17 00:00:00 2001 From: eilifhl Date: Mon, 20 Apr 2026 14:33:49 +0200 Subject: [PATCH 11/15] fix: add missing failure translations and next stop button --- src/locales/en.ts | 5 +++++ src/locales/nb.ts | 5 +++++ src/map/pages/StopSuccessPage.vue | 12 +++++++++--- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/locales/en.ts b/src/locales/en.ts index 7aa0ff61..868268b4 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -186,6 +186,11 @@ export const en = { 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 d89c20e0..6cf3a685 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -186,6 +186,11 @@ export const nb = { 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/StopSuccessPage.vue b/src/map/pages/StopSuccessPage.vue index 3b6511c2..0e6fd378 100644 --- a/src/map/pages/StopSuccessPage.vue +++ b/src/map/pages/StopSuccessPage.vue @@ -13,13 +13,19 @@ {{ completedStopName }}

-
+

{{ t('success.next_stop_label') }}

-

+

{{ nextStop.title }}

+ + {{ t('success.next_stop') }} +
{ }); const completedStop = computed(() => { - const slug = route.params.stopSlug as string; + const slug = route.params.slug as string; return mapStore.stops.find(s => s.slug === slug); }); From 0bf1aa787504edc9ec962f185d4b6739d8f643b1 Mon Sep 17 00:00:00 2001 From: eilifhl Date: Tue, 21 Apr 2026 14:50:44 +0200 Subject: [PATCH 12/15] wire stop submission for failed/completed task flows --- src/locales/en.ts | 3 ++ src/locales/nb.ts | 3 ++ src/stops/api/stops.api.ts | 76 +++++++++++++++++++++++++++---- src/stops/components/TaskCard.vue | 33 +++++++++++++- src/stops/model/stop.store.ts | 75 ++++++++++++++++++++++++++++-- src/stops/pages/StopPage.vue | 39 +++++++++++++--- 6 files changed, 211 insertions(+), 18 deletions(-) diff --git a/src/locales/en.ts b/src/locales/en.ts index eb1e46d3..30b885b3 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -115,9 +115,12 @@ export const en = { stop: { loading: "Loading tasks…", finish: "Finish stop", + submitting: "Checking answers…", answerAllFirst: "Answer all tasks first", selected: "Selected", selectThis: "Select this", + correct: "Correct", + incorrect: "Not quite right", unsupportedTaskType: 'Task type "{type}" is not yet supported.', }, pupilHome: { diff --git a/src/locales/nb.ts b/src/locales/nb.ts index f22d9b0a..9ff68fbd 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -115,9 +115,12 @@ export const nb = { stop: { loading: "Laster oppgaver…", finish: "Fullfør stopp", + submitting: "Sjekker svar…", answerAllFirst: "Svar på alle oppgavene først", selected: "Valgt", selectThis: "Velg denne", + correct: "Riktig", + incorrect: "Ikke helt riktig", unsupportedTaskType: "Oppgavetype «{type}» støttes ikke ennå.", }, pupilHome: { diff --git a/src/stops/api/stops.api.ts b/src/stops/api/stops.api.ts index d265d7e5..318f5c87 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')) { @@ -51,6 +59,8 @@ export interface TaskItemResponse { promptText: LocalizedText mediaUrl: string | null metadataJson: string | null + isCorrectSelection: boolean | null + selectionExplanationText: LocalizedOptionalText | null options: TaskOptionResponse[] } @@ -81,6 +91,40 @@ export interface TaskSummaryResponse { itemCount: number } +export interface SubmitStopTaskAnswerPayload { + taskId: number + selectedItemId: number | null + 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 + selectedItemId: number | null + 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' @@ -120,4 +164,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/TaskCard.vue b/src/stops/components/TaskCard.vue index 31b2cb99..28a3b432 100644 --- a/src/stops/components/TaskCard.vue +++ b/src/stops/components/TaskCard.vue @@ -1,16 +1,18 @@ diff --git a/src/stops/model/stop.store.ts b/src/stops/model/stop.store.ts index 03107bad..e6e944f2 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,6 +19,9 @@ export const useStopStore = defineStore('stop', () => { const tasks = ref([]) const status = ref('idle') const errorMessage = ref(null) + const submitStatus = ref('idle') + const submitErrorMessage = ref(null) + const submissionResult = ref(null) // answers: taskId → selected itemId (local until submission) const answers = ref>(new Map()) @@ -31,6 +39,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 = requireSession() @@ -64,12 +75,64 @@ export const useStopStore = defineStore('stop', () => { function setAnswer(taskId: number, itemId: number): void { answers.value = new Map(answers.value).set(taskId, itemId) + 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 = requireSession() + + submitStatus.value = 'submitting' + submitErrorMessage.value = null + + try { + const taskAnswers = tasks.value.map((task) => ({ + taskId: task.id, + selectedItemId: answers.value.get(task.id) ?? null, + itemAnswers: [], + })) + + 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() } @@ -77,9 +140,15 @@ export const useStopStore = defineStore('stop', () => { tasks, status, errorMessage, + submitStatus, + submitErrorMessage, + submissionResult, answers, loadTasksForStop, setAnswer, + getTaskResult, + clearTaskResult, + submitStop, reset, } -}) \ No newline at end of file +}) diff --git a/src/stops/pages/StopPage.vue b/src/stops/pages/StopPage.vue index 4cecad1a..6102d038 100644 --- a/src/stops/pages/StopPage.vue +++ b/src/stops/pages/StopPage.vue @@ -26,13 +26,25 @@ const allAnswered = computed( stopStore.tasks.every((task) => stopStore.answers.has(task.id)), ) +const isSubmitting = computed(() => stopStore.submitStatus === 'submitting') + function handleSelect(taskId: number, itemId: number): void { stopStore.setAnswer(taskId, itemId) } -function handleFinish(): void { - // ToDo - 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(() => { @@ -85,6 +97,13 @@ onUnmounted(() => {

{{ stopStore.errorMessage }}

+
+

{{ stopStore.submitErrorMessage }}

+
+