diff --git a/README.md b/README.md index 9f3f41d..fd59fec 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ inferred, conflicting, and fixture states instead of presenting guesses as facts ```text Foldkit web -> TypeBox HTTP contract -> Elysia transport -> Effect service - -> NTNU search/detail + -> NTNU search/detail/schedule -> DBH grades + exam activity ``` @@ -30,6 +30,8 @@ Implemented now: visible or shortlisted courses; - a local saved list with labelled collections and comparison of two to four courses; +- a weekly timetable for saved courses, with URL-backed ISO-week selection, + dated NTNU activities, source freshness, and named provider limitations; - ordinary-term, bounded grade aggregation with pass/fail outcomes kept separate from ordinal letter grades; - official DBH exam registrations, attendance, pass, failure, and repeat-pass diff --git a/apps/course-api/src/app.test.ts b/apps/course-api/src/app.test.ts index 0b22ab7..2c2bcfa 100644 --- a/apps/course-api/src/app.test.ts +++ b/apps/course-api/src/app.test.ts @@ -19,6 +19,17 @@ const unavailableSearchService: CourseDecisionService = { ), }; +const unavailableScheduleService: CourseDecisionService = { + ...fixtureCourseDecisionService, + getSchedule: () => + Effect.fail( + new CourseSourcesUnavailableError({ + operation: 'schedule', + message: 'The NTNU schedule source is unavailable.', + }), + ), +}; + describe('course decision HTTP transport', () => { it.each([ { @@ -78,6 +89,126 @@ describe('course decision HTTP transport', () => { expect(body).toMatchObject(expected); }); + it('returns a contract-valid schedule response in the requested order', async () => { + const response = await app.handle( + new Request('http://localhost/v1/course-schedules', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + courseCodes: ['TDT4136', 'TDT4136-1'], + term: '2026-autumn', + week: 45, + }), + }), + ); + const body: unknown = await response.json(); + + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe( + 'public, max-age=60, stale-while-revalidate=900', + ); + expect(body).toEqual( + expect.objectContaining({ + items: [ + expect.objectContaining({ + courseCode: 'TDT4136', + sourceStatus: expect.objectContaining({ + provider: 'ntnu-course-schedule', + status: 'available', + }), + activityStreams: [ + expect.objectContaining({ + activityCode: 'TDT4136-LECTURE-01', + title: 'Introduction to search', + summary: 'Lecture', + }), + expect.objectContaining({ + activityCode: 'TDT4136-EXERCISE-01', + title: 'Search exercise', + summary: 'Exercise session', + }), + ], + occurrences: expect.arrayContaining([ + expect.objectContaining({ + courseCode: 'TDT4136', + evidence: expect.objectContaining({ + provider: 'ntnu-course-schedule', + kind: 'fixture', + }), + }), + ]), + }), + expect.objectContaining({ + courseCode: 'TDT4136-1', + sourceStatus: expect.objectContaining({ + provider: 'ntnu-course-schedule', + status: 'unavailable', + }), + activityStreams: [], + occurrences: [], + }), + ], + meta: { + count: 2, + term: '2026-autumn', + week: 45, + timezone: 'Europe/Oslo', + limitations: { + activitySelection: 'all-published-activities', + activityGrouping: 'unavailable', + exceptionSemantics: 'provider-status-unverified', + }, + }, + }), + ); + }); + + it('keeps a valid fixture schedule available when the selected week has no events', async () => { + const response = await app.handle( + new Request('http://localhost/v1/course-schedules', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ courseCodes: ['TDT4136'], term: '2026-autumn', week: 44 }), + }), + ); + const body: unknown = await response.json(); + + expect(response.status).toBe(200); + expect(body).toMatchObject({ + items: [ + { + courseCode: 'TDT4136', + sourceStatus: { status: 'available' }, + occurrences: [], + activityStreams: [ + expect.objectContaining({ activityCode: 'TDT4136-LECTURE-01' }), + expect.objectContaining({ activityCode: 'TDT4136-EXERCISE-01' }), + ], + }, + ], + meta: { week: 44 }, + }); + }); + + it('returns a contract-valid problem when the schedule operation fails', async () => { + const unavailableApp = createCourseApi(unavailableScheduleService, () => 'request-test'); + const response = await unavailableApp.handle( + new Request('http://localhost/v1/course-schedules', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ courseCodes: ['TDT4136'], term: '2026-autumn', week: 45 }), + }), + ); + const body: unknown = await response.json(); + + expect(response.status).toBe(503); + expect(body).toMatchObject({ + type: 'https://course-data.example/problems/course-schedule-unavailable', + status: 503, + requestId: 'request-test', + }); + }); + it.each([ { name: 'invalid catalogue paging', @@ -92,6 +223,15 @@ describe('course decision HTTP transport', () => { body: JSON.stringify({ courseCodes: [] }), }), }, + { + name: 'an invalid schedule week', + request: () => + new Request('http://localhost/v1/course-schedules', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ courseCodes: ['TDT4136'], term: '2026-autumn', week: 0 }), + }), + }, ])('returns a declared 400 problem for $name', async ({ request }) => { const response = await app.handle(request()); const body = await response.json(); @@ -153,6 +293,11 @@ describe('course decision HTTP transport', () => { method: 'post', statuses: ['200', '400', '503'], }, + { + path: '/v1/course-schedules', + method: 'post', + statuses: ['200', '400', '503'], + }, { path: '/v1/courses/{courseCode}/insight', method: 'get', diff --git a/apps/course-api/src/app.ts b/apps/course-api/src/app.ts index 567fb22..7b0ad0a 100644 --- a/apps/course-api/src/app.ts +++ b/apps/course-api/src/app.ts @@ -1,11 +1,13 @@ import { CourseDecisionSignalsRequestDto, CourseDecisionSignalsResponseDto, + CourseGradeSummariesRequestDto, + CourseGradeSummariesResponseDto, CourseInsightParamsDto, CourseInsightQueryDto, CourseInsightResponseDto, - CourseGradeSummariesRequestDto, - CourseGradeSummariesResponseDto, + CourseScheduleRequestDto, + CourseScheduleResponseDto, CourseSearchQueryDto, CourseSearchResponseDto, ProblemDto, @@ -25,6 +27,7 @@ import { toCourseDecisionSignalsDto, toCourseGradeSummaryDto, toCourseInsightDto, + toCourseScheduleResponseDto, toCourseSearchItemDto, } from './http/course-dto'; @@ -86,6 +89,7 @@ export const createCourseApi = ( search: '/v1/course-search', gradeSummaries: '/v1/course-grade-summaries', decisionSignals: '/v1/course-decision-signals', + schedule: '/v1/course-schedules', insight: '/v1/courses/:courseCode/insight', openapi: '/openapi', openapiJson: '/openapi/json', @@ -101,6 +105,7 @@ export const createCourseApi = ( gradeSummaries: t.String(), decisionSignals: t.String(), insight: t.String(), + schedule: t.String(), openapi: t.String(), openapiJson: t.String(), }), @@ -247,6 +252,60 @@ export const createCourseApi = ( }, }, ) + .post( + '/v1/course-schedules', + async ({ body, request, set, status }) => { + const requestId = request.headers.get('cf-ray') ?? makeRequestId(); + const result = await Effect.runPromise( + Effect.result( + service.getSchedule({ + courseCodes: body.courseCodes, + term: body.term, + week: body.week, + }), + ), + ); + set.headers['x-request-id'] = requestId; + set.headers['cache-control'] = 'public, max-age=60, stale-while-revalidate=900'; + + if (Result.isFailure(result)) { + if (result.failure._tag === 'CourseInvalidTermError') { + return status( + 400, + problem( + requestId, + 400, + 'invalid-course-term', + 'Invalid course term', + result.failure.message, + ), + ); + } + return status( + 503, + problem( + requestId, + 503, + 'course-schedule-unavailable', + 'Course schedule unavailable', + result.failure.message, + ), + ); + } + + return toCourseScheduleResponseDto(result.success); + }, + { + body: CourseScheduleRequestDto, + response: { 200: CourseScheduleResponseDto, 400: ProblemDto, 503: ProblemDto }, + detail: { + summary: 'Get published NTNU course schedule occurrences for one week', + description: + 'Returns published activities in the requested Oslo-local ISO week with explicit source availability.', + tags: ['Courses'], + }, + }, + ) .post( '/v1/course-grade-summaries', async ({ body, request, set, status }) => { diff --git a/apps/course-api/src/course-decision/fixture.ts b/apps/course-api/src/course-decision/fixture.ts index 5da6a63..76596f5 100644 --- a/apps/course-api/src/course-decision/fixture.ts +++ b/apps/course-api/src/course-decision/fixture.ts @@ -12,7 +12,13 @@ import { } from './model/course-insight'; import * as Effect from 'effect/Effect'; -import { CourseInvalidTermError, CourseNotFoundError, type CourseDecisionService } from './service'; +import { + CourseInvalidTermError, + CourseNotFoundError, + type CourseDecisionService, + type CourseScheduleActivityStream, + type CourseScheduleOccurrence, +} from './service'; const observedAt = '2026-07-23T10:00:00.000Z'; const coursePageEvidenceId = 'fixture:ntnu-course-page:TDT4136:2026-spring'; @@ -54,6 +60,66 @@ const examParticipationEvidence = { inferenceRule: null, }; +const fixtureScheduleSourceUrl = + 'https://www.ntnu.no/web/studier/emner?p_p_resource_id=schedules&courseCode=TDT4136&year=2026&version=1'; +const fixtureScheduleOccurrences: ReadonlyArray = [ + { + id: 'fixture:ntnu-course-schedule:TDT4136:2026-11-03T09:15:00.000Z', + courseCode: 'TDT4136', + activityCode: 'TDT4136-LECTURE-01', + title: 'Introduction to search', + summary: 'Lecture', + status: 'published', + startsAt: new Date('2026-11-03T09:15:00.000Z'), + endsAt: new Date('2026-11-03T11:00:00.000Z'), + rooms: [ + { + building: 'Gløshaugen', + room: 'R7', + url: 'https://www.ntnu.no/kart', + }, + ], + evidence: { + provider: 'ntnu-course-schedule', + kind: 'fixture', + sourceRecordId: 'fixture:ntnu-course-schedule:TDT4136:2026-11-03T09:15:00.000Z', + sourceUrl: fixtureScheduleSourceUrl, + observedAt: new Date(observedAt), + }, + }, + { + id: 'fixture:ntnu-course-schedule:TDT4136:2026-11-05T12:15:00.000Z', + courseCode: 'TDT4136', + activityCode: 'TDT4136-EXERCISE-01', + title: 'Search exercise', + summary: 'Exercise session', + status: 'published', + startsAt: new Date('2026-11-05T12:15:00.000Z'), + endsAt: new Date('2026-11-05T14:00:00.000Z'), + rooms: [ + { + building: 'Gløshaugen', + room: 'EL5', + url: 'https://www.ntnu.no/kart', + }, + ], + evidence: { + provider: 'ntnu-course-schedule', + kind: 'fixture', + sourceRecordId: 'fixture:ntnu-course-schedule:TDT4136:2026-11-05T12:15:00.000Z', + sourceUrl: fixtureScheduleSourceUrl, + observedAt: new Date(observedAt), + }, + }, +]; + +const fixtureScheduleActivityStreams: ReadonlyArray = + fixtureScheduleOccurrences.map(({ activityCode, title, summary }) => ({ + activityCode, + title, + summary, + })); + const missing = (reason: string): Fact => unknown(reason); const encodedInsight = { @@ -307,4 +373,46 @@ export const fixtureCourseDecisionService: CourseDecisionService = { ), }); }, + getSchedule: ({ courseCodes, term, week }) => { + if (!/^\d{4}-(spring|autumn)$/.test(term)) { + return Effect.fail( + new CourseInvalidTermError({ + term, + message: 'Term must use the form YYYY-spring or YYYY-autumn.', + }), + ); + } + + const normalizedCodes = courseCodes.map((courseCode) => courseCode.trim().toUpperCase()); + const missingReason = 'The fixture contains no published schedule for this course and term.'; + return Effect.succeed({ + items: normalizedCodes.map((courseCode) => + courseCode === 'TDT4136' && term === '2026-autumn' + ? { + courseCode, + sourceStatus: { + provider: 'ntnu-course-schedule' as const, + status: 'available' as const, + observedAt: new Date(observedAt), + warning: 'Fixture evidence; live adapter not connected.', + }, + activityStreams: fixtureScheduleActivityStreams, + occurrences: week === 45 ? fixtureScheduleOccurrences : [], + } + : { + courseCode, + sourceStatus: { + provider: 'ntnu-course-schedule' as const, + status: 'unavailable' as const, + observedAt: null, + warning: missingReason, + }, + activityStreams: [], + occurrences: [], + }, + ), + term, + week, + }); + }, }; diff --git a/apps/course-api/src/course-decision/live.test.ts b/apps/course-api/src/course-decision/live.test.ts index 17f37bc..5bc2462 100644 --- a/apps/course-api/src/course-decision/live.test.ts +++ b/apps/course-api/src/course-decision/live.test.ts @@ -23,6 +23,50 @@ const searchPayload = { pageSize: 500, hasMoreResults: false, }; +const scheduleSearchPayload = (courseCode: string, courseVersion: string | null) => ({ + courses: [ + { + courseCode, + courseVersion, + courseName: `${courseCode} schedule test`, + examOnly: false, + hasMultimedia: false, + courseUrl: `https://www.ntnu.no/studier/emner/${courseCode}/2026`, + location: 'Trondheim', + }, + ], + numFound: 1, + pageNr: 1, + pageSize: 500, + hasMoreResults: false, +}); + +const scheduleOccurrence = ( + courseCode: string, + activityCode: string, + startsAt: string, + week: number | null, +) => { + const from = Date.parse(startsAt); + return { + courseCode, + activityCode, + tpId: `${courseCode}-${activityCode}`, + artermin: '2026_HØST', + status: 'published', + acronym: null, + name: null, + title: activityCode, + summary: null, + week, + from, + to: from + 60 * 60 * 1_000, + rooms: [], + staff: [], + studyProgramKeys: [], + }; +}; + const detailHtml = `

TDT4136 Introduction to Artificial Intelligence

@@ -184,6 +228,257 @@ describe('live course decision service', () => { }); }); + it('keeps an available schedule item when its batch neighbour fails', async () => { + const service = makeLiveCourseDecisionService( + { + fetch: async (url, init) => { + if (url.includes('fetch-courselist-as-json')) { + const courseCode = + new URLSearchParams(String(init?.body)).get('searchQueryString') ?? 'UNKNOWN'; + return Response.json(scheduleSearchPayload(courseCode, '1')); + } + if (url.includes('p_p_resource_id=schedules')) { + if (url.includes('TST404')) return new Response('unavailable', { status: 503 }); + return Response.json({ + schedules: [ + scheduleOccurrence('TST200', 'TST200-LECTURE', '2026-11-02T09:15:00.000Z', null), + ], + }); + } + return new Response('not found', { status: 404 }); + }, + now: () => new Date('2026-07-24T12:00:00.000Z'), + sha256Hex: async () => '0'.repeat(64), + }, + defaults, + ); + + const result = await Effect.runPromise( + service.getSchedule({ + courseCodes: ['TST200', 'TST404'], + term: '2026-autumn', + week: 45, + }), + ); + + expect(result.items.map((item) => item.courseCode)).toEqual(['TST200', 'TST404']); + expect(result.items[0]).toMatchObject({ + sourceStatus: { status: 'available' }, + occurrences: [expect.objectContaining({ activityCode: 'TST200-LECTURE' })], + }); + expect(result.items[1]).toMatchObject({ + sourceStatus: { status: 'failed' }, + activityStreams: [], + occurrences: [], + }); + }); + + it('does not default a missing provider course version', async () => { + let scheduleRequests = 0; + let searchQuery = ''; + const service = makeLiveCourseDecisionService( + { + fetch: async (url, init) => { + if (url.includes('fetch-courselist-as-json')) { + searchQuery = new URLSearchParams(String(init?.body)).get('searchQueryString') ?? ''; + return Response.json(scheduleSearchPayload('TDT4136-1', null)); + } + if (url.includes('p_p_resource_id=schedules')) { + scheduleRequests += 1; + return Response.json({ schedules: [] }); + } + return new Response('not found', { status: 404 }); + }, + now: () => new Date('2026-07-24T12:00:00.000Z'), + sha256Hex: async () => '0'.repeat(64), + }, + defaults, + ); + + const result = await Effect.runPromise( + service.getSchedule({ + courseCodes: ['tdt4136-1'], + term: '2026-autumn', + week: 45, + }), + ); + + expect(searchQuery).toBe('TDT4136-1'); + expect(scheduleRequests).toBe(0); + expect(result.items).toMatchObject([ + { + courseCode: 'TDT4136-1', + sourceStatus: { status: 'unavailable' }, + activityStreams: [], + occurrences: [], + }, + ]); + }); + + it('filters by Oslo-local ISO week instead of the nullable provider week', async () => { + const service = makeLiveCourseDecisionService( + { + fetch: async (url) => { + if (url.includes('fetch-courselist-as-json')) { + return Response.json(scheduleSearchPayload('TDT4136', '1')); + } + if (url.includes('p_p_resource_id=schedules')) { + return Response.json({ + schedules: [ + scheduleOccurrence('TDT4136', 'LOCAL-WEEK-45', '2026-11-01T23:30:00.000Z', null), + scheduleOccurrence( + 'TDT4136', + 'PROVIDER-WEEK-45-BUT-LOCAL-46', + '2026-11-08T23:30:00.000Z', + 45, + ), + { courseCode: 'TDT4136' }, + ], + }); + } + return new Response('not found', { status: 404 }); + }, + now: () => new Date('2026-07-24T12:00:00.000Z'), + sha256Hex: async () => '0'.repeat(64), + }, + defaults, + ); + + const result = await Effect.runPromise( + service.getSchedule({ + courseCodes: ['TDT4136'], + term: '2026-autumn', + week: 45, + }), + ); + + expect(result.items[0]).toMatchObject({ + sourceStatus: { + status: 'available', + warning: expect.stringContaining('1 malformed NTNU schedule occurrence(s) were excluded.'), + }, + }); + expect(result.items[0]?.occurrences.map((occurrence) => occurrence.activityCode)).toEqual([ + 'LOCAL-WEEK-45', + ]); + }); + + it('derives full-term activity streams before filtering and resolves labels conservatively', async () => { + const service = makeLiveCourseDecisionService( + { + fetch: async (url) => { + if (url.includes('fetch-courselist-as-json')) { + return Response.json(scheduleSearchPayload('TDT4136', '1')); + } + if (url.includes('p_p_resource_id=schedules')) { + return Response.json({ + schedules: [ + { + ...scheduleOccurrence('TDT4136', 'LECTURE', '2026-11-02T09:15:00.000Z', null), + title: 'Shared title', + summary: 'Shared summary', + }, + { + ...scheduleOccurrence( + 'TDT4136', + 'OUTSIDE-REQUESTED-WEEK', + '2026-11-09T09:15:00.000Z', + null, + ), + title: 'Other title', + summary: 'Other summary', + }, + { + ...scheduleOccurrence('TDT4136', 'LECTURE', '2026-11-16T09:15:00.000Z', null), + title: 'Shared title', + summary: 'Shared summary', + }, + { + ...scheduleOccurrence( + 'TDT4136', + 'CONFLICTING-LABELS', + '2026-11-17T09:15:00.000Z', + null, + ), + title: 'First title', + summary: 'First summary', + }, + { + ...scheduleOccurrence( + 'TDT4136', + 'CONFLICTING-LABELS', + '2026-11-18T09:15:00.000Z', + null, + ), + title: 'Second title', + summary: 'Second summary', + }, + { + ...scheduleOccurrence( + 'TDT4136', + 'NULL-THEN-KNOWN', + '2026-11-19T09:15:00.000Z', + null, + ), + title: null, + summary: null, + }, + { + ...scheduleOccurrence( + 'TDT4136', + 'NULL-THEN-KNOWN', + '2026-11-20T09:15:00.000Z', + null, + ), + title: 'Known title', + summary: 'Known summary', + }, + ], + }); + } + return new Response('not found', { status: 404 }); + }, + now: () => new Date('2026-07-24T12:00:00.000Z'), + sha256Hex: async () => '0'.repeat(64), + }, + defaults, + ); + + const result = await Effect.runPromise( + service.getSchedule({ + courseCodes: ['TDT4136'], + term: '2026-autumn', + week: 45, + }), + ); + + expect(result.items[0]?.occurrences.map((occurrence) => occurrence.activityCode)).toEqual([ + 'LECTURE', + ]); + expect(result.items[0]?.activityStreams).toEqual([ + { + activityCode: 'LECTURE', + title: 'Shared title', + summary: 'Shared summary', + }, + { + activityCode: 'OUTSIDE-REQUESTED-WEEK', + title: 'Other title', + summary: 'Other summary', + }, + { + activityCode: 'CONFLICTING-LABELS', + title: null, + summary: null, + }, + { + activityCode: 'NULL-THEN-KNOWN', + title: 'Known title', + summary: 'Known summary', + }, + ]); + }); + it('coalesces normalized concurrent searches and retains the successful result', async () => { const campusInput = fc.array( fc.constantFrom('trondheim' as const, 'gjovik' as const, 'alesund' as const), diff --git a/apps/course-api/src/course-decision/live.ts b/apps/course-api/src/course-decision/live.ts index c2949b7..605abed 100644 --- a/apps/course-api/src/course-decision/live.ts +++ b/apps/course-api/src/course-decision/live.ts @@ -20,12 +20,15 @@ import { } from '../sources/grades'; import { fetchNtnuCourseDetail, + fetchNtnuCourseSchedule, fetchNtnuCourseSearch, mapNtnuDetailToCourseDecisionSignals, mapNtnuToCourseInsightFields, type NtnuDetailParseResult, + type NtnuScheduleParseResult, type NtnuSearchParseResult, type ValidatedNtnuCourseDetail, + type ValidatedNtnuScheduleOccurrence, type ValidatedNtnuSearchHit, } from '../sources/ntnu'; import * as Effect from 'effect/Effect'; @@ -36,6 +39,10 @@ import { CourseSourcesUnavailableError, type CourseDecisionService, type CourseInsightInput, + type CourseScheduleActivityStream, + type CourseScheduleInput, + type CourseScheduleItem, + type CourseScheduleOccurrence, type CourseSearchCampus, type CourseSearchInput, type CourseSearchLevel, @@ -231,6 +238,136 @@ const mapConcurrent = async ( return results; }; +const osloDateFormatter = new Intl.DateTimeFormat('en-CA', { + day: '2-digit', + month: '2-digit', + timeZone: 'Europe/Oslo', + year: 'numeric', +}); + +const osloIsoWeek = (instant: string): number => { + let day = 0; + let month = 0; + let year = 0; + for (const part of osloDateFormatter.formatToParts(new Date(instant))) { + if (part.type === 'day') day = Number(part.value); + if (part.type === 'month') month = Number(part.value); + if (part.type === 'year') year = Number(part.value); + } + + const localDate = new Date(Date.UTC(year, month - 1, day)); + const localDayOfWeek = localDate.getUTCDay() || 7; + localDate.setUTCDate(localDate.getUTCDate() + 4 - localDayOfWeek); + const isoYearStart = new Date(Date.UTC(localDate.getUTCFullYear(), 0, 1)); + return Math.ceil((localDate.getTime() - isoYearStart.getTime() + 86_400_000) / 604_800_000); +}; + +const combineWarnings = (...warnings: ReadonlyArray): string | null => { + const message = warnings.filter((warning): warning is string => warning !== null).join(' '); + return message === '' ? null : message; +}; + +const unavailableScheduleItem = ( + courseCode: string, + observedAt: Date | null, + warning: string, +): CourseScheduleItem => ({ + courseCode, + sourceStatus: { + provider: 'ntnu-course-schedule', + status: 'unavailable', + observedAt, + warning, + }, + activityStreams: [], + occurrences: [], +}); + +const failedScheduleItem = ( + courseCode: string, + observedAt: Date | null, + warning: string, +): CourseScheduleItem => ({ + courseCode, + sourceStatus: { + provider: 'ntnu-course-schedule', + status: 'failed', + observedAt, + warning, + }, + activityStreams: [], + occurrences: [], +}); + +const toCourseScheduleOccurrence = ( + occurrence: ValidatedNtnuScheduleOccurrence, +): CourseScheduleOccurrence => ({ + id: occurrence.sourceRecordId, + courseCode: occurrence.courseCode, + activityCode: occurrence.activityCode, + title: occurrence.title, + summary: occurrence.summary, + status: occurrence.status, + startsAt: new Date(occurrence.startsAt), + endsAt: new Date(occurrence.endsAt), + rooms: occurrence.rooms.map((room) => ({ + building: room.building, + room: room.room, + url: room.url, + })), + evidence: { + provider: occurrence.attribution.provider, + kind: occurrence.attribution.evidenceKind, + sourceRecordId: occurrence.attribution.sourceRecordId, + sourceUrl: occurrence.attribution.requestUrl, + observedAt: new Date(occurrence.attribution.retrievedAt), + }, +}); + +const toCourseScheduleActivityStreams = ( + occurrences: ReadonlyArray, +): ReadonlyArray => { + const streams = new Map< + string, + { + activityCode: string; + title: string | null; + summary: string | null; + titleConflicted: boolean; + summaryConflicted: boolean; + } + >(); + + for (const occurrence of occurrences) { + const stream = streams.get(occurrence.activityCode); + if (stream === undefined) { + streams.set(occurrence.activityCode, { + activityCode: occurrence.activityCode, + title: occurrence.title, + summary: occurrence.summary, + titleConflicted: false, + summaryConflicted: false, + }); + continue; + } + + if (occurrence.title !== null) { + if (stream.title === null) stream.title = occurrence.title; + else if (stream.title !== occurrence.title) stream.titleConflicted = true; + } + if (occurrence.summary !== null) { + if (stream.summary === null) stream.summary = occurrence.summary; + else if (stream.summary !== occurrence.summary) stream.summaryConflicted = true; + } + } + + return Array.from(streams.values(), (stream) => ({ + activityCode: stream.activityCode, + title: stream.titleConflicted ? null : stream.title, + summary: stream.summaryConflicted ? null : stream.summary, + })); +}; + const toSearchItem = (hit: ValidatedNtnuSearchHit): CourseSearchItem => { const evidenceId = `evidence:${hit.sourceRecordId}`; const knownCampuses = campuses(hit.location); @@ -341,6 +478,12 @@ export const makeLiveCourseDecisionService = ( deps.now, (result) => result.rejected.length === 0, ); + const ntnuScheduleRequests = makeSourceRequestCache( + config.sourceCacheTtlMs, + config.sourceCacheMaxEntriesPerProvider, + deps.now, + (result) => result.rejected.length === 0, + ); const ntnuDetailRequests = makeSourceRequestCache( config.sourceCacheTtlMs, config.sourceCacheMaxEntriesPerProvider, @@ -624,5 +767,136 @@ export const makeLiveCourseDecisionService = ( }), }); - return { search, getInsight, getGradeSummaries, getDecisionSignals }; + const getSchedule = (input: CourseScheduleInput) => + Effect.tryPromise({ + try: async () => { + const term = resolveTerm(input.term, config); + const courseCodes = input.courseCodes.map((courseCode) => courseCode.trim().toUpperCase()); + const items = await mapConcurrent(courseCodes, 4, async (courseCode) => { + try { + return await runWithSourceDeadline( + deps, + config.sourceRequestTimeoutMs, + async (sourceDeps) => { + const searchQuery = { + queryString: courseCode, + academicYear: term.academicYear, + season: term.season, + page: 1, + sort: 'relevance' as const, + campuses: defaultCampuses, + levels: defaultLevels, + continuingEducation: true, + open: false, + english: false, + }; + const { value: searchResult, observedAt: searchObservedAt } = + await ntnuSearchRequests(JSON.stringify(searchQuery), () => + fetchNtnuCourseSearch(sourceDeps, searchQuery), + ); + const searchWarning = + searchResult.rejected.length === 0 + ? null + : `${searchResult.rejected.length} malformed NTNU catalogue row(s) were excluded.`; + if (searchResult.rejected.length > 0 && searchResult.accepted.length === 0) { + return failedScheduleItem( + courseCode, + searchObservedAt, + searchResult.rejected[0]?.message ?? 'NTNU course search was rejected.', + ); + } + + const hit = searchResult.accepted.find( + (candidate) => + candidate.exactMatch && candidate.courseCode.toUpperCase() === courseCode, + ); + if (hit === undefined) { + return unavailableScheduleItem( + courseCode, + searchObservedAt, + combineWarnings( + `NTNU course search had no exact hit for ${courseCode}.`, + searchWarning, + ) ?? `NTNU course search had no exact hit for ${courseCode}.`, + ); + } + + const courseVersion = hit.courseVersion; + if (courseVersion === null || courseVersion.trim() === '') { + return unavailableScheduleItem( + courseCode, + new Date(hit.attribution.retrievedAt), + combineWarnings( + `NTNU course search did not publish a version for ${courseCode}.`, + searchWarning, + ) ?? `NTNU course search did not publish a version for ${courseCode}.`, + ); + } + + const { value: scheduleResult, observedAt: scheduleObservedAt } = + await ntnuScheduleRequests( + JSON.stringify([hit.courseCode, courseVersion, term.academicYear, term.season]), + () => + fetchNtnuCourseSchedule(sourceDeps, { + courseCode: hit.courseCode, + courseVersion, + academicYear: term.academicYear, + season: term.season, + }), + ); + const firstOccurrence = scheduleResult.accepted[0]; + const observedAt = + firstOccurrence === undefined + ? scheduleObservedAt + : new Date(firstOccurrence.attribution.retrievedAt); + const warning = combineWarnings( + searchWarning, + scheduleResult.rejected.length === 0 + ? null + : `${scheduleResult.rejected.length} malformed NTNU schedule occurrence(s) were excluded.`, + ); + if (scheduleResult.rejected.length > 0 && scheduleResult.accepted.length === 0) { + return failedScheduleItem( + courseCode, + observedAt, + warning ?? 'NTNU schedule response was rejected.', + ); + } + + return { + courseCode, + sourceStatus: { + provider: 'ntnu-course-schedule' as const, + status: 'available' as const, + observedAt, + warning, + }, + activityStreams: toCourseScheduleActivityStreams(scheduleResult.accepted), + occurrences: scheduleResult.accepted + .filter((occurrence) => osloIsoWeek(occurrence.startsAt) === input.week) + .map(toCourseScheduleOccurrence), + }; + }, + ); + } catch (cause) { + return failedScheduleItem(courseCode, null, errorMessage(cause)); + } + }); + + return { + items, + term: `${term.academicYear}-${term.season}`, + week: input.week, + }; + }, + catch: (cause) => + cause instanceof CourseInvalidTermError + ? cause + : new CourseSourcesUnavailableError({ + operation: 'schedule', + message: errorMessage(cause), + }), + }); + + return { search, getInsight, getGradeSummaries, getDecisionSignals, getSchedule }; }; diff --git a/apps/course-api/src/course-decision/service.ts b/apps/course-api/src/course-decision/service.ts index 53797d3..1a83162 100644 --- a/apps/course-api/src/course-decision/service.ts +++ b/apps/course-api/src/course-decision/service.ts @@ -38,6 +38,12 @@ export interface CourseDecisionSignalsInput { readonly term?: string; } +export interface CourseScheduleInput { + readonly courseCodes: ReadonlyArray; + readonly term: string; + readonly week: number; +} + export interface CourseSearchResult { readonly items: ReadonlyArray; readonly sourceStatuses: ReadonlyArray; @@ -64,6 +70,52 @@ export interface CourseDecisionSignalsResult { readonly items: ReadonlyArray; } +export interface CourseScheduleEvidence { + readonly provider: 'ntnu-course-schedule'; + readonly kind: 'source-fact' | 'fixture'; + readonly sourceRecordId: string; + readonly sourceUrl: string; + readonly observedAt: Date; +} + +export interface CourseScheduleRoom { + readonly building: string | null; + readonly room: string | null; + readonly url: string | null; +} + +export interface CourseScheduleActivityStream { + readonly activityCode: string; + readonly title: string | null; + readonly summary: string | null; +} + +export interface CourseScheduleOccurrence { + readonly id: string; + readonly courseCode: string; + readonly activityCode: string; + readonly title: string | null; + readonly summary: string | null; + readonly status: string; + readonly startsAt: Date; + readonly endsAt: Date; + readonly rooms: ReadonlyArray; + readonly evidence: CourseScheduleEvidence; +} + +export interface CourseScheduleItem { + readonly courseCode: string; + readonly sourceStatus: SourceStatus; + readonly activityStreams: ReadonlyArray; + readonly occurrences: ReadonlyArray; +} + +export interface CourseScheduleResult { + readonly items: ReadonlyArray; + readonly term: string; + readonly week: number; +} + export class CourseNotFoundError extends Data.TaggedError('CourseNotFoundError')<{ readonly courseCode: string; }> {} @@ -71,7 +123,7 @@ export class CourseNotFoundError extends Data.TaggedError('CourseNotFoundError') export class CourseSourcesUnavailableError extends Data.TaggedError( 'CourseSourcesUnavailableError', )<{ - readonly operation: 'search' | 'insight' | 'grade-summaries' | 'decision-signals'; + readonly operation: 'search' | 'insight' | 'grade-summaries' | 'decision-signals' | 'schedule'; readonly message: string; }> {} @@ -86,6 +138,8 @@ export type CourseInsightError = | CourseInvalidTermError | CourseSourcesUnavailableError; +export type CourseScheduleError = CourseInvalidTermError | CourseSourcesUnavailableError; + export interface CourseDecisionService { readonly search: ( input: CourseSearchInput, @@ -102,4 +156,7 @@ export interface CourseDecisionService { CourseDecisionSignalsResult, CourseInvalidTermError | CourseSourcesUnavailableError >; + readonly getSchedule: ( + input: CourseScheduleInput, + ) => Effect.Effect; } diff --git a/apps/course-api/src/http/course-dto.ts b/apps/course-api/src/http/course-dto.ts index d99f3a0..0ca6b30 100644 --- a/apps/course-api/src/http/course-dto.ts +++ b/apps/course-api/src/http/course-dto.ts @@ -2,6 +2,7 @@ import type { CourseDecisionSignalsDtoType, CourseGradeSummaryDtoType, CourseInsightDtoType, + CourseScheduleResponseDtoType, CourseSearchItemDtoType, } from '@course-data/course-contracts'; @@ -13,6 +14,7 @@ import type { Fact, Offering, } from '../course-decision/model/course-insight'; +import type { CourseScheduleResult } from '../course-decision/service'; type ProtocolFact
= | { @@ -140,6 +142,58 @@ export const toCourseDecisionSignalsDto = ( evidence: signals.evidence.map(mapEvidence), }); +export const toCourseScheduleResponseDto = ( + schedule: CourseScheduleResult, +): CourseScheduleResponseDtoType => ({ + items: schedule.items.map((item) => ({ + courseCode: item.courseCode, + sourceStatus: { + provider: item.sourceStatus.provider, + status: item.sourceStatus.status, + observedAt: item.sourceStatus.observedAt?.toISOString() ?? null, + warning: item.sourceStatus.warning, + }, + activityStreams: item.activityStreams.map((activityStream) => ({ + activityCode: activityStream.activityCode, + title: activityStream.title, + summary: activityStream.summary, + })), + occurrences: item.occurrences.map((occurrence) => ({ + id: occurrence.id, + courseCode: occurrence.courseCode, + activityCode: occurrence.activityCode, + title: occurrence.title, + summary: occurrence.summary, + status: occurrence.status, + startsAt: occurrence.startsAt.toISOString(), + endsAt: occurrence.endsAt.toISOString(), + rooms: occurrence.rooms.map((room) => ({ + building: room.building, + room: room.room, + url: room.url, + })), + evidence: { + provider: occurrence.evidence.provider, + kind: occurrence.evidence.kind, + sourceRecordId: occurrence.evidence.sourceRecordId, + sourceUrl: occurrence.evidence.sourceUrl, + observedAt: occurrence.evidence.observedAt.toISOString(), + }, + })), + })), + meta: { + count: schedule.items.length, + term: schedule.term, + week: schedule.week, + timezone: 'Europe/Oslo', + limitations: { + activitySelection: 'all-published-activities', + activityGrouping: 'unavailable', + exceptionSemantics: 'provider-status-unverified', + }, + }, +}); + export const toCourseInsightDto = (insight: CourseInsight): CourseInsightDtoType => ({ ...insight, title: mapFact(insight.title, String), diff --git a/apps/course-api/src/sources/ntnu/fixtures/tdt4136-schedule-2026-autumn.json b/apps/course-api/src/sources/ntnu/fixtures/tdt4136-schedule-2026-autumn.json new file mode 100644 index 0000000..54bbdbb --- /dev/null +++ b/apps/course-api/src/sources/ntnu/fixtures/tdt4136-schedule-2026-autumn.json @@ -0,0 +1 @@ +{"schedules":[{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_1","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 1","tpId":"194-TDT4136-1-1-2-1_2026_HØST_TDT4136","disiplin":[],"from":1793970900000,"staff":[],"rooms":[{"id":"194_380_3088","building":"Adolf Øien-bygget","room":"U36","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3088"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1793977200000,"week":45},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_1","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 1","tpId":"194-TDT4136-1-1-2-1_2026_HØST_TDT4136","disiplin":[],"from":1792757700000,"staff":[],"rooms":[{"id":"194_380_3088","building":"Adolf Øien-bygget","room":"U36","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3088"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1792764000000,"week":43},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_1","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 1","tpId":"194-TDT4136-1-1-2-1_2026_HØST_TDT4136","disiplin":[],"from":1793366100000,"staff":[],"rooms":[{"id":"194_380_3088","building":"Adolf Øien-bygget","room":"U36","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3088"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1793372400000,"week":44},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_1","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 1","tpId":"194-TDT4136-1-1-2-1_2026_HØST_TDT4136","disiplin":[],"from":1789128900000,"staff":[],"rooms":[{"id":"194_380_3088","building":"Adolf Øien-bygget","room":"U36","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3088"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1789135200000,"week":37},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_1","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 1","tpId":"194-TDT4136-1-1-2-1_2026_HØST_TDT4136","disiplin":[],"from":1789733700000,"staff":[],"rooms":[{"id":"194_380_3088","building":"Adolf Øien-bygget","room":"U36","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3088"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1789740000000,"week":38},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_1","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 1","tpId":"194-TDT4136-1-1-2-1_2026_HØST_TDT4136","disiplin":[],"from":1787919300000,"staff":[],"rooms":[{"id":"194_380_3088","building":"Adolf Øien-bygget","room":"U36","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3088"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1787925600000,"week":35},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_1","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 1","tpId":"194-TDT4136-1-1-2-1_2026_HØST_TDT4136","disiplin":[],"from":1788524100000,"staff":[],"rooms":[{"id":"194_380_3088","building":"Adolf Øien-bygget","room":"U36","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3088"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1788530400000,"week":36},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_1","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 1","tpId":"194-TDT4136-1-1-2-1_2026_HØST_TDT4136","disiplin":[],"from":1791548100000,"staff":[],"rooms":[{"id":"194_380_3088","building":"Adolf Øien-bygget","room":"U36","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3088"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1791554400000,"week":41},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_1","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 1","tpId":"194-TDT4136-1-1-2-1_2026_HØST_TDT4136","disiplin":[],"from":1792152900000,"staff":[],"rooms":[{"id":"194_380_3088","building":"Adolf Øien-bygget","room":"U36","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3088"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1792159200000,"week":42},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_1","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 1","tpId":"194-TDT4136-1-1-2-1_2026_HØST_TDT4136","disiplin":[],"from":1790338500000,"staff":[],"rooms":[{"id":"194_380_3088","building":"Adolf Øien-bygget","room":"U36","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3088"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1790344800000,"week":39},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_1","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 1","tpId":"194-TDT4136-1-1-2-1_2026_HØST_TDT4136","disiplin":[],"from":1790943300000,"staff":[],"rooms":[{"id":"194_380_3088","building":"Adolf Øien-bygget","room":"U36","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3088"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1790949600000,"week":40},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_4","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 4","tpId":"194-TDT4136-1-1-2-4_2026_HØST_TDT4136","disiplin":[],"from":1790000100000,"staff":[],"rooms":[{"id":"194_380_3086","building":"Adolf Øien-bygget","room":"U35","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3086"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1790006400000,"week":39},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_4","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 4","tpId":"194-TDT4136-1-1-2-4_2026_HØST_TDT4136","disiplin":[],"from":1789395300000,"staff":[],"rooms":[{"id":"194_380_3086","building":"Adolf Øien-bygget","room":"U35","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3086"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1789401600000,"week":38},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_4","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 4","tpId":"194-TDT4136-1-1-2-4_2026_HØST_TDT4136","disiplin":[],"from":1788790500000,"staff":[],"rooms":[{"id":"194_380_3086","building":"Adolf Øien-bygget","room":"U35","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3086"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1788796800000,"week":37},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_4","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 4","tpId":"194-TDT4136-1-1-2-4_2026_HØST_TDT4136","disiplin":[],"from":1788185700000,"staff":[],"rooms":[{"id":"194_380_3086","building":"Adolf Øien-bygget","room":"U35","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3086"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1788192000000,"week":36},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_4","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 4","tpId":"194-TDT4136-1-1-2-4_2026_HØST_TDT4136","disiplin":[],"from":1792419300000,"staff":[],"rooms":[{"id":"194_380_3086","building":"Adolf Øien-bygget","room":"U35","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3086"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1792425600000,"week":43},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_4","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 4","tpId":"194-TDT4136-1-1-2-4_2026_HØST_TDT4136","disiplin":[],"from":1791814500000,"staff":[],"rooms":[{"id":"194_380_3086","building":"Adolf Øien-bygget","room":"U35","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3086"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1791820800000,"week":42},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_4","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 4","tpId":"194-TDT4136-1-1-2-4_2026_HØST_TDT4136","disiplin":[],"from":1791209700000,"staff":[],"rooms":[{"id":"194_380_3086","building":"Adolf Øien-bygget","room":"U35","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3086"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1791216000000,"week":41},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_4","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 4","tpId":"194-TDT4136-1-1-2-4_2026_HØST_TDT4136","disiplin":[],"from":1790604900000,"staff":[],"rooms":[{"id":"194_380_3086","building":"Adolf Øien-bygget","room":"U35","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3086"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1790611200000,"week":40},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_4","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 4","tpId":"194-TDT4136-1-1-2-4_2026_HØST_TDT4136","disiplin":[],"from":1793632500000,"staff":[],"rooms":[{"id":"194_380_3086","building":"Adolf Øien-bygget","room":"U35","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3086"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1793638800000,"week":45},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_4","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 4","tpId":"194-TDT4136-1-1-2-4_2026_HØST_TDT4136","disiplin":[],"from":1793027700000,"staff":[],"rooms":[{"id":"194_380_3086","building":"Adolf Øien-bygget","room":"U35","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3086"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1793034000000,"week":44},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_2","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 2","tpId":"194-TDT4136-1-1-2-2_2026_HØST_TDT4136","disiplin":[],"from":1792563300000,"staff":[],"rooms":[{"id":"194_380_3086","building":"Adolf Øien-bygget","room":"U35","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3086"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for Assignment","to":1792569600000,"week":43},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_2","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 2","tpId":"194-TDT4136-1-1-2-2_2026_HØST_TDT4136","disiplin":[],"from":1791958500000,"staff":[],"rooms":[{"id":"194_380_3086","building":"Adolf Øien-bygget","room":"U35","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3086"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for Assignment","to":1791964800000,"week":42},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_2","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 2","tpId":"194-TDT4136-1-1-2-2_2026_HØST_TDT4136","disiplin":[],"from":1791353700000,"staff":[],"rooms":[{"id":"194_380_3086","building":"Adolf Øien-bygget","room":"U35","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3086"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for Assignment","to":1791360000000,"week":41},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_2","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 2","tpId":"194-TDT4136-1-1-2-2_2026_HØST_TDT4136","disiplin":[],"from":1790748900000,"staff":[],"rooms":[{"id":"194_380_3086","building":"Adolf Øien-bygget","room":"U35","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3086"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for Assignment","to":1790755200000,"week":40},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_2","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 2","tpId":"194-TDT4136-1-1-2-2_2026_HØST_TDT4136","disiplin":[],"from":1790144100000,"staff":[],"rooms":[{"id":"194_380_3086","building":"Adolf Øien-bygget","room":"U35","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3086"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for Assignment","to":1790150400000,"week":39},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_2","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 2","tpId":"194-TDT4136-1-1-2-2_2026_HØST_TDT4136","disiplin":[],"from":1789539300000,"staff":[],"rooms":[{"id":"194_380_3086","building":"Adolf Øien-bygget","room":"U35","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3086"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for Assignment","to":1789545600000,"week":38},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_2","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 2","tpId":"194-TDT4136-1-1-2-2_2026_HØST_TDT4136","disiplin":[],"from":1788934500000,"staff":[],"rooms":[{"id":"194_380_3086","building":"Adolf Øien-bygget","room":"U35","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3086"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for Assignment","to":1788940800000,"week":37},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_2","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 2","tpId":"194-TDT4136-1-1-2-2_2026_HØST_TDT4136","disiplin":[],"from":1788329700000,"staff":[],"rooms":[{"id":"194_380_3086","building":"Adolf Øien-bygget","room":"U35","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3086"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for Assignment","to":1788336000000,"week":36},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_2","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 2","tpId":"194-TDT4136-1-1-2-2_2026_HØST_TDT4136","disiplin":[],"from":1793776500000,"staff":[],"rooms":[{"id":"194_380_3086","building":"Adolf Øien-bygget","room":"U35","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3086"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for Assignment","to":1793782800000,"week":45},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_2","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 2","tpId":"194-TDT4136-1-1-2-2_2026_HØST_TDT4136","disiplin":[],"from":1793171700000,"staff":[],"rooms":[{"id":"194_380_3086","building":"Adolf Øien-bygget","room":"U35","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3086"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for Assignment","to":1793178000000,"week":44},{"acronym":"FORM","activityCode":"194_TDT4136_1_HØST_2026_1_3","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Main lecture","tpId":"194-TDT4136-1-1-3_2026_HØST_TDT4136","disiplin":[],"from":1792671300000,"staff":[{"username":"ruocco","name":"M. Ruocco","url":"http://www.ntnu.no/ansatte/ruocco"},{"username":"eidheim","name":"O.C. Eidheim","url":"http://www.ntnu.no/ansatte/eidheim"}],"rooms":[{"id":"194_307_205","building":"Verkstedteknisk","room":"VE1","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=307-205"}],"status":"active","studyProgramKeys":["MSNEUR","MSELSYS","MTDT","MBIOT5","MTMART","MSIT","MIDT","BIELDIG","MTTK","BIT","MSN1","MTING"],"summary":"1 Lecture","to":1792677600000,"week":43,"selectedProgram":"MSNEUR"},{"acronym":"FORM","activityCode":"194_TDT4136_1_HØST_2026_1_3","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Main lecture","tpId":"194-TDT4136-1-1-3_2026_HØST_TDT4136","disiplin":[],"from":1792066500000,"staff":[{"username":"ruocco","name":"M. Ruocco","url":"http://www.ntnu.no/ansatte/ruocco"},{"username":"eidheim","name":"O.C. Eidheim","url":"http://www.ntnu.no/ansatte/eidheim"}],"rooms":[{"id":"194_307_205","building":"Verkstedteknisk","room":"VE1","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=307-205"}],"status":"active","studyProgramKeys":["MSNEUR","MSELSYS","MTDT","MBIOT5","MTMART","MSIT","MIDT","BIELDIG","MTTK","BIT","MSN1","MTING"],"summary":"1 Lecture","to":1792072800000,"week":42,"selectedProgram":"MSNEUR"},{"acronym":"FORM","activityCode":"194_TDT4136_1_HØST_2026_1_3","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Main lecture","tpId":"194-TDT4136-1-1-3_2026_HØST_TDT4136","disiplin":[],"from":1793884500000,"staff":[{"username":"ruocco","name":"M. Ruocco","url":"http://www.ntnu.no/ansatte/ruocco"},{"username":"eidheim","name":"O.C. Eidheim","url":"http://www.ntnu.no/ansatte/eidheim"}],"rooms":[{"id":"194_307_205","building":"Verkstedteknisk","room":"VE1","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=307-205"}],"status":"active","studyProgramKeys":["MSNEUR","MSELSYS","MTDT","MBIOT5","MTMART","MSIT","MIDT","BIELDIG","MTTK","BIT","MSN1","MTING"],"summary":"1 Lecture","to":1793890800000,"week":45,"selectedProgram":"MSNEUR"},{"acronym":"FORM","activityCode":"194_TDT4136_1_HØST_2026_1_3","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Main lecture","tpId":"194-TDT4136-1-1-3_2026_HØST_TDT4136","disiplin":[],"from":1793279700000,"staff":[{"username":"eidheim","name":"O.C. Eidheim","url":"http://www.ntnu.no/ansatte/eidheim"},{"username":"ruocco","name":"M. Ruocco","url":"http://www.ntnu.no/ansatte/ruocco"}],"rooms":[{"id":"194_307_205","building":"Verkstedteknisk","room":"VE1","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=307-205"}],"status":"active","studyProgramKeys":["MSNEUR","MSELSYS","MTDT","MBIOT5","MTMART","MSIT","MIDT","BIELDIG","MTTK","BIT","MSN1","MTING"],"summary":"1 Lecture","to":1793286000000,"week":44,"selectedProgram":"MSNEUR"},{"acronym":"FORM","activityCode":"194_TDT4136_1_HØST_2026_1_3","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Main lecture","tpId":"194-TDT4136-1-1-3_2026_HØST_TDT4136","disiplin":[],"from":1794489300000,"staff":[{"username":"ruocco","name":"M. Ruocco","url":"http://www.ntnu.no/ansatte/ruocco"},{"username":"eidheim","name":"O.C. Eidheim","url":"http://www.ntnu.no/ansatte/eidheim"}],"rooms":[{"id":"194_307_205","building":"Verkstedteknisk","room":"VE1","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=307-205"}],"status":"active","studyProgramKeys":["MSNEUR","MSELSYS","MTDT","MBIOT5","MTMART","MSIT","MIDT","BIELDIG","MTTK","BIT","MSN1","MTING"],"summary":"1 Lecture","to":1794495600000,"week":46,"selectedProgram":"MSNEUR"},{"acronym":"FORM","activityCode":"194_TDT4136_1_HØST_2026_1_3","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Main lecture","tpId":"194-TDT4136-1-1-3_2026_HØST_TDT4136","disiplin":[],"from":1787832900000,"staff":[{"username":"eidheim","name":"O.C. Eidheim","url":"http://www.ntnu.no/ansatte/eidheim"},{"username":"ruocco","name":"M. Ruocco","url":"http://www.ntnu.no/ansatte/ruocco"}],"rooms":[{"id":"194_307_205","building":"Verkstedteknisk","room":"VE1","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=307-205"}],"status":"active","studyProgramKeys":["MSNEUR","MSELSYS","MTDT","MBIOT5","MTMART","MSIT","MIDT","BIELDIG","MTTK","BIT","MSN1","MTING"],"summary":"1 Lecture","to":1787839200000,"week":35,"selectedProgram":"MSNEUR"},{"acronym":"FORM","activityCode":"194_TDT4136_1_HØST_2026_1_3","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Main lecture","tpId":"194-TDT4136-1-1-3_2026_HØST_TDT4136","disiplin":[],"from":1787228100000,"staff":[{"username":"eidheim","name":"O.C. Eidheim","url":"http://www.ntnu.no/ansatte/eidheim"},{"username":"ruocco","name":"M. Ruocco","url":"http://www.ntnu.no/ansatte/ruocco"}],"rooms":[{"id":"194_307_205","building":"Verkstedteknisk","room":"VE1","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=307-205"}],"status":"active","studyProgramKeys":["MSNEUR","MSELSYS","MTDT","MBIOT5","MTMART","MSIT","MIDT","BIELDIG","MTTK","BIT","MSN1","MTING"],"summary":"1 Lecture","to":1787234400000,"week":34,"selectedProgram":"MSNEUR"},{"acronym":"FORM","activityCode":"194_TDT4136_1_HØST_2026_1_3","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Main lecture","tpId":"194-TDT4136-1-1-3_2026_HØST_TDT4136","disiplin":[],"from":1789042500000,"staff":[{"username":"ruocco","name":"M. Ruocco","url":"http://www.ntnu.no/ansatte/ruocco"},{"username":"eidheim","name":"O.C. Eidheim","url":"http://www.ntnu.no/ansatte/eidheim"}],"rooms":[{"id":"194_307_205","building":"Verkstedteknisk","room":"VE1","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=307-205"}],"status":"active","studyProgramKeys":["MSNEUR","MSELSYS","MTDT","MBIOT5","MTMART","MSIT","MIDT","BIELDIG","MTTK","BIT","MSN1","MTING"],"summary":"1 Lecture","to":1789048800000,"week":37,"selectedProgram":"MSNEUR"},{"acronym":"FORM","activityCode":"194_TDT4136_1_HØST_2026_1_3","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Main lecture","tpId":"194-TDT4136-1-1-3_2026_HØST_TDT4136","disiplin":[],"from":1788437700000,"staff":[{"username":"eidheim","name":"O.C. Eidheim","url":"http://www.ntnu.no/ansatte/eidheim"},{"username":"ruocco","name":"M. Ruocco","url":"http://www.ntnu.no/ansatte/ruocco"}],"rooms":[{"id":"194_307_205","building":"Verkstedteknisk","room":"VE1","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=307-205"}],"status":"active","studyProgramKeys":["MSNEUR","MSELSYS","MTDT","MBIOT5","MTMART","MSIT","MIDT","BIELDIG","MTTK","BIT","MSN1","MTING"],"summary":"1 Lecture","to":1788444000000,"week":36,"selectedProgram":"MSNEUR"},{"acronym":"FORM","activityCode":"194_TDT4136_1_HØST_2026_1_3","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Main lecture","tpId":"194-TDT4136-1-1-3_2026_HØST_TDT4136","disiplin":[],"from":1790252100000,"staff":[{"username":"ruocco","name":"M. Ruocco","url":"http://www.ntnu.no/ansatte/ruocco"},{"username":"eidheim","name":"O.C. Eidheim","url":"http://www.ntnu.no/ansatte/eidheim"}],"rooms":[{"id":"194_307_205","building":"Verkstedteknisk","room":"VE1","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=307-205"}],"status":"active","studyProgramKeys":["MSNEUR","MSELSYS","MTDT","MBIOT5","MTMART","MSIT","MIDT","BIELDIG","MTTK","BIT","MSN1","MTING"],"summary":"1 Lecture","to":1790258400000,"week":39,"selectedProgram":"MSNEUR"},{"acronym":"FORM","activityCode":"194_TDT4136_1_HØST_2026_1_3","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Main lecture","tpId":"194-TDT4136-1-1-3_2026_HØST_TDT4136","disiplin":[],"from":1789647300000,"staff":[{"username":"eidheim","name":"O.C. Eidheim","url":"http://www.ntnu.no/ansatte/eidheim"},{"username":"ruocco","name":"M. Ruocco","url":"http://www.ntnu.no/ansatte/ruocco"}],"rooms":[{"id":"194_307_205","building":"Verkstedteknisk","room":"VE1","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=307-205"}],"status":"active","studyProgramKeys":["MSNEUR","MSELSYS","MTDT","MBIOT5","MTMART","MSIT","MIDT","BIELDIG","MTTK","BIT","MSN1","MTING"],"summary":"1 Lecture","to":1789653600000,"week":38,"selectedProgram":"MSNEUR"},{"acronym":"FORM","activityCode":"194_TDT4136_1_HØST_2026_1_3","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Main lecture","tpId":"194-TDT4136-1-1-3_2026_HØST_TDT4136","disiplin":[],"from":1791461700000,"staff":[{"username":"eidheim","name":"O.C. Eidheim","url":"http://www.ntnu.no/ansatte/eidheim"},{"username":"ruocco","name":"M. Ruocco","url":"http://www.ntnu.no/ansatte/ruocco"}],"rooms":[{"id":"194_307_205","building":"Verkstedteknisk","room":"VE1","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=307-205"}],"status":"active","studyProgramKeys":["MSNEUR","MSELSYS","MTDT","MBIOT5","MTMART","MSIT","MIDT","BIELDIG","MTTK","BIT","MSN1","MTING"],"summary":"1 Lecture","to":1791468000000,"week":41,"selectedProgram":"MSNEUR"},{"acronym":"FORM","activityCode":"194_TDT4136_1_HØST_2026_1_3","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Main lecture","tpId":"194-TDT4136-1-1-3_2026_HØST_TDT4136","disiplin":[],"from":1790856900000,"staff":[{"username":"eidheim","name":"O.C. Eidheim","url":"http://www.ntnu.no/ansatte/eidheim"},{"username":"ruocco","name":"M. Ruocco","url":"http://www.ntnu.no/ansatte/ruocco"}],"rooms":[{"id":"194_307_205","building":"Verkstedteknisk","room":"VE1","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=307-205"}],"status":"active","studyProgramKeys":["MSNEUR","MSELSYS","MTDT","MBIOT5","MTMART","MSIT","MIDT","BIELDIG","MTTK","BIT","MSN1","MTING"],"summary":"1 Lecture","to":1790863200000,"week":40,"selectedProgram":"MSNEUR"},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_3","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 3","tpId":"194-TDT4136-1-1-2-3_2026_HØST_TDT4136","disiplin":[],"from":1793711700000,"staff":[],"rooms":[{"id":"194_380_3088","building":"Adolf Øien-bygget","room":"U36","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3088"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1793718000000,"week":45},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_3","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 3","tpId":"194-TDT4136-1-1-2-3_2026_HØST_TDT4136","disiplin":[],"from":1793106900000,"staff":[],"rooms":[{"id":"194_380_3088","building":"Adolf Øien-bygget","room":"U36","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3088"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1793113200000,"week":44},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_3","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 3","tpId":"194-TDT4136-1-1-2-3_2026_HØST_TDT4136","disiplin":[],"from":1792498500000,"staff":[],"rooms":[{"id":"194_380_3088","building":"Adolf Øien-bygget","room":"U36","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3088"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1792504800000,"week":43},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_3","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 3","tpId":"194-TDT4136-1-1-2-3_2026_HØST_TDT4136","disiplin":[],"from":1791893700000,"staff":[],"rooms":[{"id":"194_380_3088","building":"Adolf Øien-bygget","room":"U36","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3088"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1791900000000,"week":42},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_3","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 3","tpId":"194-TDT4136-1-1-2-3_2026_HØST_TDT4136","disiplin":[],"from":1791288900000,"staff":[],"rooms":[{"id":"194_380_3088","building":"Adolf Øien-bygget","room":"U36","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3088"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1791295200000,"week":41},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_3","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 3","tpId":"194-TDT4136-1-1-2-3_2026_HØST_TDT4136","disiplin":[],"from":1790684100000,"staff":[],"rooms":[{"id":"194_380_3088","building":"Adolf Øien-bygget","room":"U36","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3088"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1790690400000,"week":40},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_3","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 3","tpId":"194-TDT4136-1-1-2-3_2026_HØST_TDT4136","disiplin":[],"from":1790079300000,"staff":[],"rooms":[{"id":"194_380_3088","building":"Adolf Øien-bygget","room":"U36","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3088"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1790085600000,"week":39},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_3","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 3","tpId":"194-TDT4136-1-1-2-3_2026_HØST_TDT4136","disiplin":[],"from":1789474500000,"staff":[],"rooms":[{"id":"194_380_3088","building":"Adolf Øien-bygget","room":"U36","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3088"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1789480800000,"week":38},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_3","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 3","tpId":"194-TDT4136-1-1-2-3_2026_HØST_TDT4136","disiplin":[],"from":1788869700000,"staff":[],"rooms":[{"id":"194_380_3088","building":"Adolf Øien-bygget","room":"U36","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3088"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1788876000000,"week":37},{"acronym":"FORM-P","activityCode":"194_TDT4136_1_HØST_2026_1_2_3","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Lab hour for assignments 3","tpId":"194-TDT4136-1-1-2-3_2026_HØST_TDT4136","disiplin":[],"from":1788264900000,"staff":[],"rooms":[{"id":"194_380_3088","building":"Adolf Øien-bygget","room":"U36","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3088"}],"status":"active","studyProgramKeys":[],"summary":"Lab hour for assignments","to":1788271200000,"week":36},{"acronym":"FORM","activityCode":"194_TDT4136_1_HØST_2026_1_1","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Assignment lecture","tpId":"194-TDT4136-1-1-1_2026_HØST_TDT4136","disiplin":[],"from":1795166100000,"staff":[],"rooms":[{"id":"194_360_AU1-103","building":"Realfagbygget","room":"R8","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=360-AU1-103"}],"status":"active","studyProgramKeys":["MSNEUR","MSELSYS","MTDT","MBIOT5","MTMART","MSIT","MIDT","BIELDIG","MTTK","BIT","MSN1","MTING"],"summary":" Assignment lecture","to":1795172400000,"week":47,"selectedProgram":"MSNEUR"},{"acronym":"FORM","activityCode":"194_TDT4136_1_HØST_2026_1_1","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Assignment lecture","tpId":"194-TDT4136-1-1-1_2026_HØST_TDT4136","disiplin":[],"from":1793956500000,"staff":[],"rooms":[{"id":"194_360_AU1-103","building":"Realfagbygget","room":"R8","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=360-AU1-103"}],"status":"active","studyProgramKeys":["MSNEUR","MSELSYS","MTDT","MBIOT5","MTMART","MSIT","MIDT","BIELDIG","MTTK","BIT","MSN1","MTING"],"summary":" Assignment lecture","to":1793962800000,"week":45,"selectedProgram":"MSNEUR"},{"acronym":"FORM","activityCode":"194_TDT4136_1_HØST_2026_1_1","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Assignment lecture","tpId":"194-TDT4136-1-1-1_2026_HØST_TDT4136","disiplin":[],"from":1794561300000,"staff":[],"rooms":[{"id":"194_360_AU1-103","building":"Realfagbygget","room":"R8","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=360-AU1-103"}],"status":"active","studyProgramKeys":["MSNEUR","MSELSYS","MTDT","MBIOT5","MTMART","MSIT","MIDT","BIELDIG","MTTK","BIT","MSN1","MTING"],"summary":" Assignment lecture","to":1794567600000,"week":46,"selectedProgram":"MSNEUR"},{"acronym":"FORM","activityCode":"194_TDT4136_1_HØST_2026_1_1","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Assignment lecture","tpId":"194-TDT4136-1-1-1_2026_HØST_TDT4136","disiplin":[],"from":1792743300000,"staff":[],"rooms":[{"id":"194_360_AU1-103","building":"Realfagbygget","room":"R8","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=360-AU1-103"}],"status":"active","studyProgramKeys":["MSNEUR","MSELSYS","MTDT","MBIOT5","MTMART","MSIT","MIDT","BIELDIG","MTTK","BIT","MSN1","MTING"],"summary":" Assignment lecture","to":1792749600000,"week":43,"selectedProgram":"MSNEUR"},{"acronym":"FORM","activityCode":"194_TDT4136_1_HØST_2026_1_1","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Assignment lecture","tpId":"194-TDT4136-1-1-1_2026_HØST_TDT4136","disiplin":[],"from":1793351700000,"staff":[],"rooms":[{"id":"194_360_AU1-103","building":"Realfagbygget","room":"R8","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=360-AU1-103"}],"status":"active","studyProgramKeys":["MSNEUR","MSELSYS","MTDT","MBIOT5","MTMART","MSIT","MIDT","BIELDIG","MTTK","BIT","MSN1","MTING"],"summary":" Assignment lecture","to":1793358000000,"week":44,"selectedProgram":"MSNEUR"},{"acronym":"FORM","activityCode":"194_TDT4136_1_HØST_2026_1_1","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Assignment lecture","tpId":"194-TDT4136-1-1-1_2026_HØST_TDT4136","disiplin":[],"from":1791533700000,"staff":[],"rooms":[{"id":"194_360_AU1-103","building":"Realfagbygget","room":"R8","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=360-AU1-103"}],"status":"active","studyProgramKeys":["MSNEUR","MSELSYS","MTDT","MBIOT5","MTMART","MSIT","MIDT","BIELDIG","MTTK","BIT","MSN1","MTING"],"summary":" Assignment lecture","to":1791540000000,"week":41,"selectedProgram":"MSNEUR"},{"acronym":"FORM","activityCode":"194_TDT4136_1_HØST_2026_1_1","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Assignment lecture","tpId":"194-TDT4136-1-1-1_2026_HØST_TDT4136","disiplin":[],"from":1792138500000,"staff":[],"rooms":[{"id":"194_360_AU1-103","building":"Realfagbygget","room":"R8","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=360-AU1-103"}],"status":"active","studyProgramKeys":["MSNEUR","MSELSYS","MTDT","MBIOT5","MTMART","MSIT","MIDT","BIELDIG","MTTK","BIT","MSN1","MTING"],"summary":" Assignment lecture","to":1792144800000,"week":42,"selectedProgram":"MSNEUR"},{"acronym":"FORM","activityCode":"194_TDT4136_1_HØST_2026_1_1","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Assignment lecture","tpId":"194-TDT4136-1-1-1_2026_HØST_TDT4136","disiplin":[],"from":1790324100000,"staff":[],"rooms":[{"id":"194_360_AU1-103","building":"Realfagbygget","room":"R8","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=360-AU1-103"}],"status":"active","studyProgramKeys":["MSNEUR","MSELSYS","MTDT","MBIOT5","MTMART","MSIT","MIDT","BIELDIG","MTTK","BIT","MSN1","MTING"],"summary":" Assignment lecture","to":1790330400000,"week":39,"selectedProgram":"MSNEUR"},{"acronym":"FORM","activityCode":"194_TDT4136_1_HØST_2026_1_1","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Assignment lecture","tpId":"194-TDT4136-1-1-1_2026_HØST_TDT4136","disiplin":[],"from":1790928900000,"staff":[],"rooms":[{"id":"194_360_AU1-103","building":"Realfagbygget","room":"R8","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=360-AU1-103"}],"status":"active","studyProgramKeys":["MSNEUR","MSELSYS","MTDT","MBIOT5","MTMART","MSIT","MIDT","BIELDIG","MTTK","BIT","MSN1","MTING"],"summary":" Assignment lecture","to":1790935200000,"week":40,"selectedProgram":"MSNEUR"},{"acronym":"FORM","activityCode":"194_TDT4136_1_HØST_2026_1_1","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Assignment lecture","tpId":"194-TDT4136-1-1-1_2026_HØST_TDT4136","disiplin":[],"from":1789114500000,"staff":[],"rooms":[{"id":"194_360_AU1-103","building":"Realfagbygget","room":"R8","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=360-AU1-103"}],"status":"active","studyProgramKeys":["MSNEUR","MSELSYS","MTDT","MBIOT5","MTMART","MSIT","MIDT","BIELDIG","MTTK","BIT","MSN1","MTING"],"summary":" Assignment lecture","to":1789120800000,"week":37,"selectedProgram":"MSNEUR"},{"acronym":"FORM","activityCode":"194_TDT4136_1_HØST_2026_1_1","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Assignment lecture","tpId":"194-TDT4136-1-1-1_2026_HØST_TDT4136","disiplin":[],"from":1789719300000,"staff":[],"rooms":[{"id":"194_360_AU1-103","building":"Realfagbygget","room":"R8","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=360-AU1-103"}],"status":"active","studyProgramKeys":["MSNEUR","MSELSYS","MTDT","MBIOT5","MTMART","MSIT","MIDT","BIELDIG","MTTK","BIT","MSN1","MTING"],"summary":" Assignment lecture","to":1789725600000,"week":38,"selectedProgram":"MSNEUR"},{"acronym":"FORM","activityCode":"194_TDT4136_1_HØST_2026_1_1","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Assignment lecture","tpId":"194-TDT4136-1-1-1_2026_HØST_TDT4136","disiplin":[],"from":1787904900000,"staff":[],"rooms":[{"id":"194_360_AU1-103","building":"Realfagbygget","room":"R8","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=360-AU1-103"}],"status":"active","studyProgramKeys":["MSNEUR","MSELSYS","MTDT","MBIOT5","MTMART","MSIT","MIDT","BIELDIG","MTTK","BIT","MSN1","MTING"],"summary":" Assignment lecture","to":1787911200000,"week":35,"selectedProgram":"MSNEUR"},{"acronym":"FORM","activityCode":"194_TDT4136_1_HØST_2026_1_1","artermin":"2026_HØST","courseCode":"TDT4136","courseName":{"nameNob":"Introduksjon til kunstig intelligens ","nameNno":"Introduksjon til kunstig intelligens ","nameEng":"Introduction to Artificial Intelligence"},"name":"Formidling","termnr":1,"title":"Assignment lecture","tpId":"194-TDT4136-1-1-1_2026_HØST_TDT4136","disiplin":[],"from":1788509700000,"staff":[],"rooms":[{"id":"194_360_AU1-103","building":"Realfagbygget","room":"R8","url":"http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=360-AU1-103"}],"status":"active","studyProgramKeys":["MSNEUR","MSELSYS","MTDT","MBIOT5","MTMART","MSIT","MIDT","BIELDIG","MTTK","BIT","MSN1","MTING"],"summary":" Assignment lecture","to":1788516000000,"week":36,"selectedProgram":"MSNEUR"}]} \ No newline at end of file diff --git a/apps/course-api/src/sources/ntnu/fixtures/tdt4136-schedule-2026-autumn.source.json b/apps/course-api/src/sources/ntnu/fixtures/tdt4136-schedule-2026-autumn.source.json new file mode 100644 index 0000000..0d945a2 --- /dev/null +++ b/apps/course-api/src/sources/ntnu/fixtures/tdt4136-schedule-2026-autumn.source.json @@ -0,0 +1,26 @@ +{ + "sourceProvider": "ntnu-course-schedule", + "requestUrl": "https://www.ntnu.no/web/studier/emner?p_p_id=coursedetailsportlet_WAR_courselistportlet&p_p_lifecycle=2&p_p_state=normal&p_p_mode=view&p_p_cacheability=cacheLevelPage&p_p_resource_id=schedules&_coursedetailsportlet_WAR_courselistportlet_courseCode=TDT4136&year=2026&version=1", + "requestMethod": "GET", + "courseCode": "TDT4136", + "courseVersion": "1", + "academicYear": 2026, + "season": "autumn", + "timezone": "Europe/Oslo", + "capturedAt": "2026-09-22T08:45:58.226Z", + "contentHash": { + "algorithm": "sha-256", + "rawBody": "5bc525f14a2a97ae65f662742f447fc618a1359653ff893ab96dae8ee03dfbf5" + }, + "httpValidator": { + "state": "known", + "kind": "http-etag", + "value": "\"9365106e\"" + }, + "observedOccurrenceCount": 67, + "observedStatuses": [ + "active" + ], + "isRepresentativeFixture": false, + "note": "Live capture from NTNU's public unauthenticated Liferay course-page resource. Its representation-specific HTTP ETag still matched these exact response bytes on 2026-09-22. NTNU's robots.txt disallows automated access to /web and p_p resource URLs, and no reuse license was found. This fixture therefore proves dated occurrences, a response validator, and room fields only; it does not authorize automated reuse or prove a provider revision, fixed-versus-alternative groups, campus identity, or cancellation/exception semantics." +} diff --git a/apps/course-api/src/sources/ntnu/index.ts b/apps/course-api/src/sources/ntnu/index.ts index 06f5e83..d425445 100644 --- a/apps/course-api/src/sources/ntnu/index.ts +++ b/apps/course-api/src/sources/ntnu/index.ts @@ -2,5 +2,7 @@ export * from './search'; export * from './search-client'; export * from './detail'; export * from './detail-client'; +export * from './schedule'; +export * from './schedule-client'; export * from './map-course-insight'; export * from './map-course-decision-signals'; diff --git a/apps/course-api/src/sources/ntnu/schedule-client.ts b/apps/course-api/src/sources/ntnu/schedule-client.ts new file mode 100644 index 0000000..4ead3a4 --- /dev/null +++ b/apps/course-api/src/sources/ntnu/schedule-client.ts @@ -0,0 +1,58 @@ +import { + parseNtnuCourseSchedule, + type NtnuScheduleParseResult, + type NtnuScheduleHttpValidator, +} from './schedule'; +import type { FetchLike } from './search-client'; + +const SCHEDULE_RESOURCE_URL = + 'https://www.ntnu.no/web/studier/emner?p_p_id=coursedetailsportlet_WAR_courselistportlet&p_p_lifecycle=2&p_p_state=normal&p_p_mode=view&p_p_cacheability=cacheLevelPage&p_p_resource_id=schedules'; + +export interface FetchNtnuCourseScheduleDeps { + readonly fetch: FetchLike; + readonly now: () => Date; + readonly sha256Hex: (input: string) => Promise; +} + +export interface NtnuCourseScheduleQuery { + readonly courseCode: string; + readonly courseVersion: string; + readonly academicYear: number; + readonly season: 'spring' | 'autumn'; +} + +/** + * Impure edge: performs the live NTNU schedule request and stamps capture + * metadata from injected fetch, clock, and hash implementations before + * delegating to the pure {@link parseNtnuCourseSchedule} boundary parser. + */ +export const fetchNtnuCourseSchedule = async ( + deps: FetchNtnuCourseScheduleDeps, + query: NtnuCourseScheduleQuery, +): Promise => { + const requestUrl = `${SCHEDULE_RESOURCE_URL}&_coursedetailsportlet_WAR_courselistportlet_courseCode=${encodeURIComponent(query.courseCode)}&year=${encodeURIComponent(String(query.academicYear))}&version=${encodeURIComponent(query.courseVersion)}`; + const response = await deps.fetch(requestUrl, { method: 'GET' }); + if (!response.ok) { + throw new Error(`NTNU course schedule returned HTTP ${response.status}.`); + } + const etag = response.headers.get('etag')?.trim(); + const httpValidator: NtnuScheduleHttpValidator = + etag === undefined || etag === '' + ? { state: 'unknown', reason: 'etag-not-published' } + : { state: 'known', kind: 'http-etag', value: etag }; + const rawBody = await response.text(); + const contentHash = await deps.sha256Hex(rawBody); + + return parseNtnuCourseSchedule(rawBody, { + retrievedAt: deps.now().toISOString(), + contentHash, + httpValidator, + requestUrl, + courseCode: query.courseCode, + courseVersion: query.courseVersion, + academicYear: query.academicYear, + season: query.season, + timezone: 'Europe/Oslo', + evidenceKind: 'source-fact', + }); +}; diff --git a/apps/course-api/src/sources/ntnu/schedule.test.ts b/apps/course-api/src/sources/ntnu/schedule.test.ts new file mode 100644 index 0000000..45e6e5b --- /dev/null +++ b/apps/course-api/src/sources/ntnu/schedule.test.ts @@ -0,0 +1,123 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; + +import { describe, expect, it } from 'vitest'; + +import scheduleFixture from './fixtures/tdt4136-schedule-2026-autumn.json'; +import scheduleSource from './fixtures/tdt4136-schedule-2026-autumn.source.json'; +import { parseNtnuCourseSchedule, type NtnuScheduleCaptureMetadata } from './schedule'; + +const fixtureUrl = new URL('./fixtures/tdt4136-schedule-2026-autumn.json', import.meta.url); +const capture: NtnuScheduleCaptureMetadata = { + retrievedAt: scheduleSource.capturedAt, + contentHash: scheduleSource.contentHash.rawBody, + httpValidator: { + state: 'known', + kind: 'http-etag', + value: scheduleSource.httpValidator.value, + }, + requestUrl: scheduleSource.requestUrl, + courseCode: 'TDT4136', + courseVersion: '1', + academicYear: 2026, + season: 'autumn', + timezone: 'Europe/Oslo', + evidenceKind: 'fixture', +}; + +describe('NTNU course schedule boundary', () => { + it('retains stable dated occurrences from a live current-period capture', () => { + const result = parseNtnuCourseSchedule(scheduleFixture, capture); + expect(result.rejected).toEqual([]); + expect(result.accepted).toHaveLength(67); + expect(new Set(result.accepted.map((occurrence) => occurrence.sourceRecordId)).size).toBe(67); + expect(result.accepted[0]).toMatchObject({ + sourceRecordId: + 'ntnu-course-schedule:194-TDT4136-1-1-2-1_2026_HØST_TDT4136:2026-11-06T13:15:00.000Z', + courseCode: 'TDT4136', + courseVersion: '1', + activityCode: '194_TDT4136_1_HØST_2026_1_2_1', + startsAt: '2026-11-06T13:15:00.000Z', + endsAt: '2026-11-06T15:00:00.000Z', + status: 'active', + week: 45, + rooms: [ + { + id: '194_380_3088', + building: 'Adolf Øien-bygget', + room: 'U36', + url: 'http://use.mazemap.com/?v=1&campuses=ntnu&sharepoitype=identifier&sharepoi=380-3088', + }, + ], + attribution: { + httpValidator: { + state: 'known', + kind: 'http-etag', + value: '"9365106e"', + }, + }, + }); + }); + + it('states what the capture cannot prove instead of inferring schedule semantics', () => { + const result = parseNtnuCourseSchedule(scheduleFixture, capture); + + expect(result.coverage).toEqual({ + sourceAccess: 'public-unauthenticated-page-resource', + automatedReuse: 'unverified-robots-disallowed', + activityIdentity: 'provider-recorded', + dateTime: 'dated-occurrences', + recurrence: 'expanded-occurrences-only', + timezone: 'provider-declared', + activityType: 'provider-prose', + activitySelection: 'manual-selection-documented-source-linkage-unknown', + exceptions: 'status-field-undocumented', + campus: 'unknown', + location: 'rooms-when-published', + sourceRevision: 'unavailable', + integrity: 'sha256-captured-body', + httpValidation: 'etag-when-published-representation-specific', + freshness: 'observed-at-only', + }); + }); + + it('rejects a malformed neighbouring occurrence without erasing valid evidence', () => { + const malformed = { + ...scheduleFixture.schedules[1], + tpId: '', + }; + const result = parseNtnuCourseSchedule( + { schedules: [scheduleFixture.schedules[0], malformed] }, + capture, + ); + + expect(result.accepted).toHaveLength(1); + expect(result.rejected).toHaveLength(1); + expect(result.rejected[0]?.code).toBe('invalid-response-shape'); + }); + + it('does not emit duplicate identities or occurrences from another academic period', () => { + const duplicate = { ...scheduleFixture.schedules[0] }; + const wrongPeriod = { + ...scheduleFixture.schedules[1], + artermin: '2026_VÅR', + }; + const result = parseNtnuCourseSchedule( + { schedules: [scheduleFixture.schedules[0], duplicate, wrongPeriod] }, + capture, + ); + + expect(result.accepted).toHaveLength(1); + expect(result.rejected.map((rejection) => rejection.code)).toEqual([ + 'invalid-response-shape', + 'invalid-response-shape', + ]); + }); + + it('keeps the checked-in fixture byte-identical to its capture hash', () => { + const fixtureBytes = readFileSync(fixtureUrl); + const digest = createHash('sha256').update(fixtureBytes).digest('hex'); + + expect(digest).toBe(scheduleSource.contentHash.rawBody); + }); +}); diff --git a/apps/course-api/src/sources/ntnu/schedule.ts b/apps/course-api/src/sources/ntnu/schedule.ts new file mode 100644 index 0000000..0d831f1 --- /dev/null +++ b/apps/course-api/src/sources/ntnu/schedule.ts @@ -0,0 +1,358 @@ +import * as Result from 'effect/Result'; +import * as Schema from 'effect/Schema'; + +export type NtnuScheduleHttpValidator = + | { + readonly state: 'known'; + readonly kind: 'http-etag'; + readonly value: string; + } + | { + readonly state: 'unknown'; + readonly reason: 'etag-not-published'; + }; + +export interface NtnuScheduleCaptureMetadata { + readonly retrievedAt: string; + readonly contentHash: string; + readonly httpValidator: NtnuScheduleHttpValidator; + readonly requestUrl: string; + readonly courseCode: string; + readonly courseVersion: string; + readonly academicYear: number; + readonly season: 'spring' | 'autumn'; + readonly timezone: 'Europe/Oslo'; + readonly evidenceKind: 'source-fact' | 'fixture'; +} + +export interface NtnuScheduleAttribution { + readonly provider: 'ntnu-course-schedule'; + readonly sourceRecordId: string; + readonly retrievedAt: string; + readonly requestUrl: string; + readonly contentHash: string; + readonly httpValidator: NtnuScheduleHttpValidator; + readonly evidenceKind: 'source-fact' | 'fixture'; +} + +export interface NtnuScheduleRoom { + readonly id: string | null; + readonly building: string | null; + readonly room: string | null; + readonly url: string | null; +} + +export interface NtnuScheduleStaffMember { + readonly username: string | null; + readonly name: string | null; + readonly url: string | null; +} + +export interface ValidatedNtnuScheduleOccurrence { + readonly courseCode: string; + readonly courseVersion: string; + readonly academicYear: number; + readonly season: 'spring' | 'autumn'; + readonly timezone: 'Europe/Oslo'; + readonly activityCode: string; + readonly tpId: string; + readonly artermin: string; + readonly status: string; + readonly acronym: string | null; + readonly name: string | null; + readonly title: string | null; + readonly summary: string | null; + readonly week: number | null; + readonly startsAt: string; + readonly endsAt: string; + readonly rooms: ReadonlyArray; + readonly staff: ReadonlyArray; + readonly studyProgramKeys: ReadonlyArray; + readonly sourceRecordId: string; + readonly attribution: NtnuScheduleAttribution; +} + +export interface NtnuScheduleCoverage { + readonly sourceAccess: 'public-unauthenticated-page-resource'; + readonly automatedReuse: 'unverified-robots-disallowed'; + readonly activityIdentity: 'provider-recorded'; + readonly dateTime: 'dated-occurrences'; + readonly recurrence: 'expanded-occurrences-only'; + readonly timezone: 'provider-declared'; + readonly activityType: 'provider-prose'; + readonly activitySelection: 'manual-selection-documented-source-linkage-unknown'; + readonly exceptions: 'status-field-undocumented'; + readonly campus: 'unknown'; + readonly location: 'rooms-when-published'; + readonly sourceRevision: 'unavailable'; + readonly integrity: 'sha256-captured-body'; + readonly httpValidation: 'etag-when-published-representation-specific'; + readonly freshness: 'observed-at-only'; +} + +export type NtnuScheduleRejectionCode = + | 'invalid-response-bytes' + | 'invalid-response-json' + | 'invalid-response-shape' + | 'invalid-capture-metadata'; + +export interface NtnuScheduleRejection { + readonly code: NtnuScheduleRejectionCode; + readonly message: string; + readonly raw: unknown; +} + +export interface NtnuScheduleParseResult { + readonly accepted: ReadonlyArray; + readonly rejected: ReadonlyArray; + readonly coverage: NtnuScheduleCoverage; +} + +const IsoTimestampSchema = Schema.String.pipe( + Schema.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/)), +); +const Sha256Schema = Schema.String.pipe(Schema.check(Schema.isPattern(/^[a-f0-9]{64}$/))); +const NullableStringSchema = Schema.NullOr(Schema.String); +const HttpValidatorSchema = Schema.Union([ + Schema.Struct({ + state: Schema.Literal('known'), + kind: Schema.Literal('http-etag'), + value: Schema.NonEmptyString, + }), + Schema.Struct({ + state: Schema.Literal('unknown'), + reason: Schema.Literal('etag-not-published'), + }), +]); + +const CaptureSchema = Schema.Struct({ + retrievedAt: IsoTimestampSchema, + contentHash: Sha256Schema, + httpValidator: HttpValidatorSchema, + requestUrl: Schema.String.pipe(Schema.check(Schema.isStartsWith('https://www.ntnu.no/'))), + courseCode: Schema.NonEmptyString, + courseVersion: Schema.NonEmptyString, + academicYear: Schema.Int.pipe(Schema.check(Schema.isBetween({ minimum: 2000, maximum: 2200 }))), + season: Schema.Literals(['spring', 'autumn']), + timezone: Schema.Literals(['Europe/Oslo']), + evidenceKind: Schema.Literals(['source-fact', 'fixture']), +}); + +const RoomSchema = Schema.Struct({ + id: NullableStringSchema, + building: NullableStringSchema, + room: NullableStringSchema, + url: NullableStringSchema, +}); + +const StaffMemberSchema = Schema.Struct({ + username: NullableStringSchema, + name: NullableStringSchema, + url: NullableStringSchema, +}); + +const OccurrenceSchema = Schema.Struct({ + courseCode: Schema.NonEmptyString, + activityCode: Schema.NonEmptyString, + tpId: Schema.NonEmptyString, + artermin: Schema.NonEmptyString, + status: Schema.NonEmptyString, + acronym: NullableStringSchema, + name: NullableStringSchema, + title: NullableStringSchema, + summary: NullableStringSchema, + week: Schema.NullOr(Schema.Int.pipe(Schema.check(Schema.isBetween({ minimum: 1, maximum: 53 })))), + from: Schema.Int, + to: Schema.Int, + rooms: Schema.Array(RoomSchema), + staff: Schema.Array(StaffMemberSchema), + studyProgramKeys: Schema.Array(Schema.String), +}); + +const ResponseSchema = Schema.Struct({ + schedules: Schema.Array(Schema.Unknown), +}); + +const coverage: NtnuScheduleCoverage = Object.freeze({ + sourceAccess: 'public-unauthenticated-page-resource', + automatedReuse: 'unverified-robots-disallowed', + activityIdentity: 'provider-recorded', + dateTime: 'dated-occurrences', + recurrence: 'expanded-occurrences-only', + timezone: 'provider-declared', + activityType: 'provider-prose', + activitySelection: 'manual-selection-documented-source-linkage-unknown', + exceptions: 'status-field-undocumented', + campus: 'unknown', + location: 'rooms-when-published', + sourceRevision: 'unavailable', + integrity: 'sha256-captured-body', + httpValidation: 'etag-when-published-representation-specific', + freshness: 'observed-at-only', +}); + +const decodeInput = ( + input: unknown | Uint8Array, +): { readonly value?: unknown; readonly code?: NtnuScheduleRejectionCode } => { + if (input instanceof Uint8Array) { + try { + input = new TextDecoder('utf-8', { fatal: true }).decode(input); + } catch { + return { code: 'invalid-response-bytes' }; + } + } + if (typeof input === 'string') { + try { + return { value: JSON.parse(input) as unknown }; + } catch { + return { code: 'invalid-response-json' }; + } + } + return { value: input }; +}; + +const rejectOne = ( + code: NtnuScheduleRejectionCode, + message: string, + raw: unknown, +): NtnuScheduleParseResult => ({ + accepted: [], + rejected: [{ code, message, raw }], + coverage, +}); + +/** + * Boundary parser for dated occurrences published by NTNU's schedules resource. + * It retains provider-recorded facts and makes unsupported timetable semantics + * explicit through the coverage attached to every result. + */ +export const parseNtnuCourseSchedule = ( + input: unknown | Uint8Array, + capture: NtnuScheduleCaptureMetadata, +): NtnuScheduleParseResult => { + const captureResult = Schema.decodeUnknownResult(CaptureSchema)(capture); + if (Result.isFailure(captureResult)) { + return rejectOne( + 'invalid-capture-metadata', + 'NTNU schedule capture metadata failed validation.', + capture, + ); + } + + const decoded = decodeInput(input); + if (decoded.code !== undefined) { + return rejectOne(decoded.code, 'NTNU schedule response could not be decoded.', input); + } + + const responseResult = Schema.decodeUnknownResult(ResponseSchema)(decoded.value); + if (Result.isFailure(responseResult)) { + return rejectOne( + 'invalid-response-shape', + 'NTNU schedule response failed boundary validation.', + decoded.value, + ); + } + + const captured = captureResult.success; + const expectedArtermin = `${captured.academicYear}_${captured.season === 'spring' ? 'VÅR' : 'HØST'}`; + const accepted: ValidatedNtnuScheduleOccurrence[] = []; + const acceptedSourceRecordIds = new Set(); + const rejected: NtnuScheduleRejection[] = []; + + for (const rawOccurrence of responseResult.success.schedules) { + const occurrenceResult = Schema.decodeUnknownResult(OccurrenceSchema)(rawOccurrence); + if (Result.isFailure(occurrenceResult)) { + rejected.push({ + code: 'invalid-response-shape', + message: 'An NTNU schedule occurrence failed boundary validation.', + raw: rawOccurrence, + }); + continue; + } + + const occurrence = occurrenceResult.success; + if (occurrence.courseCode !== captured.courseCode) { + rejected.push({ + code: 'invalid-response-shape', + message: 'An NTNU schedule occurrence did not match the captured course code.', + raw: rawOccurrence, + }); + continue; + } + if (occurrence.artermin !== expectedArtermin) { + rejected.push({ + code: 'invalid-response-shape', + message: 'An NTNU schedule occurrence did not match the captured academic period.', + raw: rawOccurrence, + }); + continue; + } + if (occurrence.to <= occurrence.from) { + rejected.push({ + code: 'invalid-response-shape', + message: 'An NTNU schedule occurrence ended at or before it started.', + raw: rawOccurrence, + }); + continue; + } + + const startsAtDate = new Date(occurrence.from); + const endsAtDate = new Date(occurrence.to); + if (Number.isNaN(startsAtDate.getTime()) || Number.isNaN(endsAtDate.getTime())) { + rejected.push({ + code: 'invalid-response-shape', + message: 'An NTNU schedule occurrence had an epoch outside the ISO date range.', + raw: rawOccurrence, + }); + continue; + } + + const startsAt = startsAtDate.toISOString(); + const endsAt = endsAtDate.toISOString(); + + const sourceRecordId = `ntnu-course-schedule:${occurrence.tpId}:${startsAt}`; + if (acceptedSourceRecordIds.has(sourceRecordId)) { + rejected.push({ + code: 'invalid-response-shape', + message: 'An NTNU schedule occurrence duplicated a source record identity.', + raw: rawOccurrence, + }); + continue; + } + acceptedSourceRecordIds.add(sourceRecordId); + const attribution: NtnuScheduleAttribution = { + provider: 'ntnu-course-schedule', + sourceRecordId, + retrievedAt: captured.retrievedAt, + requestUrl: captured.requestUrl, + contentHash: captured.contentHash, + httpValidator: captured.httpValidator, + evidenceKind: captured.evidenceKind, + }; + accepted.push({ + courseCode: occurrence.courseCode, + courseVersion: captured.courseVersion, + academicYear: captured.academicYear, + season: captured.season, + timezone: captured.timezone, + activityCode: occurrence.activityCode, + tpId: occurrence.tpId, + artermin: occurrence.artermin, + status: occurrence.status, + acronym: occurrence.acronym, + name: occurrence.name, + title: occurrence.title, + summary: occurrence.summary, + week: occurrence.week, + startsAt, + endsAt, + rooms: occurrence.rooms, + staff: occurrence.staff, + studyProgramKeys: occurrence.studyProgramKeys, + sourceRecordId, + attribution, + }); + } + + return { accepted, rejected, coverage }; +}; diff --git a/apps/course-api/wrangler.jsonc b/apps/course-api/wrangler.jsonc index 9a1a24a..d8860d3 100644 --- a/apps/course-api/wrangler.jsonc +++ b/apps/course-api/wrangler.jsonc @@ -3,4 +3,5 @@ "name": "course-decision-api-dev", "main": "src/worker.ts", "compatibility_date": "2026-07-21", + "previews": {}, } diff --git a/apps/student-web/scripts/validate-i18n.ts b/apps/student-web/scripts/validate-i18n.ts index b9c51d3..11a9ed1 100644 --- a/apps/student-web/scripts/validate-i18n.ts +++ b/apps/student-web/scripts/validate-i18n.ts @@ -1,6 +1,7 @@ -import { validateMessageCatalogues } from '../src/i18n'; +import { englishMessages, validateMessageCatalogues } from '../src/i18n'; +import { norwegianMessages } from '../src/i18n.nb'; -const issues = validateMessageCatalogues(); +const issues = validateMessageCatalogues({ en: englishMessages, nb: norwegianMessages }); if (issues.length > 0) { console.error('Translation catalogue validation failed:'); diff --git a/apps/student-web/src/app.ts b/apps/student-web/src/app.ts index b73d70a..fcbcece 100644 --- a/apps/student-web/src/app.ts +++ b/apps/student-web/src/app.ts @@ -12,15 +12,24 @@ import { CourseGradeSummariesResponseSchema, CourseInsightResponseSchema, CourseSearchResponseSchema, - courseClient, type CourseDecisionSignalsResponse, type CourseGradeSummariesResponse, type CourseSearchRequest, type CourseSearchResponse, type CourseSearchSort, } from './course-client'; +import { courseClient } from './course-client-runtime'; import { courseIdentity, type CourseIdentity } from './course-identity'; -import { isLocale, localeTag, translate, type Locale } from './i18n'; +import { + IndexedMessageCatalogueSchema, + isLocale, + LocaleSchema, + localeTag, + Localization, + TokenCatalogueSchema, + translate, + type Locale, +} from './i18n'; import type { AppIcon } from './icons'; import { desktopNavigation, mobileNavigation } from './navigation'; import { @@ -116,12 +125,25 @@ import { update as updateProgress, view as progressView, } from './features/progress'; +import { + Message as ScheduleMessage, + Model as ScheduleModel, + currentOsloIsoWeek, + init as initSchedule, + normalizedWeek, + parseHiddenActivityKeys, + parseScheduleCodes, + syncFromUrl as syncScheduleFromUrl, + update as updateSchedule, + view as scheduleView, +} from './features/schedule'; const DISPLAY_CHUNK = 20; export const DEFAULT_TERM = '2026-autumn'; export const DEFAULT_SORT: CourseSearchSort = 'relevance'; const EXPLORE_PATH = '/'; const LIST_PATH = '/list'; +const SCHEDULE_PATH = '/schedule'; const PROGRESS_PATH = '/progress'; const APPEARANCE_PATH = '/appearance'; const LEGACY_LIST_APPEARANCE_PATH = '/list/appearance'; @@ -152,7 +174,6 @@ const CampusSchema = S.Literals(['all', 'trondheim', 'gjovik', 'alesund']); const LevelSchema = S.Literals(['all', 'bachelor', 'master', 'phd']); const OutcomeViewSchema = S.Literals(['letter', 'pass-fail']); const SortSchema = S.Literals(['relevance', 'title-asc', 'title-desc', 'code-asc', 'code-desc']); -const LocaleSchema = S.Literals(['en', 'nb']); const SelectControlIdSchema = S.Literals([ 'campus-inline', 'term-refine', @@ -237,6 +258,22 @@ export const NextPageIdle = NextPageState.NextPageIdle; export const NextPageLoading = NextPageState.NextPageLoading; export const NextPageFailure = NextPageState.NextPageFailure; +/** + * The successful catalogue belongs to `localization`; this state records the + * asynchronous boundary so a selected Norwegian locale can fall back safely. + */ +const NorwegianMessagesState = defineTaggedUnion({ + NorwegianMessagesIdle: {}, + NorwegianMessagesLoading: {}, + NorwegianMessagesLoaded: {}, + NorwegianMessagesFailed: { error: S.String }, +}); +export const NorwegianMessagesIdle = NorwegianMessagesState.NorwegianMessagesIdle; +export const NorwegianMessagesLoading = NorwegianMessagesState.NorwegianMessagesLoading; +export const NorwegianMessagesLoaded = NorwegianMessagesState.NorwegianMessagesLoaded; +export const NorwegianMessagesFailed = NorwegianMessagesState.NorwegianMessagesFailed; +export type NorwegianMessagesState = typeof NorwegianMessagesState.Type; + const DetailResult = defineTaggedUnion({ DetailClosed: {}, DetailLoading: {}, @@ -364,7 +401,7 @@ const LabelFilterNoticeSchema = S.Struct({ const LabelRejectionSchema = S.Literals(labelRejections); -const RouteSchema = S.Literals(['explore', 'list', 'progress', 'appearance']); +const RouteSchema = S.Literals(['explore', 'list', 'schedule', 'progress', 'appearance']); type Route = typeof RouteSchema.Type; /** @@ -378,9 +415,11 @@ export type ListDensity = typeof ListDensitySchema.Type; const CourseOriginFilterSchema = S.Literals(courseOriginFilters); export const Model = S.Struct({ - locale: LocaleSchema, + localization: Localization, + norwegianMessages: NorwegianMessagesState, route: RouteSchema, progress: ProgressModel, + schedule: ScheduleModel, savedCourses: SavedCoursesResultSchema, savedListActions: S.Array(SavedListNoticeSchema), noteDrafts: S.Array(NoteDraftSchema), @@ -438,17 +477,23 @@ export const Model = S.Struct({ type SchemaModel = typeof Model.Type; export type Model = Omit< SchemaModel, - 'catalogue' | 'gradeSignals' | 'decisionSignals' | 'detail' + 'catalogue' | 'gradeSignals' | 'decisionSignals' | 'detail' | 'norwegianMessages' > & { readonly catalogue: CatalogueResult; readonly gradeSignals: GradeSignalsResult; readonly decisionSignals: DecisionSignalsResult; readonly detail: DetailResult; + readonly norwegianMessages: NorwegianMessagesState; }; export const Message = defineMessageUnion({ UpdatedQuery: { value: S.String }, ChangedLocale: { value: S.String }, + LoadedNorwegianMessages: { + messages: IndexedMessageCatalogueSchema, + tokens: TokenCatalogueSchema, + }, + FailedNorwegianMessages: { error: S.String }, SubmittedSearch: {}, ChangedTerm: { value: S.String }, ChangedCampus: { value: S.String }, @@ -508,6 +553,7 @@ export const Message = defineMessageUnion({ GotAppearanceMessage: { message: AppearanceMessage }, GotCompareMessage: { message: CompareMessage }, GotProgressMessage: { message: ProgressMessage }, + GotScheduleMessage: { message: ScheduleMessage }, GotSelectFieldMessage: { id: SelectControlIdSchema, message: Listbox.Message, @@ -569,6 +615,8 @@ export type Message = typeof Message.Type; export const UpdatedQuery = Message.UpdatedQuery; export const ChangedLocale = Message.ChangedLocale; +export const LoadedNorwegianMessages = Message.LoadedNorwegianMessages; +export const FailedNorwegianMessages = Message.FailedNorwegianMessages; export const SubmittedSearch = Message.SubmittedSearch; export const ChangedTerm = Message.ChangedTerm; export const ChangedCampus = Message.ChangedCampus; @@ -601,6 +649,7 @@ export const GotRefineDialogMessage = Message.GotRefineDialogMessage; export const GotAppearanceMessage = Message.GotAppearanceMessage; export const GotCompareMessage = Message.GotCompareMessage; export const GotProgressMessage = Message.GotProgressMessage; +export const GotScheduleMessage = Message.GotScheduleMessage; export const GotLabelFilterModeRadioGroupMessage = Message.GotLabelFilterModeRadioGroupMessage; export const GotListDensityRadioGroupMessage = Message.GotListDensityRadioGroupMessage; export const GotLabelDraftColorRadioGroupMessage = Message.GotLabelDraftColorRadioGroupMessage; @@ -792,6 +841,28 @@ export const PersistLocale = Command.define('PersistLocale', { ), }); +/** + * A static import would retain Norwegian strings in the initial production + * chunk, so this is the only runtime edge to the Norwegian chunk. + */ +export const LoadNorwegianMessages = Command.define('LoadNorwegianMessages', { + messages: [Message.LoadedNorwegianMessages, Message.FailedNorwegianMessages], + execute: Effect.tryPromise({ + try: async () => { + const { norwegianIndexedMessages, norwegianTokenCatalogue } = await import('./i18n.nb'); + return Message.LoadedNorwegianMessages({ + messages: norwegianIndexedMessages, + tokens: norwegianTokenCatalogue, + }); + }, + catch: () => new Error('Norwegian translations could not be loaded'), + }).pipe( + Effect.catch((error) => + Effect.succeed(Message.FailedNorwegianMessages({ error: error.message })), + ), + ), +}); + export const PersistSidebarPreference = Command.define('PersistSidebarPreference', { args: { collapsed: S.Boolean }, messages: [Message.PersistedSidebarPreference, Message.FailedSidebarPreferencePersistence], @@ -892,6 +963,33 @@ const fetchCommand = ( append, }); +const loadNorwegianMessages = (model: Model): UpdateReturn => { + if ( + model.localization.locale !== 'nb' || + model.norwegianMessages._tag === 'NorwegianMessagesLoading' || + model.norwegianMessages._tag === 'NorwegianMessagesLoaded' + ) { + return { model }; + } + return { + model: modifyFields(model, { + norwegianMessages: () => NorwegianMessagesLoading(), + }), + commands: [LoadNorwegianMessages()], + }; +}; + +const selectLocale = (model: Model, locale: Locale): UpdateReturn => + loadNorwegianMessages( + modifyFields(model, { + localization: () => ({ + locale, + messages: model.localization.messages, + tokens: model.localization.tokens, + }), + }), + ); + export const catalogueResponse = (result: CatalogueResult): CourseSearchResponse | null => result._tag === 'CatalogueSuccess' || result._tag === 'CataloguePartial' ? result.response : null; @@ -1100,7 +1198,20 @@ export const normalizedUrl = ( pathname = EXPLORE_PATH, ): string => { const params = new URLSearchParams(); - params.set('lang', model.locale); + params.set('lang', model.localization.locale); + if (pathname === SCHEDULE_PATH) { + // Schedule has its own compact URL state. `term` is still the root model's + // value, but it remains explicit so a schedule link cannot silently change + // period when the application default moves. + params.set('term', model.term); + params.set('week', String(model.schedule.week)); + // An explicit empty value means no courses are selected; it never means all. + params.set('courses', model.schedule.selectedCodes.join(',')); + for (const key of model.schedule.hiddenActivityKeys) { + params.append('hideActivity', key); + } + return `${pathname}?${params.toString()}`; + } if (model.query.trim().length > 0) params.set('q', model.query.trim()); if (model.term !== DEFAULT_TERM) params.set('term', model.term); if (model.campus !== 'all') params.set('campus', model.campus); @@ -1149,11 +1260,13 @@ const sameLabelFilter = (left: LabelFilter, right: LabelFilter): boolean => const routePath = (route: Route): string => route === 'list' ? LIST_PATH - : route === 'progress' - ? PROGRESS_PATH - : route === 'appearance' - ? APPEARANCE_PATH - : EXPLORE_PATH; + : route === 'schedule' + ? SCHEDULE_PATH + : route === 'progress' + ? PROGRESS_PATH + : route === 'appearance' + ? APPEARANCE_PATH + : EXPLORE_PATH; /** The shareable URL for the model as it currently stands. */ const currentUrl = (model: Model, selectedCode: string | null = model.selectedCode): string => @@ -1165,6 +1278,8 @@ export const listUrl = (model: Model): string => normalizedUrl(model, null, LIST const progressUrl = (model: Model): string => normalizedUrl(model, null, PROGRESS_PATH); +export const scheduleUrl = (model: Model): string => normalizedUrl(model, null, SCHEDULE_PATH); + export const exploreUrl = (model: Model): string => normalizedUrl(model, null, EXPLORE_PATH); export const savedListState = (result: SavedCoursesResult): SavedListState | null => @@ -1415,6 +1530,9 @@ interface ParsedLocation { readonly route: Route; readonly query: string; readonly term: string; + readonly scheduleWeek: number; + readonly scheduleCodes: ReadonlyArray; + readonly scheduleHiddenActivityKeys: ReadonlyArray; readonly campus: Campus; readonly level: Level; readonly sort: CourseSearchSort; @@ -1430,6 +1548,8 @@ const parsePathname = (pathname: string): Route => { switch (normalized === '' ? EXPLORE_PATH : normalized) { case LIST_PATH: return 'list'; + case SCHEDULE_PATH: + return 'schedule'; case PROGRESS_PATH: return 'progress'; // Appearance was once an overlay over whichever page you were on, so it @@ -1461,11 +1581,24 @@ const parseLocation = (href: string, fallbackLocale: Locale = 'en'): ParsedLocat const url = new URL(href, 'http://course-lens.local'); const requestedLocale = url.searchParams.get('lang'); const path = parsePathname(url.pathname); + const term = url.searchParams.get('term') ?? DEFAULT_TERM; + const scheduleCodes = + path === 'schedule' ? parseScheduleCodes(url.searchParams.get('courses')) : []; + const scheduleHiddenActivityKeys = + path === 'schedule' + ? parseHiddenActivityKeys(url.searchParams.getAll('hideActivity'), scheduleCodes) + : []; return { route: path, locale: isLocale(requestedLocale) ? requestedLocale : fallbackLocale, query: url.searchParams.get('q') ?? '', - term: url.searchParams.get('term') ?? DEFAULT_TERM, + term, + scheduleWeek: + path === 'schedule' + ? normalizedWeek(url.searchParams.get('week'), term, currentOsloIsoWeek()) + : currentOsloIsoWeek(), + scheduleCodes, + scheduleHiddenActivityKeys, campus: oneOf( url.searchParams.get('campus') ?? 'all', ['all', 'trondheim', 'gjovik', 'alesund'] as const, @@ -1483,9 +1616,9 @@ const parseLocation = (href: string, fallbackLocale: Locale = 'en'): ParsedLocat ), openOnly: url.searchParams.get('open') === '1', englishOnly: url.searchParams.get('english') === '1', - // List and Progress do not own course-detail state. + // List, Schedule, and Progress do not own course-detail state. selectedCode: - path === 'list' || path === 'progress' + path === 'list' || path === 'schedule' || path === 'progress' ? null : url.searchParams.get('course')?.trim().toUpperCase() || null, compareCodes: path === 'list' ? parseCompareCodes(url.searchParams.get('compare')) : [], @@ -1523,6 +1656,65 @@ const forRoute = (model: Model, route: Route): Model => labelDialogTarget: [], }; +const scheduleSavedCourses = (result: SavedCoursesResult): ReadonlyArray | null => + result._tag === 'SavedCoursesReady' ? result.state.savedCourses : null; + +const applyScheduleRouteState = ( + model: Model, + term: string, + week: number, + selectedCodes: ReadonlyArray, + hiddenActivityKeys: ReadonlyArray, +): UpdateReturn => { + const savedCourses = scheduleSavedCourses(model.savedCourses); + const scheduled = syncScheduleFromUrl(model.schedule, { + term, + week, + selectedCodes, + hiddenActivityKeys, + availableCodes: savedCourses?.map((course) => course.courseCode) ?? null, + }); + const nextModel = modifyFields(model, { schedule: () => scheduled.model }); + const commands = Command.mapMessages(scheduled.commands, (message) => + Message.GotScheduleMessage({ message }), + ); + return scheduled.outMessage === undefined + ? { model: nextModel, commands } + : { + model: nextModel, + commands: [ + ...commands, + Navigate({ href: scheduleUrl(nextModel), mode: scheduled.outMessage.mode }), + ], + }; +}; + +const updateScheduleFeature = ( + model: Model, + message: ScheduleMessage, + toRootMessage: (message: ScheduleMessage) => Message, +): UpdateReturn => { + const scheduled = updateSchedule(model.schedule, message, model.term); + if ( + scheduled.model === model.schedule && + scheduled.commands === undefined && + scheduled.outMessage === undefined + ) { + return { model }; + } + const nextModel = modifyFields(model, { schedule: () => scheduled.model }); + const commands = Command.mapMessages(scheduled.commands, toRootMessage); + return scheduled.outMessage === undefined + ? { model: nextModel, commands } + : { + model: nextModel, + commands: [ + ...commands, + Navigate({ href: scheduleUrl(nextModel), mode: scheduled.outMessage.mode }), + ], + }; +}; + const locationMatchesModel = (location: ParsedLocation, model: Model): boolean => location.query === model.query && location.term === model.term && @@ -1601,10 +1793,12 @@ const applySelectValue = (model: Model, id: SelectControlId, value: string): Upd case 'language-desktop': case 'language-mobile': { const locale = isLocale(value) ? value : 'en'; - const nextModel = modifyFields(model, { locale: () => locale }); + const selected = selectLocale(model, locale); + const nextModel = selected.model; return { model: nextModel, commands: [ + ...(selected.commands ?? []), PersistLocale({ locale }), Navigate({ href: currentUrl(nextModel), mode: 'replace' }), ], @@ -1714,15 +1908,34 @@ export const update = (model: Model, message: Message) => }), ChangedLocale: ({ value }) => { const locale = isLocale(value) ? value : 'en'; - const nextModel = modifyFields(model, { locale: () => locale }); + const selected = selectLocale(model, locale); + const nextModel = selected.model; return { model: nextModel, commands: [ + ...(selected.commands ?? []), PersistLocale({ locale }), Navigate({ href: currentUrl(nextModel), mode: 'replace' }), ], }; }, + LoadedNorwegianMessages: ({ messages, tokens }) => { + return { + model: modifyFields(model, { + localization: () => ({ + locale: model.localization.locale, + messages, + tokens, + }), + norwegianMessages: () => NorwegianMessagesLoaded(), + }), + }; + }, + FailedNorwegianMessages: ({ error }) => ({ + model: modifyFields(model, { + norwegianMessages: () => NorwegianMessagesFailed({ error }), + }), + }), ToggledSidebar: () => { const sidebarCollapsed = !model.sidebarCollapsed; return { @@ -1808,6 +2021,41 @@ export const update = (model: Model, message: Message) => }), ChangedUrl: ({ href }) => { const location = parseLocation(href); + if (location.route === 'schedule') { + const locationModel = modifyFields(forRoute(model, 'schedule'), { + route: () => 'schedule', + query: () => location.query, + term: () => location.term, + campus: () => location.campus, + level: () => location.level, + sort: () => location.sort, + openOnly: () => location.openOnly, + englishOnly: () => location.englishOnly, + selectedCode: () => null, + detail: () => DetailResult.DetailClosed(), + labelFilter: () => emptyLabelFilter, + compareCodes: () => [], + labelFilterNotice: () => null, + }); + const localized = selectLocale(locationModel, location.locale); + const scheduled = applyScheduleRouteState( + localized.model, + location.term, + location.scheduleWeek, + location.scheduleCodes, + location.scheduleHiddenActivityKeys, + ); + return { + model: scheduled.model, + commands: [ + ...(localized.commands ?? []), + ...(location.locale === model.localization.locale + ? [] + : [PersistLocale({ locale: location.locale })]), + ...(scheduled.commands ?? []), + ], + }; + } const withCanonicalFilter = (result: UpdateReturn): UpdateReturn => { const canonicalization = canonicalizeLabelFilter(result.model); const signals = requestListCourseSignals(canonicalization.model); @@ -1820,9 +2068,8 @@ export const update = (model: Model, message: Message) => ], }; }; - if (!locationMatchesModel(location, model)) { + if (!locationMatchesModel(location, model) || model.route === 'schedule') { const locationModel = modifyFields(forRoute(model, location.route), { - locale: () => location.locale, route: () => location.route, query: () => location.query, term: () => location.term, @@ -1845,14 +2092,19 @@ export const update = (model: Model, message: Message) => compareCodes: () => location.compareCodes, labelFilterNotice: () => null, }); - const request = searchRequest(locationModel, 1); + const localized = selectLocale(locationModel, location.locale); + const request = searchRequest(localized.model, 1); const key = requestKey(request); - const nextModel = modifyFields(locationModel, { + const nextModel = modifyFields(localized.model, { activeRequestKey: () => key, }); return withCanonicalFilter({ model: nextModel, commands: [ + ...(localized.commands ?? []), + ...(location.locale === model.localization.locale + ? [] + : [PersistLocale({ locale: location.locale })]), fetchCommand(request, key, false), ...(location.selectedCode === null ? [] @@ -1866,12 +2118,11 @@ export const update = (model: Model, message: Message) => * without refetching anything. */ const routedModel: Model = - location.locale === model.locale && + location.locale === model.localization.locale && location.route === model.route && sameLabelFilter(location.labelFilter, model.labelFilter) ? model : modifyFields(forRoute(model, location.route), { - locale: () => location.locale, route: () => location.route, labelFilter: () => location.labelFilter, compareCodes: () => location.compareCodes, @@ -1880,12 +2131,17 @@ export const update = (model: Model, message: Message) => ? model.labelFilterNotice : null, }); - const localeCommands: Commands = - location.locale === model.locale ? [] : [PersistLocale({ locale: location.locale })]; + const localized = selectLocale(routedModel, location.locale); + const localeCommands: Commands = [ + ...(localized.commands ?? []), + ...(location.locale === model.localization.locale + ? [] + : [PersistLocale({ locale: location.locale })]), + ]; if (location.selectedCode === model.selectedCode) { - return withCanonicalFilter({ model: routedModel, commands: localeCommands }); + return withCanonicalFilter({ model: localized.model, commands: localeCommands }); } - const nextModel = modifyFields(routedModel, { + const nextModel = modifyFields(localized.model, { selectedCode: () => location.selectedCode, detail: () => location.selectedCode === null @@ -2116,7 +2372,15 @@ export const update = (model: Model, message: Message) => repairedEntries: 0, }), }); - return requestListCourseSignals(loadedModel); + return loadedModel.route === 'schedule' + ? applyScheduleRouteState( + loadedModel, + loadedModel.term, + loadedModel.schedule.week, + loadedModel.schedule.selectedCodes, + loadedModel.schedule.hiddenActivityKeys, + ) + : requestListCourseSignals(loadedModel); } case 'SavedListLoaded': { // A filter recipe can only be judged against a loaded label set, so @@ -2128,6 +2392,22 @@ export const update = (model: Model, message: Message) => repairedEntries: load.repairedEntries, }), }); + if (loadedModel.route === 'schedule') { + const scheduled = applyScheduleRouteState( + loadedModel, + loadedModel.term, + loadedModel.schedule.week, + loadedModel.schedule.selectedCodes, + loadedModel.schedule.hiddenActivityKeys, + ); + return { + model: scheduled.model, + commands: [ + ...(load.repairedEntries === 0 ? [] : [PersistSavedCourses({ state: load.state })]), + ...(scheduled.commands ?? []), + ], + }; + } const canonicalization = canonicalizeLabelFilter(loadedModel); const signals = requestListCourseSignals(canonicalization.model); return { @@ -2409,6 +2689,10 @@ export const update = (model: Model, message: Message) => }, GotCompareMessage: ({ message: compareMessage }) => updateComparison(model, compareMessage, (message) => Message.GotCompareMessage({ message })), + GotScheduleMessage: ({ message: scheduleMessage }) => + updateScheduleFeature(model, scheduleMessage, (message) => + Message.GotScheduleMessage({ message }), + ), RequestedRemoveSelected: () => ({ model: modifyFields(model, { selectionRemovePending: () => true }), }), @@ -2711,9 +2995,15 @@ export const initForHref = ( const location = parseLocation(href, fallbackLocale); const progressInit = initProgress(); const base: Model = { - locale: location.locale, + localization: { locale: location.locale }, + norwegianMessages: NorwegianMessagesIdle(), route: location.route, progress: progressInit.model, + schedule: initSchedule( + location.scheduleWeek, + location.scheduleCodes, + location.scheduleHiddenActivityKeys, + ), savedCourses: SavedCoursesResultSchema.SavedCoursesLoading(), savedListActions: [], noteDrafts: [], @@ -2774,18 +3064,20 @@ export const initForHref = ( languageMobile: initSelectField('language-mobile'), }, }; - const request = searchRequest(base, 1); - const key = requestKey(request); - const model = modifyFields(base, { activeRequestKey: () => key }); + const localized = loadNorwegianMessages(base); + const request = location.route === 'schedule' ? null : searchRequest(localized.model, 1); + const key = request === null ? '' : requestKey(request); + const model = modifyFields(localized.model, { activeRequestKey: () => key }); return { model, commands: [ + ...(localized.commands ?? []), LoadSavedCourses(), ...Command.mapMessages(progressInit.commands, (message) => Message.GotProgressMessage({ message }), ), - fetchCommand(request, key, false), - ...(location.selectedCode === null + ...(request === null ? [] : [fetchCommand(request, key, false)]), + ...(request === null || location.selectedCode === null ? [] : [FetchCourseInsight({ courseCode: location.selectedCode, term: location.term })]), ], @@ -2833,11 +3125,12 @@ const browserListDensity = (): ListDensity => { }; const documentTitle = (model: Model): string => { - if (model.route === 'list') return translate(model.locale, 'app.listTitle'); - if (model.route === 'progress') return translate(model.locale, 'app.progressTitle'); + if (model.route === 'list') return translate(model.localization, 'app.listTitle'); + if (model.route === 'schedule') return translate(model.localization, 'app.scheduleTitle'); + if (model.route === 'progress') return translate(model.localization, 'app.progressTitle'); return model.detail._tag === 'DetailSuccess' || model.detail._tag === 'DetailPartial' - ? `${model.detail.response.item.code} · ${translate(model.locale, 'app.name')}` - : translate(model.locale, 'app.catalogueTitle'); + ? `${model.detail.response.item.code} · ${translate(model.localization, 'app.name')}` + : translate(model.localization, 'app.catalogueTitle'); }; export const view = (model: Model, h: HtmlBuilder): Document => ({ @@ -2845,23 +3138,37 @@ export const view = (model: Model, h: HtmlBuilder): Document => ({ body: appView(model, h), }); +const norwegianMessagesFailureAlert = (model: Model, h: HtmlBuilder): Html => + model.localization.locale === 'nb' && model.norwegianMessages._tag === 'NorwegianMessagesFailed' + ? h.p( + [ + h.Class( + 'm-0 rounded-m3-medium border border-error bg-error-container px-4 py-3 text-sm font-bold text-on-error-container', + ), + h.Role('alert'), + ], + ['Norwegian translations could not be loaded. English is shown instead.'], + ) + : h.empty; + const appView = (model: Model, h: HtmlBuilder): Html => h.div( [h.Class('min-h-screen')], [ lazyDesktopNavigation(desktopNavigation, [ - model.locale, + model.localization, model.sidebarCollapsed, model.route, exploreUrl(model), listUrl(model), + scheduleUrl(model), progressUrl(model), appearanceUrl(model), Message.ToggledSidebar(), languageSelectControl( model.selectFields, 'language-desktop', - model.locale, + model.localization, model.sidebarCollapsed, h, ), @@ -2874,52 +3181,65 @@ const appView = (model: Model, h: HtmlBuilder): Html => [h.Class(mainColumnClass)], [ savedCoursesPersistenceAlert(model, h), + norwegianMessagesFailureAlert(model, h), model.route === 'appearance' ? h.submodel({ slotId: 'appearance-settings', model: initAppearance(model.themePreference), view: appearanceView, viewInputs: { - locale: model.locale, + locale: model.localization, renderMobileLanguageControl: () => selectControl( model.selectFields, 'language-mobile', - translate(model.locale, 'locale.label'), - model.locale, + translate(model.localization, 'locale.label'), + model.localization.locale, [ - ['en', translate(model.locale, 'locale.en')], - ['nb', translate(model.locale, 'locale.nb')], + ['en', translate(model.localization, 'locale.en')], + ['nb', translate(model.localization, 'locale.nb')], ], {}, h, ), - renderFooter: () => lazyProductFooter(productFooter, [model.locale, h]), + renderFooter: () => lazyProductFooter(productFooter, [model.localization, h]), }, toParentMessage: (message) => Message.GotAppearanceMessage({ message }), }) - : model.route === 'progress' + : model.route === 'schedule' ? h.submodel({ - slotId: 'progress', - model: model.progress, - view: progressView, + slotId: 'schedule', + model: model.schedule, + view: scheduleView, viewInputs: { - locale: model.locale, - courseUrl: (courseCode) => normalizedUrl(model, courseCode, EXPLORE_PATH), + locale: model.localization, + term: model.term, + savedCourses: scheduleSavedCourses(model.savedCourses), }, - toParentMessage: (message) => Message.GotProgressMessage({ message }), + toParentMessage: (message) => Message.GotScheduleMessage({ message }), }) - : model.route === 'list' - ? listView(model, h) - : model.selectedCode === null - ? catalogueView(model, h) - : selectedCourseView(model, h), + : model.route === 'progress' + ? h.submodel({ + slotId: 'progress', + model: model.progress, + view: progressView, + viewInputs: { + locale: model.localization, + courseUrl: (courseCode) => normalizedUrl(model, courseCode, EXPLORE_PATH), + }, + toParentMessage: (message) => Message.GotProgressMessage({ message }), + }) + : model.route === 'list' + ? listView(model, h) + : model.selectedCode === null + ? catalogueView(model, h) + : selectedCourseView(model, h), ], ), ], ), lazyCatalogueRefineDialog(catalogueRefineDialogFromValues, [ - model.locale, + model.localization, model.query, model.term, model.campus, @@ -2935,10 +3255,11 @@ const appView = (model: Model, h: HtmlBuilder): Html => bottomStackView(model, selectedSavedCourses(model), h), model.route === 'list' ? labelDialogView(model, h) : h.empty, lazyMobileNavigation(mobileNavigation, [ - model.locale, + model.localization, model.route, exploreUrl(model), listUrl(model), + scheduleUrl(model), progressUrl(model), appearanceUrl(model), h, @@ -2977,18 +3298,18 @@ export const selectControl = ( export const languageSelectControl = ( fields: Model['selectFields'], id: 'language-desktop' | 'language-mobile', - locale: Locale, + localization: Localization, compact: boolean, h: HtmlBuilder, ): Html => selectControl( fields, id, - translate(locale, 'locale.label'), - locale, + translate(localization, 'locale.label'), + localization.locale, [ - ['en', compact ? 'EN' : translate(locale, 'locale.en')], - ['nb', compact ? 'NO' : translate(locale, 'locale.nb')], + ['en', compact ? 'EN' : translate(localization, 'locale.en')], + ['nb', compact ? 'NO' : translate(localization, 'locale.nb')], ], { compact }, h, @@ -3036,7 +3357,7 @@ export const checkboxControl = ( * the VITE_TIP_URL pattern exactly: a static HTTPS link that exists only when * the operator configured a valid HTTPS URL, and opens in a new tab. */ -export const feedbackRow = (locale: Locale, h: HtmlBuilder): Html => { +export const feedbackRow = (locale: Localization, h: HtmlBuilder): Html => { if (feedbackUrl === null) return h.empty; return h.p( [h.Class('m-0 text-on-surface-variant text-sm leading-[1.45]')], @@ -3056,7 +3377,7 @@ export const feedbackRow = (locale: Locale, h: HtmlBuilder): Html => { ); }; -export const productFooter = (locale: Locale, h: HtmlBuilder): Html => { +export const productFooter = (locale: Localization, h: HtmlBuilder): Html => { const externalLink = (url: string, label: string): Html => h.a( [h.Href(url), h.Target('_blank'), h.Rel('noreferrer'), h.Class('relative font-semibold')], diff --git a/apps/student-web/src/course-client-runtime.ts b/apps/student-web/src/course-client-runtime.ts new file mode 100644 index 0000000..1235b3f --- /dev/null +++ b/apps/student-web/src/course-client-runtime.ts @@ -0,0 +1,9 @@ +import { makeCourseClient } from './course-client'; +import { makeFixtureCourseClient } from './course-client.fixture'; + +const apiBaseUrl = import.meta.env.VITE_API_URL as string | undefined; + +export const courseClient = + import.meta.env.VITE_USE_FIXTURE === 'true' + ? makeFixtureCourseClient() + : makeCourseClient(apiBaseUrl); diff --git a/apps/student-web/src/course-client.fixture.ts b/apps/student-web/src/course-client.fixture.ts new file mode 100644 index 0000000..cf18d74 --- /dev/null +++ b/apps/student-web/src/course-client.fixture.ts @@ -0,0 +1,337 @@ +import { Effect } from 'effect'; + +import { + findFixtureCourse, + fixtureCourses, + fixtureDistribution, + fixtureFailureRatePercent, + fixtureSampleSize, +} from './catalogue.fixture'; +import { + parseCourseDecisionSignals, + parseCourseGradeSummaries, + parseCourseSchedule, + parseCourseSearch, + type CourseClient, + type CourseDecisionSignalsResponse, + type CourseGradeSummariesResponse, + type CourseScheduleResponse, + type CourseSearchResponse, +} from './course-client'; +import { partialCourseInsightFixture } from './course-insight.fixture'; + +/** + * Every fixture response is expanded from `fixtureCourses`, so a course is + * described in one place and search, outcomes, and decision signals cannot + * disagree about it. + */ + +export const fixtureSearchResponse = (page: number): CourseSearchResponse => + parseCourseSearch({ + items: + page === 1 + ? fixtureCourses.map((course) => ({ + courseKey: `ntnu:${course.code}:${course.academicYear}-${course.season}`, + institutionCode: 'NTNU', + code: course.code, + title: { state: 'known', value: course.title, evidenceIds: ['ntnu-course'] }, + credits: { state: 'known', value: course.credits, evidenceIds: ['ntnu-course'] }, + level: { state: 'known', value: course.level, evidenceIds: ['ntnu-course'] }, + offerings: { + state: 'known', + value: [ + { + academicYear: course.academicYear, + season: course.season, + campuses: [...course.campuses], + deliveryModes: ['in-person'], + }, + ], + evidenceIds: ['ntnu-course'], + }, + assessmentSignals: { + state: 'known', + value: [...course.assessmentForms], + evidenceIds: ['ntnu-assessment'], + }, + workFormSignals: { + state: 'known', + value: [...course.workForms], + evidenceIds: ['ntnu-teaching'], + }, + enrichment: 'partial', + evidence: partialCourseInsightFixture.item.evidence, + })) + : [], + sourceStatuses: partialCourseInsightFixture.item.sourceStatuses, + meta: { + count: page === 1 ? fixtureCourses.length : 0, + total: fixtureCourses.length, + page, + pageSize: fixtureCourses.length, + hasMore: false, + exactMatchCode: null, + }, + }); + +export const fixtureGradeSummariesResponse = ( + courseCodes: ReadonlyArray, +): CourseGradeSummariesResponse => + parseCourseGradeSummaries({ + items: courseCodes.map((courseCode) => { + const normalizedCode = courseCode.trim().toUpperCase(); + const course = findFixtureCourse(normalizedCode); + const grades = course?.grades; + // A course the fixture does not know, and a known course whose outcomes + // the source suppresses, are different facts. Both stay unavailable + // rather than empty, and each says why. + const reason = + grades === undefined + ? 'No official DBH/HK-dir grade outcomes were found for this period.' + : grades.scale === 'unavailable' + ? grades.reason + : ''; + const available = grades !== undefined && grades.scale !== 'unavailable'; + const evidenceId = 'grades-fixture'; + const failureRate = grades === undefined ? null : fixtureFailureRatePercent(grades); + return { + courseCode: normalizedCode, + period: + available && course !== undefined + ? { + state: 'known', + value: { fromYear: course.period[0], toYear: course.period[1] }, + evidenceIds: [evidenceId], + } + : { state: 'unavailable', reason, evidenceIds: [] }, + sampleSize: + available && grades !== undefined + ? { state: 'known', value: fixtureSampleSize(grades), evidenceIds: [evidenceId] } + : { state: 'unavailable', reason, evidenceIds: [] }, + distribution: + available && grades !== undefined + ? { + state: 'known', + value: fixtureDistribution(grades), + evidenceIds: [evidenceId], + } + : { state: 'unavailable', reason, evidenceIds: [] }, + failureRatePercent: + available && failureRate !== null + ? { state: 'known', value: failureRate, evidenceIds: [evidenceId] } + : { state: 'unavailable', reason, evidenceIds: [] }, + gradingScale: + available && grades !== undefined + ? { state: 'known', value: grades.scale, evidenceIds: [evidenceId] } + : { state: 'unavailable', reason, evidenceIds: [] }, + evidence: available + ? [ + { + id: evidenceId, + provider: 'dbh', + kind: 'fixture' as const, + recordId: `dbh:308:${normalizedCode}:2022-2025`, + sourceUrl: null, + sourcePeriod: '2022-2025', + observedAt: '2026-07-24T01:00:00.000Z', + excerpt: null, + inferenceRule: null, + }, + ] + : [], + }; + }), + sourceStatuses: [ + { + provider: 'dbh', + status: 'available', + observedAt: '2026-07-24T01:00:00.000Z', + warning: null, + }, + ], + meta: { count: courseCodes.length, fromYear: 2022, toYear: 2025 }, + }); + +export const fixtureDecisionSignalsResponse = ( + courseCodes: ReadonlyArray, +): CourseDecisionSignalsResponse => + parseCourseDecisionSignals({ + items: courseCodes.map((courseCode) => { + const normalizedCode = courseCode.trim().toUpperCase(); + const course = findFixtureCourse(normalizedCode); + const available = course !== undefined; + const reason = 'The fixture contains no NTNU decision signals for this course.'; + return { + courseCode: normalizedCode, + credits: course + ? { state: 'known' as const, value: course.credits, evidenceIds: ['ntnu-course'] } + : { state: 'unavailable' as const, reason, evidenceIds: [] }, + assessment: course + ? { + state: 'known' as const, + // The catalogue names each part's form; the surrounding detail is + // shared, so a fixture course does not have to restate it. + value: course.assessmentForms.map((form, index) => ({ + ...partialCourseInsightFixture.item.assessment.value[0]!, + form, + weightPercent: { + state: 'known' as const, + value: Math.round((100 / course.assessmentForms.length) * 100) / 100, + evidenceIds: ['ntnu-assessment'], + }, + description: + index === 0 + ? 'Principal assessment component' + : 'Additional assessment component', + })), + evidenceIds: ['ntnu-assessment'], + } + : { state: 'unavailable' as const, reason, evidenceIds: [] }, + workFormSignals: course + ? { + state: 'known' as const, + value: [...course.workForms], + evidenceIds: ['ntnu-teaching'], + } + : { state: 'unavailable' as const, reason, evidenceIds: [] }, + obligatoryActivities: available + ? partialCourseInsightFixture.item.obligatoryActivities + : { state: 'unavailable' as const, reason, evidenceIds: [] }, + collaboration: available + ? partialCourseInsightFixture.item.collaboration + : { state: 'unavailable' as const, reason, evidenceIds: [] }, + attendance: available + ? partialCourseInsightFixture.item.attendance + : { state: 'unavailable' as const, reason, evidenceIds: [] }, + onlineParticipation: available + ? partialCourseInsightFixture.item.onlineParticipation + : { state: 'unavailable' as const, reason, evidenceIds: [] }, + sourceStatus: { + provider: 'ntnu-course-page', + status: available ? ('available' as const) : ('unavailable' as const), + observedAt: available ? '2026-07-24T01:00:00.000Z' : null, + warning: available ? null : reason, + }, + evidence: available ? partialCourseInsightFixture.item.evidence : [], + }; + }), + meta: { count: courseCodes.length }, + }); + +export const fixtureScheduleResponse = ( + courseCodes: ReadonlyArray, + term: string, + week: number, +): CourseScheduleResponse => + parseCourseSchedule({ + items: courseCodes.map((courseCode) => { + const normalizedCode = courseCode.trim().toUpperCase(); + const knownCourse = findFixtureCourse(normalizedCode); + const hasPublishedTermActivities = normalizedCode === 'TDT4136' && term === '2026-autumn'; + const hasWeekFortyFiveEvents = hasPublishedTermActivities && week === 45; + return { + courseCode: normalizedCode, + sourceStatus: + knownCourse === undefined + ? { + provider: 'NTNU schedule fixture', + status: 'unavailable' as const, + observedAt: null, + warning: 'The fixture has no published schedule for this course.', + } + : { + provider: 'NTNU schedule fixture', + status: 'available' as const, + observedAt: '2026-09-22T08:00:00.000Z', + warning: null, + }, + activityStreams: hasPublishedTermActivities + ? [ + { + activityCode: 'lecture', + title: 'Search and planning', + summary: 'Published lecture activity.', + }, + { + activityCode: 'exercise', + title: 'Constraint satisfaction exercise', + summary: null, + }, + ] + : [], + occurrences: hasWeekFortyFiveEvents + ? [ + { + id: 'tdt4136-2026-w45-lecture', + courseCode: normalizedCode, + activityCode: 'lecture', + title: 'Search and planning', + summary: 'Published lecture activity.', + status: 'published', + startsAt: '2026-11-02T09:15:00+01:00', + endsAt: '2026-11-02T11:00:00+01:00', + rooms: [{ building: 'IT-bygget', room: 'R2', url: null }], + evidence: { + provider: 'ntnu-course-schedule', + kind: 'fixture' as const, + sourceRecordId: 'fixture:tdt4136:2026-w45:lecture', + sourceUrl: 'https://www.ntnu.edu/studies/courses/TDT4136', + observedAt: '2026-09-22T08:00:00.000Z', + }, + }, + { + id: 'tdt4136-2026-w45-exercise', + courseCode: normalizedCode, + activityCode: 'exercise', + title: 'Constraint satisfaction exercise', + summary: null, + status: 'published', + startsAt: '2026-11-04T12:15:00+01:00', + endsAt: '2026-11-04T14:00:00+01:00', + rooms: [{ building: 'IT-bygget', room: 'R5', url: null }], + evidence: { + provider: 'ntnu-course-schedule', + kind: 'fixture' as const, + sourceRecordId: 'fixture:tdt4136:2026-w45:exercise', + sourceUrl: 'https://www.ntnu.edu/studies/courses/TDT4136', + observedAt: '2026-09-22T08:00:00.000Z', + }, + }, + ] + : [], + }; + }), + meta: { + count: + term === '2026-autumn' && week === 45 + ? courseCodes.filter((courseCode) => courseCode.trim().toUpperCase() === 'TDT4136') + .length * 2 + : 0, + term, + week, + timezone: 'Europe/Oslo', + limitations: { + activitySelection: 'all-published-activities', + activityGrouping: 'unavailable', + exceptionSemantics: 'provider-status-unverified', + }, + }, + }); + +export const makeFixtureCourseClient = (): CourseClient => ({ + search: (request) => + Effect.sleep('150 millis').pipe(Effect.as(fixtureSearchResponse(request.page))), + getGradeSummaries: (courseCodes) => + Effect.sleep('100 millis').pipe(Effect.as(fixtureGradeSummariesResponse(courseCodes))), + getDecisionSignals: (courseCodes) => + Effect.sleep('120 millis').pipe(Effect.as(fixtureDecisionSignalsResponse(courseCodes))), + getSchedule: (courseCodes, term, week) => + Effect.sleep('120 millis').pipe(Effect.as(fixtureScheduleResponse(courseCodes, term, week))), + getInsight: (courseCode) => { + const normalizedCode = courseCode.trim().toUpperCase(); + if (normalizedCode !== 'TDT4136') { + return Effect.fail(new Error('The local walking skeleton currently contains only TDT4136.')); + } + return Effect.sleep('250 millis').pipe(Effect.as(partialCourseInsightFixture)); + }, +}); diff --git a/apps/student-web/src/course-client.test.ts b/apps/student-web/src/course-client.test.ts index 6e2518a..0ec3e67 100644 --- a/apps/student-web/src/course-client.test.ts +++ b/apps/student-web/src/course-client.test.ts @@ -1,11 +1,12 @@ import { Effect } from 'effect'; import { afterEach, describe, expect, test, vi } from 'vitest'; +import { makeCourseClient } from './course-client'; import { fixtureDecisionSignalsResponse, + fixtureScheduleResponse, fixtureSearchResponse, - makeCourseClient, -} from './course-client'; +} from './course-client.fixture'; describe('course client public boundary', () => { afterEach(() => { @@ -65,6 +66,60 @@ describe('course client public boundary', () => { expect(response.items.map((item) => item.courseCode)).toEqual(courseCodes); }); + test('posts a cancellable weekly schedule request and validates its response', async () => { + const courseCodes = ['TDT4136', 'TDT4109']; + const fetchMock = vi.fn(async () => + Response.json(fixtureScheduleResponse(courseCodes, '2026-autumn', 45)), + ); + vi.stubGlobal('fetch', fetchMock); + + const response = await Effect.runPromise( + makeCourseClient('http://course-api.test').getSchedule(courseCodes, '2026-autumn', 45), + ); + + const [request, init] = fetchMock.mock.calls[0] ?? []; + expect(new URL(String(request)).pathname).toBe('/v1/course-schedules'); + expect(init).toMatchObject({ method: 'POST' }); + expect(init?.signal).toBeInstanceOf(AbortSignal); + expect(JSON.parse(String(init?.body))).toEqual({ + courseCodes, + term: '2026-autumn', + week: 45, + }); + expect(response.meta).toMatchObject({ term: '2026-autumn', week: 45, timezone: 'Europe/Oslo' }); + expect(response.items[0]?.activityStreams).toEqual([ + { + activityCode: 'lecture', + title: 'Search and planning', + summary: 'Published lecture activity.', + }, + { + activityCode: 'exercise', + title: 'Constraint satisfaction exercise', + summary: null, + }, + ]); + + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json({ + ...fixtureScheduleResponse(courseCodes, '2026-autumn', 45), + meta: { + ...fixtureScheduleResponse(courseCodes, '2026-autumn', 45).meta, + timezone: 'UTC', + }, + }), + ), + ); + + await expect( + Effect.runPromise( + makeCourseClient('http://course-api.test').getSchedule(courseCodes, '2026-autumn', 45), + ), + ).rejects.toThrow('The course API returned an invalid CourseSchedule response.'); + }); + test('rejects malformed success payloads and surfaces a provider problem detail', async () => { const malformedSearch = vi.fn(async () => Response.json({ ...fixtureSearchResponse(1), meta: { count: 1, exactMatchCode: null } }), diff --git a/apps/student-web/src/course-client.ts b/apps/student-web/src/course-client.ts index e056114..a42c6cb 100644 --- a/apps/student-web/src/course-client.ts +++ b/apps/student-web/src/course-client.ts @@ -2,26 +2,18 @@ import { CourseDecisionSignalsResponseDto, CourseGradeSummariesResponseDto, CourseInsightResponseDto, + CourseScheduleResponseDto, CourseSearchResponseDto, ProblemDto, type CourseDecisionSignalsResponseDtoType, type CourseGradeSummariesResponseDtoType, type CourseInsightResponseDtoType, + type CourseScheduleResponseDtoType, type CourseSearchResponseDtoType, } from '@course-data/course-contracts'; import { Value } from '@sinclair/typebox/value'; import { Effect, Schema as S } from 'effect'; -import { - findFixtureCourse, - fixtureCourses, - fixtureDistribution, - fixtureFailureRatePercent, - fixtureSampleSize, - type FixtureCourse, -} from './catalogue.fixture'; -import { partialCourseInsightFixture } from './course-insight.fixture'; - export interface CourseClient { readonly search: (request: CourseSearchRequest) => Effect.Effect; readonly getGradeSummaries: ( @@ -31,6 +23,11 @@ export interface CourseClient { courseCodes: ReadonlyArray, term?: string, ) => Effect.Effect; + readonly getSchedule: ( + courseCodes: ReadonlyArray, + term: string, + week: number, + ) => Effect.Effect; readonly getInsight: ( courseCode: string, term?: string, @@ -54,6 +51,7 @@ export interface CourseSearchRequest { export type CourseSearchResponse = CourseSearchResponseDtoType; export type CourseGradeSummariesResponse = CourseGradeSummariesResponseDtoType; export type CourseDecisionSignalsResponse = CourseDecisionSignalsResponseDtoType; +export type CourseScheduleResponse = CourseScheduleResponseDtoType; const isCourseInsightResponse = (input: unknown): input is CourseInsightResponseDtoType => Value.Check(CourseInsightResponseDto, input); @@ -67,10 +65,14 @@ const isCourseGradeSummariesResponse = (input: unknown): input is CourseGradeSum const isCourseDecisionSignalsResponse = (input: unknown): input is CourseDecisionSignalsResponse => Value.Check(CourseDecisionSignalsResponseDto, input); +const isCourseScheduleResponse = (input: unknown): input is CourseScheduleResponse => + Value.Check(CourseScheduleResponseDto, input); + export const CourseInsightResponseSchema = S.declare(isCourseInsightResponse); export const CourseSearchResponseSchema = S.declare(isCourseSearchResponse); export const CourseGradeSummariesResponseSchema = S.declare(isCourseGradeSummariesResponse); export const CourseDecisionSignalsResponseSchema = S.declare(isCourseDecisionSignalsResponse); +export const CourseScheduleResponseSchema = S.declare(isCourseScheduleResponse); const parseCourseInsight = (input: unknown): CourseInsightResponseDtoType => { if (!isCourseInsightResponse(input)) { @@ -79,222 +81,33 @@ const parseCourseInsight = (input: unknown): CourseInsightResponseDtoType => { return input; }; -const parseCourseSearch = (input: unknown): CourseSearchResponse => { +export const parseCourseSearch = (input: unknown): CourseSearchResponse => { if (!isCourseSearchResponse(input)) { throw new Error('The course API returned an invalid CourseSearch response.'); } return input; }; -const parseCourseGradeSummaries = (input: unknown): CourseGradeSummariesResponse => { +export const parseCourseGradeSummaries = (input: unknown): CourseGradeSummariesResponse => { if (!isCourseGradeSummariesResponse(input)) { throw new Error('The course API returned an invalid CourseGradeSummaries response.'); } return input; }; -const parseCourseDecisionSignals = (input: unknown): CourseDecisionSignalsResponse => { +export const parseCourseDecisionSignals = (input: unknown): CourseDecisionSignalsResponse => { if (!isCourseDecisionSignalsResponse(input)) { throw new Error('The course API returned an invalid CourseDecisionSignals response.'); } return input; }; -/** - * Every fixture response is expanded from `fixtureCourses`, so a course is - * described in one place and search, outcomes, and decision signals cannot - * disagree about it. - */ -const fixtureSearchItem = (course: FixtureCourse): unknown => ({ - courseKey: `ntnu:${course.code}:${course.academicYear}-${course.season}`, - institutionCode: 'NTNU', - code: course.code, - title: { state: 'known', value: course.title, evidenceIds: ['ntnu-course'] }, - credits: { state: 'known', value: course.credits, evidenceIds: ['ntnu-course'] }, - level: { state: 'known', value: course.level, evidenceIds: ['ntnu-course'] }, - offerings: { - state: 'known', - value: [ - { - academicYear: course.academicYear, - season: course.season, - campuses: [...course.campuses], - deliveryModes: ['in-person'], - }, - ], - evidenceIds: ['ntnu-course'], - }, - assessmentSignals: { - state: 'known', - value: [...course.assessmentForms], - evidenceIds: ['ntnu-assessment'], - }, - workFormSignals: { - state: 'known', - value: [...course.workForms], - evidenceIds: ['ntnu-teaching'], - }, - enrichment: 'partial', - evidence: partialCourseInsightFixture.item.evidence, -}); - -export const fixtureSearchResponse = (page: number): CourseSearchResponse => - parseCourseSearch({ - items: page === 1 ? fixtureCourses.map(fixtureSearchItem) : [], - sourceStatuses: partialCourseInsightFixture.item.sourceStatuses, - meta: { - count: page === 1 ? fixtureCourses.length : 0, - total: fixtureCourses.length, - page, - pageSize: fixtureCourses.length, - hasMore: false, - exactMatchCode: null, - }, - }); - -export const fixtureGradeSummariesResponse = ( - courseCodes: ReadonlyArray, -): CourseGradeSummariesResponse => - parseCourseGradeSummaries({ - items: courseCodes.map((courseCode) => { - const normalizedCode = courseCode.trim().toUpperCase(); - const course = findFixtureCourse(normalizedCode); - const grades = course?.grades; - // A course the fixture does not know, and a known course whose outcomes - // the source suppresses, are different facts. Both stay unavailable - // rather than empty, and each says why. - const reason = - grades === undefined - ? 'No official DBH/HK-dir grade outcomes were found for this period.' - : grades.scale === 'unavailable' - ? grades.reason - : ''; - const available = grades !== undefined && grades.scale !== 'unavailable'; - const evidenceId = 'grades-fixture'; - const failureRate = grades === undefined ? null : fixtureFailureRatePercent(grades); - return { - courseCode: normalizedCode, - period: - available && course !== undefined - ? { - state: 'known', - value: { fromYear: course.period[0], toYear: course.period[1] }, - evidenceIds: [evidenceId], - } - : { state: 'unavailable', reason, evidenceIds: [] }, - sampleSize: - available && grades !== undefined - ? { state: 'known', value: fixtureSampleSize(grades), evidenceIds: [evidenceId] } - : { state: 'unavailable', reason, evidenceIds: [] }, - distribution: - available && grades !== undefined - ? { - state: 'known', - value: fixtureDistribution(grades), - evidenceIds: [evidenceId], - } - : { state: 'unavailable', reason, evidenceIds: [] }, - failureRatePercent: - available && failureRate !== null - ? { state: 'known', value: failureRate, evidenceIds: [evidenceId] } - : { state: 'unavailable', reason, evidenceIds: [] }, - gradingScale: - available && grades !== undefined - ? { state: 'known', value: grades.scale, evidenceIds: [evidenceId] } - : { state: 'unavailable', reason, evidenceIds: [] }, - evidence: available - ? [ - { - id: evidenceId, - provider: 'dbh', - kind: 'fixture' as const, - recordId: `dbh:308:${normalizedCode}:2022-2025`, - sourceUrl: null, - sourcePeriod: '2022-2025', - observedAt: '2026-07-24T01:00:00.000Z', - excerpt: null, - inferenceRule: null, - }, - ] - : [], - }; - }), - sourceStatuses: [ - { - provider: 'dbh', - status: 'available', - observedAt: '2026-07-24T01:00:00.000Z', - warning: null, - }, - ], - meta: { count: courseCodes.length, fromYear: 2022, toYear: 2025 }, - }); - -export const fixtureDecisionSignalsResponse = ( - courseCodes: ReadonlyArray, -): CourseDecisionSignalsResponse => - parseCourseDecisionSignals({ - items: courseCodes.map((courseCode) => { - const normalizedCode = courseCode.trim().toUpperCase(); - const course = findFixtureCourse(normalizedCode); - const available = course !== undefined; - const reason = 'The fixture contains no NTNU decision signals for this course.'; - return { - courseCode: normalizedCode, - credits: course - ? { state: 'known' as const, value: course.credits, evidenceIds: ['ntnu-course'] } - : { state: 'unavailable' as const, reason, evidenceIds: [] }, - assessment: course - ? { - state: 'known' as const, - // The catalogue names each part's form; the surrounding detail is - // shared, so a fixture course does not have to restate it. - value: course.assessmentForms.map((form, index) => ({ - ...partialCourseInsightFixture.item.assessment.value[0]!, - form, - weightPercent: { - state: 'known' as const, - value: Math.round((100 / course.assessmentForms.length) * 100) / 100, - evidenceIds: ['ntnu-assessment'], - }, - description: - index === 0 - ? 'Principal assessment component' - : 'Additional assessment component', - })), - evidenceIds: ['ntnu-assessment'], - } - : { state: 'unavailable' as const, reason, evidenceIds: [] }, - workFormSignals: course - ? { - state: 'known' as const, - value: [...course.workForms], - evidenceIds: ['ntnu-teaching'], - } - : { state: 'unavailable' as const, reason, evidenceIds: [] }, - obligatoryActivities: available - ? partialCourseInsightFixture.item.obligatoryActivities - : { state: 'unavailable' as const, reason, evidenceIds: [] }, - collaboration: available - ? partialCourseInsightFixture.item.collaboration - : { state: 'unavailable' as const, reason, evidenceIds: [] }, - attendance: available - ? partialCourseInsightFixture.item.attendance - : { state: 'unavailable' as const, reason, evidenceIds: [] }, - onlineParticipation: available - ? partialCourseInsightFixture.item.onlineParticipation - : { state: 'unavailable' as const, reason, evidenceIds: [] }, - sourceStatus: { - provider: 'ntnu-course-page', - status: available ? ('available' as const) : ('unavailable' as const), - observedAt: available ? '2026-07-24T01:00:00.000Z' : null, - warning: available ? null : reason, - }, - evidence: available ? partialCourseInsightFixture.item.evidence : [], - }; - }), - meta: { count: courseCodes.length }, - }); +export const parseCourseSchedule = (input: unknown): CourseScheduleResponse => { + if (!isCourseScheduleResponse(input)) { + throw new Error('The course API returned an invalid CourseSchedule response.'); + } + return input; +}; const readProblem = async (response: Response, fallback: string): Promise => { const problem: unknown = await response.json().catch(() => null); @@ -304,12 +117,8 @@ const readProblem = async (response: Response, fallback: string): Promise throw new Error(fallback); }; -export const makeCourseClient = (apiBaseUrl?: string, useFixture = false): CourseClient => ({ +export const makeCourseClient = (apiBaseUrl?: string): CourseClient => ({ search: (request) => { - if (useFixture) { - return Effect.sleep('150 millis').pipe(Effect.as(fixtureSearchResponse(request.page))); - } - if (apiBaseUrl === undefined || apiBaseUrl.length === 0) { return Effect.fail( new Error( @@ -350,10 +159,6 @@ export const makeCourseClient = (apiBaseUrl?: string, useFixture = false): Cours }); }, getGradeSummaries: (courseCodes) => { - if (useFixture) { - return Effect.sleep('100 millis').pipe(Effect.as(fixtureGradeSummariesResponse(courseCodes))); - } - if (apiBaseUrl === undefined || apiBaseUrl.length === 0) { return Effect.fail( new Error( @@ -384,12 +189,6 @@ export const makeCourseClient = (apiBaseUrl?: string, useFixture = false): Cours }); }, getDecisionSignals: (courseCodes, term) => { - if (useFixture) { - return Effect.sleep('120 millis').pipe( - Effect.as(fixtureDecisionSignalsResponse(courseCodes)), - ); - } - if (apiBaseUrl === undefined || apiBaseUrl.length === 0) { return Effect.fail( new Error( @@ -423,19 +222,40 @@ export const makeCourseClient = (apiBaseUrl?: string, useFixture = false): Cours : new Error('The course decision-signal request failed unexpectedly.'), }); }, + getSchedule: (courseCodes, term, week) => { + if (apiBaseUrl === undefined || apiBaseUrl.length === 0) { + return Effect.fail( + new Error( + 'Course API URL is not configured. Set VITE_API_URL or explicitly enable the local fixture.', + ), + ); + } + + return Effect.tryPromise({ + try: async (signal) => { + const response = await fetch(`${apiBaseUrl.replace(/\/$/, '')}/v1/course-schedules`, { + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + body: JSON.stringify({ courseCodes, term, week }), + signal, + }); + if (!response.ok) { + return readProblem( + response, + `Course schedule request failed with status ${response.status}.`, + ); + } + return parseCourseSchedule(await response.json()); + }, + catch: (cause) => + cause instanceof Error + ? cause + : new Error('The course schedule request failed unexpectedly.'), + }); + }, getInsight: (courseCode, term) => { const normalizedCode = courseCode.trim().toUpperCase(); - if (useFixture) { - if (normalizedCode !== 'TDT4136') { - return Effect.fail( - new Error('The local walking skeleton currently contains only TDT4136.'), - ); - } - - return Effect.sleep('250 millis').pipe(Effect.as(partialCourseInsightFixture)); - } - if (apiBaseUrl === undefined || apiBaseUrl.length === 0) { return Effect.fail( new Error( @@ -465,10 +285,3 @@ export const makeCourseClient = (apiBaseUrl?: string, useFixture = false): Cours }); }, }); - -const apiBaseUrl = import.meta.env.VITE_API_URL as string | undefined; - -export const courseClient = makeCourseClient( - apiBaseUrl, - import.meta.env.VITE_USE_FIXTURE === 'true', -); diff --git a/apps/student-web/src/course-detail.ts b/apps/student-web/src/course-detail.ts index 3dd0877..09bc634 100644 --- a/apps/student-web/src/course-detail.ts +++ b/apps/student-web/src/course-detail.ts @@ -1,7 +1,7 @@ import type { CourseInsightResponseDtoType } from '@course-data/course-contracts'; import type { Html, HtmlBuilder } from 'foldkit/html'; -import { localeTag, translate, translateToken, type Locale } from './i18n'; +import { localeTag, translate, translateToken, type Localization } from './i18n'; import { collaborationIconName, icon, termSeasonIconName } from './icons'; type CourseInsightResponse = CourseInsightResponseDtoType; @@ -51,7 +51,7 @@ const uncertainFactStateClass = export const courseInsightView = ( response: CourseInsightResponse, partial: boolean, - locale: Locale, + locale: Localization, h: HtmlBuilder, ): Html => { const course = response.item; @@ -245,7 +245,7 @@ const compactFact = ( label: string, fact: ProtocolFact, format: (value: A) => string, - locale: Locale, + locale: Localization, h: HtmlBuilder, ): Html => { return h.div( @@ -291,7 +291,7 @@ const factView = ( label: string, fact: ProtocolFact, renderKnown: (value: A) => Html, - locale: Locale, + locale: Localization, inferenceEvidenceIds: ReadonlySet, h: HtmlBuilder, ): Html => { @@ -365,7 +365,7 @@ const factView = ( const gradeSection = ( course: CourseInsight, - locale: Locale, + locale: Localization, h: HtmlBuilder, ): Html => { const grades = course.gradeOutcomes; @@ -397,7 +397,7 @@ const gradeSection = ( (value) => paragraph( translate(locale, 'detail.results', { - count: value.toLocaleString(localeTag(locale)), + count: value.toLocaleString(localeTag(locale.locale)), }), h, ), @@ -410,7 +410,7 @@ const gradeSection = ( grades.failureRatePercent, (value) => paragraph( - new Intl.NumberFormat(localeTag(locale), { + new Intl.NumberFormat(localeTag(locale.locale), { style: 'percent', maximumFractionDigits: 1, }).format(value / 100), @@ -454,7 +454,7 @@ const gradeDistribution = ( distribution: CourseInsight['gradeOutcomes']['distribution'] extends ProtocolFact ? A : never, - locale: Locale, + locale: Localization, h: HtmlBuilder, ): Html => { return h.div( @@ -502,14 +502,14 @@ const gradeDistribution = ( const examParticipationSection = ( course: CourseInsight, - locale: Locale, + locale: Localization, h: HtmlBuilder, ): Html => { const participation = course.examParticipation; const count = (value: number) => paragraph( translate(locale, 'detail.examRegistrations', { - count: value.toLocaleString(localeTag(locale)), + count: value.toLocaleString(localeTag(locale.locale)), }), h, ); @@ -583,7 +583,7 @@ const examParticipationSection = ( const sourceSection = ( course: CourseInsight, - locale: Locale, + locale: Localization, h: HtmlBuilder, ): Html => { return h.section( @@ -683,7 +683,7 @@ const sourceSection = ( const evidenceLinks = ( evidenceIds: ReadonlyArray, - locale: Locale, + locale: Localization, contextClass: string, h: HtmlBuilder, ): Html => { @@ -716,7 +716,7 @@ const evidenceLinks = ( const offeringList = ( offerings: CourseInsight['offerings'] extends ProtocolFact ? A : never, - locale: Locale, + locale: Localization, h: HtmlBuilder, ): Html => { if (offerings.length === 0) { @@ -751,11 +751,11 @@ const offeringList = ( const assessmentList = ( assessment: CourseInsight['assessment'] extends ProtocolFact ? A : never, - locale: Locale, + locale: Localization, h: HtmlBuilder, ): Html => { const formatWeight = (value: number): string => - new Intl.NumberFormat(localeTag(locale), { maximumFractionDigits: 2 }).format(value); + new Intl.NumberFormat(localeTag(locale.locale), { maximumFractionDigits: 2 }).format(value); return h.ul( [h.Class('mt-[0.35rem] mr-0 mb-0 ml-0 pl-[1.2rem] [&_li]:my-[0.35rem] [&_li]:leading-[1.5]')], assessment.map((part) => @@ -791,7 +791,7 @@ const assessmentList = ( const obligatoryActivityList = ( activities: CourseInsight['obligatoryActivities'] extends ProtocolFact ? A : never, - locale: Locale, + locale: Localization, h: HtmlBuilder, ): Html => { if (activities.length === 0) { @@ -822,7 +822,7 @@ const paragraph = (value: string, h: HtmlBuilder): Html => h.p const collaborationPill = ( collaboration: 'individual' | 'group' | 'mixed', - locale: Locale, + locale: Localization, h: HtmlBuilder, ): Html => { return h.span( @@ -853,7 +853,11 @@ const chipList = (items: ReadonlyArray, h: HtmlBuilder ); }; -const formatOfferingPeriod = (academicYear: number, season: string, locale: Locale): string => { +const formatOfferingPeriod = ( + academicYear: number, + season: string, + locale: Localization, +): string => { const academicYearLabel = `${academicYear}/${String(academicYear + 1).slice(-2)}`; if (season === 'full-year') { return translate(locale, 'offering.academicYear', { year: academicYearLabel }); @@ -862,8 +866,8 @@ const formatOfferingPeriod = (academicYear: number, season: string, locale: Loca return `${translateToken(locale, season)} ${calendarYear} · ${academicYearLabel}`; }; -const formatTimestamp = (value: string, locale: Locale): string => - new Intl.DateTimeFormat(localeTag(locale), { +const formatTimestamp = (value: string, locale: Localization): string => + new Intl.DateTimeFormat(localeTag(locale.locale), { dateStyle: 'medium', timeZone: 'Europe/Oslo', }).format(new Date(value)); diff --git a/apps/student-web/src/course-facts.ts b/apps/student-web/src/course-facts.ts index dc1941b..97611e5 100644 --- a/apps/student-web/src/course-facts.ts +++ b/apps/student-web/src/course-facts.ts @@ -4,7 +4,7 @@ import type { } from '@course-data/course-contracts'; import { Match as M } from 'effect'; -import { localeTag, translate, translateToken, type Locale } from './i18n'; +import { localeTag, translate, translateToken, type Localization } from './i18n'; export type DecisionSignal = | CourseDecisionSignalsDtoType @@ -13,13 +13,13 @@ export type DecisionSignal = | 'idle' | 'missing'; -export const factStateLabel = (state: string, locale: Locale): string => +export const factStateLabel = (state: string, locale: Localization): string => translateToken(locale, state); export const formatOfferingPeriod = ( academicYear: number, season: string, - locale: Locale, + locale: Localization, ): string => { if (season === 'full-year') { const academicYearLabel = `${academicYear}/${String(academicYear + 1).slice(-2)}`; @@ -45,7 +45,7 @@ export interface CourseOfferingFacts { export const courseOfferingFacts = ( course: CourseSearchItemDtoType, decisionSignal: DecisionSignal, - locale: Locale, + locale: Localization, ): CourseOfferingFacts => { const offering = course.offerings.state === 'known' && course.offerings.value.length > 0 @@ -72,7 +72,7 @@ export const courseOfferingFacts = ( const credits = creditsFact.state === 'known' ? translate(locale, 'course.creditsValue', { - value: new Intl.NumberFormat(localeTag(locale), { + value: new Intl.NumberFormat(localeTag(locale.locale), { maximumFractionDigits: 1, }).format(creditsFact.value), }) @@ -90,7 +90,7 @@ export type AssessmentForm = | 'assignment' | 'other'; -export const assessmentLabel = (form: AssessmentForm, locale: Locale): string => +export const assessmentLabel = (form: AssessmentForm, locale: Localization): string => M.value(form).pipe( M.when('written-exam', () => translate(locale, 'signals.writtenExam')), M.when('oral-exam', () => translate(locale, 'signals.oralExam')), @@ -105,7 +105,7 @@ export const assessmentLabel = (form: AssessmentForm, locale: Locale): string => export const collaborationLabel = ( collaboration: 'individual' | 'group' | 'mixed', - locale: Locale, + locale: Localization, ): string => M.value(collaboration).pipe( M.when('individual', () => translate(locale, 'signals.individual')), @@ -114,7 +114,10 @@ export const collaborationLabel = ( M.exhaustive, ); -export const gradeScaleLabel = (scale: 'letter' | 'pass-fail' | 'mixed', locale: Locale): string => +export const gradeScaleLabel = ( + scale: 'letter' | 'pass-fail' | 'mixed', + locale: Localization, +): string => M.value(scale).pipe( M.when('letter', () => translate(locale, 'outcomes.letter')), M.when('pass-fail', () => translate(locale, 'outcomes.passFail')), @@ -122,5 +125,5 @@ export const gradeScaleLabel = (scale: 'letter' | 'pass-fail' | 'mixed', locale: M.exhaustive, ); -export const formatPercentage = (value: number, locale: Locale): string => - new Intl.NumberFormat(localeTag(locale), { maximumFractionDigits: 1 }).format(value); +export const formatPercentage = (value: number, locale: Localization): string => + new Intl.NumberFormat(localeTag(locale.locale), { maximumFractionDigits: 1 }).format(value); diff --git a/apps/student-web/src/features/appearance/index.ts b/apps/student-web/src/features/appearance/index.ts index ed43fd3..e6f7780 100644 --- a/apps/student-web/src/features/appearance/index.ts +++ b/apps/student-web/src/features/appearance/index.ts @@ -19,7 +19,7 @@ import { } from '../../theme'; import { buttonSecondary, eyebrowClass } from '../../app-styles'; import { icon } from '../../icons'; -import { translate, type Locale } from '../../i18n'; +import { translate, type Localization } from '../../i18n'; const BaseColorSchema = S.Literals(['mist', 'zinc', 'stone', 'mauve', 'olive', 'neutral']); const ThemeColorSchema = S.Literals(['blue', 'violet', 'amber', 'rose', 'emerald', 'sky']); @@ -97,12 +97,12 @@ export const update = (model: Model, message: Message) => }); export interface ViewInputs { - readonly locale: Locale; + readonly locale: Localization; readonly renderMobileLanguageControl: () => Html; readonly renderFooter: () => Html; } -const themePresetName = (locale: Locale, presetId: ThemePresetId): string => { +const themePresetName = (locale: Localization, presetId: ThemePresetId): string => { switch (presetId) { case 'fjord': return translate(locale, 'appearance.fjord'); @@ -119,7 +119,7 @@ const themePresetName = (locale: Locale, presetId: ThemePresetId): string => { } }; -const themePresetDescription = (locale: Locale, presetId: ThemePresetId): string => { +const themePresetDescription = (locale: Localization, presetId: ThemePresetId): string => { switch (presetId) { case 'fjord': return translate(locale, 'appearance.fjordDescription'); @@ -136,7 +136,7 @@ const themePresetDescription = (locale: Locale, presetId: ThemePresetId): string } }; -const colorModeLabel = (locale: Locale, mode: ColorMode): string => { +const colorModeLabel = (locale: Localization, mode: ColorMode): string => { switch (mode) { case 'system': return translate(locale, 'appearance.system'); @@ -147,7 +147,7 @@ const colorModeLabel = (locale: Locale, mode: ColorMode): string => { } }; -const themePreview = (locale: Locale, h: HtmlBuilder): Html => { +const themePreview = (locale: Localization, h: HtmlBuilder): Html => { return h.section( [ h.Class( diff --git a/apps/student-web/src/features/compare/compare.test.ts b/apps/student-web/src/features/compare/compare.test.ts index 1392b9e..3df840e 100644 --- a/apps/student-web/src/features/compare/compare.test.ts +++ b/apps/student-web/src/features/compare/compare.test.ts @@ -4,10 +4,11 @@ import { fixtureDecisionSignalsResponse, fixtureGradeSummariesResponse, fixtureSearchResponse, -} from '../../course-client'; +} from '../../course-client.fixture'; import { courseIdentity } from '../../course-identity'; import { emptySavedList, saveCourse, savedCoursesNewestFirst } from '../../saved-courses'; import { Message, compareRows, init, update, type CompareCourseFacts } from './index'; +const english = { locale: 'en' } as const; const identity = (courseCode: string) => { const parsed = courseIdentity(courseCode); @@ -53,7 +54,7 @@ describe('saved-course comparison', () => { }), ); - const credits = compareRows('en', facts).find((row) => row.label === 'Credits'); + const credits = compareRows(english, facts).find((row) => row.label === 'Credits'); expect(credits?.cells.map((cell) => cell.text)).toEqual(['15 credits', '7.5 credits']); }); @@ -79,7 +80,7 @@ describe('saved-course comparison', () => { }), ); - const failureRate = compareRows('en', facts).find((row) => row.label === 'Failure rate'); + const failureRate = compareRows(english, facts).find((row) => row.label === 'Failure rate'); expect(failureRate?.cells.map((cell) => cell.text).sort()).toEqual(['Not loaded', 'Unknown']); expect(failureRate?.cells.every((cell) => !cell.known)).toBe(true); }); diff --git a/apps/student-web/src/features/compare/index.ts b/apps/student-web/src/features/compare/index.ts index 7cb8c62..c0cce64 100644 --- a/apps/student-web/src/features/compare/index.ts +++ b/apps/student-web/src/features/compare/index.ts @@ -12,7 +12,7 @@ import type { } from '@course-data/course-contracts'; import { buttonSecondary, compactButtonBase } from '../../app-styles'; -import { localeTag, translate, type Locale } from '../../i18n'; +import { localeTag, translate, type Localization } from '../../i18n'; import type { SavedCourse } from '../../saved-courses'; import { assessmentLabel, @@ -66,7 +66,7 @@ export interface CompareCourseFacts { } export interface ViewInputs { - readonly locale: Locale; + readonly locale: Localization; readonly courses: ReadonlyArray; readonly facts: ReadonlyArray; readonly feedback: Html; @@ -84,7 +84,7 @@ const compareCell = (text: string, known = true): CompareCell => ({ text, known const factCell = ( fact: { readonly state: string; readonly value?: Value } | null | undefined, - locale: Locale, + locale: Localization, render: (value: Value) => string, ): CompareCell => fact === null || fact === undefined @@ -99,7 +99,7 @@ interface CompareRow { } export const compareRows = ( - locale: Locale, + locale: Localization, facts: ReadonlyArray, ): ReadonlyArray => { const items = facts.map((fact) => fact.item); @@ -191,7 +191,9 @@ export const compareRows = ( row( translate(locale, 'compare.sample'), grades.map((grade) => - factCell(grade?.sampleSize, locale, (value) => value.toLocaleString(localeTag(locale))), + factCell(grade?.sampleSize, locale, (value) => + value.toLocaleString(localeTag(locale.locale)), + ), ), ), row( diff --git a/apps/student-web/src/features/explore/index.ts b/apps/student-web/src/features/explore/index.ts index f1d8225..a796496 100644 --- a/apps/student-web/src/features/explore/index.ts +++ b/apps/student-web/src/features/explore/index.ts @@ -69,7 +69,7 @@ import { type AssessmentForm, type DecisionSignal, } from '../../course-facts'; -import { localeTag, translate, translateToken, type Locale } from '../../i18n'; +import { localeTag, translate, translateToken, type Localization } from '../../i18n'; import { collaborationIconName, icon, termSeasonIconName, type AppIcon } from '../../icons'; const lazyCourseCard = createKeyedLazy(); @@ -80,9 +80,9 @@ export const catalogueView = (model: Model, h: HtmlBuilder): Html => { return h.div( [h.Class('grid gap-6')], [ - lazyCatalogueHeader(catalogueHeader, [model.locale, h]), + lazyCatalogueHeader(catalogueHeader, [model.localization, h]), lazyCatalogueControls(catalogueControlsFromValues, [ - model.locale, + model.localization, model.query, model.term, model.campus, @@ -99,7 +99,7 @@ export const catalogueView = (model: Model, h: HtmlBuilder): Html => { ); }; -const catalogueHeader = (locale: Locale, h: HtmlBuilder): Html => { +const catalogueHeader = (locale: Localization, h: HtmlBuilder): Html => { return h.header( [h.Class('pt-[clamp(2rem,5vw,3.5rem)] pb-2')], [ @@ -121,7 +121,7 @@ const catalogueHeader = (locale: Locale, h: HtmlBuilder): Html => { }; interface CatalogueControlsState { - readonly locale: Locale; + readonly localization: Localization; readonly query: string; readonly term: string; readonly campus: Campus; @@ -134,7 +134,7 @@ interface CatalogueControlsState { } const catalogueControlsFromValues = ( - locale: Locale, + locale: Localization, query: string, term: string, campus: Campus, @@ -148,7 +148,7 @@ const catalogueControlsFromValues = ( ): Html => catalogueControls( { - locale, + localization: locale, query, term, campus, @@ -164,7 +164,7 @@ const catalogueControlsFromValues = ( ); export const catalogueRefineDialogFromValues = ( - locale: Locale, + locale: Localization, query: string, term: string, campus: Campus, @@ -179,7 +179,7 @@ export const catalogueRefineDialogFromValues = ( ): Html => catalogueRefineDialog( { - locale, + localization: locale, query, term, campus, @@ -218,7 +218,7 @@ const catalogueControls = ( h.Class(isDialog ? 'grid gap-4' : catalogueControlsFrameClass), h.Role('search'), h.OnSubmit(SubmittedSearch()), - h.AriaLabel(translate(model.locale, 'catalogue.searchRegion')), + h.AriaLabel(translate(model.localization, 'catalogue.searchRegion')), ], [ h.div( @@ -228,7 +228,7 @@ const catalogueControls = ( { id: `${idPrefix}course-query`, value: model.query, - placeholder: translate(model.locale, 'catalogue.searchPlaceholder'), + placeholder: translate(model.localization, 'catalogue.searchPlaceholder'), onInput: (value) => UpdatedQuery({ value }), toView: (attributes) => h.div( @@ -236,11 +236,11 @@ const catalogueControls = ( [ h.label( [...attributes.label, h.Class(fieldLabelClass)], - [translate(model.locale, 'catalogue.searchLabel')], + [translate(model.localization, 'catalogue.searchLabel')], ), h.input([ ...attributes.input, - h.Placeholder(translate(model.locale, 'catalogue.searchPlaceholder')), + h.Placeholder(translate(model.localization, 'catalogue.searchPlaceholder')), h.Class( 'w-full min-h-14 px-4 border border-outline rounded-m3-medium outline-0 bg-surface-container-low text-on-surface text-base normal-case transition-[border-color,box-shadow] duration-150 ease-in-out focus-visible:border-primary focus-visible:shadow-[0_0_0_3px_var(--md-sys-color-primary-container)] disabled:opacity-70', ), @@ -260,8 +260,8 @@ const catalogueControls = ( [...attributes.button, h.Class(buttonPrimary)], [ loading - ? translate(model.locale, 'catalogue.searching') - : translate(model.locale, 'catalogue.search'), + ? translate(model.localization, 'catalogue.searching') + : translate(model.localization, 'catalogue.search'), ], ), }, @@ -282,27 +282,27 @@ const catalogueControls = ( selectControl( model.selectFields, 'term-refine', - translate(model.locale, 'catalogue.term'), + translate(model.localization, 'catalogue.term'), model.term, [ [ '2026-autumn', - formatOfferingPeriod(2026, 'autumn', model.locale), + formatOfferingPeriod(2026, 'autumn', model.localization), termSeasonIconName('autumn'), ], [ '2026-spring', - formatOfferingPeriod(2026, 'spring', model.locale), + formatOfferingPeriod(2026, 'spring', model.localization), termSeasonIconName('spring'), ], [ '2027-autumn', - formatOfferingPeriod(2027, 'autumn', model.locale), + formatOfferingPeriod(2027, 'autumn', model.localization), termSeasonIconName('autumn'), ], [ '2027-spring', - formatOfferingPeriod(2027, 'spring', model.locale), + formatOfferingPeriod(2027, 'spring', model.localization), termSeasonIconName('spring'), ], ], @@ -312,13 +312,13 @@ const catalogueControls = ( selectControl( model.selectFields, 'campus-refine', - translate(model.locale, 'catalogue.campus'), + translate(model.localization, 'catalogue.campus'), model.campus, [ - ['all', translate(model.locale, 'catalogue.allCampuses')], - ['trondheim', translate(model.locale, 'catalogue.trondheim')], - ['gjovik', translate(model.locale, 'catalogue.gjovik')], - ['alesund', translate(model.locale, 'catalogue.alesund')], + ['all', translate(model.localization, 'catalogue.allCampuses')], + ['trondheim', translate(model.localization, 'catalogue.trondheim')], + ['gjovik', translate(model.localization, 'catalogue.gjovik')], + ['alesund', translate(model.localization, 'catalogue.alesund')], ], { portal: false }, h, @@ -326,13 +326,13 @@ const catalogueControls = ( selectControl( model.selectFields, 'level-refine', - translate(model.locale, 'catalogue.level'), + translate(model.localization, 'catalogue.level'), model.level, [ - ['all', translate(model.locale, 'catalogue.allLevels')], - ['bachelor', translate(model.locale, 'catalogue.bachelor')], - ['master', translate(model.locale, 'catalogue.master')], - ['phd', translate(model.locale, 'catalogue.phd')], + ['all', translate(model.localization, 'catalogue.allLevels')], + ['bachelor', translate(model.localization, 'catalogue.bachelor')], + ['master', translate(model.localization, 'catalogue.master')], + ['phd', translate(model.localization, 'catalogue.phd')], ], { portal: false }, h, @@ -340,14 +340,14 @@ const catalogueControls = ( selectControl( model.selectFields, 'sort-refine', - translate(model.locale, 'catalogue.sort'), + translate(model.localization, 'catalogue.sort'), model.sort, [ - ['relevance', translate(model.locale, 'catalogue.relevance')], - ['title-asc', translate(model.locale, 'catalogue.titleAsc')], - ['title-desc', translate(model.locale, 'catalogue.titleDesc')], - ['code-asc', translate(model.locale, 'catalogue.codeAsc')], - ['code-desc', translate(model.locale, 'catalogue.codeDesc')], + ['relevance', translate(model.localization, 'catalogue.relevance')], + ['title-asc', translate(model.localization, 'catalogue.titleAsc')], + ['title-desc', translate(model.localization, 'catalogue.titleDesc')], + ['code-asc', translate(model.localization, 'catalogue.codeAsc')], + ['code-desc', translate(model.localization, 'catalogue.codeDesc')], ], { portal: false }, h, @@ -357,13 +357,13 @@ const catalogueControls = ( selectControl( model.selectFields, 'campus-inline', - translate(model.locale, 'catalogue.campus'), + translate(model.localization, 'catalogue.campus'), model.campus, [ - ['all', translate(model.locale, 'catalogue.allCampuses')], - ['trondheim', translate(model.locale, 'catalogue.trondheim')], - ['gjovik', translate(model.locale, 'catalogue.gjovik')], - ['alesund', translate(model.locale, 'catalogue.alesund')], + ['all', translate(model.localization, 'catalogue.allCampuses')], + ['trondheim', translate(model.localization, 'catalogue.trondheim')], + ['gjovik', translate(model.localization, 'catalogue.gjovik')], + ['alesund', translate(model.localization, 'catalogue.alesund')], ], {}, h, @@ -376,14 +376,14 @@ const catalogueControls = ( [ checkboxControl( `${idPrefix}open-admission`, - translate(model.locale, 'catalogue.openAdmission'), + translate(model.localization, 'catalogue.openAdmission'), model.openOnly, (isChecked) => ToggledOpen({ isChecked }), h, ), checkboxControl( `${idPrefix}english`, - translate(model.locale, 'catalogue.english'), + translate(model.localization, 'catalogue.english'), model.englishOnly, (isChecked) => ToggledEnglish({ isChecked }), h, @@ -426,10 +426,10 @@ export const catalogueRefineAction = (model: Model, h: HtmlBuilder): Ht [h.Class('[@media(min-width:48rem)_and_(min-height:34rem)]:font-bold')], [ count === 0 - ? translate(model.locale, 'catalogue.allCourses') - : translate(model.locale, 'catalogue.activeRefinements', { + ? translate(model.localization, 'catalogue.allCourses') + : translate(model.localization, 'catalogue.activeRefinements', { count, - suffix: model.locale === 'en' && count !== 1 ? 's' : '', + suffix: model.localization.locale === 'en' && count !== 1 ? 's' : '', }), ], ), @@ -439,7 +439,7 @@ export const catalogueRefineAction = (model: Model, h: HtmlBuilder): Ht '[@media(min-width:48rem)_and_(min-height:34rem)]:overflow-hidden [@media(min-width:48rem)_and_(min-height:34rem)]:text-on-surface-variant [@media(min-width:48rem)_and_(min-height:34rem)]:text-sm [@media(min-width:48rem)_and_(min-height:34rem)]:text-ellipsis [@media(min-width:48rem)_and_(min-height:34rem)]:whitespace-nowrap', ), ], - [translate(model.locale, 'catalogue.refineHelp')], + [translate(model.localization, 'catalogue.refineHelp')], ), ], ), @@ -457,8 +457,8 @@ export const catalogueRefineAction = (model: Model, h: HtmlBuilder): Ht [], [ count === 0 - ? translate(model.locale, 'catalogue.refine') - : translate(model.locale, 'catalogue.refineCount', { count }), + ? translate(model.localization, 'catalogue.refine') + : translate(model.localization, 'catalogue.refineCount', { count }), ], ), ], @@ -512,20 +512,23 @@ const catalogueRefineDialog = ( h.div( [], [ - h.p([h.Class(eyebrowClass)], [translate(model.locale, 'nav.explore')]), + h.p( + [h.Class(eyebrowClass)], + [translate(model.localization, 'nav.explore')], + ), h.h2( [ ...title, h.Class('text-[clamp(1.6rem,6vw,2.25rem)] tracking-[-0.035em]'), ], - [translate(model.locale, 'catalogue.refineHeading')], + [translate(model.localization, 'catalogue.refineHeading')], ), h.p( [ ...description, h.Class('mt-[0.4rem] text-on-surface-variant leading-[1.5]'), ], - [translate(model.locale, 'catalogue.refineDescription')], + [translate(model.localization, 'catalogue.refineDescription')], ), ], ), @@ -538,7 +541,9 @@ const catalogueRefineDialog = ( 'grid size-11 flex-none p-[0.7rem] place-items-center border-0 rounded-full bg-surface-container text-on-surface cursor-pointer', ), h.Type('button'), - h.AriaLabel(translate(model.locale, 'catalogue.closeRefinements')), + h.AriaLabel( + translate(model.localization, 'catalogue.closeRefinements'), + ), ], [icon('close', undefined, h)], ), @@ -561,7 +566,7 @@ const catalogueRefineDialog = ( h.Class(`${buttonPrimary} min-w-[min(100%,12rem)]`), h.Type('button'), ], - [translate(model.locale, 'catalogue.viewResults')], + [translate(model.localization, 'catalogue.viewResults')], ), ], ), @@ -582,26 +587,35 @@ const catalogueResultView = (model: Model, h: HtmlBuilder): Html => { [h.Class(stateCardBase), h.Role('status'), h.AriaLive('polite')], [ h.div([h.Class(loadingIndicatorClass), h.AriaHidden(true)], []), - h.h2([h.Class(stateCardH2Class)], [translate(model.locale, 'catalogue.loading')]), - h.p([h.Class(stateCardPClass)], [translate(model.locale, 'catalogue.loadingHelp')]), + h.h2([h.Class(stateCardH2Class)], [translate(model.localization, 'catalogue.loading')]), + h.p([h.Class(stateCardPClass)], [translate(model.localization, 'catalogue.loadingHelp')]), ], ); case 'CatalogueFailure': return h.section( [h.Class(stateCardFailure), h.Role('alert')], [ - h.p([h.Class(statusLabelErrorClass)], [translate(model.locale, 'catalogue.unavailable')]), - h.h2([h.Class(stateCardH2Class)], [translate(model.locale, 'catalogue.loadFailed')]), + h.p( + [h.Class(statusLabelErrorClass)], + [translate(model.localization, 'catalogue.unavailable')], + ), + h.h2( + [h.Class(stateCardH2Class)], + [translate(model.localization, 'catalogue.loadFailed')], + ), h.p([h.Class(stateCardFailurePClass)], [model.catalogue.error]), - h.p([h.Class(stateCardFailurePClass)], [translate(model.locale, 'catalogue.retry')]), + h.p( + [h.Class(stateCardFailurePClass)], + [translate(model.localization, 'catalogue.retry')], + ), ], ); case 'CatalogueEmpty': return h.section( [h.Class(stateCardBase), h.Role('status')], [ - h.h2([h.Class(stateCardH2Class)], [translate(model.locale, 'catalogue.empty')]), - h.p([h.Class(stateCardPClass)], [translate(model.locale, 'catalogue.emptyHelp')]), + h.h2([h.Class(stateCardH2Class)], [translate(model.localization, 'catalogue.empty')]), + h.p([h.Class(stateCardPClass)], [translate(model.localization, 'catalogue.emptyHelp')]), ], ); case 'CataloguePartial': @@ -623,7 +637,7 @@ const catalogueList = ( return h.section( [ h.Class('grid gap-4'), - h.AriaLabel(translate(model.locale, 'catalogue.results')), + h.AriaLabel(translate(model.localization, 'catalogue.results')), h.AriaBusy(model.nextPage._tag === 'NextPageLoading'), ], [ @@ -633,7 +647,7 @@ const catalogueList = ( h.Class('py-4 px-5 rounded-m3-medium bg-warning-container text-on-warning-container'), h.Role('status'), ], - [translate(model.locale, 'catalogue.partial')], + [translate(model.localization, 'catalogue.partial')], ) : h.empty, h.header( @@ -646,11 +660,11 @@ const catalogueList = ( h.div( [], [ - h.h2([], [translate(model.locale, 'catalogue.courses')]), + h.h2([], [translate(model.localization, 'catalogue.courses')]), h.p( [h.AriaLive('polite'), h.Class('text-on-surface-variant text-sm')], [ - translate(model.locale, 'catalogue.showing', { + translate(model.localization, 'catalogue.showing', { shown: shown.length, total: response.meta.total, }), @@ -660,7 +674,7 @@ const catalogueList = ( ), h.p( [h.Class('text-on-surface-variant text-sm')], - [translate(model.locale, 'catalogue.official')], + [translate(model.localization, 'catalogue.official')], ), ], ), @@ -672,7 +686,7 @@ const catalogueList = ( course, decisionSignalForCourse(model.decisionSignals, course.code), gradeSignalForCourse(model.gradeSignals, course.code), - model.locale, + model.localization, model.outcomeView, isCourseSaved(model.savedCourses, course.code), savedToggleAvailability(model.savedCourses), @@ -690,7 +704,7 @@ const catalogueList = ( h.Role('alert'), ], [ - h.strong([], [translate(model.locale, 'catalogue.moreFailed')]), + h.strong([], [translate(model.localization, 'catalogue.moreFailed')]), h.span([], [` ${model.nextPage.error}`]), ], ) @@ -709,8 +723,8 @@ const catalogueList = ( ], [ model.nextPage._tag === 'NextPageLoading' - ? translate(model.locale, 'catalogue.loadingMore') - : translate(model.locale, 'catalogue.showMore'), + ? translate(model.localization, 'catalogue.loadingMore') + : translate(model.localization, 'catalogue.showMore'), ], ), }, @@ -718,7 +732,7 @@ const catalogueList = ( ) : h.p( [h.Class('m-0 text-on-surface-variant text-center')], - [translate(model.locale, 'catalogue.end')], + [translate(model.localization, 'catalogue.end')], ), ], ); @@ -756,7 +770,7 @@ export const savedCourseToggle = ( courseCode: string, saved: boolean, availability: 'ready' | 'loading' | 'paused', - locale: Locale, + locale: Localization, recoveryHref: string, tone: SavedToggleTone, h: HtmlBuilder, @@ -877,7 +891,7 @@ export const savedGradeSignal = ( return typeof signal === 'string' ? null : signal; }; -export const courseTitle = (course: CourseSearchItemDtoType, locale: Locale): string => +export const courseTitle = (course: CourseSearchItemDtoType, locale: Localization): string => course.title.state === 'known' ? course.title.value : translate(locale, 'course.titleUnavailable'); @@ -885,7 +899,7 @@ export const courseTitle = (course: CourseSearchItemDtoType, locale: Locale): st export const courseIdentityFacts = ( course: CourseSearchItemDtoType, decisionSignal: DecisionSignal, - locale: Locale, + locale: Localization, h: HtmlBuilder, ): Html => { const { offering, place, term, credits } = courseOfferingFacts(course, decisionSignal, locale); @@ -934,7 +948,7 @@ const courseCard = ( course: CourseSearchItemDtoType, decisionSignal: DecisionSignal, gradeSignal: GradeSignal, - locale: Locale, + locale: Localization, outcomeView: OutcomeView, saved: boolean, savedAvailability: 'ready' | 'loading' | 'paused', @@ -1032,12 +1046,12 @@ const assessmentIconName = (form: AssessmentForm): AppIcon => M.exhaustive, ); -const formatAssessmentWeight = (value: number, locale: Locale): string => - `${new Intl.NumberFormat(localeTag(locale), { maximumFractionDigits: 2 }).format(value)}%`; +const formatAssessmentWeight = (value: number, locale: Localization): string => + `${new Intl.NumberFormat(localeTag(locale.locale), { maximumFractionDigits: 2 }).format(value)}%`; export const decisionSignalView = ( signal: DecisionSignal, - locale: Locale, + locale: Localization, h: HtmlBuilder, ): Html => { const stateClass = @@ -1263,7 +1277,7 @@ const outcomeStateClass = export const gradeSignalView = ( signal: GradeSignal, - locale: Locale, + locale: Localization, outcomeView: OutcomeView, h: HtmlBuilder, ): Html => { @@ -1287,7 +1301,7 @@ export const gradeSignalView = ( const gradeOrder = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'] as const; -const gradeDisplayLabel = (grade: string, locale: Locale): string => +const gradeDisplayLabel = (grade: string, locale: Localization): string => M.value(grade).pipe( M.when('G', () => translate(locale, 'outcomes.pass')), M.when('H', () => translate(locale, 'outcomes.fail')), @@ -1296,7 +1310,7 @@ const gradeDisplayLabel = (grade: string, locale: Locale): string => const distributionStateMessage = ( distribution: CourseGradeSummaryDtoType['distribution'], - locale: Locale, + locale: Localization, ): string => { switch (distribution.state) { case 'known': @@ -1331,7 +1345,7 @@ const normalizeGradeBuckets = (buckets: ReadonlyArray): ReadonlyArr const gradeSummaryView = ( summary: CourseGradeSummaryDtoType, - locale: Locale, + locale: Localization, requestedView: OutcomeView, h: HtmlBuilder, ): Html => { @@ -1352,7 +1366,7 @@ const gradeSummaryView = ( const sample = summary.sampleSize.state === 'known' ? translate(locale, 'outcomes.sample', { - value: summary.sampleSize.value.toLocaleString(localeTag(locale)), + value: summary.sampleSize.value.toLocaleString(localeTag(locale.locale)), }) : null; const failure = @@ -1411,7 +1425,7 @@ const gradeSummaryView = ( const sample = selectedSampleSize > 0 ? translate(locale, 'outcomes.sample', { - value: selectedSampleSize.toLocaleString(localeTag(locale)), + value: selectedSampleSize.toLocaleString(localeTag(locale.locale)), }) : null; const metadata = [failure, sample, period].filter((value): value is string => value !== null); @@ -1631,7 +1645,7 @@ export const selectedCourseView = (model: Model, h: HtmlBuilder): Html toView: (attributes) => h.button( [...attributes.button, h.Class(backButtonClass)], - [translate(model.locale, 'course.back')], + [translate(model.localization, 'course.back')], ), }, h, @@ -1642,7 +1656,7 @@ export const selectedCourseView = (model: Model, h: HtmlBuilder): Html selectedCode, isCourseSaved(model.savedCourses, selectedCode), savedToggleAvailability(model.savedCourses), - model.locale, + model.localization, listUrl(model), 'state', h, @@ -1650,14 +1664,18 @@ export const selectedCourseView = (model: Model, h: HtmlBuilder): Html ], ), model.detail._tag === 'DetailSuccess' || model.detail._tag === 'DetailPartial' - ? feedbackRow(model.locale, h) + ? feedbackRow(model.localization, h) : h.empty, - detailResultView(model.detail, model.locale, h), + detailResultView(model.detail, model.localization, h), ], ); }; -const detailResultView = (detail: DetailResult, locale: Locale, h: HtmlBuilder): Html => { +const detailResultView = ( + detail: DetailResult, + locale: Localization, + h: HtmlBuilder, +): Html => { switch (detail._tag) { case 'DetailClosed': return h.empty; diff --git a/apps/student-web/src/features/progress/editor.ts b/apps/student-web/src/features/progress/editor.ts index 78b45f6..00cdbc2 100644 --- a/apps/student-web/src/features/progress/editor.ts +++ b/apps/student-web/src/features/progress/editor.ts @@ -13,7 +13,7 @@ import { controlGroupClass, fieldLabelClass, } from '../../app-styles'; -import type { Locale } from '../../i18n'; +import type { Localization } from '../../i18n'; import { CourseDraftFieldsSchema, CourseResultSchema, @@ -248,7 +248,7 @@ export interface Labels { } export interface ViewInputs { - readonly locale: Locale; + readonly locale: Localization; readonly labels: Labels; } @@ -409,7 +409,7 @@ export const view = defineView((model, { locale, lab const saveLabel = isEditing ? labels.saveChanges : labels.addCourse; return h.section( - [h.Class(editorPanelClass), h.Lang(locale), h.AriaLabel(heading)], + [h.Class(editorPanelClass), h.Lang(locale.locale), h.AriaLabel(heading)], [ h.header( [], diff --git a/apps/student-web/src/features/progress/index.ts b/apps/student-web/src/features/progress/index.ts index 5d217f9..a867229 100644 --- a/apps/student-web/src/features/progress/index.ts +++ b/apps/student-web/src/features/progress/index.ts @@ -20,7 +20,7 @@ import { stateCardFailurePClass, stateCardH2Class, } from '../../app-styles'; -import { localeTag, translate, type Locale } from '../../i18n'; +import { localeTag, translate, type Locale, type Localization } from '../../i18n'; import { CourseDraftFieldsSchema, CourseResultSchema, @@ -57,7 +57,6 @@ import { type TranscriptVocabulary, } from './domain'; import * as Editor from './editor'; -import { openTranscriptPreview, parseTranscriptPreview, releaseTranscriptPreview } from './pdf'; const RetakePolicySchema = S.Literals(['latest', 'best']); const DraftTextFieldSchema = S.Literals([ @@ -360,12 +359,19 @@ export const PersistProgress = Command.define('PersistProgress', { ), }); +/** + * A static PDF import would retain the parser in the initial app chunk. Each + * command imports the complete browser-only adapter at the boundary instead. + */ const OpenTranscriptPreview = Command.define('OpenTranscriptPreview', { args: { file: S.File, requestId: S.Number }, messages: [Message.OpenedTranscriptPreview, Message.FailedTranscriptPreview], execute: ({ file, requestId }) => - Effect.try({ - try: () => openTranscriptPreview(file), + Effect.tryPromise({ + try: async () => { + const { openTranscriptPreview } = await import('./pdf'); + return openTranscriptPreview(file); + }, catch: () => undefined, }).pipe( Effect.map(({ fileName, objectUrl }) => @@ -387,8 +393,10 @@ const ParseTranscriptPreview = Command.define('ParseTranscriptPreview', { messages: [Message.ParsedTranscript, Message.FailedTranscriptParse], execute: ({ objectUrl, fileName, requestId, vocabulary }) => Effect.tryPromise({ - try: (signal) => - parseTranscriptPreview(objectUrl, signal, validateTranscriptVocabulary(vocabulary)), + try: async (signal) => { + const { parseTranscriptPreview } = await import('./pdf'); + return parseTranscriptPreview(objectUrl, signal, validateTranscriptVocabulary(vocabulary)); + }, catch: () => undefined, }).pipe( Effect.map((proposal) => Message.ParsedTranscript({ requestId, fileName, proposal })), @@ -400,7 +408,20 @@ const ReleaseTranscriptPreview = Command.define('ReleaseTranscriptPreview', { args: { objectUrl: S.String }, messages: [Message.ReleasedTranscriptPreview], execute: ({ objectUrl }) => - Effect.sync(() => releaseTranscriptPreview(objectUrl)).pipe( + Effect.tryPromise({ + try: async () => { + const { releaseTranscriptPreview } = await import('./pdf'); + releaseTranscriptPreview(objectUrl); + }, + catch: () => new Error('Transcript preview could not be released'), + }).pipe( + Effect.catch(() => + Effect.sync(() => { + if (typeof URL !== 'undefined' && typeof URL.revokeObjectURL === 'function') { + URL.revokeObjectURL(objectUrl); + } + }), + ), Effect.as(Message.ReleasedTranscriptPreview({ objectUrl })), ), }); @@ -1243,12 +1264,13 @@ const numberFormatters: Readonly> = { nb: new Intl.NumberFormat(localeTag('nb'), { maximumFractionDigits: 2 }), }; -const formatNumber = (value: number, locale: Locale): string => - numberFormatters[locale].format(value); +const formatNumber = (value: number, locale: Localization): string => + numberFormatters[locale.locale].format(value); -const formatCredits = (credits: number, locale: Locale): string => formatNumber(credits, locale); +const formatCredits = (credits: number, locale: Localization): string => + formatNumber(credits, locale); -const gradeLabel = (grade: Grade, locale: Locale): string => { +const gradeLabel = (grade: Grade, locale: Localization): string => { switch (grade) { case 'pass': return translate(locale, 'progress.gradePass'); @@ -1264,7 +1286,7 @@ const gradeLabel = (grade: Grade, locale: Locale): string => { const resultTitleId = (scope: 'history' | 'review', index: number): string => `progress-${scope}-result-${index}-title`; -const resultFacts = (result: CourseResult, locale: Locale, h: HtmlBuilder): Html => +const resultFacts = (result: CourseResult, locale: Localization, h: HtmlBuilder): Html => h.dl( [h.Class('grid grid-cols-2 gap-x-4 gap-y-3 m-0 @min-[28rem]:grid-cols-4')], [ @@ -1316,7 +1338,7 @@ const checkboxControl = ( describedBy: string, isChecked: boolean, onToggle: (isChecked: boolean) => Message, - locale: Locale, + locale: Localization, h: HtmlBuilder, ): Html => Checkbox.view( @@ -1344,7 +1366,7 @@ const checkboxControl = ( h, ); -const pageHeader = (locale: Locale, h: HtmlBuilder): Html => +const pageHeader = (locale: Localization, h: HtmlBuilder): Html => h.header( [], [ @@ -1409,7 +1431,7 @@ const actionButton = ( h, ); -const summaryView = (model: Model, locale: Locale, h: HtmlBuilder): Html => { +const summaryView = (model: Model, locale: Localization, h: HtmlBuilder): Html => { const state = model.session.current; const summary = calculateProgress(state.results, state.policy); const average = @@ -1525,7 +1547,7 @@ const policyButton = ( ); }; -const calculatorView = (model: Model, locale: Locale, h: HtmlBuilder): Html => { +const calculatorView = (model: Model, locale: Localization, h: HtmlBuilder): Html => { const state = model.session.current; return h.section( [h.Class(panelClass), h.AriaLabel(translate(locale, 'progress.calculatorHeading'))], @@ -1636,7 +1658,7 @@ const calculatorView = (model: Model, locale: Locale, h: HtmlBuilder): ); }; -const distributionView = (model: Model, locale: Locale, h: HtmlBuilder): Html => { +const distributionView = (model: Model, locale: Localization, h: HtmlBuilder): Html => { const rows = gradeCreditDistribution( model.session.current.results, model.session.current.policy, @@ -1691,7 +1713,7 @@ const distributionView = (model: Model, locale: Locale, h: HtmlBuilder) ); }; -const trendView = (model: Model, locale: Locale, h: HtmlBuilder): Html => { +const trendView = (model: Model, locale: Localization, h: HtmlBuilder): Html => { const points = cumulativeSemesterAverageSeries( model.session.current.results, model.session.current.policy, @@ -1754,7 +1776,7 @@ const trendView = (model: Model, locale: Locale, h: HtmlBuilder): Html const importFailureMessage = ( reason: typeof ImportFailureReasonSchema.Type, - locale: Locale, + locale: Localization, ): string => { switch (reason) { case 'non-file': @@ -1770,7 +1792,7 @@ const importFailureMessage = ( } }; -const fileDropView = (model: Model, locale: Locale, h: HtmlBuilder): Html => { +const fileDropView = (model: Model, locale: Localization, h: HtmlBuilder): Html => { const retrying = model.importState._tag === 'ImportFailure'; const helpId = 'progress-transcript-file-help'; return h.submodel({ @@ -1840,7 +1862,7 @@ const vocabularyField = ( ], ); -const vocabularyView = (model: Model, locale: Locale, h: HtmlBuilder): Html => +const vocabularyView = (model: Model, locale: Localization, h: HtmlBuilder): Html => h.details( [h.Class('rounded-m3-large border border-outline-variant bg-surface-container p-4')], [ @@ -1890,7 +1912,7 @@ const vocabularyView = (model: Model, locale: Locale, h: HtmlBuilder): const pdfPreview = ( objectUrl: string, fileName: string, - locale: Locale, + locale: Localization, h: HtmlBuilder, ): Html => h.div( @@ -1913,7 +1935,11 @@ const pdfPreview = ( ], ); -const previewView = (previewing: ImportPreview, locale: Locale, h: HtmlBuilder): Html => +const previewView = ( + previewing: ImportPreview, + locale: Localization, + h: HtmlBuilder, +): Html => h.div( [h.Class('grid gap-4')], [ @@ -1997,7 +2023,7 @@ const importDraftField = ( ); }; -const draftLabels = (field: DraftTextField, locale: Locale): string => { +const draftLabels = (field: DraftTextField, locale: Localization): string => { switch (field) { case 'institution': return translate(locale, 'progress.fieldInstitution'); @@ -2016,7 +2042,7 @@ const draftLabels = (field: DraftTextField, locale: Locale): string => { } }; -const differenceLabel = (disposition: string, locale: Locale): string => { +const differenceLabel = (disposition: string, locale: Localization): string => { switch (disposition) { case 'new': return translate(locale, 'progress.reviewNew'); @@ -2036,7 +2062,7 @@ const reviewRow = ( index: number, disposition: string | null, reviewing: ImportReview, - locale: Locale, + locale: Localization, h: HtmlBuilder, ): Html => { const validation = validateCourseDraft(row.fields); @@ -2111,7 +2137,7 @@ const reviewRow = ( const reviewView = ( reviewing: ImportReview, state: ProgressState, - locale: Locale, + locale: Localization, h: HtmlBuilder, ): Html => { const selected = selectedDraftResults(reviewing); @@ -2292,7 +2318,7 @@ const reviewView = ( ); }; -const importView = (model: Model, locale: Locale, h: HtmlBuilder): Html => { +const importView = (model: Model, locale: Localization, h: HtmlBuilder): Html => { const state = model.importState; return h.section( [h.Class(panelClass), h.AriaLabel(translate(locale, 'progress.importHeading'))], @@ -2362,7 +2388,7 @@ const importView = (model: Model, locale: Locale, h: HtmlBuilder): Html ); }; -const editorLabels = (locale: Locale): Editor.Labels => ({ +const editorLabels = (locale: Localization): Editor.Labels => ({ addHeading: translate(locale, 'progress.addCourse'), editHeading: translate(locale, 'progress.editResult'), description: translate(locale, 'progress.editorDescription'), @@ -2389,7 +2415,7 @@ const editorLabels = (locale: Locale): Editor.Labels => ({ const historyRow = ( result: CourseResult, - locale: Locale, + locale: Localization, courseUrl: (courseCode: string) => string, h: HtmlBuilder, ): Html => { @@ -2453,17 +2479,17 @@ const historyRow = ( const historyView = ( model: Model, - locale: Locale, + locale: Localization, courseUrl: (courseCode: string) => string, h: HtmlBuilder, ): Html => { const state = model.session.current; - const query = model.search.trim().toLocaleLowerCase(localeTag(locale)); + const query = model.search.trim().toLocaleLowerCase(localeTag(locale.locale)); const filtered = state.results.filter((result) => query.length === 0 ? true : `${result.code} ${result.name} ${result.institution}` - .toLocaleLowerCase(localeTag(locale)) + .toLocaleLowerCase(localeTag(locale.locale)) .includes(query), ); const partitioned = partitionAttempts(filtered, state.policy); @@ -2579,7 +2605,7 @@ const historyView = ( ); }; -const receiptsView = (model: Model, locale: Locale, h: HtmlBuilder): Html => { +const receiptsView = (model: Model, locale: Localization, h: HtmlBuilder): Html => { const rows = []; let sequence = 0; for (const receipt of model.session.current.importReceipts) { @@ -2627,7 +2653,7 @@ const receiptsView = (model: Model, locale: Locale, h: HtmlBuilder): Ht const timelineKindLabel = ( kind: ProgressSession['timeline'][number]['kind'], - locale: Locale, + locale: Localization, ): string => { switch (kind) { case 'import': @@ -2643,7 +2669,7 @@ const timelineKindLabel = ( } }; -const timelineView = (model: Model, locale: Locale, h: HtmlBuilder): Html => { +const timelineView = (model: Model, locale: Localization, h: HtmlBuilder): Html => { const session = model.session; const rows = []; let cursor = 0; @@ -2741,7 +2767,7 @@ const timelineView = (model: Model, locale: Locale, h: HtmlBuilder): Ht ); }; -const calculationView = (model: Model, locale: Locale, h: HtmlBuilder): Html => { +const calculationView = (model: Model, locale: Localization, h: HtmlBuilder): Html => { const summary = calculateProgress(model.session.current.results, model.session.current.policy); const average = summary.average === null @@ -2800,7 +2826,7 @@ const calculationView = (model: Model, locale: Locale, h: HtmlBuilder): ); }; -const dataActionsView = (model: Model, locale: Locale, h: HtmlBuilder): Html => +const dataActionsView = (model: Model, locale: Localization, h: HtmlBuilder): Html => h.section( [h.Class(panelClass), h.AriaLabel(translate(locale, 'progress.dataHeading'))], [ @@ -2839,7 +2865,7 @@ const dataActionsView = (model: Model, locale: Locale, h: HtmlBuilder): ], ); -const statusView = (model: Model, locale: Locale, h: HtmlBuilder): Html => { +const statusView = (model: Model, locale: Localization, h: HtmlBuilder): Html => { if (model.persistence._tag === 'PersistenceSaving') { return h.p( [h.Class('m-0 text-sm text-on-surface-variant'), h.Role('status'), h.AriaLive('polite')], @@ -2890,7 +2916,7 @@ const statusView = (model: Model, locale: Locale, h: HtmlBuilder): Html ); }; -const confirmationView = (model: Model, locale: Locale, h: HtmlBuilder): Html => { +const confirmationView = (model: Model, locale: Localization, h: HtmlBuilder): Html => { if (model.confirmation === 'none') return h.empty; const destructive = model.confirmation === 'clear'; const prompt = @@ -2937,7 +2963,7 @@ const confirmationView = (model: Model, locale: Locale, h: HtmlBuilder) ); }; -const loadingView = (locale: Locale, h: HtmlBuilder): Html => +const loadingView = (locale: Localization, h: HtmlBuilder): Html => h.section( [h.Class(stateCardBase), h.Role('status'), h.AriaLive('polite')], [ @@ -2948,7 +2974,7 @@ const loadingView = (locale: Locale, h: HtmlBuilder): Html => const recoveryView = ( loadState: Exclude, - locale: Locale, + locale: Localization, h: HtmlBuilder, ): Html => { const unavailable = loadState._tag === 'ProgressUnavailable'; @@ -2993,7 +3019,7 @@ const recoveryView = ( }; export interface ViewInputs { - readonly locale: Locale; + readonly locale: Localization; readonly courseUrl: (courseCode: string) => string; } diff --git a/apps/student-web/src/features/saved/index.ts b/apps/student-web/src/features/saved/index.ts index dbb782e..5c19898 100644 --- a/apps/student-web/src/features/saved/index.ts +++ b/apps/student-web/src/features/saved/index.ts @@ -69,7 +69,7 @@ import { stateCardH2Class, stateCardPClass, } from '../../app-styles'; -import { localeTag, translate, type Locale } from '../../i18n'; +import { localeTag, translate, type Localization } from '../../i18n'; import { courseOfferingFacts } from '../../course-facts'; import { icon } from '../../icons'; import { @@ -136,7 +136,7 @@ export const savedCoursesPersistenceAlert = (model: Model, h: HtmlBuilder): Html => { const removed = notice._tag === 'SavedActionRemoved' ? notice.courses : []; const message = notice._tag === 'SavedActionSaved' - ? translate(model.locale, 'list.savedStatus', { code: notice.courseCode }) + ? translate(model.localization, 'list.savedStatus', { code: notice.courseCode }) : single && removed[0] !== undefined - ? translate(model.locale, 'list.removedStatus', { code: removed[0].courseCode }) - : translate(model.locale, 'list.removedManyStatus', { count: removed.length }); + ? translate(model.localization, 'list.removedStatus', { code: removed[0].courseCode }) + : translate(model.localization, 'list.removedManyStatus', { count: removed.length }); const undoLabel = notice._tag === 'SavedActionSaved' - ? translate(model.locale, 'list.undoSave', { code: notice.courseCode }) + ? translate(model.localization, 'list.undoSave', { code: notice.courseCode }) : single && removed[0] !== undefined - ? translate(model.locale, 'list.undoRemove', { code: removed[0].courseCode }) - : translate(model.locale, 'list.undoRemoveMany', { count: removed.length }); + ? translate(model.localization, 'list.undoRemove', { code: removed[0].courseCode }) + : translate(model.localization, 'list.undoRemoveMany', { count: removed.length }); return h.div( [ @@ -188,7 +188,7 @@ const savedListActionStatus = (model: Model, h: HtmlBuilder): Html => { toView: (attributes) => h.button( [...attributes.button, h.Class(buttonClass), h.AriaLabel(undoLabel)], - [translate(model.locale, 'list.undo')], + [translate(model.localization, 'list.undo')], ), }, h, @@ -202,7 +202,7 @@ const savedListActionStatus = (model: Model, h: HtmlBuilder): Html => { toView: (attributes) => h.button( [...attributes.button, h.Class(buttonClass)], - [translate(model.locale, 'list.dismissStatus')], + [translate(model.localization, 'list.dismissStatus')], ), }, h, @@ -235,7 +235,11 @@ const savedListActionStatus = (model: Model, h: HtmlBuilder): Html => { toView: (attributes) => h.button( [...attributes.button, h.Class(`${buttonClass} min-h-11`)], - [translate(model.locale, 'list.dismissAllStatus', { count: notices.length })], + [ + translate(model.localization, 'list.dismissAllStatus', { + count: notices.length, + }), + ], ), }, h, @@ -246,7 +250,7 @@ const savedListActionStatus = (model: Model, h: HtmlBuilder): Html => { ); }; -const listHeader = (locale: Locale, h: HtmlBuilder): Html => { +const listHeader = (locale: Localization, h: HtmlBuilder): Html => { return h.header( [h.Class('pt-[clamp(1.5rem,4vw,3rem)] pb-2 grid gap-4')], [ @@ -297,7 +301,7 @@ const labelDotTone = (color: LabelColor): string => M.exhaustive, ); -const labelColorName = (color: LabelColor, locale: Locale): string => +const labelColorName = (color: LabelColor, locale: Localization): string => M.value(color).pipe( M.when('violet', () => translate(locale, 'label.colorViolet')), M.when('amber', () => translate(locale, 'label.colorAmber')), @@ -331,7 +335,7 @@ const labelDot = (color: LabelColor, h: HtmlBuilder): Html => { /** The count is visible as a number and named for assistive technology, so the * chip never depends on the digit alone to explain itself. */ -const labelCountBadge = (count: number, locale: Locale, h: HtmlBuilder): Html => { +const labelCountBadge = (count: number, locale: Localization, h: HtmlBuilder): Html => { return h.span( [h.Class('inline-flex items-center gap-1')], [ @@ -384,7 +388,7 @@ const noteFieldClass = const systemBadgeClass = 'inline-flex min-h-7 items-center rounded-full border border-primary/40 bg-primary-container px-2.5 text-xs font-extrabold text-on-primary-container'; -const resultGradeLabel = (grade: string, locale: Locale): string => { +const resultGradeLabel = (grade: string, locale: Localization): string => { switch (grade) { case 'pass': return translate(locale, 'progress.gradePass'); @@ -397,20 +401,24 @@ const resultGradeLabel = (grade: string, locale: Locale): string => { } }; -const resultSummary = (course: StudentCourse, locale: Locale): string | null => { +const resultSummary = (course: StudentCourse, locale: Localization): string | null => { const result = course.resultCourse?.latest; if (result === undefined) return null; return translate(locale, 'list.resultSummary', { grade: resultGradeLabel(result.grade, locale), term: translate(locale, result.term === 1 ? 'progress.termSpring' : 'progress.termAutumn'), year: result.year, - credits: new Intl.NumberFormat(localeTag(locale), { maximumFractionDigits: 2 }).format( + credits: new Intl.NumberFormat(localeTag(locale.locale), { maximumFractionDigits: 2 }).format( result.credits, ), }); }; -const resultEvidence = (course: StudentCourse, locale: Locale, h: HtmlBuilder): Html => { +const resultEvidence = ( + course: StudentCourse, + locale: Localization, + h: HtmlBuilder, +): Html => { const summary = resultSummary(course, locale); if (summary === null) return h.empty; return h.section( @@ -445,7 +453,7 @@ const savedCourseRow = ( gradeSignal: CourseGradeSummaryDtoType | null, noteDraft: string, isSelected: boolean, - locale: Locale, + locale: Localization, outcomeView: OutcomeView, density: ListDensity, h: HtmlBuilder, @@ -731,12 +739,16 @@ const savedCourseRow = ( ); }; -const courseCountLabel = (count: number, locale: Locale): string => +const courseCountLabel = (count: number, locale: Localization): string => count === 1 ? translate(locale, 'list.courseCountOne') : translate(locale, 'list.courseCountMany', { count }); -const originFilterLabel = (filter: CourseOriginFilter, count: number, locale: Locale): string => +const originFilterLabel = ( + filter: CourseOriginFilter, + count: number, + locale: Localization, +): string => translate( locale, filter === 'all' @@ -756,14 +768,14 @@ const originFilterView = ( [ h.Class('flex flex-wrap items-center gap-2'), h.Role('group'), - h.AriaLabel(translate(model.locale, 'list.originFilter')), + h.AriaLabel(translate(model.localization, 'list.originFilter')), ], courseOriginFilters.map((filter) => { const selected = model.courseOriginFilter === filter; const label = originFilterLabel( filter, filterStudentCoursesByOrigin(courses, filter).length, - model.locale, + model.localization, ); return Button.view( { @@ -792,9 +804,9 @@ const originFilterView = ( const nameList = ( names: ReadonlyArray, - locale: Locale, + locale: Localization, type: 'conjunction' | 'disjunction', -): string => new Intl.ListFormat(localeTag(locale), { style: 'long', type }).format(names); +): string => new Intl.ListFormat(localeTag(locale.locale), { style: 'long', type }).format(names); const labelsFromIds = ( state: SavedListState, @@ -812,7 +824,7 @@ const predicateNames = ( state: SavedListState, labelIds: ReadonlyArray, unlabeled: boolean, - locale: Locale, + locale: Localization, ): ReadonlyArray => [ ...labelsFromIds(state, labelIds).map((label) => label.name), ...(unlabeled ? [translate(locale, 'list.filterUnlabeled')] : []), @@ -823,7 +835,11 @@ const predicateNames = ( * conjunction and `Any` as a disjunction, so the sentence and the switch can * never disagree about what is being shown. */ -const labelFilterSummary = (state: SavedListState, filter: LabelFilter, locale: Locale): string => { +const labelFilterSummary = ( + state: SavedListState, + filter: LabelFilter, + locale: Localization, +): string => { const included = predicateNames(state, filter.includeLabelIds, filter.includeUnlabeled, locale); const excluded = predicateNames(state, filter.excludeLabelIds, filter.excludeUnlabeled, locale); const includedText = nameList( @@ -864,7 +880,7 @@ const excludeCheckbox = ( predicate: LabelPredicate, name: string, dot: Html, - locale: Locale, + locale: Localization, h: HtmlBuilder, ): Html => { return Checkbox.view( @@ -905,7 +921,7 @@ const excludeCheckbox = ( * single tap and the bounded composition is still reachable by keyboard. */ const labelFilterView = (model: Model, state: SavedListState, h: HtmlBuilder): Html => { - const locale = model.locale; + const locale = model.localization; const labels = labelsByName(state); if (labels.length === 0) { return h.section( @@ -1048,7 +1064,7 @@ const labelFilterView = (model: Model, state: SavedListState, h: HtmlBuilder, - locale: Locale, + locale: Localization, h: HtmlBuilder, ): Html => { const forSelection = courseCodes.length > 0; @@ -1321,7 +1337,7 @@ const selectionTrayView = ( h: HtmlBuilder, ): Html => { if (selected.length === 0) return h.empty; - const locale = model.locale; + const locale = model.localization; return h.div( [ h.Class(selectionTrayClass), @@ -1444,7 +1460,7 @@ const selectionTrayView = ( const recoveryMessage = ( recovery: Extract, - locale: Locale, + locale: Localization, ): string => M.value(recovery.reason).pipe( M.when('unavailable', () => translate(locale, 'list.recoveryUnavailable')), @@ -1464,7 +1480,7 @@ const recoveryMessage = ( */ const savedCoursesRecoveryView = ( recovery: Extract, - locale: Locale, + locale: Localization, h: HtmlBuilder, ): Html => { return h.section( @@ -1531,7 +1547,7 @@ const savedCoursesRecoveryView = ( */ const listDensityChoice = ( density: ListDensity, - locale: Locale, + locale: Localization, radioGroup: Model['listDensityRadioGroup'], h: HtmlBuilder, ): Html => @@ -1601,20 +1617,20 @@ const savedCourseListView = ( return h.section( [h.Class(stateCardBase), h.Role('status')], [ - h.h2([h.Class(stateCardH2Class)], [translate(model.locale, 'list.empty')]), - h.p([h.Class(stateCardPClass)], [translate(model.locale, 'list.emptyHelp')]), + h.h2([h.Class(stateCardH2Class)], [translate(model.localization, 'list.empty')]), + h.p([h.Class(stateCardPClass)], [translate(model.localization, 'list.emptyHelp')]), h.a( [ h.Href(exploreUrl(model)), h.Class(`${backButtonClass} mt-4 inline-flex items-center no-underline`), ], - [translate(model.locale, 'list.backToExplore')], + [translate(model.localization, 'list.backToExplore')], ), ], ); } return h.section( - [h.Class('grid gap-4'), h.AriaLabel(translate(model.locale, 'list.heading'))], + [h.Class('grid gap-4'), h.AriaLabel(translate(model.localization, 'list.heading'))], [ repaired === 0 ? h.empty @@ -1623,7 +1639,7 @@ const savedCourseListView = ( h.Class('py-4 px-5 rounded-m3-medium bg-warning-container text-on-warning-container'), h.Role('status'), ], - [translate(model.locale, 'list.repaired', { count: repaired })], + [translate(model.localization, 'list.repaired', { count: repaired })], ), (() => { const selection = compareSelection(state, model.compareCodes); @@ -1640,10 +1656,10 @@ const savedCourseListView = ( model: initCompare(model.compareCodes, model.compareDifferencesOnly), view: compareView, viewInputs: { - locale: model.locale, + locale: model.localization, courses, facts, - feedback: feedbackRow(model.locale, h), + feedback: feedbackRow(model.localization, h), }, toParentMessage: (message) => GotCompareMessage({ message }), }); @@ -1657,14 +1673,14 @@ const savedCourseListView = ( [h.AriaLive('polite'), h.Class('m-0 text-on-surface-variant text-sm')], [ filterActive - ? translate(model.locale, 'list.filteredCount', { + ? translate(model.localization, 'list.filteredCount', { shown: courses.length, total: total.length, }) - : courseCountLabel(total.length, model.locale), + : courseCountLabel(total.length, model.localization), ], ), - listDensityChoice(model.listDensity, model.locale, model.listDensityRadioGroup, h), + listDensityChoice(model.listDensity, model.localization, model.listDensityRadioGroup, h), ], ), // A collection that matches nothing is a filter outcome, never a failure @@ -1673,8 +1689,14 @@ const savedCourseListView = ( ? h.section( [h.Class(stateCardBase), h.Role('status')], [ - h.h2([h.Class(stateCardH2Class)], [translate(model.locale, 'list.filterEmpty')]), - h.p([h.Class(stateCardPClass)], [translate(model.locale, 'list.filterEmptyHelp')]), + h.h2( + [h.Class(stateCardH2Class)], + [translate(model.localization, 'list.filterEmpty')], + ), + h.p( + [h.Class(stateCardPClass)], + [translate(model.localization, 'list.filterEmptyHelp')], + ), Button.view( { type: 'button', @@ -1685,7 +1707,7 @@ const savedCourseListView = ( ...attributes.button, h.Class(`${compactButtonBase} ${buttonSecondary} mt-4`), ], - [translate(model.locale, 'list.filtersClear')], + [translate(model.localization, 'list.filtersClear')], ), }, h, @@ -1704,7 +1726,7 @@ const savedCourseListView = ( savedGradeSignal(model, course.courseCode), course.savedCourse === null ? '' : noteDraftFor(model, course.savedCourse), course.savedCourse !== null && selectedCodes.has(course.courseCode), - model.locale, + model.localization, model.outcomeView, model.listDensity, h, @@ -1718,12 +1740,12 @@ const savedCourseListView = ( const labelErrorMessage = (model: Model): string | null => { if (model.labelError === null) return null; return M.value(model.labelError).pipe( - M.when('empty-name', () => translate(model.locale, 'list.labelEmptyName')), - M.when('duplicate-name', () => translate(model.locale, 'list.labelDuplicate')), + M.when('empty-name', () => translate(model.localization, 'list.labelEmptyName')), + M.when('duplicate-name', () => translate(model.localization, 'list.labelDuplicate')), M.when('limit-reached', () => - translate(model.locale, 'list.labelLimit', { count: labelsMaxCount }), + translate(model.localization, 'list.labelLimit', { count: labelsMaxCount }), ), - M.when('unknown-label', () => translate(model.locale, 'list.labelUnknownError')), + M.when('unknown-label', () => translate(model.localization, 'list.labelUnknownError')), M.exhaustive, ); }; @@ -1738,7 +1760,7 @@ const labelErrorMessage = (model: Model): string | null => { * how many. */ export const labelDialogView = (model: Model, h: HtmlBuilder): Html => { - const locale = model.locale; + const locale = model.localization; const state = savedListState(model.savedCourses); const labels = state === null ? [] : labelsByName(state); const targets = state === null ? [] : labelTargetIdentities(state, model.labelDialogTarget); @@ -2146,12 +2168,12 @@ const savedCoursesResultView = (model: Model, h: HtmlBuilder): Html => [h.Class(stateCardBase), h.Role('status'), h.AriaLive('polite')], [ h.div([h.Class(loadingIndicatorClass), h.AriaHidden(true)], []), - h.h2([h.Class(stateCardH2Class)], [translate(model.locale, 'list.loading')]), - h.p([h.Class(stateCardPClass)], [translate(model.locale, 'list.loadingHelp')]), + h.h2([h.Class(stateCardH2Class)], [translate(model.localization, 'list.loading')]), + h.p([h.Class(stateCardPClass)], [translate(model.localization, 'list.loadingHelp')]), ], ); case 'SavedCoursesRecovery': - return savedCoursesRecoveryView(model.savedCourses, model.locale, h); + return savedCoursesRecoveryView(model.savedCourses, model.localization, h); case 'SavedCoursesReady': return savedCourseListView( model, @@ -2165,6 +2187,6 @@ const savedCoursesResultView = (model: Model, h: HtmlBuilder): Html => export const listView = (model: Model, h: HtmlBuilder): Html => { return h.div( [h.Class('grid gap-6')], - [lazyListHeader(listHeader, [model.locale, h]), savedCoursesResultView(model, h)], + [lazyListHeader(listHeader, [model.localization, h]), savedCoursesResultView(model, h)], ); }; diff --git a/apps/student-web/src/features/schedule/index.test.ts b/apps/student-web/src/features/schedule/index.test.ts new file mode 100644 index 0000000..3b00c36 --- /dev/null +++ b/apps/student-web/src/features/schedule/index.test.ts @@ -0,0 +1,220 @@ +import { expect, test } from 'vitest'; + +import { fixtureScheduleResponse } from '../../course-client.fixture'; +import { + Message, + init, + isKnownEmptyWeek, + scheduleMaximumCourses, + scheduleRequestKey, + syncFromUrl, + update, + weeksInIsoYear, +} from './index'; + +const commandNames = (commands: ReadonlyArray<{ readonly name: string }>): ReadonlyArray => + commands.map((command) => command.name); + +test('ISO-week controls do not offer an invalid week 53', () => { + expect(weeksInIsoYear(2025)).toBe(52); + expect(weeksInIsoYear(2026)).toBe(53); + + const atFinalWeek = update(init(52, ['TDT4136']), Message.RequestedNextWeek(), '2025-autumn'); + expect(atFinalWeek.model.week).toBe(52); + expect(atFinalWeek.commands ?? []).toEqual([]); +}); + +test('schedule responses cannot overwrite a newer week request', () => { + const newer = update(init(45, ['TDT4136']), Message.ChangedWeek({ value: '46' }), '2026-autumn'); + const stale = update( + newer.model, + Message.SucceededSchedule({ + requestKey: scheduleRequestKey('2026-autumn', 45, ['TDT4136']), + response: fixtureScheduleResponse(['TDT4136'], '2026-autumn', 45), + }), + '2026-autumn', + ); + + expect(stale.model).toBe(newer.model); + expect(stale.commands ?? []).toEqual([]); +}); + +test('activity visibility is URL-backed local state and never starts another schedule request', () => { + const hidden = update( + init(45, ['TDT4136']), + Message.ToggledActivityVisibility({ + courseCode: 'tdt4136', + activityCode: 'lecture', + isVisible: false, + }), + '2026-autumn', + ); + + expect(hidden.model.hiddenActivityKeys).toEqual(['TDT4136:lecture']); + expect(commandNames(hidden.commands ?? [])).toEqual([]); + expect(hidden.outMessage).toMatchObject({ _tag: 'RequestedScheduleUrlWrite', mode: 'push' }); + + const shown = update( + hidden.model, + Message.ToggledActivityVisibility({ + courseCode: 'TDT4136', + activityCode: 'lecture', + isVisible: true, + }), + '2026-autumn', + ); + + expect(shown.model.hiddenActivityKeys).toEqual([]); + expect(commandNames(shown.commands ?? [])).toEqual([]); +}); + +test('URL-restored activity visibility reuses a successful schedule response', () => { + const requested = syncFromUrl(init(45, ['TDT4136']), { + term: '2026-autumn', + week: 45, + selectedCodes: ['TDT4136'], + hiddenActivityKeys: [], + availableCodes: ['TDT4136'], + }); + const loaded = update( + requested.model, + Message.SucceededSchedule({ + requestKey: requested.model.activeRequestKey, + response: fixtureScheduleResponse(['TDT4136'], '2026-autumn', 45), + }), + '2026-autumn', + ); + const restored = syncFromUrl(loaded.model, { + term: '2026-autumn', + week: 45, + selectedCodes: ['TDT4136'], + hiddenActivityKeys: ['TDT4136:lecture'], + availableCodes: ['TDT4136'], + }); + + expect(restored.model.hiddenActivityKeys).toEqual(['TDT4136:lecture']); + expect(restored.model.result).toBe(loaded.model.result); + expect(commandNames(restored.commands ?? [])).toEqual([]); +}); + +test('removing a selected course also removes its hidden activity keys', () => { + const removed = update( + init(45, ['TDT4136'], ['TDT4136:lecture']), + Message.ToggledCourse({ courseCode: 'TDT4136', isSelected: false }), + '2026-autumn', + ); + + expect(removed.model.selectedCodes).toEqual([]); + expect(removed.model.hiddenActivityKeys).toEqual([]); + expect(commandNames(removed.commands ?? [])).toEqual([]); + expect(removed.outMessage).toMatchObject({ _tag: 'RequestedScheduleUrlWrite', mode: 'push' }); +}); + +test('a twelfth selected course produces feedback instead of a thirteenth request', () => { + const selectedCodes = Array.from( + { length: scheduleMaximumCourses }, + (_, index) => `TDT${4100 + index}`, + ); + const rejected = update( + init(45, selectedCodes), + Message.ToggledCourse({ courseCode: 'TDT4999', isSelected: true }), + '2026-autumn', + ); + + expect(rejected.model.selectedCodes).toEqual(selectedCodes); + expect(rejected.model.selectionLimitReached).toBe(true); + expect(commandNames(rejected.commands ?? [])).toEqual([]); +}); + +test('URL selections wait for saved courses, then canonicalize to their intersection', () => { + const pending = syncFromUrl(init(45), { + term: '2026-autumn', + week: 45, + selectedCodes: ['TDT4136', 'UNKNOWN'], + hiddenActivityKeys: ['TDT4136:lecture', 'UNKNOWN:exercise', 'TDT4109:seminar'], + availableCodes: null, + }); + expect(pending.model.selectedCodes).toEqual(['TDT4136', 'UNKNOWN']); + expect(pending.commands ?? []).toEqual([]); + expect(pending.model.hiddenActivityKeys).toEqual(['TDT4136:lecture', 'UNKNOWN:exercise']); + + const ready = syncFromUrl(pending.model, { + term: '2026-autumn', + week: 45, + selectedCodes: ['TDT4136', 'UNKNOWN'], + hiddenActivityKeys: ['TDT4136:lecture', 'UNKNOWN:exercise', 'TDT4109:seminar'], + availableCodes: ['TDT4136'], + }); + expect(ready.model.selectedCodes).toEqual(['TDT4136']); + expect(ready.model.hiddenActivityKeys).toEqual(['TDT4136:lecture']); + + expect(commandNames(ready.commands ?? [])).toEqual(['FetchSchedule']); + expect(ready.outMessage).toMatchObject({ _tag: 'RequestedScheduleUrlWrite', mode: 'replace' }); +}); + +test('a successful available source removes stale hidden activities but preserves failed-source keys', () => { + const pending = syncFromUrl( + init( + 45, + ['TDT4136', 'TDT4109'], + ['TDT4136:lecture', 'TDT4136:obsolete', 'TDT4109:still-hidden'], + ), + { + term: '2026-autumn', + week: 45, + selectedCodes: ['TDT4136', 'TDT4109'], + hiddenActivityKeys: ['TDT4136:lecture', 'TDT4136:obsolete', 'TDT4109:still-hidden'], + availableCodes: ['TDT4136', 'TDT4109'], + }, + ); + const response = fixtureScheduleResponse(['TDT4136', 'TDT4109'], '2026-autumn', 45); + const partialResponse = { + ...response, + items: response.items.map((item) => + item.courseCode === 'TDT4109' + ? { + ...item, + sourceStatus: { + ...item.sourceStatus, + status: 'failed' as const, + warning: 'Fixture source timed out.', + }, + } + : item, + ), + }; + + const succeeded = update( + pending.model, + Message.SucceededSchedule({ + requestKey: pending.model.activeRequestKey, + response: partialResponse, + }), + '2026-autumn', + ); + + expect(succeeded.model.hiddenActivityKeys).toEqual(['TDT4136:lecture', 'TDT4109:still-hidden']); + expect(commandNames(succeeded.commands ?? [])).toEqual([]); + expect(succeeded.outMessage).toMatchObject({ + _tag: 'RequestedScheduleUrlWrite', + mode: 'replace', + }); +}); + +test('a known empty week stays distinct from a failed course source', () => { + const empty = fixtureScheduleResponse(['TDT4109'], '2026-autumn', 45); + const unavailable = { + ...empty, + items: empty.items.map((item) => ({ + ...item, + sourceStatus: { + ...item.sourceStatus, + status: 'failed' as const, + warning: 'Fixture source timed out.', + }, + })), + }; + + expect(isKnownEmptyWeek(empty)).toBe(true); + expect(isKnownEmptyWeek(unavailable)).toBe(false); +}); diff --git a/apps/student-web/src/features/schedule/index.ts b/apps/student-web/src/features/schedule/index.ts new file mode 100644 index 0000000..8889047 --- /dev/null +++ b/apps/student-web/src/features/schedule/index.ts @@ -0,0 +1,1100 @@ +import type { + CourseScheduleOccurrenceDtoType, + CourseScheduleResponseDtoType, +} from '@course-data/course-contracts'; +import { Checkbox } from '@foldkit/ui'; +import { Effect, Schema as S } from 'effect'; +import { Command } from 'foldkit'; +import type { Update } from 'foldkit'; +import type { Html, HtmlBuilder } from 'foldkit/html'; +import { defineMessageUnion } from 'foldkit/message'; +import { defineTaggedUnion } from 'foldkit/schema'; +import { defineView } from 'foldkit/submodel'; +import { modifyFields } from 'foldkit/struct'; + +import { + buttonSecondary, + compactButtonBase, + eyebrowClass, + fieldLabelClass, + loadingIndicatorClass, + stateCardBase, + stateCardFailure, + stateCardH2Class, + stateCardPClass, +} from '../../app-styles'; +import { CourseScheduleResponseSchema } from '../../course-client'; +import { courseClient } from '../../course-client-runtime'; +import { localeTag, translate, type Localization } from '../../i18n'; +import type { SavedCourse } from '../../saved-courses'; + +export const scheduleMaximumCourses = 12; + +const ScheduleResult = defineTaggedUnion({ + ScheduleIdle: {}, + ScheduleLoading: {}, + ScheduleSuccess: { response: CourseScheduleResponseSchema }, + ScheduleFailure: { error: S.String }, +}); +export type ScheduleResult = typeof ScheduleResult.Type; +export const ScheduleIdle = ScheduleResult.ScheduleIdle; +export const ScheduleLoading = ScheduleResult.ScheduleLoading; +export const ScheduleSuccess = ScheduleResult.ScheduleSuccess; +export const ScheduleFailure = ScheduleResult.ScheduleFailure; + +/** + * Term deliberately is not schedule state. The root model remains its sole + * authority and passes it to each transition that needs it. + */ +export const Model = S.Struct({ + week: S.Number, + selectedCodes: S.Array(S.String), + hiddenActivityKeys: S.Array(S.String), + activeRequestKey: S.String, + result: ScheduleResult, + selectionLimitReached: S.Boolean, +}); +export type Model = typeof Model.Type; + +export const Message = defineMessageUnion({ + ChangedWeek: { value: S.String }, + RequestedPreviousWeek: {}, + RequestedNextWeek: {}, + ToggledCourse: { courseCode: S.String, isSelected: S.Boolean }, + ToggledActivityVisibility: { + courseCode: S.String, + activityCode: S.String, + isVisible: S.Boolean, + }, + SucceededSchedule: { requestKey: S.String, response: CourseScheduleResponseSchema }, + FailedSchedule: { requestKey: S.String, error: S.String }, +}); +export type Message = typeof Message.Type; + +export const OutMessage = defineMessageUnion({ + RequestedScheduleUrlWrite: { mode: S.Literals(['push', 'replace']) }, +}); +export type OutMessage = typeof OutMessage.Type; + +export const ChangedWeek = Message.ChangedWeek; +export const RequestedPreviousWeek = Message.RequestedPreviousWeek; +export const RequestedNextWeek = Message.RequestedNextWeek; +export const ToggledCourse = Message.ToggledCourse; +export const ToggledActivityVisibility = Message.ToggledActivityVisibility; +export const SucceededSchedule = Message.SucceededSchedule; +export const FailedSchedule = Message.FailedSchedule; + +const utcDay = 86_400_000; + +/** ISO week number for a calendar day already represented as a UTC date. */ +export const isoWeekNumber = (date: Date): number => { + const calendarDay = new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()), + ); + const weekday = calendarDay.getUTCDay() || 7; + calendarDay.setUTCDate(calendarDay.getUTCDate() + 4 - weekday); + const yearStart = new Date(Date.UTC(calendarDay.getUTCFullYear(), 0, 1)); + return Math.ceil(((calendarDay.getTime() - yearStart.getTime()) / utcDay + 1) / 7); +}; + +/** December 28 is always in the final ISO week of its ISO year. */ +export const weeksInIsoYear = (year: number): number => + isoWeekNumber(new Date(Date.UTC(year, 11, 28))); + +export const termYear = (term: string): number => { + const match = /^(\d{4})-(?:spring|autumn)$/.exec(term); + return match === null ? 2000 : Number(match[1]); +}; + +export const weekLimitForTerm = (term: string): number => weeksInIsoYear(termYear(term)); + +export const clampWeek = (value: number, term: string): number => + Math.min(Math.max(Math.trunc(value), 1), weekLimitForTerm(term)); + +const osloCalendarDate = (instant: Date): Date => { + const parts = new Intl.DateTimeFormat('en-GB', { + timeZone: 'Europe/Oslo', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(instant); + const value = (type: string): number => Number(parts.find((part) => part.type === type)?.value); + return new Date(Date.UTC(value('year'), value('month') - 1, value('day'))); +}; + +/** The current week follows the place where the timetable is published, not the browser clock zone. */ +export const currentOsloIsoWeek = (now: Date = new Date()): number => + isoWeekNumber(osloCalendarDate(now)); + +export const normalizedWeek = ( + value: string | number | null, + term: string, + fallback: number, +): number => { + if (value === null || value === '') return clampWeek(fallback, term); + const parsed = typeof value === 'number' ? value : Number(value); + return Number.isInteger(parsed) ? clampWeek(parsed, term) : clampWeek(fallback, term); +}; + +/** Returns Monday 00:00 UTC for an ISO week; callers format it in Oslo. */ +export const isoWeekStart = (year: number, week: number): Date => { + const januaryFourth = new Date(Date.UTC(year, 0, 4)); + const weekday = januaryFourth.getUTCDay() || 7; + januaryFourth.setUTCDate(januaryFourth.getUTCDate() - weekday + 1 + (week - 1) * 7); + return januaryFourth; +}; + +const normalizeCode = (code: string): string => code.trim().toUpperCase(); + +/** URL parsing preserves syntactically invalid values until local saved state is available to reject them. */ +export const parseScheduleCodes = (raw: string | null): ReadonlyArray => { + if (raw === null) return []; + const seen = new Set(); + const codes: Array = []; + for (const candidate of raw.split(',')) { + const code = normalizeCode(candidate); + if (code.length === 0 || seen.has(code)) continue; + seen.add(code); + codes.push(code); + if (codes.length === scheduleMaximumCourses) break; + } + return codes; +}; + +interface ActivityKeyParts { + readonly courseCode: string; + readonly activityCode: string; +} + +/** Activity codes may contain colons, so the URL separator is only the first one. */ +const activityKeyParts = (value: string): ActivityKeyParts | null => { + const separator = value.indexOf(':'); + if (separator <= 0) return null; + const courseCode = normalizeCode(value.slice(0, separator)); + const activityCode = value.slice(separator + 1).trim(); + return courseCode.length === 0 || activityCode.length === 0 ? null : { courseCode, activityCode }; +}; + +const activityVisibilityKey = (courseCode: string, activityCode: string): string | null => { + const normalizedCourseCode = normalizeCode(courseCode); + const normalizedActivityCode = activityCode.trim(); + return normalizedCourseCode.length === 0 || normalizedActivityCode.length === 0 + ? null + : `${normalizedCourseCode}:${normalizedActivityCode}`; +}; + +const canonicalHiddenActivityKeys = ( + keys: ReadonlyArray, + selectedCodes: ReadonlyArray, +): ReadonlyArray => { + const selected = new Set(selectedCodes.map(normalizeCode)); + const seen = new Set(); + const canonical: Array = []; + for (const value of keys) { + const parts = activityKeyParts(value); + if (parts === null || !selected.has(parts.courseCode)) continue; + const key = `${parts.courseCode}:${parts.activityCode}`; + if (seen.has(key)) continue; + seen.add(key); + canonical.push(key); + } + return canonical; +}; + +/** Repeated `hideActivity` parameters are normalized and limited to the selected courses. */ +export const parseHiddenActivityKeys = ( + values: ReadonlyArray, + selectedCodes: ReadonlyArray, +): ReadonlyArray => canonicalHiddenActivityKeys(values, selectedCodes); + +const sameCodes = (left: ReadonlyArray, right: ReadonlyArray): boolean => + left.length === right.length && left.every((code, index) => code === right[index]); + +/** Keeps the URL-requested order while allowing only a currently saved course. */ +export const selectedSavedCodes = ( + requestedCodes: ReadonlyArray, + availableCodes: ReadonlyArray, +): ReadonlyArray => { + const available = new Set(availableCodes.map(normalizeCode)); + return requestedCodes.filter((code) => available.has(normalizeCode(code))); +}; + +export const scheduleRequestKey = ( + term: string, + week: number, + selectedCodes: ReadonlyArray, +): string => `${term}|${week}|${selectedCodes.join(',')}`; + +export const init = ( + week: number, + selectedCodes: ReadonlyArray = [], + hiddenActivityKeys: ReadonlyArray = [], +): Model => ({ + week, + selectedCodes: [...selectedCodes], + hiddenActivityKeys: canonicalHiddenActivityKeys(hiddenActivityKeys, selectedCodes), + activeRequestKey: '', + result: ScheduleIdle(), + selectionLimitReached: false, +}); + +export const FetchSchedule = Command.define('FetchSchedule', { + args: { courseCodes: S.Array(S.String), term: S.String, week: S.Number, requestKey: S.String }, + messages: [Message.SucceededSchedule, Message.FailedSchedule], + execute: ({ courseCodes, term, week, requestKey }) => + courseClient.getSchedule(courseCodes, term, week).pipe( + Effect.map((response) => Message.SucceededSchedule({ requestKey, response })), + Effect.catch((error) => + Effect.succeed(Message.FailedSchedule({ requestKey, error: error.message })), + ), + ), +}); + +type UpdateReturn = Update.ReturnWithOutMessage; + +const startRequest = (model: Model, term: string): UpdateReturn => { + if (model.selectedCodes.length === 0) { + return { + model: modifyFields(model, { + activeRequestKey: () => '', + result: () => ScheduleIdle(), + }), + }; + } + + const requestKey = scheduleRequestKey(term, model.week, model.selectedCodes); + if ( + requestKey === model.activeRequestKey && + (model.result._tag === 'ScheduleLoading' || model.result._tag === 'ScheduleSuccess') + ) { + return { model }; + } + + return { + model: modifyFields(model, { + activeRequestKey: () => requestKey, + result: () => ScheduleLoading(), + }), + commands: [ + FetchSchedule({ + courseCodes: model.selectedCodes, + term, + week: model.week, + requestKey, + }), + ], + }; +}; + +const withStudentUrlWrite = (next: UpdateReturn): UpdateReturn => ({ + ...next, + outMessage: OutMessage.RequestedScheduleUrlWrite({ mode: 'push' }), +}); + +/** Only an available source can disprove a hidden provider-published activity. */ +const retainedHiddenActivityKeys = ( + hiddenActivityKeys: ReadonlyArray, + response: CourseScheduleResponseDtoType, +): ReadonlyArray => { + const availableStreams = new Map>(); + for (const item of response.items) { + if (item.sourceStatus.status !== 'available') continue; + availableStreams.set( + normalizeCode(item.courseCode), + new Set(item.activityStreams.map((stream) => stream.activityCode.trim())), + ); + } + return hiddenActivityKeys.filter((key) => { + const parts = activityKeyParts(key); + if (parts === null) return false; + const streams = availableStreams.get(parts.courseCode); + return streams === undefined || streams.has(parts.activityCode); + }); +}; + +/** Local interaction transitions; term comes from the root model at the call site. */ +export const update = (model: Model, message: Message, term: string): UpdateReturn => + Message.match(message, { + ChangedWeek: ({ value }) => { + if (value.trim().length === 0) return { model }; + const week = normalizedWeek(value, term, model.week); + if (week === model.week) return { model }; + return withStudentUrlWrite( + startRequest( + modifyFields(model, { week: () => week, selectionLimitReached: () => false }), + term, + ), + ); + }, + RequestedPreviousWeek: () => { + const week = Math.max(1, model.week - 1); + if (week === model.week) return { model }; + return withStudentUrlWrite( + startRequest( + modifyFields(model, { week: () => week, selectionLimitReached: () => false }), + term, + ), + ); + }, + RequestedNextWeek: () => { + const week = Math.min(weekLimitForTerm(term), model.week + 1); + if (week === model.week) return { model }; + return withStudentUrlWrite( + startRequest( + modifyFields(model, { week: () => week, selectionLimitReached: () => false }), + term, + ), + ); + }, + ToggledCourse: ({ courseCode, isSelected }) => { + const normalizedCode = normalizeCode(courseCode); + const selected = model.selectedCodes.includes(normalizedCode); + if (isSelected === selected) return { model }; + if (isSelected && model.selectedCodes.length >= scheduleMaximumCourses) { + return { model: modifyFields(model, { selectionLimitReached: () => true }) }; + } + const selectedCodes = isSelected + ? [...model.selectedCodes, normalizedCode] + : model.selectedCodes.filter((code) => code !== normalizedCode); + return withStudentUrlWrite( + startRequest( + modifyFields(model, { + selectedCodes: () => selectedCodes, + hiddenActivityKeys: () => + isSelected + ? model.hiddenActivityKeys + : canonicalHiddenActivityKeys(model.hiddenActivityKeys, selectedCodes), + selectionLimitReached: () => false, + }), + term, + ), + ); + }, + ToggledActivityVisibility: ({ courseCode, activityCode, isVisible }) => { + const key = activityVisibilityKey(courseCode, activityCode); + if (key === null || !model.selectedCodes.includes(normalizeCode(courseCode))) { + return { model }; + } + const isHidden = model.hiddenActivityKeys.includes(key); + if (isVisible === !isHidden) return { model }; + const hiddenActivityKeys = isVisible + ? model.hiddenActivityKeys.filter((candidate) => candidate !== key) + : [...model.hiddenActivityKeys, key]; + return withStudentUrlWrite({ + model: modifyFields(model, { + hiddenActivityKeys: () => hiddenActivityKeys, + }), + }); + }, + SucceededSchedule: ({ requestKey, response }) => { + if (requestKey !== model.activeRequestKey) return { model }; + const hiddenActivityKeys = retainedHiddenActivityKeys(model.hiddenActivityKeys, response); + const next = modifyFields(model, { + hiddenActivityKeys: () => hiddenActivityKeys, + result: () => ScheduleSuccess({ response }), + }); + return sameCodes(hiddenActivityKeys, model.hiddenActivityKeys) + ? { model: next } + : { + model: next, + outMessage: OutMessage.RequestedScheduleUrlWrite({ mode: 'replace' }), + }; + }, + FailedSchedule: ({ requestKey, error }) => + requestKey !== model.activeRequestKey + ? { model } + : { + model: modifyFields(model, { result: () => ScheduleFailure({ error }) }), + }, + }); + +export interface RouteState { + readonly term: string; + readonly week: number; + readonly selectedCodes: ReadonlyArray; + readonly hiddenActivityKeys: ReadonlyArray; + /** null means saved local state is still unavailable, not that no courses are saved. */ + readonly availableCodes: ReadonlyArray | null; +} + +/** + * Applies an address-bar state. Saved-course canonicalization waits for local + * state; activity keys outside the requested selection can be dropped now. + */ +export const syncFromUrl = (model: Model, route: RouteState): UpdateReturn => { + const selectedCodes = + route.availableCodes === null + ? route.selectedCodes + : selectedSavedCodes(route.selectedCodes, route.availableCodes); + const hiddenActivityKeys = canonicalHiddenActivityKeys(route.hiddenActivityKeys, selectedCodes); + const requestChanged = + model.week !== route.week || !sameCodes(model.selectedCodes, selectedCodes); + const visibilityChanged = !sameCodes(model.hiddenActivityKeys, hiddenActivityKeys); + const next = requestChanged + ? modifyFields(model, { + week: () => route.week, + selectedCodes: () => selectedCodes, + hiddenActivityKeys: () => hiddenActivityKeys, + activeRequestKey: () => '', + result: () => ScheduleIdle(), + selectionLimitReached: () => false, + }) + : visibilityChanged + ? modifyFields(model, { hiddenActivityKeys: () => hiddenActivityKeys }) + : model; + const canonicalized = + !sameCodes(route.selectedCodes, selectedCodes) || + !sameCodes(route.hiddenActivityKeys, hiddenActivityKeys); + + if (route.availableCodes === null) { + return canonicalized + ? { model: next, outMessage: OutMessage.RequestedScheduleUrlWrite({ mode: 'replace' }) } + : { model: next }; + } + + const requested = startRequest(next, route.term); + return canonicalized + ? { ...requested, outMessage: OutMessage.RequestedScheduleUrlWrite({ mode: 'replace' }) } + : requested; +}; + +export const scheduleOccurrences = ( + response: CourseScheduleResponseDtoType, +): ReadonlyArray => + response.items + .flatMap((item) => item.occurrences) + .slice() + .sort((left, right) => { + const byStart = left.startsAt.localeCompare(right.startsAt); + if (byStart !== 0) return byStart; + return left.id.localeCompare(right.id); + }); + +export const isKnownEmptyWeek = (response: CourseScheduleResponseDtoType): boolean => + response.items.length > 0 && + response.items.every( + (item) => item.sourceStatus.status === 'available' && item.occurrences.length === 0, + ); + +const formatDate = (value: string | Date, locale: Localization): string => + new Intl.DateTimeFormat(localeTag(locale.locale), { + weekday: 'long', + day: 'numeric', + month: 'long', + timeZone: 'Europe/Oslo', + }).format(value instanceof Date ? value : new Date(value)); + +const dateKey = (value: string): string => { + const parts = new Intl.DateTimeFormat('en-GB', { + timeZone: 'Europe/Oslo', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(new Date(value)); + const part = (type: string): string => + parts.find((candidate) => candidate.type === type)?.value ?? ''; + return `${part('year')}-${part('month')}-${part('day')}`; +}; + +const formatTime = (value: string, locale: Localization): string => + new Intl.DateTimeFormat(localeTag(locale.locale), { + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + timeZone: 'Europe/Oslo', + }).format(new Date(value)); + +const roomLabel = (occurrence: CourseScheduleOccurrenceDtoType): string | null => { + const rooms = occurrence.rooms + .map((room) => [room.building, room.room].filter((part) => part !== null).join(' ')) + .filter((room) => room.length > 0); + return rooms.length === 0 ? null : rooms.join(', '); +}; + +interface DayGroup { + readonly key: string; + readonly label: string; + readonly occurrences: ReadonlyArray; +} + +const agendaDays = ( + occurrences: ReadonlyArray, + term: string, + week: number, + locale: Localization, +): ReadonlyArray => { + const groups = new Map(); + const weekStart = isoWeekStart(termYear(term), week); + for (let offset = 0; offset < 7; offset += 1) { + const day = new Date(weekStart.getTime() + offset * utcDay); + const key = day.toISOString().slice(0, 10); + groups.set(key, { key, label: formatDate(day, locale), occurrences: [] }); + } + for (const occurrence of occurrences) { + const key = dateKey(occurrence.startsAt); + const existing = groups.get(key); + groups.set( + key, + existing === undefined + ? { key, label: formatDate(occurrence.startsAt, locale), occurrences: [occurrence] } + : { ...existing, occurrences: [...existing.occurrences, occurrence] }, + ); + } + return [...groups.values()]; +}; + +const sourceFreshness = ( + occurrence: CourseScheduleOccurrenceDtoType, + locale: Localization, + h: HtmlBuilder, +): Html => + h.details( + [h.Class('mt-3 rounded-m3-medium bg-surface-container-low px-3 py-2 text-sm')], + [ + h.summary( + [h.Class('cursor-pointer font-bold text-primary')], + [translate(locale, 'schedule.sources')], + ), + h.dl( + [h.Class('grid gap-1 mt-2 mb-0 text-on-surface-variant')], + [ + h.div( + [h.Class('grid gap-0.5')], + [ + h.dt([h.Class('font-semibold')], [translate(locale, 'schedule.sourceProvider')]), + h.dd([h.Class('m-0')], [occurrence.evidence.provider]), + ], + ), + h.div( + [h.Class('grid gap-0.5')], + [ + h.dt([h.Class('font-semibold')], [translate(locale, 'schedule.sourceObserved')]), + h.dd( + [h.Class('m-0')], + [ + new Intl.DateTimeFormat(localeTag(locale.locale), { + dateStyle: 'medium', + timeStyle: 'short', + timeZone: 'Europe/Oslo', + }).format(new Date(occurrence.evidence.observedAt)), + ], + ), + ], + ), + ], + ), + ], + ); + +const eventCard = ( + occurrence: CourseScheduleOccurrenceDtoType, + locale: Localization, + h: HtmlBuilder, +): Html => { + const room = roomLabel(occurrence); + const title = + occurrence.title ?? + translate(locale, 'schedule.activityFallback', { code: occurrence.activityCode }); + return h.article( + [ + h.Class( + 'grid gap-2 rounded-m3-large border border-outline-variant bg-surface-container p-3 shadow-m3-1', + ), + ], + [ + h.div( + [h.Class('flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1')], + [ + h.p([h.Class('m-0 text-sm font-extrabold text-primary')], [occurrence.courseCode]), + h.p( + [h.Class('m-0 text-sm font-semibold text-on-surface-variant')], + [ + translate(locale, 'schedule.timeRange', { + start: formatTime(occurrence.startsAt, locale), + end: formatTime(occurrence.endsAt, locale), + }), + ], + ), + ], + ), + h.h3([h.Class('m-0 text-base font-extrabold')], [title]), + occurrence.summary === null + ? h.empty + : h.p( + [h.Class('m-0 text-sm leading-[1.45] text-on-surface-variant')], + [occurrence.summary], + ), + h.dl( + [h.Class('grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 m-0 text-sm')], + [ + h.dt( + [h.Class('font-semibold text-on-surface-variant')], + [translate(locale, 'schedule.status')], + ), + h.dd([h.Class('m-0 font-semibold')], [occurrence.status]), + ...(room === null + ? [] + : [ + h.dt( + [h.Class('font-semibold text-on-surface-variant')], + [translate(locale, 'schedule.room')], + ), + h.dd([h.Class('m-0')], [room]), + ]), + ], + ), + sourceFreshness(occurrence, locale, h), + ], + ); +}; + +const sourceStatusCard = ( + item: CourseScheduleResponseDtoType['items'][number], + locale: Localization, + h: HtmlBuilder, +): Html => + h.article( + [ + h.Class( + item.sourceStatus.status === 'failed' + ? 'grid gap-1 rounded-m3-large border border-error bg-error-container p-4 text-on-error-container' + : 'grid gap-1 rounded-m3-large border border-warning bg-warning-container p-4 text-on-warning-container', + ), + ...(item.sourceStatus.status === 'failed' ? [h.Role('alert')] : [h.Role('status')]), + ], + [ + h.h2( + [h.Class('m-0 text-base font-extrabold')], + [ + translate( + locale, + item.sourceStatus.status === 'failed' + ? 'schedule.sourceFailed' + : 'schedule.sourceUnavailable', + { code: item.courseCode }, + ), + ], + ), + item.sourceStatus.warning === null + ? h.empty + : h.p([h.Class('m-0 text-sm leading-[1.45]')], [item.sourceStatus.warning]), + ], + ); + +const courseChoice = (course: SavedCourse, isSelected: boolean, h: HtmlBuilder): Html => { + const id = `schedule-course-${course.courseCode.toLowerCase()}`; + return Checkbox.view( + { + id, + isChecked: isSelected, + onToggle: (checked) => + Message.ToggledCourse({ courseCode: course.courseCode, isSelected: checked }), + toView: (attributes) => + h.label( + [ + ...attributes.label, + h.Class( + 'inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-[1.5rem] border border-outline px-3 text-sm font-bold text-on-surface-variant has-[[data-checked]]:border-primary has-[[data-checked]]:bg-primary-container has-[[data-checked]]:text-on-primary-container', + ), + ], + [ + h.span( + [ + ...attributes.checkbox, + h.AriaLabelledBy(`${id}-label`), + h.Class( + 'grid size-[1.15rem] place-items-center rounded-[0.3rem] border-2 border-current text-xs leading-none', + ), + ], + [h.span([h.AriaHidden(true)], [isSelected ? '✓' : ''])], + ), + h.span([], [course.courseCode]), + ], + ), + }, + h, + ); +}; + +const courseSelection = ( + model: Model, + savedCourses: ReadonlyArray | null, + locale: Localization, + h: HtmlBuilder, +): Html => + h.fieldset( + [ + h.Class( + 'grid gap-3 rounded-m3-large border border-outline-variant bg-surface-container-low p-4', + ), + h.AriaDescribedBy('schedule-courses-help'), + ], + [ + h.legend([h.Class('px-1 text-base font-extrabold')], [translate(locale, 'schedule.courses')]), + h.p( + [ + h.Id('schedule-courses-help'), + h.Class('m-0 text-sm leading-[1.45] text-on-surface-variant'), + ], + [translate(locale, 'schedule.coursesHelp', { maximum: scheduleMaximumCourses })], + ), + savedCourses === null + ? h.p( + [h.Class('m-0 text-sm font-semibold text-on-surface-variant'), h.Role('status')], + [translate(locale, 'schedule.savedCoursesLoading')], + ) + : savedCourses.length === 0 + ? h.p( + [h.Class('m-0 text-sm leading-[1.45] text-on-surface-variant')], + [translate(locale, 'schedule.noSavedCourses')], + ) + : h.div( + [h.Class('flex flex-wrap gap-2')], + savedCourses.map((course) => + courseChoice(course, model.selectedCodes.includes(course.courseCode), h), + ), + ), + model.selectionLimitReached + ? h.p( + [h.Class('m-0 text-sm font-bold text-error'), h.Role('status'), h.AriaLive('polite')], + [translate(locale, 'schedule.maximumSelected', { maximum: scheduleMaximumCourses })], + ) + : h.empty, + ], + ); + +const activityStreamLabel = ( + stream: CourseScheduleResponseDtoType['items'][number]['activityStreams'][number], + locale: Localization, +): string => + stream.title ?? + stream.summary ?? + translate(locale, 'schedule.activityFallback', { code: stream.activityCode }); + +const activityChoice = ( + courseCode: string, + stream: CourseScheduleResponseDtoType['items'][number]['activityStreams'][number], + isVisible: boolean, + locale: Localization, + h: HtmlBuilder, +): Html => { + const id = `schedule-activity-${courseCode.toLowerCase()}-${encodeURIComponent(stream.activityCode)}`; + return Checkbox.view( + { + id, + isChecked: isVisible, + onToggle: (visible) => + Message.ToggledActivityVisibility({ + courseCode, + activityCode: stream.activityCode, + isVisible: visible, + }), + toView: (attributes) => + h.label( + [ + ...attributes.label, + h.Class( + 'inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-[1.5rem] border border-outline px-3 text-sm font-bold text-on-surface-variant has-[[data-checked]]:border-primary has-[[data-checked]]:bg-primary-container has-[[data-checked]]:text-on-primary-container', + ), + ], + [ + h.span( + [ + ...attributes.checkbox, + h.AriaLabelledBy(`${id}-label`), + h.Class( + 'grid size-[1.15rem] place-items-center rounded-[0.3rem] border-2 border-current text-xs leading-none', + ), + ], + [h.span([h.AriaHidden(true)], [isVisible ? '✓' : ''])], + ), + h.span([], [activityStreamLabel(stream, locale)]), + ], + ), + }, + h, + ); +}; + +const activitySelection = ( + response: CourseScheduleResponseDtoType, + hiddenActivityKeys: ReadonlyArray, + locale: Localization, + h: HtmlBuilder, +): Html => { + const items = response.items.filter((item) => item.activityStreams.length > 0); + if (items.length === 0) return h.empty; + const hidden = new Set(hiddenActivityKeys); + return h.div( + [h.Class('grid gap-3')], + items.map((item) => { + const id = `schedule-activities-${item.courseCode.toLowerCase()}`; + return h.fieldset( + [ + h.Class( + 'grid gap-3 rounded-m3-large border border-outline-variant bg-surface-container-low p-4', + ), + h.AriaDescribedBy(`${id}-help`), + ], + [ + h.legend( + [h.Class('px-1 text-base font-extrabold')], + [translate(locale, 'schedule.activities', { code: item.courseCode })], + ), + h.p( + [h.Id(`${id}-help`), h.Class('m-0 text-sm leading-[1.45] text-on-surface-variant')], + [translate(locale, 'schedule.activitiesHelp')], + ), + h.div( + [h.Class('flex flex-wrap gap-2')], + item.activityStreams.map((stream) => { + const key = activityVisibilityKey(item.courseCode, stream.activityCode); + return activityChoice( + item.courseCode, + stream, + key === null || !hidden.has(key), + locale, + h, + ); + }), + ), + ], + ); + }), + ); +}; + +const weekControl = ( + model: Model, + term: string, + locale: Localization, + h: HtmlBuilder, +): Html => { + const maximum = weekLimitForTerm(term); + return h.section( + [ + h.Class( + 'grid gap-3 rounded-m3-large border border-outline-variant bg-surface-container-low p-4', + ), + ], + [ + h.div( + [h.Class('grid gap-1')], + [ + h.label( + [h.For('schedule-week'), h.Class(fieldLabelClass)], + [translate(locale, 'schedule.week')], + ), + h.p( + [h.Id('schedule-week-help'), h.Class('m-0 text-sm text-on-surface-variant')], + [translate(locale, 'schedule.weekHelp', { maximum })], + ), + ], + ), + h.div( + [h.Class('flex flex-wrap items-end gap-2')], + [ + h.input([ + h.Id('schedule-week'), + h.Name('week'), + h.Type('number'), + h.Min('1'), + h.Max(String(maximum)), + h.Value(String(model.week)), + h.OnInput((value) => Message.ChangedWeek({ value })), + h.AriaDescribedBy('schedule-week-help'), + h.Class( + 'min-h-12 w-24 rounded-m3-medium border border-outline bg-surface-container px-3 text-base font-bold', + ), + ]), + h.button( + [ + h.Type('button'), + h.OnClick(Message.RequestedPreviousWeek()), + h.Disabled(model.week <= 1), + h.AriaLabel(translate(locale, 'schedule.previousWeek')), + h.Class(`${compactButtonBase} ${buttonSecondary} min-h-12`), + ], + [translate(locale, 'schedule.previousWeek')], + ), + h.button( + [ + h.Type('button'), + h.OnClick(Message.RequestedNextWeek()), + h.Disabled(model.week >= maximum), + h.AriaLabel(translate(locale, 'schedule.nextWeek')), + h.Class(`${compactButtonBase} ${buttonSecondary} min-h-12`), + ], + [translate(locale, 'schedule.nextWeek')], + ), + ], + ), + ], + ); +}; + +const agenda = ( + response: CourseScheduleResponseDtoType, + hiddenActivityKeys: ReadonlyArray, + term: string, + week: number, + locale: Localization, + h: HtmlBuilder, +): Html => { + const unavailable = response.items.filter((item) => item.sourceStatus.status !== 'available'); + const occurrences = scheduleOccurrences(response); + const hidden = new Set(hiddenActivityKeys); + const visibleOccurrences = occurrences.filter((occurrence) => { + const key = activityVisibilityKey(occurrence.courseCode, occurrence.activityCode); + return key === null || !hidden.has(key); + }); + const days = + visibleOccurrences.length === 0 ? [] : agendaDays(visibleOccurrences, term, week, locale); + const allPublishedActivitiesHidden = occurrences.length > 0 && visibleOccurrences.length === 0; + return h.div( + [h.Class('grid gap-4')], + [ + activitySelection(response, hiddenActivityKeys, locale, h), + ...unavailable.map((item) => sourceStatusCard(item, locale, h)), + isKnownEmptyWeek(response) + ? h.section( + [h.Class(stateCardBase), h.Role('status')], + [ + h.h2([h.Class(stateCardH2Class)], [translate(locale, 'schedule.empty')]), + h.p([h.Class(stateCardPClass)], [translate(locale, 'schedule.emptyHelp')]), + ], + ) + : allPublishedActivitiesHidden + ? h.section( + [h.Class(stateCardBase), h.Role('status')], + [ + h.h2([h.Class(stateCardH2Class)], [translate(locale, 'schedule.hiddenActivities')]), + h.p( + [h.Class(stateCardPClass)], + [translate(locale, 'schedule.hiddenActivitiesHelp')], + ), + ], + ) + : visibleOccurrences.length === 0 + ? h.empty + : h.section( + [h.Class('grid gap-3')], + [ + h.h2( + [h.Class('m-0 text-lg font-extrabold')], + [translate(locale, 'schedule.agenda')], + ), + h.ol( + [ + h.Class( + 'grid gap-3 m-0 p-0 list-none [@media(min-width:72rem)]:grid-cols-7 [@media(min-width:72rem)]:items-start', + ), + ], + days.map((day) => + h.li( + [ + h.Key(day.key), + h.Class( + `${day.occurrences.length === 0 ? 'hidden [@media(min-width:72rem)]:grid' : 'grid'} min-w-0 gap-2 rounded-m3-large bg-surface-container-low p-2 [@media(min-width:72rem)]:min-h-64`, + ), + ], + [ + h.h3( + [h.Class('m-0 px-1 text-sm font-extrabold text-on-surface-variant')], + [day.label], + ), + h.ol( + [h.Class('grid gap-2 m-0 p-0 list-none')], + day.occurrences.map((occurrence) => + h.li([h.Key(occurrence.id)], [eventCard(occurrence, locale, h)]), + ), + ), + ], + ), + ), + ), + ], + ), + ], + ); +}; + +export interface ViewInputs { + readonly locale: Localization; + readonly term: string; + readonly savedCourses: ReadonlyArray | null; +} + +export const view = defineView( + (model, { locale, term, savedCourses }, h) => + h.section( + [h.Class('grid gap-5')], + [ + h.header( + [], + [ + h.p([h.Class(eyebrowClass)], [translate(locale, 'schedule.eyebrow')]), + h.h1( + [h.Class('m-0 text-[clamp(1.6rem,6vw,2.25rem)] tracking-[-0.035em]')], + [translate(locale, 'schedule.heading')], + ), + h.p( + [h.Class('mt-2 mb-0 max-w-3xl leading-[1.55] text-on-surface-variant')], + [translate(locale, 'schedule.intro')], + ), + ], + ), + h.p( + [ + h.Class( + 'm-0 rounded-m3-medium bg-secondary-container px-4 py-3 text-sm font-semibold text-on-secondary-container', + ), + ], + [translate(locale, 'schedule.limitations')], + ), + weekControl(model, term, locale, h), + courseSelection(model, savedCourses, locale, h), + savedCourses !== null && savedCourses.length === 0 + ? h.p( + [h.Class('m-0 text-sm leading-[1.45] text-on-surface-variant')], + [translate(locale, 'schedule.noSavedCoursesHelp')], + ) + : model.selectedCodes.length === 0 + ? h.section( + [h.Class(stateCardBase), h.Role('status')], + [ + h.h2( + [h.Class(stateCardH2Class)], + [translate(locale, 'schedule.selectionRequired')], + ), + h.p( + [h.Class(stateCardPClass)], + [translate(locale, 'schedule.selectionRequiredHelp')], + ), + ], + ) + : ScheduleResult.match(model.result, { + ScheduleIdle: () => h.empty, + ScheduleLoading: () => + h.section( + [h.Class(stateCardBase), h.Role('status'), h.AriaLive('polite')], + [ + h.div([h.Class(loadingIndicatorClass), h.AriaHidden(true)], []), + h.h2([h.Class(stateCardH2Class)], [translate(locale, 'schedule.loading')]), + h.p([h.Class(stateCardPClass)], [translate(locale, 'schedule.loadingHelp')]), + ], + ), + ScheduleFailure: ({ error }) => + h.section( + [h.Class(stateCardFailure), h.Role('alert')], + [ + h.h2([h.Class(stateCardH2Class)], [translate(locale, 'schedule.loadFailed')]), + h.p([h.Class(stateCardPClass)], [error]), + ], + ), + ScheduleSuccess: ({ response }) => + agenda(response, model.hiddenActivityKeys, term, model.week, locale, h), + }), + ], + ), +); diff --git a/apps/student-web/src/i18n.nb.ts b/apps/student-web/src/i18n.nb.ts new file mode 100644 index 0000000..b0cc069 --- /dev/null +++ b/apps/student-web/src/i18n.nb.ts @@ -0,0 +1,714 @@ +import { encodeMessageCatalogue, type MessageCatalogue } from './i18n'; + +export const norwegianMessages = { + 'app.name': 'Emneblikk', + 'app.catalogueTitle': 'Utforsk NTNU-emner · Emneblikk', + 'app.listTitle': 'Dine emner · Emneblikk', + 'app.progressTitle': 'Studieprogresjon · Emneblikk', + 'app.scheduleTitle': 'Ukeplan · Emneblikk', + 'locale.label': 'Språk', + 'locale.en': 'English', + 'locale.nb': 'Norsk bokmål', + 'nav.primary': 'Hovednavigasjon', + 'nav.list': 'Lagret', + 'nav.schedule': 'Timeplan', + 'nav.explore': 'Utforsk', + 'nav.progress': 'Progresjon', + 'nav.collapse': 'Slå sammen sidemenyen', + 'nav.expand': 'Utvid sidemenyen', + 'nav.claim': 'Fakta kan spores. Manglende informasjon forblir synlig.', + 'appearance.open': 'Åpne utseendeinnstillinger', + 'appearance.label': 'Utseende', + 'appearance.navLabel': 'Stil', + 'appearance.heading': 'Temalaboratorium', + 'appearance.description': + 'Velg en nordisk palett og hvordan den følger enheten din. Plan- og statusfarger beholder betydningen sin.', + 'appearance.close': 'Lukk utseendeinnstillingene', + 'appearance.palettes': 'Nordisk palett', + 'appearance.mode': 'Lys og mørk modus', + 'appearance.system': 'System', + 'appearance.light': 'Lys', + 'appearance.dark': 'Mørk', + 'appearance.preview': 'Forhåndsvisning av tema', + 'appearance.previewTerm': 'Høstsemester', + 'appearance.previewCredits': '30 studiepoeng', + 'appearance.previewCourse': 'Menneskesentrert databehandling', + 'appearance.previewRequired': 'Påkrevd', + 'appearance.previewValid': 'Passer i planen', + 'appearance.previewWarning': 'Sjekk overlapp', + 'appearance.reset': 'Tilbakestill til Fjord', + 'appearance.fjord': 'Fjord', + 'appearance.fjordDescription': 'Klare blåtoner over kjølig dis.', + 'appearance.aurora': 'Nordlys', + 'appearance.auroraDescription': 'Fiolett lys over dempet sink.', + 'appearance.birch': 'Bjørk', + 'appearance.birchDescription': 'Varm rav over lys stein.', + 'appearance.heather': 'Lyng', + 'appearance.heatherDescription': 'Myk rose over dempet lilla.', + 'appearance.pine': 'Furu', + 'appearance.pineDescription': 'Den opprinnelige smaragd- og olivenpaletten.', + 'appearance.polarNight': 'Polarnatt', + 'appearance.polarNightDescription': 'Himmelblått over dype nøytraler.', + 'catalogue.eyebrow': 'NTNUs emnekatalog', + 'catalogue.heading': 'Utforsk emner før du velger.', + 'catalogue.intro': + 'Se gjennom NTNUs offisielle emnetilbud, snevre inn katalogen og åpne et emne for vurderingsform, arbeidsform og karakterdata.', + 'catalogue.searchRegion': 'Finn og filtrer NTNU-emner', + 'catalogue.searchLabel': 'Søk i emner', + 'catalogue.searchPlaceholder': 'Emnekode eller tittel', + 'catalogue.search': 'Søk', + 'catalogue.searching': 'Søker …', + 'catalogue.term': 'Semester', + 'catalogue.campus': 'Studiested', + 'catalogue.level': 'Studienivå', + 'catalogue.sort': 'Sorter', + 'catalogue.allCampuses': 'Alle studiesteder', + 'catalogue.trondheim': 'Trondheim', + 'catalogue.gjovik': 'Gjøvik', + 'catalogue.alesund': 'Ålesund', + 'catalogue.allLevels': 'Alle nivåer', + 'catalogue.bachelor': 'Bachelor', + 'catalogue.master': 'Master', + 'catalogue.phd': 'Ph.d.', + 'catalogue.relevance': 'NTNU-relevans', + 'catalogue.titleAsc': 'Tittel A–Å', + 'catalogue.titleDesc': 'Tittel Å–A', + 'catalogue.codeAsc': 'Kode A–Å', + 'catalogue.codeDesc': 'Kode Å–A', + 'catalogue.openAdmission': 'Åpent opptak', + 'catalogue.english': 'Undervises på engelsk', + 'catalogue.allCourses': 'Alle NTNU-emner', + 'catalogue.activeRefinements': '{count} aktive avgrensninger{suffix}', + 'catalogue.refineHelp': 'Endre søk, filtre eller sortering hvor som helst i listen.', + 'catalogue.refine': 'Avgrens', + 'catalogue.refineCount': 'Avgrens · {count}', + 'catalogue.refineHeading': 'Avgrens emner', + 'catalogue.refineDescription': 'Endringer brukes med én gang og beholdes i den delbare lenken.', + 'catalogue.closeRefinements': 'Lukk emnefiltrene', + 'catalogue.viewResults': 'Vis resultater', + 'catalogue.loading': 'Laster NTNUs emnekatalog', + 'catalogue.loadingHelp': + 'Offisielle emnesammendrag vises før mer detaljert dokumentasjon lastes.', + 'catalogue.unavailable': 'Emnekatalogen er utilgjengelig', + 'catalogue.loadFailed': 'Vi kunne ikke laste emnene', + 'catalogue.retry': 'Filtrene dine er bevart. Send søket på nytt for å prøve igjen.', + 'catalogue.empty': 'Ingen emner passer til disse filtrene', + 'catalogue.emptyHelp': 'Prøv et annet uttrykk, studiested, semester eller studienivå.', + 'catalogue.results': 'Emneresultater', + 'catalogue.partial': + 'Noen katalogdata kunne ikke brukes. Offisielle resultater som ble validert, er fortsatt synlige.', + 'catalogue.courses': 'Emner', + 'catalogue.showing': 'Viser {shown} av {total} emner', + 'catalogue.official': 'Offisiell NTNU-katalog', + 'catalogue.moreFailed': 'Flere emner kunne ikke lastes.', + 'catalogue.loadingMore': 'Laster flere emner …', + 'catalogue.showMore': 'Vis flere emner', + 'catalogue.end': 'Slutten av resultatene', + 'course.open': 'Åpne {code}: {title}', + 'course.titleUnavailable': 'Tittel er utilgjengelig', + 'course.termUnavailable': 'Semester er utilgjengelig', + 'course.campusUnreported': 'Studiested er ikke oppgitt', + 'course.creditsValue': '{value} studiepoeng', + 'course.termFact': 'Semester', + 'course.campusFact': 'Studiested', + 'course.back': '← Tilbake til emneresultatene', + 'schedule.eyebrow': 'Ukeplan', + 'schedule.heading': 'Timeplanen for de lagrede emnene dine', + 'schedule.intro': + 'Velg lagrede emner og se daterte, leverandørpubliserte aktiviteter for én ISO-uke.', + 'schedule.limitations': + 'Leverandørpubliserte aktiviteter vises som standard. Tilordning til alternative økter og unntakssemantikk er ikke tilgjengelig.', + 'schedule.week': 'Ukenummer', + 'schedule.weekHelp': 'Velg en ISO-uke fra 1 til {maximum}.', + 'schedule.previousWeek': 'Forrige uke', + 'schedule.nextWeek': 'Neste uke', + 'schedule.courses': 'Velg lagrede emner', + 'schedule.coursesHelp': 'Velg opptil {maximum} lagrede emner. Valget beholdes i denne lenken.', + 'schedule.activities': 'Aktiviteter for {code}', + 'schedule.activitiesHelp': + 'Velg hvilke leverandørpubliserte aktiviteter som vises i denne timeplanen. Dette identifiserer ikke en tildelt økt.', + 'schedule.savedCoursesLoading': 'Åpner de lagrede emnene dine …', + 'schedule.noSavedCourses': 'Ingen lagrede emner er tilgjengelige å velge.', + 'schedule.noSavedCoursesHelp': 'Lagre et emne i Utforsk, og kom tilbake hit for å se timeplanen.', + 'schedule.maximumSelected': 'Du kan velge opptil {maximum} emner.', + 'schedule.selectionRequired': 'Velg minst ett lagret emne', + 'schedule.selectionRequiredHelp': 'Timeplanen er tom til du velger et lagret emne.', + 'schedule.loading': 'Laster ukeplanen', + 'schedule.loadingHelp': 'Publiserte aktiviteter sjekkes for den valgte uken.', + 'schedule.loadFailed': 'Vi kunne ikke laste denne ukeplanen', + 'schedule.empty': 'Ingen publiserte aktiviteter denne uken', + 'schedule.emptyHelp': + 'De valgte tilgjengelige emnene har ingen leverandørpubliserte aktiviteter denne ISO-uken.', + 'schedule.hiddenActivities': 'Alle publiserte aktiviteter er skjult', + 'schedule.hiddenActivitiesHelp': 'Velg en aktivitet over for å vise den i timeplanen.', + 'schedule.agenda': 'Ukentlig timeplan', + 'schedule.sourceFailed': 'Timeplankilden feilet for {code}', + 'schedule.sourceUnavailable': 'Timeplankilden er utilgjengelig for {code}', + 'schedule.sources': 'Kilde og ferskhet', + 'schedule.sourceProvider': 'Leverandør', + 'schedule.sourceObserved': 'Observert', + 'schedule.activityFallback': 'Aktivitet {code}', + 'schedule.timeRange': '{start}–{end} Oslo-tid', + 'schedule.status': 'Status', + 'schedule.room': 'Rom', + 'list.eyebrow': 'Dine emner', + 'list.heading': 'Lagrede emner og resultater', + 'list.intro': + 'Lagrede emner og NTNU-resultater møtes her. Resultater kommer fra din lokale progresjonshistorikk og blir aldri bokmerker av seg selv.', + 'list.backToExplore': 'Utforsk flere emner', + 'list.loading': 'Åpner emnene dine', + 'list.loadingHelp': + 'Lagrede emner og lokale resultater leses fra denne nettleseren før listen vises.', + 'list.density': 'Visning', + 'list.densityCard': 'Kort', + 'list.densityCompact': 'Kompakt', + 'list.originFilter': 'Emnekilde', + 'list.originAll': 'Alle {count}', + 'list.originSaved': 'Lagret {count}', + 'list.originResults': 'Resultater {count}', + 'list.rowOrigins': 'Kilder for {code}', + 'list.resultBadge': 'Resultat', + 'list.savedBadge': 'Lagret', + 'list.resultOnlyHelp': 'Lagre emnet for å legge til etiketter og et privat notat.', + 'list.resultEvidence': 'Siste lokale resultat', + 'list.resultEvidenceHelp': + 'Fra din lokale progresjonshistorikk, ikke gjeldende emnedokumentasjon.', + 'list.resultSummary': '{grade} · {term} {year} · {credits} studiepoeng', + 'list.courseCountOne': '1 emne', + 'list.courseCountMany': '{count} emner', + 'list.countOne': '1 lagret emne', + 'list.countMany': '{count} lagrede emner', + 'list.empty': 'Du har ingen lagrede emner eller NTNU-resultater ennå', + 'list.emptyHelp': 'Lagre et emne i Utforsk, eller legg til et NTNU-resultat i Progresjon.', + 'list.saveCourse': 'Lagre {code} i listen', + 'list.removeCourse': 'Fjern {code} fra listen', + 'list.save': 'Lagre', + 'list.remove': 'Fjern', + 'list.savePending': 'De lagrede emnene lastes fortsatt', + 'list.savePaused': + 'Lagring er satt på pause til den lagrede listen er gjenopprettet eller tilbakestilt.', + 'list.savePausedLink': 'Åpne listen for å gjenopprette lagrede emner', + 'list.factsNotLoaded': 'Emnedetaljer ble ikke lastet i denne økten.', + 'list.factsNotLoadedHelp': + 'Åpne emnet for å laste dokumentasjon om vurdering, arbeid og historiske resultater.', + 'list.note': 'Notatet ditt', + 'list.notePlaceholder': + 'Hvorfor emnet er interessant, spørsmål du vil stille, kollisjoner å sjekke', + 'list.noteHelp': + 'Privat på denne enheten. Notater er skrevet av deg og teller aldri som emnedokumentasjon.', + 'list.saveNote': 'Lagre notat', + 'list.openCourse': 'Åpne {code}', + 'list.repaired': 'Ubrukelige lagrede oppføringer ble fjernet: {count}.', + 'list.recoveryHeading': 'Kunne ikke laste lagrede emner', + 'list.recoveryUnsupported': + 'Nettleseren har lagret en nyere versjon av listen (versjon {version}). Oppdater applikasjonen, eller tilbakestill listen for å begynne på nytt på denne enheten.', + 'list.recoveryCorrupt': 'Den lagrede listen kunne ikke leses.', + 'list.recoveryUnavailable': + 'Nettleseren tillot ikke lokal lagring, så lagrede emner er utilgjengelige her.', + 'list.recoveryKept': + 'Ingenting er slettet. Den lagrede verdien beholdes nedenfor slik at du kan kopiere den før du tilbakestiller.', + 'list.recoveryShowStored': 'Vis den lagrede verdien', + 'list.reset': 'Tilbakestill lagrede emner', + 'list.resetHelp': 'Tilbakestilling sletter den lagrede listen på denne enheten permanent.', + 'list.persistFailed': + 'Den siste endringen i lagrede emner kunne ikke lagres i denne nettleseren.', + 'list.savedStatus': '{code} er lagret i listen.', + 'list.removedStatus': '{code} er fjernet fra listen.', + 'list.removedManyStatus': '{count} emner er fjernet fra listen.', + 'list.dismissAllStatus': 'Lukk alle {count}', + 'list.undo': 'Angre', + 'list.undoSave': 'Angre lagring av {code}', + 'list.undoRemove': 'Angre fjerning av {code}', + 'list.undoRemoveMany': 'Angre fjerning av {count} emner', + 'list.dismissStatus': 'Lukk', + 'list.labels': 'Etiketter', + 'list.labelsHeading': 'Etikettene dine', + 'list.labelsHelp': + 'Etiketter er dine egne ord for dine egne planer. De endrer aldri emnefakta og teller aldri som dokumentasjon.', + 'list.labelsForCourse': 'Etiketter for {code}', + 'list.labelsForSelection': 'Etiketter for {count} valgte lagrede emner', + 'list.labelsManageOnly': 'Opprett og endre etiketter', + 'list.openLabels': 'Etiketter', + 'list.editLabelsFor': 'Endre etiketter for {code}', + 'list.closeLabels': 'Lukk etiketter', + 'list.labelName': 'Etikettnavn', + 'list.labelNamePlaceholder': 'Høst 2027, Spør veileder, Digitale kandidater', + 'list.labelColor': 'Etikettfarge', + 'list.addLabel': 'Legg til etikett', + 'list.saveLabel': 'Lagre etikett', + 'list.cancelLabelEdit': 'Avbryt endringen', + 'list.editLabel': 'Endre etikett', + 'list.deleteLabel': 'Slett etikett', + 'list.labelRowActions': 'Handlinger for {name}', + 'list.deleteLabelConfirm': + 'Slette «{name}»? Lagrede emner beholder identiteten sin; bare etiketten fjernes.', + 'list.cancelDeleteLabel': 'Avbryt slettingen', + 'list.labelCountUnit': 'lagrede emner', + 'list.labelCountUnitOne': 'lagret emne', + 'list.filterExcludedBadge': 'Utelatt', + 'list.noLabels': 'Du har ikke opprettet en etikett ennå.', + 'list.labelOnCourse': 'På {code}', + 'list.labelPartlyOnSelection': 'På {matched} av {count} valgte', + 'list.labelDuplicate': 'Det finnes allerede en etikett med det navnet.', + 'list.labelEmptyName': 'Gi etiketten et navn først.', + 'list.labelLimit': 'Maksimum er {count} etiketter. Slett en for å legge til en ny.', + 'list.labelUnknownError': 'Den etiketten finnes ikke lenger.', + 'list.rowLabels': 'Etiketter på {code}', + 'list.rowNoLabels': 'Ingen etiketter ennå', + 'compare.heading': 'Sammenlign lagrede emner', + 'compare.intro': 'Bare det som skiller vises. Fakta kildene ikke oppgav beholder navnet sitt.', + 'compare.close': 'Lukk sammenligningen', + 'compare.open': 'Sammenlign', + 'compare.differencesOnly': 'Forskjeller', + 'compare.showAll': 'Vis alt', + 'compare.dimension': 'Dimensjon', + 'compare.notLoaded': 'Ikke lastet', + 'compare.none': 'Ingen', + 'compare.activityCount': '{count} aktiviteter', + 'compare.credits': 'Studiepoeng', + 'compare.term': 'Termin', + 'compare.campus': 'Studiested', + 'compare.assessment': 'Vurdering', + 'compare.obligatory': 'Obligatorisk arbeid', + 'compare.collaboration': 'Samarbeid', + 'compare.outcomeScale': 'Karakterskala', + 'compare.failureRate': 'Strykprosent', + 'compare.sample': 'Utvalg', + 'compare.period': 'Observert periode', + 'compare.identical': 'Alle gjenværende dimensjoner er like for disse emnene.', + 'list.filterHeading': 'Filtrer på etikett', + 'list.filterCombine': 'Kombiner etiketter', + 'list.filterMode': 'Treff på inkluderte etiketter', + 'list.filterModeAny': 'Minst én', + 'list.filterModeAll': 'Alle', + 'list.filterIncludeHeading': 'Inkluder etiketter', + 'list.filterExcludeHeading': 'Utelat etiketter', + 'list.filterExcludeHelp': 'Å utelate flere etiketter fjerner emner som har én av dem.', + 'list.filterExclude': 'Utelat {name}', + 'list.filterClear': 'Fjern etikettfilteret', + 'list.filterSummaryInclude': 'Viser lagrede emner i {labels}.', + 'list.filterSummaryExclude': 'Viser lagrede emner, utenom {excluded}.', + 'list.filterSummaryBoth': 'Viser lagrede emner i {labels}, utenom {excluded}.', + 'list.filterSummaryNone': 'Viser alle lagrede emner.', + 'list.filterContradiction': + '{labels} forblir utelatt og ble derfor fjernet fra de inkluderte etikettene.', + 'list.filterUnknownDropped': + 'Filteret viste til etiketter som ikke finnes lenger. De ble fjernet: {count}.', + 'list.filterUnlabeled': 'Uten etikett', + 'list.filterUnsatisfiable': + '{unlabeled} betyr ingen etikett i det hele tatt, så Alle kan aldri treffe den sammen med en etikett. Bytt til Minst én, eller fjern en av dem.', + 'list.filteredCount': 'Viser {shown} av {total} emner', + 'list.filterEmpty': 'Ingen emner passer til disse filtrene', + 'list.filterEmptyHelp': 'Endre filtrene, eller fjern dem for å se alle emner.', + 'list.filtersClear': 'Fjern filtre', + 'list.selectCourse': 'Velg {code}', + 'list.selectionTray': 'Valgte lagrede emner', + 'list.selectionCount': '{count} valgt', + 'list.selectionCountOne': '1 valgt', + 'list.selectionAddLabels': 'Legg til etiketter', + 'list.selectionClear': 'Fjern utvalget', + 'list.selectionRemove': 'Fjern valgte', + 'list.selectionRemoveConfirm': 'Fjerne {count} lagrede emner, med notater og etiketter?', + 'list.selectionRemoveConfirmAction': 'Ja, fjern dem', + 'list.selectionRemoveCancel': 'Behold dem', + 'label.colorViolet': 'Fiolett', + 'label.colorAmber': 'Rav', + 'label.colorRose': 'Rosa', + 'label.colorEmerald': 'Smaragd', + 'label.colorSky': 'Himmelblå', + 'signals.heading': 'Vurdering og arbeid', + 'signals.gradedAssessment': 'Tellende vurdering', + 'signals.checking': 'Sjekker NTNUs emneside …', + 'signals.failed': 'Emnesignaler er utilgjengelige', + 'signals.waiting': 'Venter på kontroll', + 'signals.missing': 'Ingen emnesignaler', + 'signals.inferred': 'Utledet fra NTNUs emnetekst', + 'signals.writtenExam': 'Skriftlig eksamen', + 'signals.oralExam': 'Muntlig eksamen', + 'signals.homeExam': 'Hjemmeeksamen', + 'signals.project': 'Prosjekt', + 'signals.portfolio': 'Mappe', + 'signals.practical': 'Praktisk', + 'signals.assignment': 'Innlevering', + 'signals.otherAssessment': 'Annen vurdering', + 'signals.obligatory': 'Obligatorisk arbeid', + 'signals.noObligatory': 'Ingen obligatoriske aktiviteter oppgitt', + 'signals.individual': 'Individuelt arbeid', + 'signals.group': 'Gruppearbeid', + 'signals.mixedCollaboration': 'Individuelt + gruppearbeid', + 'signals.required': 'Påkrevd', + 'signals.graded': 'tellende', + 'signals.ungraded': 'Ikke tellende', + 'signals.oneActivity': '1 aktivitet', + 'signals.activityCount': '{count} aktiviteter', + 'signals.noneReported': 'Ikke oppgitt', + 'outcomes.heading': 'Historiske resultater', + 'outcomes.source': 'HK-dir (DBH)', + 'outcomes.checking': 'Sjekker HK-dir …', + 'outcomes.failed': 'Karakterdata er utilgjengelig', + 'outcomes.waiting': 'Venter på kontroll', + 'outcomes.missing': 'Ingen karakteroversikt', + 'outcomes.view': 'Velg karakterskala for historiske resultater', + 'outcomes.letter': 'Bokstavkarakterer', + 'outcomes.passFail': 'Bestått/ikke bestått', + 'outcomes.mixed': 'Blandede skalaer', + 'outcomes.available': 'Historiske resultater er tilgjengelige', + 'outcomes.noBuckets': 'Ingen vurderte karaktergrupper ble returnert', + 'outcomes.protected': 'Små tall er personvernskjermet', + 'outcomes.conflicting': 'Publiserte resultatkilder er motstridende', + 'outcomes.unknown': 'Resultatfordelingen er ikke fastslått', + 'outcomes.unavailable': 'Ingen publisert resultatfordeling', + 'outcomes.failedRate': '{value} % strøk', + 'outcomes.sample': 'n={value}', + 'outcomes.chartLabel': 'Historiske resultater fra HK-dir DBH. {summary}', + 'outcomes.percent': '{label} {value} prosent', + 'outcomes.pass': 'Bestått', + 'outcomes.fail': 'Ikke bestått', + 'detail.aria': 'Emnedetaljer for {code}', + 'detail.titleUnavailable': 'Emnetittel er utilgjengelig', + 'detail.loading': 'Samler emnedokumentasjon', + 'detail.loadingHelp': 'Offisielle emnedetaljer og historiske resultater lastes uavhengig.', + 'detail.unavailable': 'Emnet er utilgjengelig', + 'detail.loadFailed': 'Vi kunne ikke laste dette emnet', + 'detail.partial': 'Delvis resultat', + 'detail.partialHelp': + 'Én kilde er utilgjengelig. Emnedetaljer fra andre kilder vises fortsatt, og manglende resultater behandles ikke som null.', + 'detail.complete': 'Alle konfigurerte kilder svarte.', + 'detail.credits': 'Studiepoeng', + 'detail.level': 'Nivå', + 'detail.language': 'Språk', + 'detail.availability': 'Tilgjengelighet', + 'detail.availabilityHelp': 'Når og hvor emnet tilbys.', + 'detail.termLocation': 'Undervisningssemester og sted', + 'detail.learn': 'Dette skal du lære', + 'detail.learnHelp': 'Emneinnhold og forventet læringsutbytte.', + 'detail.content': 'Innhold', + 'detail.learningOutcomes': 'Læringsutbytte', + 'detail.works': 'Slik fungerer emnet', + 'detail.worksHelp': 'Dokumentasjon om undervisning, samarbeid, oppmøte og deltakelse.', + 'detail.teachingMethods': 'Undervisningsmetoder', + 'detail.workForms': 'Arbeidsformer', + 'detail.collaboration': 'Samarbeid', + 'detail.attendance': 'Oppmøte', + 'detail.online': 'Digital deltakelse', + 'detail.assessment': 'Vurdering og obligatorisk arbeid', + 'detail.assessmentHelp': 'Hva som teller i karakteren, og hva som må godkjennes først.', + 'detail.assessmentFact': 'Vurdering', + 'detail.assessmentWeight': 'Vekting i karakteren', + 'detail.obligatory': 'Obligatoriske aktiviteter', + 'detail.approvalGate': 'må godkjennes før vurdering', + 'detail.requirements': 'Krav', + 'detail.requirementsHelp': 'Anbefalt bakgrunn og adgangsbegrensninger.', + 'detail.prerequisites': 'Forkunnskapskrav', + 'detail.access': 'Adgangsbegrensninger', + 'detail.inferred': 'Utledet', + 'detail.conflicting': 'Motstridende', + 'detail.gradeOutcomes': 'Karakterresultater', + 'detail.gradeHelp': + 'Historiske resultater beskriver tidligere kull og forutsier ikke et enkeltresultat.', + 'detail.examParticipation': 'Eksamensaktivitet', + 'detail.examParticipationHelp': + 'Offisielle DBH-tall viser oppmeldinger og resultater. De teller ikke unike studenter.', + 'detail.examRegistrations': '{count} oppmeldinger', + 'detail.registered': 'Oppmeldt', + 'detail.attended': 'Møtt', + 'detail.passed': 'Bestått', + 'detail.failed': 'Stryk', + 'detail.passedAfterRepeat': 'Bestått ved gjentak', + 'detail.coveredPeriod': 'Tidsperiode', + 'detail.sampleSize': 'Utvalgsstørrelse', + 'detail.results': '{count} resultater', + 'detail.failureRate': 'Strykprosent', + 'detail.averageGrade': 'Gjennomsnittskarakter', + 'detail.medianGrade': 'Mediankarakter', + 'detail.distribution': 'Karakterfordeling', + 'detail.distributionCaption': 'Historisk karakterfordeling', + 'detail.grade': 'Karakter', + 'detail.count': 'Antall', + 'detail.share': 'Andel', + 'detail.sources': 'Kilder og ferskhet', + 'detail.sourcesHelp': + 'Hvert faktum lenker til kildeobservasjonen eller utledningen brukt i svaret. Testdata merkes uttrykkelig.', + 'detail.noObservation': 'Ingen observasjonstid', + 'detail.observed': 'Observert {date}', + 'detail.noSourcePeriod': 'Ingen kildeperiode', + 'detail.observedInline': '{period} · observert {date}', + 'detail.noExternalLink': 'Ingen ekstern kildelenke', + 'detail.openSource': 'Åpne kilde ↗', + 'detail.noEvidence': 'Ingen støttende dokumentasjon', + 'detail.supportingEvidence': 'Støttende dokumentasjon', + 'detail.viewEvidence': 'Vis dokumentasjon', + 'detail.viewEvidenceLabel': 'Vis dokumentasjon {id}', + 'detail.deliveryUnknown': 'Undervisningsform er ukjent', + 'detail.noneReported': 'Ikke oppgitt.', + 'offering.academicYear': 'Studieåret {year}', + 'footer.licensePrefix': + 'Opphavsrett © bidragsyterne til Course Data Platform. Fri programvare lisensiert under ', + 'footer.licenseSuffix': '; tilbys uten garanti.', + 'footer.useful': 'Var dette nyttig? ', + 'footer.optional': ' — helt valgfritt.', + 'footer.license': + 'Opphavsrett © bidragsyterne til Course Data Platform. Fri programvare lisensiert under {license}; tilbys uten garanti.', + 'footer.licenseName': 'AGPL-3.0-only', + 'footer.source': 'Vis kildekoden', + 'footer.tip': 'Støtt prosjektet', + 'feedback.prompt': 'Var dette til hjelp i avgjørelsen? ', + 'feedback.action': 'Fortell oss', + 'feedback.optional': ' — helt valgfritt.', + 'progress.label': 'Progresjon', + 'progress.heading': 'Studieprogresjonen din', + 'progress.description': + 'Importer en karakterhistorikk-PDF fra denne nettleseren for å gå gjennom fullførte emner. Opplysningene blir på denne enheten og er aldri offisiell dokumentasjon.', + 'progress.loading': 'Laster studieprogresjonen din', + 'progress.recoveryHeading': 'Kunne ikke laste studieprogresjonen din', + 'progress.recoveryDescription': + 'Den lagrede studieprogresjonen kunne ikke leses. Ingenting er endret.', + 'progress.recoveryUnavailableHeading': 'Lokal lagring er utilgjengelig', + 'progress.recoveryUnavailableDescription': + 'Denne nettleseren kan ikke lagre studieprogresjonen din. Du kan fortsatt gjennomgå en import i dette besøket.', + 'progress.recoveryReset': 'Tilbakestill studieprogresjon', + 'progress.persistenceFailed': + 'Den siste endringen i studieprogresjonen din kunne ikke lagres i denne nettleseren.', + 'progress.persistenceSaving': 'Lagrer studieprogresjon …', + 'progress.summaryHeading': 'Sammendrag', + 'progress.weightedAverage': 'Studiepoengvektet snitt', + 'progress.noAverage': 'Ingen karakteremner er inkludert', + 'progress.earnedCredits': 'Oppnådde studiepoeng', + 'progress.gradedCredits': 'Studiepoeng med karakter', + 'progress.includedCourses': 'Inkluderte emner', + 'progress.creditsValue': '{credits} studiepoeng', + 'progress.courseCountOne': '{count} inkludert emne', + 'progress.courseCountMany': '{count} inkluderte emner', + 'progress.calculatorHeading': 'Snittinnstillinger', + 'progress.calculatorDescription': + 'Velg hvordan gjentak og stryk skal tas med i det studiepoengvektede snittet.', + 'progress.retakePolicy': 'Regel for gjentak', + 'progress.retakeLatest': 'Bruk siste resultat', + 'progress.retakeBest': 'Bruk beste resultat', + 'progress.includeF': 'Ta med F-karakterer i snittet', + 'progress.importHeading': 'Importer karakterhistorikk', + 'progress.importDescription': + 'Velg en PDF med karakterhistorikk fra denne enheten. Den leses lokalt og lastes ikke opp eller lagres.', + 'progress.importChoose': 'Velg PDF', + 'progress.importDropHint': 'eller slipp én PDF her', + 'progress.importRequirements': 'Bare PDF · opptil 10 MiB · 1–30 sider', + 'progress.importParsing': 'Leser {fileName} lokalt …', + 'progress.importNonFile': 'Velg en fil som skal importeres.', + 'progress.importOneFile': 'Velg én PDF-fil.', + 'progress.importPdfOnly': 'Velg en PDF-fil.', + 'progress.importFailed': 'PDF-en kunne ikke leses som karakterhistorikk.', + 'progress.importRetry': 'Prøv en annen fil', + 'progress.reviewHeading': 'Se gjennom importerte resultater', + 'progress.reviewDescription': + 'Bekreft de studentførte radene som skal legges til i den lokale studieprogresjonen.', + 'progress.reviewFile': 'Fil: {fileName}', + 'progress.reviewInstitution': 'Utstedende institusjon: {institution}', + 'progress.reviewInstitutionUnknown': 'Fant ikke utstedende institusjon', + 'progress.reviewWarningsHeading': 'Advarsler for gjennomgang', + 'progress.reviewSelectAll': 'Velg alle', + 'progress.reviewSelectNone': 'Velg ingen', + 'progress.reviewSelection': '{count} valgt', + 'progress.reviewApprove': 'Legg til valgte resultater', + 'progress.reviewCancel': 'Avbryt import', + 'progress.reviewNoRows': 'Fant ingen emneresultater i denne PDF-en.', + 'progress.reviewNoSelection': 'Velg minst ett resultat som skal legges til.', + 'progress.importConflict': 'Importen har motstridende resultater for samme emne og semester.', + 'progress.resultCourse': 'Emne', + 'progress.resultSemester': 'Semester', + 'progress.resultGrade': 'Karakter', + 'progress.resultCredits': 'Studiepoeng', + 'progress.resultStatedCredits': 'Oppgitte studiepoeng', + 'progress.resultInclude': 'Ta med', + 'progress.historyHeading': 'Historikken din', + 'progress.historyDescription': + 'Dette er lokale oppføringer du har lagt til. De er ikke offisiell emnedokumentasjon.', + 'progress.emptyHeading': 'Ingen resultater ennå', + 'progress.emptyDescription': + 'Importer en PDF med karakterhistorikk for å regne ut det studiepoengvektede snittet.', + 'progress.removeResult': 'Fjern {courseCode}', + 'progress.remove': 'Fjern', + 'progress.gradePass': 'Bestått', + 'progress.gradeFail': 'Ikke bestått', + 'progress.gradeRecognized': 'Godkjent', + 'progress.retrySave': 'Prøv å lagre på nytt', + 'progress.previewHeading': 'Kontroller den opprinnelige PDF-en', + 'progress.previewDescription': + 'Dokumentet blir i denne nettleseren. Kontroller at dette er riktig utskrift før Emneblikk leser den.', + 'progress.previewReady': 'Forhåndsvisning av originaldokumentet', + 'progress.previewParse': 'Les resultater fra denne PDF-en', + 'progress.previewCancel': 'Avbryt', + 'progress.reviewSource': 'Se original PDF', + 'progress.reviewBack': 'Tilbake til importerte resultater', + 'progress.reviewEdit': 'Rediger importerte rader', + 'progress.reviewDoneEditing': 'Ferdig med redigering', + 'progress.reviewDraftChanged': + 'Kladden er endret. Lagret progresjon endres ikke før godkjenning.', + 'progress.reviewDraftUnchanged': + 'Dette er en kladd. Lagret progresjon endres ikke før godkjenning.', + 'progress.reviewRowError': 'Denne valgte raden må rettes før godkjenning.', + 'progress.reviewNew': 'Nytt resultat', + 'progress.reviewUpdate': 'Oppdaterer lagret resultat fra dette semesteret', + 'progress.reviewUnchanged': 'Allerede lagret med samme verdier', + 'progress.reviewPrevious': 'Beholdes som et tidligere forsøk', + 'progress.fieldInstitution': 'Lærested', + 'progress.fieldCode': 'Emnekode', + 'progress.fieldName': 'Emnenavn', + 'progress.fieldYear': 'År', + 'progress.fieldTerm': 'Semester', + 'progress.fieldCredits': 'Studiepoeng', + 'progress.fieldGrade': 'Karakter', + 'progress.termSpring': 'Vår', + 'progress.termAutumn': 'Høst', + 'progress.addCourse': 'Legg til emneresultat', + 'progress.editCourse': 'Rediger {courseCode}', + 'progress.editorDescription': + 'Endringene blir i kladden til du lagrer. Studiepoeng skrives som desimaltall; semester er 1 for vår eller 2 for høst.', + 'progress.editorUnsaved': 'Kladden har ulagrede endringer', + 'progress.editorValidation': 'Rett feltene med feil før du lagrer.', + 'progress.saveCourse': 'Lagre emne', + 'progress.cancelEdit': 'Avbryt redigering', + 'progress.discardHeading': 'Forkaste disse endringene?', + 'progress.discardDescription': 'Fortsett redigeringen for å beholde kladden.', + 'progress.keepEditing': 'Fortsett redigering', + 'progress.discardChanges': 'Forkast endringer', + 'progress.removeCourseHeading': 'Fjerne dette emnet og tidligere forsøk?', + 'progress.removeCourseDescription': 'Denne emneendringen kan angres i denne økten.', + 'progress.confirmRemove': 'Fjern emne', + 'progress.searchLabel': 'Finn et emne i progresjonen din', + 'progress.searchPlaceholder': 'Emnekode, navn eller lærested', + 'progress.searchCount': 'Viser {shown} av {total} emner', + 'progress.searchEmpty': 'Ingen emner samsvarer med søket.', + 'progress.openCourse': 'Åpne {courseCode} i Emneblikk', + 'progress.targetHeading': 'På vei mot studiepoengmålet', + 'progress.targetLabel': 'Studiepoengmål', + 'progress.targetApply': 'Oppdater mål', + 'progress.targetRemaining': '{credits} studiepoeng gjenstår.', + 'progress.targetComplete': 'Du har nådd dette studiepoengmålet.', + 'progress.targetHelp': + 'Dette er en studiepoengoversikt. Lærestedet avgjør om emnesammensetningen fullfører en grad.', + 'progress.distributionHeading': 'Karakterfordeling', + 'progress.distributionDescription': + 'Studiepoeng per valgt karakter. Bestått og godkjent gir studiepoeng, men påvirker ikke bokstavsnittet.', + 'progress.semesterHeading': 'Snittutvikling', + 'progress.semesterDescription': + 'Hver rad viser det samlede studiepoengvektede snittet fram til og med semesteret.', + 'progress.previousHeading': 'Tidligere forsøk', + 'progress.previousDescription': + 'Tidligere forsøk beholdes på denne enheten. Regelen for gjentak avgjør hvilket forsøk som inngår i snittet.', + 'progress.previousCountOne': '1 tidligere forsøk', + 'progress.previousCountMany': '{count} tidligere forsøk', + 'progress.receiptsHeading': 'Importhistorikk', + 'progress.receiptsDescription': 'Godkjente importer registrert på denne enheten.', + 'progress.receiptsEmpty': 'Ingen godkjente importer ennå.', + 'progress.receiptSummary': + '{courses} resultater · {credits} oppnådde studiepoeng · snitt {average}', + 'progress.dataHeading': 'Dine lokale data', + 'progress.dataDescription': + 'Flytt opplysningene med en versjonert JSON-sikkerhetskopi. PDF-filer og angrehistorikken for økten blir aldri tatt med.', + 'progress.backupDownload': 'Last ned sikkerhetskopi', + 'progress.backupDownloaded': 'Sikkerhetskopien er lastet ned.', + 'progress.backupFailed': 'Kunne ikke laste ned sikkerhetskopien.', + 'progress.backupRestoreHeading': 'Gjenopprette denne sikkerhetskopien?', + 'progress.backupRestoreDescription': + 'Dette erstatter resultater, innstillinger, mål og importhistorikk på denne enheten.', + 'progress.backupRestore': 'Gjenopprett sikkerhetskopi', + 'progress.backupRestored': 'Sikkerhetskopien er gjenopprettet.', + 'progress.clearRequest': 'Slett lokal progresjon', + 'progress.clearHeading': 'Slette progresjonen fra denne enheten?', + 'progress.clearDescription': + 'Resultater, innstillinger, importhistorikk og angrehistorikken for økten blir slettet. Last ned en sikkerhetskopi først hvis du trenger dem.', + 'progress.clearConfirm': 'Slett lokal progresjon', + 'progress.confirmCancel': 'Behold opplysningene', + 'progress.exampleLoad': 'Se eksempelprogresjon', + 'progress.exampleHeading': 'Erstatte nåværende progresjon med eksempeldata?', + 'progress.exampleDescription': + 'Last ned en sikkerhetskopi først hvis du vil beholde opplysningene dine.', + 'progress.exampleConfirm': 'Last inn eksempel', + 'progress.calculationHeading': 'Slik beregnes snittet', + 'progress.calculationFormula': 'Σ (karakterpoeng × studiepoeng) ÷ Σ studiepoeng med karakter', + 'progress.calculationDescription': + 'A=5, B=4, C=3, D=2, E=1 og F=0 når stryk tas med. Bestått, ikke bestått og godkjent gir ikke studiepoeng med bokstavkarakter.', + 'progress.calculationDisclaimer': + 'Dette er en beskrivende beregning på norsk A–F-skala, ikke et offisielt opptakssnitt eller en amerikansk GPA.', + 'progress.sessionHeading': 'Endringer i denne økten', + 'progress.sessionDescription': + 'Angre og gjør om flytter mellom emneendringer. Beregningsinnstillinger og studiepoengmål beholdes.', + 'progress.sessionUndo': 'Angre', + 'progress.sessionRedo': 'Gjør om', + 'progress.sessionStart': 'Starten av økten', + 'progress.sessionCurrent': 'Gjeldende', + 'progress.sessionUndone': 'Angret', + 'progress.sessionEmpty': 'Ingen emneendringer i denne økten.', + 'progress.restoreInvalid': 'Den valgte filen er ikke en støttet progresjonssikkerhetskopi.', + 'progress.fileType': 'PDF-utskrift eller JSON-sikkerhetskopi fra Emneblikk', + 'progress.statusCourseSaved': 'Emneresultatet er lagret.', + 'progress.statusImportSaved': 'De importerte resultatene er lagret.', + 'progress.statusCleared': 'Lokal progresjon er slettet.', + 'progress.staleDraft': + 'Progresjonen ble endret etter at denne kladden ble åpnet. Lukk den og start på nytt, slik at ingen nyere endring blir overskrevet.', + 'progress.targetProgress': '{earned} av {target} studiepoeng oppnådd', + 'progress.targetProgressLabel': 'Progresjon mot studiepoengmålet', + 'progress.noTrend': 'Legg til resultater med bokstavkarakter for å se snittutviklingen.', + 'progress.importSupportedOnly': 'Velg én PDF-utskrift eller JSON-sikkerhetskopi fra Emneblikk.', + 'progress.importOpening': 'Åpner {fileName} lokalt …', + 'progress.timelineImport': 'Importerte resultater', + 'progress.timelineAdd': 'La til et resultat', + 'progress.timelineEdit': 'Redigerte et resultat', + 'progress.timelineRemove': 'Fjernet et resultat', + 'progress.timelineRestore': 'Gjenopprettet en sikkerhetskopi', + 'progress.timelineCourses': '{count} resultater etter denne endringen', + 'progress.vocabularyHeading': 'Flere tekstetiketter i karakterutskriften', + 'progress.vocabularyDescription': + 'Legg bare til etiketter når karakterutskriften bruker ord importøren ikke kjenner. Innstillingene gjelder den neste PDF-en i denne nettleserfanen.', + 'progress.vocabularyTerms': 'Semesteretiketter', + 'progress.vocabularyTermsHelp': 'Én per linje som etikett=1 for vår eller etikett=2 for høst.', + 'progress.vocabularyGrades': 'Karakteretiketter', + 'progress.vocabularyGradesHelp': 'Én per linje som etikett=A–F, pass, fail eller recognized.', + 'progress.vocabularyTotals': 'Etiketter for sum studiepoeng', + 'progress.vocabularyTotalsHelp': 'Én etikett per linje, for eksempel «ECTS totalt».', + 'progress.vocabularyInvalid': 'Rett de ekstra tekstetikettene før PDF-en leses.', + 'progress.editResult': 'Rediger emneresultat', +} satisfies MessageCatalogue; + +export const norwegianIndexedMessages = encodeMessageCatalogue(norwegianMessages); + +export const norwegianTokens = { + autumn: 'Høst', + spring: 'Vår', + summer: 'Sommer', + 'full-year': 'Hele studieåret', + bachelor: 'Bachelor', + master: 'Master', + phd: 'Ph.d.', + 'continuing-education': 'Etter- og videreutdanning', + unknown: 'Ukjent', + unavailable: 'Utilgjengelig', + suppressed: 'Skjermet', + conflicting: 'Motstridende', + available: 'Tilgjengelig', + failed: 'Mislykket', + inference: 'Utledning', + fixture: 'Testdata', + 'source-fact': 'Kildefaktum', + 'in-person': 'Fysisk', + online: 'Digitalt', + hybrid: 'Hybrid', + lectures: 'Forelesninger', + exercises: 'Øvinger', + laboratory: 'Laboratorium', + seminar: 'Seminar', + project: 'Prosjekt', + 'self-study': 'Selvstudium', + other: 'Annet', + 'written-exam': 'Skriftlig eksamen', + 'oral-exam': 'Muntlig eksamen', + 'home-exam': 'Hjemmeeksamen', + portfolio: 'Mappevurdering', + practical: 'Praktisk', + assignment: 'Oppgave', +} as const; + +export const norwegianTokenCatalogue = Object.entries(norwegianTokens).map(([token, message]) => ({ + token, + message, +})); diff --git a/apps/student-web/src/i18n.ts b/apps/student-web/src/i18n.ts index 89ffc3a..dd22c3b 100644 --- a/apps/student-web/src/i18n.ts +++ b/apps/student-web/src/i18n.ts @@ -1,11 +1,15 @@ +import { Schema as S } from 'effect'; + export const supportedLocales = ['en', 'nb'] as const; +export const LocaleSchema = S.Literals(supportedLocales); export type Locale = (typeof supportedLocales)[number]; -const en = { +export const englishMessages = { 'app.name': 'Course lens', 'app.catalogueTitle': 'Browse NTNU courses · Course lens', 'app.listTitle': 'Your courses · Course lens', 'app.progressTitle': 'Academic progress · Course lens', + 'app.scheduleTitle': 'Weekly schedule · Course lens', 'locale.label': 'Language', 'locale.en': 'English', 'locale.nb': 'Norsk bokmål', @@ -14,9 +18,6 @@ const en = { 'nav.schedule': 'Schedule', 'nav.explore': 'Explore', 'nav.progress': 'Progress', - 'nav.more': 'More', - 'nav.planned': '{label}, planned', - 'nav.plannedTitle': '{label} is planned', 'nav.collapse': 'Collapse sidebar', 'nav.expand': 'Expand sidebar', 'nav.claim': 'Facts stay traceable. Missing information stays visible.', @@ -114,6 +115,46 @@ const en = { 'course.termFact': 'Term', 'course.campusFact': 'Campus', 'course.back': '← Back to course results', + 'schedule.eyebrow': 'Weekly schedule', + 'schedule.heading': 'Your saved-course schedule', + 'schedule.intro': + 'Choose saved courses and inspect their dated provider-published activities for one ISO week.', + 'schedule.limitations': + 'Provider-published activities are shown by default. Alternative-session assignment and exception semantics are unavailable.', + 'schedule.week': 'Week number', + 'schedule.weekHelp': 'Choose an ISO week from 1 to {maximum}.', + 'schedule.previousWeek': 'Previous week', + 'schedule.nextWeek': 'Next week', + 'schedule.courses': 'Choose saved courses', + 'schedule.coursesHelp': + 'Choose up to {maximum} saved courses. Your selection stays in this link.', + 'schedule.activities': 'Activities for {code}', + 'schedule.activitiesHelp': + 'Choose which provider-published activities appear in this timetable. This does not identify an assigned session.', + 'schedule.savedCoursesLoading': 'Opening your saved courses…', + 'schedule.noSavedCourses': 'No saved courses are available to choose.', + 'schedule.noSavedCoursesHelp': 'Save a course in Explore, then return here to see its schedule.', + 'schedule.maximumSelected': 'You can choose up to {maximum} courses.', + 'schedule.selectionRequired': 'Choose at least one saved course', + 'schedule.selectionRequiredHelp': 'The schedule stays empty until you choose a saved course.', + 'schedule.loading': 'Loading the weekly schedule', + 'schedule.loadingHelp': 'Published activities are being checked for the chosen week.', + 'schedule.loadFailed': 'We could not load this weekly schedule', + 'schedule.empty': 'No published activities this week', + 'schedule.emptyHelp': + 'The selected available courses have no provider-published activities for this ISO week.', + 'schedule.hiddenActivities': 'All published activities are hidden', + 'schedule.hiddenActivitiesHelp': 'Choose an activity above to show it in the timetable.', + 'schedule.agenda': 'Weekly timetable', + 'schedule.sourceFailed': 'Schedule source failed for {code}', + 'schedule.sourceUnavailable': 'Schedule source unavailable for {code}', + 'schedule.sources': 'Source and freshness', + 'schedule.sourceProvider': 'Provider', + 'schedule.sourceObserved': 'Observed', + 'schedule.activityFallback': 'Activity {code}', + 'schedule.timeRange': '{start}–{end} Oslo time', + 'schedule.status': 'Status', + 'schedule.room': 'Room', 'list.eyebrow': 'Your courses', 'list.heading': 'Saved courses and results', 'list.intro': @@ -627,643 +668,45 @@ const en = { 'progress.editResult': 'Edit course result', } as const; -export type MessageKey = keyof typeof en; -type MessageCatalogue = Readonly>; +export type MessageKey = keyof typeof englishMessages; +export type MessageCatalogue = Readonly>; -const nb = { - 'app.name': 'Emneblikk', - 'app.catalogueTitle': 'Utforsk NTNU-emner · Emneblikk', - 'app.listTitle': 'Dine emner · Emneblikk', - 'app.progressTitle': 'Studieprogresjon · Emneblikk', - 'locale.label': 'Språk', - 'locale.en': 'English', - 'locale.nb': 'Norsk bokmål', - 'nav.primary': 'Hovednavigasjon', - 'nav.list': 'Lagret', - 'nav.schedule': 'Timeplan', - 'nav.explore': 'Utforsk', - 'nav.progress': 'Progresjon', - 'nav.more': 'Mer', - 'nav.planned': '{label}, planlagt', - 'nav.plannedTitle': '{label} er planlagt', - 'nav.collapse': 'Slå sammen sidemenyen', - 'nav.expand': 'Utvid sidemenyen', - 'nav.claim': 'Fakta kan spores. Manglende informasjon forblir synlig.', - 'appearance.open': 'Åpne utseendeinnstillinger', - 'appearance.label': 'Utseende', - 'appearance.navLabel': 'Stil', - 'appearance.heading': 'Temalaboratorium', - 'appearance.description': - 'Velg en nordisk palett og hvordan den følger enheten din. Plan- og statusfarger beholder betydningen sin.', - 'appearance.close': 'Lukk utseendeinnstillingene', - 'appearance.palettes': 'Nordisk palett', - 'appearance.mode': 'Lys og mørk modus', - 'appearance.system': 'System', - 'appearance.light': 'Lys', - 'appearance.dark': 'Mørk', - 'appearance.preview': 'Forhåndsvisning av tema', - 'appearance.previewTerm': 'Høstsemester', - 'appearance.previewCredits': '30 studiepoeng', - 'appearance.previewCourse': 'Menneskesentrert databehandling', - 'appearance.previewRequired': 'Påkrevd', - 'appearance.previewValid': 'Passer i planen', - 'appearance.previewWarning': 'Sjekk overlapp', - 'appearance.reset': 'Tilbakestill til Fjord', - 'appearance.fjord': 'Fjord', - 'appearance.fjordDescription': 'Klare blåtoner over kjølig dis.', - 'appearance.aurora': 'Nordlys', - 'appearance.auroraDescription': 'Fiolett lys over dempet sink.', - 'appearance.birch': 'Bjørk', - 'appearance.birchDescription': 'Varm rav over lys stein.', - 'appearance.heather': 'Lyng', - 'appearance.heatherDescription': 'Myk rose over dempet lilla.', - 'appearance.pine': 'Furu', - 'appearance.pineDescription': 'Den opprinnelige smaragd- og olivenpaletten.', - 'appearance.polarNight': 'Polarnatt', - 'appearance.polarNightDescription': 'Himmelblått over dype nøytraler.', - 'catalogue.eyebrow': 'NTNUs emnekatalog', - 'catalogue.heading': 'Utforsk emner før du velger.', - 'catalogue.intro': - 'Se gjennom NTNUs offisielle emnetilbud, snevre inn katalogen og åpne et emne for vurderingsform, arbeidsform og karakterdata.', - 'catalogue.searchRegion': 'Finn og filtrer NTNU-emner', - 'catalogue.searchLabel': 'Søk i emner', - 'catalogue.searchPlaceholder': 'Emnekode eller tittel', - 'catalogue.search': 'Søk', - 'catalogue.searching': 'Søker …', - 'catalogue.term': 'Semester', - 'catalogue.campus': 'Studiested', - 'catalogue.level': 'Studienivå', - 'catalogue.sort': 'Sorter', - 'catalogue.allCampuses': 'Alle studiesteder', - 'catalogue.trondheim': 'Trondheim', - 'catalogue.gjovik': 'Gjøvik', - 'catalogue.alesund': 'Ålesund', - 'catalogue.allLevels': 'Alle nivåer', - 'catalogue.bachelor': 'Bachelor', - 'catalogue.master': 'Master', - 'catalogue.phd': 'Ph.d.', - 'catalogue.relevance': 'NTNU-relevans', - 'catalogue.titleAsc': 'Tittel A–Å', - 'catalogue.titleDesc': 'Tittel Å–A', - 'catalogue.codeAsc': 'Kode A–Å', - 'catalogue.codeDesc': 'Kode Å–A', - 'catalogue.openAdmission': 'Åpent opptak', - 'catalogue.english': 'Undervises på engelsk', - 'catalogue.allCourses': 'Alle NTNU-emner', - 'catalogue.activeRefinements': '{count} aktive avgrensninger{suffix}', - 'catalogue.refineHelp': 'Endre søk, filtre eller sortering hvor som helst i listen.', - 'catalogue.refine': 'Avgrens', - 'catalogue.refineCount': 'Avgrens · {count}', - 'catalogue.refineHeading': 'Avgrens emner', - 'catalogue.refineDescription': 'Endringer brukes med én gang og beholdes i den delbare lenken.', - 'catalogue.closeRefinements': 'Lukk emnefiltrene', - 'catalogue.viewResults': 'Vis resultater', - 'catalogue.loading': 'Laster NTNUs emnekatalog', - 'catalogue.loadingHelp': - 'Offisielle emnesammendrag vises før mer detaljert dokumentasjon lastes.', - 'catalogue.unavailable': 'Emnekatalogen er utilgjengelig', - 'catalogue.loadFailed': 'Vi kunne ikke laste emnene', - 'catalogue.retry': 'Filtrene dine er bevart. Send søket på nytt for å prøve igjen.', - 'catalogue.empty': 'Ingen emner passer til disse filtrene', - 'catalogue.emptyHelp': 'Prøv et annet uttrykk, studiested, semester eller studienivå.', - 'catalogue.results': 'Emneresultater', - 'catalogue.partial': - 'Noen katalogdata kunne ikke brukes. Offisielle resultater som ble validert, er fortsatt synlige.', - 'catalogue.courses': 'Emner', - 'catalogue.showing': 'Viser {shown} av {total} emner', - 'catalogue.official': 'Offisiell NTNU-katalog', - 'catalogue.moreFailed': 'Flere emner kunne ikke lastes.', - 'catalogue.loadingMore': 'Laster flere emner …', - 'catalogue.showMore': 'Vis flere emner', - 'catalogue.end': 'Slutten av resultatene', - 'course.open': 'Åpne {code}: {title}', - 'course.titleUnavailable': 'Tittel er utilgjengelig', - 'course.termUnavailable': 'Semester er utilgjengelig', - 'course.campusUnreported': 'Studiested er ikke oppgitt', - 'course.creditsValue': '{value} studiepoeng', - 'course.termFact': 'Semester', - 'course.campusFact': 'Studiested', - 'course.back': '← Tilbake til emneresultatene', - 'list.eyebrow': 'Dine emner', - 'list.heading': 'Lagrede emner og resultater', - 'list.intro': - 'Lagrede emner og NTNU-resultater møtes her. Resultater kommer fra din lokale progresjonshistorikk og blir aldri bokmerker av seg selv.', - 'list.backToExplore': 'Utforsk flere emner', - 'list.loading': 'Åpner emnene dine', - 'list.loadingHelp': - 'Lagrede emner og lokale resultater leses fra denne nettleseren før listen vises.', - 'list.density': 'Visning', - 'list.densityCard': 'Kort', - 'list.densityCompact': 'Kompakt', - 'list.originFilter': 'Emnekilde', - 'list.originAll': 'Alle {count}', - 'list.originSaved': 'Lagret {count}', - 'list.originResults': 'Resultater {count}', - 'list.rowOrigins': 'Kilder for {code}', - 'list.resultBadge': 'Resultat', - 'list.savedBadge': 'Lagret', - 'list.resultOnlyHelp': 'Lagre emnet for å legge til etiketter og et privat notat.', - 'list.resultEvidence': 'Siste lokale resultat', - 'list.resultEvidenceHelp': - 'Fra din lokale progresjonshistorikk, ikke gjeldende emnedokumentasjon.', - 'list.resultSummary': '{grade} · {term} {year} · {credits} studiepoeng', - 'list.courseCountOne': '1 emne', - 'list.courseCountMany': '{count} emner', - 'list.countOne': '1 lagret emne', - 'list.countMany': '{count} lagrede emner', - 'list.empty': 'Du har ingen lagrede emner eller NTNU-resultater ennå', - 'list.emptyHelp': 'Lagre et emne i Utforsk, eller legg til et NTNU-resultat i Progresjon.', - 'list.saveCourse': 'Lagre {code} i listen', - 'list.removeCourse': 'Fjern {code} fra listen', - 'list.save': 'Lagre', - 'list.remove': 'Fjern', - 'list.savePending': 'De lagrede emnene lastes fortsatt', - 'list.savePaused': - 'Lagring er satt på pause til den lagrede listen er gjenopprettet eller tilbakestilt.', - 'list.savePausedLink': 'Åpne listen for å gjenopprette lagrede emner', - 'list.factsNotLoaded': 'Emnedetaljer ble ikke lastet i denne økten.', - 'list.factsNotLoadedHelp': - 'Åpne emnet for å laste dokumentasjon om vurdering, arbeid og historiske resultater.', - 'list.note': 'Notatet ditt', - 'list.notePlaceholder': - 'Hvorfor emnet er interessant, spørsmål du vil stille, kollisjoner å sjekke', - 'list.noteHelp': - 'Privat på denne enheten. Notater er skrevet av deg og teller aldri som emnedokumentasjon.', - 'list.saveNote': 'Lagre notat', - 'list.openCourse': 'Åpne {code}', - 'list.repaired': 'Ubrukelige lagrede oppføringer ble fjernet: {count}.', - 'list.recoveryHeading': 'Kunne ikke laste lagrede emner', - 'list.recoveryUnsupported': - 'Nettleseren har lagret en nyere versjon av listen (versjon {version}). Oppdater applikasjonen, eller tilbakestill listen for å begynne på nytt på denne enheten.', - 'list.recoveryCorrupt': 'Den lagrede listen kunne ikke leses.', - 'list.recoveryUnavailable': - 'Nettleseren tillot ikke lokal lagring, så lagrede emner er utilgjengelige her.', - 'list.recoveryKept': - 'Ingenting er slettet. Den lagrede verdien beholdes nedenfor slik at du kan kopiere den før du tilbakestiller.', - 'list.recoveryShowStored': 'Vis den lagrede verdien', - 'list.reset': 'Tilbakestill lagrede emner', - 'list.resetHelp': 'Tilbakestilling sletter den lagrede listen på denne enheten permanent.', - 'list.persistFailed': - 'Den siste endringen i lagrede emner kunne ikke lagres i denne nettleseren.', - 'list.savedStatus': '{code} er lagret i listen.', - 'list.removedStatus': '{code} er fjernet fra listen.', - 'list.removedManyStatus': '{count} emner er fjernet fra listen.', - 'list.dismissAllStatus': 'Lukk alle {count}', - 'list.undo': 'Angre', - 'list.undoSave': 'Angre lagring av {code}', - 'list.undoRemove': 'Angre fjerning av {code}', - 'list.undoRemoveMany': 'Angre fjerning av {count} emner', - 'list.dismissStatus': 'Lukk', - 'list.labels': 'Etiketter', - 'list.labelsHeading': 'Etikettene dine', - 'list.labelsHelp': - 'Etiketter er dine egne ord for dine egne planer. De endrer aldri emnefakta og teller aldri som dokumentasjon.', - 'list.labelsForCourse': 'Etiketter for {code}', - 'list.labelsForSelection': 'Etiketter for {count} valgte lagrede emner', - 'list.labelsManageOnly': 'Opprett og endre etiketter', - 'list.openLabels': 'Etiketter', - 'list.editLabelsFor': 'Endre etiketter for {code}', - 'list.closeLabels': 'Lukk etiketter', - 'list.labelName': 'Etikettnavn', - 'list.labelNamePlaceholder': 'Høst 2027, Spør veileder, Digitale kandidater', - 'list.labelColor': 'Etikettfarge', - 'list.addLabel': 'Legg til etikett', - 'list.saveLabel': 'Lagre etikett', - 'list.cancelLabelEdit': 'Avbryt endringen', - 'list.editLabel': 'Endre etikett', - 'list.deleteLabel': 'Slett etikett', - 'list.labelRowActions': 'Handlinger for {name}', - 'list.deleteLabelConfirm': - 'Slette «{name}»? Lagrede emner beholder identiteten sin; bare etiketten fjernes.', - 'list.cancelDeleteLabel': 'Avbryt slettingen', - 'list.labelCountUnit': 'lagrede emner', - 'list.labelCountUnitOne': 'lagret emne', - 'list.filterExcludedBadge': 'Utelatt', - 'list.noLabels': 'Du har ikke opprettet en etikett ennå.', - 'list.labelOnCourse': 'På {code}', - 'list.labelPartlyOnSelection': 'På {matched} av {count} valgte', - 'list.labelDuplicate': 'Det finnes allerede en etikett med det navnet.', - 'list.labelEmptyName': 'Gi etiketten et navn først.', - 'list.labelLimit': 'Maksimum er {count} etiketter. Slett en for å legge til en ny.', - 'list.labelUnknownError': 'Den etiketten finnes ikke lenger.', - 'list.rowLabels': 'Etiketter på {code}', - 'list.rowNoLabels': 'Ingen etiketter ennå', - 'compare.heading': 'Sammenlign lagrede emner', - 'compare.intro': 'Bare det som skiller vises. Fakta kildene ikke oppgav beholder navnet sitt.', - 'compare.close': 'Lukk sammenligningen', - 'compare.open': 'Sammenlign', - 'compare.differencesOnly': 'Forskjeller', - 'compare.showAll': 'Vis alt', - 'compare.dimension': 'Dimensjon', - 'compare.notLoaded': 'Ikke lastet', - 'compare.none': 'Ingen', - 'compare.activityCount': '{count} aktiviteter', - 'compare.credits': 'Studiepoeng', - 'compare.term': 'Termin', - 'compare.campus': 'Studiested', - 'compare.assessment': 'Vurdering', - 'compare.obligatory': 'Obligatorisk arbeid', - 'compare.collaboration': 'Samarbeid', - 'compare.outcomeScale': 'Karakterskala', - 'compare.failureRate': 'Strykprosent', - 'compare.sample': 'Utvalg', - 'compare.period': 'Observert periode', - 'compare.identical': 'Alle gjenværende dimensjoner er like for disse emnene.', - 'list.filterHeading': 'Filtrer på etikett', - 'list.filterCombine': 'Kombiner etiketter', - 'list.filterMode': 'Treff på inkluderte etiketter', - 'list.filterModeAny': 'Minst én', - 'list.filterModeAll': 'Alle', - 'list.filterIncludeHeading': 'Inkluder etiketter', - 'list.filterExcludeHeading': 'Utelat etiketter', - 'list.filterExcludeHelp': 'Å utelate flere etiketter fjerner emner som har én av dem.', - 'list.filterExclude': 'Utelat {name}', - 'list.filterClear': 'Fjern etikettfilteret', - 'list.filterSummaryInclude': 'Viser lagrede emner i {labels}.', - 'list.filterSummaryExclude': 'Viser lagrede emner, utenom {excluded}.', - 'list.filterSummaryBoth': 'Viser lagrede emner i {labels}, utenom {excluded}.', - 'list.filterSummaryNone': 'Viser alle lagrede emner.', - 'list.filterContradiction': - '{labels} forblir utelatt og ble derfor fjernet fra de inkluderte etikettene.', - 'list.filterUnknownDropped': - 'Filteret viste til etiketter som ikke finnes lenger. De ble fjernet: {count}.', - 'list.filterUnlabeled': 'Uten etikett', - 'list.filterUnsatisfiable': - '{unlabeled} betyr ingen etikett i det hele tatt, så Alle kan aldri treffe den sammen med en etikett. Bytt til Minst én, eller fjern en av dem.', - 'list.filteredCount': 'Viser {shown} av {total} emner', - 'list.filterEmpty': 'Ingen emner passer til disse filtrene', - 'list.filterEmptyHelp': 'Endre filtrene, eller fjern dem for å se alle emner.', - 'list.filtersClear': 'Fjern filtre', - 'list.selectCourse': 'Velg {code}', - 'list.selectionTray': 'Valgte lagrede emner', - 'list.selectionCount': '{count} valgt', - 'list.selectionCountOne': '1 valgt', - 'list.selectionAddLabels': 'Legg til etiketter', - 'list.selectionClear': 'Fjern utvalget', - 'list.selectionRemove': 'Fjern valgte', - 'list.selectionRemoveConfirm': 'Fjerne {count} lagrede emner, med notater og etiketter?', - 'list.selectionRemoveConfirmAction': 'Ja, fjern dem', - 'list.selectionRemoveCancel': 'Behold dem', - 'label.colorViolet': 'Fiolett', - 'label.colorAmber': 'Rav', - 'label.colorRose': 'Rosa', - 'label.colorEmerald': 'Smaragd', - 'label.colorSky': 'Himmelblå', - 'signals.heading': 'Vurdering og arbeid', - 'signals.gradedAssessment': 'Tellende vurdering', - 'signals.checking': 'Sjekker NTNUs emneside …', - 'signals.failed': 'Emnesignaler er utilgjengelige', - 'signals.waiting': 'Venter på kontroll', - 'signals.missing': 'Ingen emnesignaler', - 'signals.inferred': 'Utledet fra NTNUs emnetekst', - 'signals.writtenExam': 'Skriftlig eksamen', - 'signals.oralExam': 'Muntlig eksamen', - 'signals.homeExam': 'Hjemmeeksamen', - 'signals.project': 'Prosjekt', - 'signals.portfolio': 'Mappe', - 'signals.practical': 'Praktisk', - 'signals.assignment': 'Innlevering', - 'signals.otherAssessment': 'Annen vurdering', - 'signals.obligatory': 'Obligatorisk arbeid', - 'signals.noObligatory': 'Ingen obligatoriske aktiviteter oppgitt', - 'signals.individual': 'Individuelt arbeid', - 'signals.group': 'Gruppearbeid', - 'signals.mixedCollaboration': 'Individuelt + gruppearbeid', - 'signals.required': 'Påkrevd', - 'signals.graded': 'tellende', - 'signals.ungraded': 'Ikke tellende', - 'signals.oneActivity': '1 aktivitet', - 'signals.activityCount': '{count} aktiviteter', - 'signals.noneReported': 'Ikke oppgitt', - 'outcomes.heading': 'Historiske resultater', - 'outcomes.source': 'HK-dir (DBH)', - 'outcomes.checking': 'Sjekker HK-dir …', - 'outcomes.failed': 'Karakterdata er utilgjengelig', - 'outcomes.waiting': 'Venter på kontroll', - 'outcomes.missing': 'Ingen karakteroversikt', - 'outcomes.view': 'Velg karakterskala for historiske resultater', - 'outcomes.letter': 'Bokstavkarakterer', - 'outcomes.passFail': 'Bestått/ikke bestått', - 'outcomes.mixed': 'Blandede skalaer', - 'outcomes.available': 'Historiske resultater er tilgjengelige', - 'outcomes.noBuckets': 'Ingen vurderte karaktergrupper ble returnert', - 'outcomes.protected': 'Små tall er personvernskjermet', - 'outcomes.conflicting': 'Publiserte resultatkilder er motstridende', - 'outcomes.unknown': 'Resultatfordelingen er ikke fastslått', - 'outcomes.unavailable': 'Ingen publisert resultatfordeling', - 'outcomes.failedRate': '{value} % strøk', - 'outcomes.sample': 'n={value}', - 'outcomes.chartLabel': 'Historiske resultater fra HK-dir DBH. {summary}', - 'outcomes.percent': '{label} {value} prosent', - 'outcomes.pass': 'Bestått', - 'outcomes.fail': 'Ikke bestått', - 'detail.aria': 'Emnedetaljer for {code}', - 'detail.titleUnavailable': 'Emnetittel er utilgjengelig', - 'detail.loading': 'Samler emnedokumentasjon', - 'detail.loadingHelp': 'Offisielle emnedetaljer og historiske resultater lastes uavhengig.', - 'detail.unavailable': 'Emnet er utilgjengelig', - 'detail.loadFailed': 'Vi kunne ikke laste dette emnet', - 'detail.partial': 'Delvis resultat', - 'detail.partialHelp': - 'Én kilde er utilgjengelig. Emnedetaljer fra andre kilder vises fortsatt, og manglende resultater behandles ikke som null.', - 'detail.complete': 'Alle konfigurerte kilder svarte.', - 'detail.credits': 'Studiepoeng', - 'detail.level': 'Nivå', - 'detail.language': 'Språk', - 'detail.availability': 'Tilgjengelighet', - 'detail.availabilityHelp': 'Når og hvor emnet tilbys.', - 'detail.termLocation': 'Undervisningssemester og sted', - 'detail.learn': 'Dette skal du lære', - 'detail.learnHelp': 'Emneinnhold og forventet læringsutbytte.', - 'detail.content': 'Innhold', - 'detail.learningOutcomes': 'Læringsutbytte', - 'detail.works': 'Slik fungerer emnet', - 'detail.worksHelp': 'Dokumentasjon om undervisning, samarbeid, oppmøte og deltakelse.', - 'detail.teachingMethods': 'Undervisningsmetoder', - 'detail.workForms': 'Arbeidsformer', - 'detail.collaboration': 'Samarbeid', - 'detail.attendance': 'Oppmøte', - 'detail.online': 'Digital deltakelse', - 'detail.assessment': 'Vurdering og obligatorisk arbeid', - 'detail.assessmentHelp': 'Hva som teller i karakteren, og hva som må godkjennes først.', - 'detail.assessmentFact': 'Vurdering', - 'detail.assessmentWeight': 'Vekting i karakteren', - 'detail.obligatory': 'Obligatoriske aktiviteter', - 'detail.approvalGate': 'må godkjennes før vurdering', - 'detail.requirements': 'Krav', - 'detail.requirementsHelp': 'Anbefalt bakgrunn og adgangsbegrensninger.', - 'detail.prerequisites': 'Forkunnskapskrav', - 'detail.access': 'Adgangsbegrensninger', - 'detail.inferred': 'Utledet', - 'detail.conflicting': 'Motstridende', - 'detail.gradeOutcomes': 'Karakterresultater', - 'detail.gradeHelp': - 'Historiske resultater beskriver tidligere kull og forutsier ikke et enkeltresultat.', - 'detail.examParticipation': 'Eksamensaktivitet', - 'detail.examParticipationHelp': - 'Offisielle DBH-tall viser oppmeldinger og resultater. De teller ikke unike studenter.', - 'detail.examRegistrations': '{count} oppmeldinger', - 'detail.registered': 'Oppmeldt', - 'detail.attended': 'Møtt', - 'detail.passed': 'Bestått', - 'detail.failed': 'Stryk', - 'detail.passedAfterRepeat': 'Bestått ved gjentak', - 'detail.coveredPeriod': 'Tidsperiode', - 'detail.sampleSize': 'Utvalgsstørrelse', - 'detail.results': '{count} resultater', - 'detail.failureRate': 'Strykprosent', - 'detail.averageGrade': 'Gjennomsnittskarakter', - 'detail.medianGrade': 'Mediankarakter', - 'detail.distribution': 'Karakterfordeling', - 'detail.distributionCaption': 'Historisk karakterfordeling', - 'detail.grade': 'Karakter', - 'detail.count': 'Antall', - 'detail.share': 'Andel', - 'detail.sources': 'Kilder og ferskhet', - 'detail.sourcesHelp': - 'Hvert faktum lenker til kildeobservasjonen eller utledningen brukt i svaret. Testdata merkes uttrykkelig.', - 'detail.noObservation': 'Ingen observasjonstid', - 'detail.observed': 'Observert {date}', - 'detail.noSourcePeriod': 'Ingen kildeperiode', - 'detail.observedInline': '{period} · observert {date}', - 'detail.noExternalLink': 'Ingen ekstern kildelenke', - 'detail.openSource': 'Åpne kilde ↗', - 'detail.noEvidence': 'Ingen støttende dokumentasjon', - 'detail.supportingEvidence': 'Støttende dokumentasjon', - 'detail.viewEvidence': 'Vis dokumentasjon', - 'detail.viewEvidenceLabel': 'Vis dokumentasjon {id}', - 'detail.deliveryUnknown': 'Undervisningsform er ukjent', - 'detail.noneReported': 'Ikke oppgitt.', - 'offering.academicYear': 'Studieåret {year}', - 'footer.licensePrefix': - 'Opphavsrett © bidragsyterne til Course Data Platform. Fri programvare lisensiert under ', - 'footer.licenseSuffix': '; tilbys uten garanti.', - 'footer.useful': 'Var dette nyttig? ', - 'footer.optional': ' — helt valgfritt.', - 'footer.license': - 'Opphavsrett © bidragsyterne til Course Data Platform. Fri programvare lisensiert under {license}; tilbys uten garanti.', - 'footer.licenseName': 'AGPL-3.0-only', - 'footer.source': 'Vis kildekoden', - 'footer.tip': 'Støtt prosjektet', - 'feedback.prompt': 'Var dette til hjelp i avgjørelsen? ', - 'feedback.action': 'Fortell oss', - 'feedback.optional': ' — helt valgfritt.', - 'progress.label': 'Progresjon', - 'progress.heading': 'Studieprogresjonen din', - 'progress.description': - 'Importer en karakterhistorikk-PDF fra denne nettleseren for å gå gjennom fullførte emner. Opplysningene blir på denne enheten og er aldri offisiell dokumentasjon.', - 'progress.loading': 'Laster studieprogresjonen din', - 'progress.recoveryHeading': 'Kunne ikke laste studieprogresjonen din', - 'progress.recoveryDescription': - 'Den lagrede studieprogresjonen kunne ikke leses. Ingenting er endret.', - 'progress.recoveryUnavailableHeading': 'Lokal lagring er utilgjengelig', - 'progress.recoveryUnavailableDescription': - 'Denne nettleseren kan ikke lagre studieprogresjonen din. Du kan fortsatt gjennomgå en import i dette besøket.', - 'progress.recoveryReset': 'Tilbakestill studieprogresjon', - 'progress.persistenceFailed': - 'Den siste endringen i studieprogresjonen din kunne ikke lagres i denne nettleseren.', - 'progress.persistenceSaving': 'Lagrer studieprogresjon …', - 'progress.summaryHeading': 'Sammendrag', - 'progress.weightedAverage': 'Studiepoengvektet snitt', - 'progress.noAverage': 'Ingen karakteremner er inkludert', - 'progress.earnedCredits': 'Oppnådde studiepoeng', - 'progress.gradedCredits': 'Studiepoeng med karakter', - 'progress.includedCourses': 'Inkluderte emner', - 'progress.creditsValue': '{credits} studiepoeng', - 'progress.courseCountOne': '{count} inkludert emne', - 'progress.courseCountMany': '{count} inkluderte emner', - 'progress.calculatorHeading': 'Snittinnstillinger', - 'progress.calculatorDescription': - 'Velg hvordan gjentak og stryk skal tas med i det studiepoengvektede snittet.', - 'progress.retakePolicy': 'Regel for gjentak', - 'progress.retakeLatest': 'Bruk siste resultat', - 'progress.retakeBest': 'Bruk beste resultat', - 'progress.includeF': 'Ta med F-karakterer i snittet', - 'progress.importHeading': 'Importer karakterhistorikk', - 'progress.importDescription': - 'Velg en PDF med karakterhistorikk fra denne enheten. Den leses lokalt og lastes ikke opp eller lagres.', - 'progress.importChoose': 'Velg PDF', - 'progress.importDropHint': 'eller slipp én PDF her', - 'progress.importRequirements': 'Bare PDF · opptil 10 MiB · 1–30 sider', - 'progress.importParsing': 'Leser {fileName} lokalt …', - 'progress.importNonFile': 'Velg en fil som skal importeres.', - 'progress.importOneFile': 'Velg én PDF-fil.', - 'progress.importPdfOnly': 'Velg en PDF-fil.', - 'progress.importFailed': 'PDF-en kunne ikke leses som karakterhistorikk.', - 'progress.importRetry': 'Prøv en annen fil', - 'progress.reviewHeading': 'Se gjennom importerte resultater', - 'progress.reviewDescription': - 'Bekreft de studentførte radene som skal legges til i den lokale studieprogresjonen.', - 'progress.reviewFile': 'Fil: {fileName}', - 'progress.reviewInstitution': 'Utstedende institusjon: {institution}', - 'progress.reviewInstitutionUnknown': 'Fant ikke utstedende institusjon', - 'progress.reviewWarningsHeading': 'Advarsler for gjennomgang', - 'progress.reviewSelectAll': 'Velg alle', - 'progress.reviewSelectNone': 'Velg ingen', - 'progress.reviewSelection': '{count} valgt', - 'progress.reviewApprove': 'Legg til valgte resultater', - 'progress.reviewCancel': 'Avbryt import', - 'progress.reviewNoRows': 'Fant ingen emneresultater i denne PDF-en.', - 'progress.reviewNoSelection': 'Velg minst ett resultat som skal legges til.', - 'progress.importConflict': 'Importen har motstridende resultater for samme emne og semester.', - 'progress.resultCourse': 'Emne', - 'progress.resultSemester': 'Semester', - 'progress.resultGrade': 'Karakter', - 'progress.resultCredits': 'Studiepoeng', - 'progress.resultStatedCredits': 'Oppgitte studiepoeng', - 'progress.resultInclude': 'Ta med', - 'progress.historyHeading': 'Historikken din', - 'progress.historyDescription': - 'Dette er lokale oppføringer du har lagt til. De er ikke offisiell emnedokumentasjon.', - 'progress.emptyHeading': 'Ingen resultater ennå', - 'progress.emptyDescription': - 'Importer en PDF med karakterhistorikk for å regne ut det studiepoengvektede snittet.', - 'progress.removeResult': 'Fjern {courseCode}', - 'progress.remove': 'Fjern', - 'progress.gradePass': 'Bestått', - 'progress.gradeFail': 'Ikke bestått', - 'progress.gradeRecognized': 'Godkjent', - 'progress.retrySave': 'Prøv å lagre på nytt', - 'progress.previewHeading': 'Kontroller den opprinnelige PDF-en', - 'progress.previewDescription': - 'Dokumentet blir i denne nettleseren. Kontroller at dette er riktig utskrift før Emneblikk leser den.', - 'progress.previewReady': 'Forhåndsvisning av originaldokumentet', - 'progress.previewParse': 'Les resultater fra denne PDF-en', - 'progress.previewCancel': 'Avbryt', - 'progress.reviewSource': 'Se original PDF', - 'progress.reviewBack': 'Tilbake til importerte resultater', - 'progress.reviewEdit': 'Rediger importerte rader', - 'progress.reviewDoneEditing': 'Ferdig med redigering', - 'progress.reviewDraftChanged': - 'Kladden er endret. Lagret progresjon endres ikke før godkjenning.', - 'progress.reviewDraftUnchanged': - 'Dette er en kladd. Lagret progresjon endres ikke før godkjenning.', - 'progress.reviewRowError': 'Denne valgte raden må rettes før godkjenning.', - 'progress.reviewNew': 'Nytt resultat', - 'progress.reviewUpdate': 'Oppdaterer lagret resultat fra dette semesteret', - 'progress.reviewUnchanged': 'Allerede lagret med samme verdier', - 'progress.reviewPrevious': 'Beholdes som et tidligere forsøk', - 'progress.fieldInstitution': 'Lærested', - 'progress.fieldCode': 'Emnekode', - 'progress.fieldName': 'Emnenavn', - 'progress.fieldYear': 'År', - 'progress.fieldTerm': 'Semester', - 'progress.fieldCredits': 'Studiepoeng', - 'progress.fieldGrade': 'Karakter', - 'progress.termSpring': 'Vår', - 'progress.termAutumn': 'Høst', - 'progress.addCourse': 'Legg til emneresultat', - 'progress.editCourse': 'Rediger {courseCode}', - 'progress.editorDescription': - 'Endringene blir i kladden til du lagrer. Studiepoeng skrives som desimaltall; semester er 1 for vår eller 2 for høst.', - 'progress.editorUnsaved': 'Kladden har ulagrede endringer', - 'progress.editorValidation': 'Rett feltene med feil før du lagrer.', - 'progress.saveCourse': 'Lagre emne', - 'progress.cancelEdit': 'Avbryt redigering', - 'progress.discardHeading': 'Forkaste disse endringene?', - 'progress.discardDescription': 'Fortsett redigeringen for å beholde kladden.', - 'progress.keepEditing': 'Fortsett redigering', - 'progress.discardChanges': 'Forkast endringer', - 'progress.removeCourseHeading': 'Fjerne dette emnet og tidligere forsøk?', - 'progress.removeCourseDescription': 'Denne emneendringen kan angres i denne økten.', - 'progress.confirmRemove': 'Fjern emne', - 'progress.searchLabel': 'Finn et emne i progresjonen din', - 'progress.searchPlaceholder': 'Emnekode, navn eller lærested', - 'progress.searchCount': 'Viser {shown} av {total} emner', - 'progress.searchEmpty': 'Ingen emner samsvarer med søket.', - 'progress.openCourse': 'Åpne {courseCode} i Emneblikk', - 'progress.targetHeading': 'På vei mot studiepoengmålet', - 'progress.targetLabel': 'Studiepoengmål', - 'progress.targetApply': 'Oppdater mål', - 'progress.targetRemaining': '{credits} studiepoeng gjenstår.', - 'progress.targetComplete': 'Du har nådd dette studiepoengmålet.', - 'progress.targetHelp': - 'Dette er en studiepoengoversikt. Lærestedet avgjør om emnesammensetningen fullfører en grad.', - 'progress.distributionHeading': 'Karakterfordeling', - 'progress.distributionDescription': - 'Studiepoeng per valgt karakter. Bestått og godkjent gir studiepoeng, men påvirker ikke bokstavsnittet.', - 'progress.semesterHeading': 'Snittutvikling', - 'progress.semesterDescription': - 'Hver rad viser det samlede studiepoengvektede snittet fram til og med semesteret.', - 'progress.previousHeading': 'Tidligere forsøk', - 'progress.previousDescription': - 'Tidligere forsøk beholdes på denne enheten. Regelen for gjentak avgjør hvilket forsøk som inngår i snittet.', - 'progress.previousCountOne': '1 tidligere forsøk', - 'progress.previousCountMany': '{count} tidligere forsøk', - 'progress.receiptsHeading': 'Importhistorikk', - 'progress.receiptsDescription': 'Godkjente importer registrert på denne enheten.', - 'progress.receiptsEmpty': 'Ingen godkjente importer ennå.', - 'progress.receiptSummary': - '{courses} resultater · {credits} oppnådde studiepoeng · snitt {average}', - 'progress.dataHeading': 'Dine lokale data', - 'progress.dataDescription': - 'Flytt opplysningene med en versjonert JSON-sikkerhetskopi. PDF-filer og angrehistorikken for økten blir aldri tatt med.', - 'progress.backupDownload': 'Last ned sikkerhetskopi', - 'progress.backupDownloaded': 'Sikkerhetskopien er lastet ned.', - 'progress.backupFailed': 'Kunne ikke laste ned sikkerhetskopien.', - 'progress.backupRestoreHeading': 'Gjenopprette denne sikkerhetskopien?', - 'progress.backupRestoreDescription': - 'Dette erstatter resultater, innstillinger, mål og importhistorikk på denne enheten.', - 'progress.backupRestore': 'Gjenopprett sikkerhetskopi', - 'progress.backupRestored': 'Sikkerhetskopien er gjenopprettet.', - 'progress.clearRequest': 'Slett lokal progresjon', - 'progress.clearHeading': 'Slette progresjonen fra denne enheten?', - 'progress.clearDescription': - 'Resultater, innstillinger, importhistorikk og angrehistorikken for økten blir slettet. Last ned en sikkerhetskopi først hvis du trenger dem.', - 'progress.clearConfirm': 'Slett lokal progresjon', - 'progress.confirmCancel': 'Behold opplysningene', - 'progress.exampleLoad': 'Se eksempelprogresjon', - 'progress.exampleHeading': 'Erstatte nåværende progresjon med eksempeldata?', - 'progress.exampleDescription': - 'Last ned en sikkerhetskopi først hvis du vil beholde opplysningene dine.', - 'progress.exampleConfirm': 'Last inn eksempel', - 'progress.calculationHeading': 'Slik beregnes snittet', - 'progress.calculationFormula': 'Σ (karakterpoeng × studiepoeng) ÷ Σ studiepoeng med karakter', - 'progress.calculationDescription': - 'A=5, B=4, C=3, D=2, E=1 og F=0 når stryk tas med. Bestått, ikke bestått og godkjent gir ikke studiepoeng med bokstavkarakter.', - 'progress.calculationDisclaimer': - 'Dette er en beskrivende beregning på norsk A–F-skala, ikke et offisielt opptakssnitt eller en amerikansk GPA.', - 'progress.sessionHeading': 'Endringer i denne økten', - 'progress.sessionDescription': - 'Angre og gjør om flytter mellom emneendringer. Beregningsinnstillinger og studiepoengmål beholdes.', - 'progress.sessionUndo': 'Angre', - 'progress.sessionRedo': 'Gjør om', - 'progress.sessionStart': 'Starten av økten', - 'progress.sessionCurrent': 'Gjeldende', - 'progress.sessionUndone': 'Angret', - 'progress.sessionEmpty': 'Ingen emneendringer i denne økten.', - 'progress.restoreInvalid': 'Den valgte filen er ikke en støttet progresjonssikkerhetskopi.', - 'progress.fileType': 'PDF-utskrift eller JSON-sikkerhetskopi fra Emneblikk', - 'progress.statusCourseSaved': 'Emneresultatet er lagret.', - 'progress.statusImportSaved': 'De importerte resultatene er lagret.', - 'progress.statusCleared': 'Lokal progresjon er slettet.', - 'progress.staleDraft': - 'Progresjonen ble endret etter at denne kladden ble åpnet. Lukk den og start på nytt, slik at ingen nyere endring blir overskrevet.', - 'progress.targetProgress': '{earned} av {target} studiepoeng oppnådd', - 'progress.targetProgressLabel': 'Progresjon mot studiepoengmålet', - 'progress.noTrend': 'Legg til resultater med bokstavkarakter for å se snittutviklingen.', - 'progress.importSupportedOnly': 'Velg én PDF-utskrift eller JSON-sikkerhetskopi fra Emneblikk.', - 'progress.importOpening': 'Åpner {fileName} lokalt …', - 'progress.timelineImport': 'Importerte resultater', - 'progress.timelineAdd': 'La til et resultat', - 'progress.timelineEdit': 'Redigerte et resultat', - 'progress.timelineRemove': 'Fjernet et resultat', - 'progress.timelineRestore': 'Gjenopprettet en sikkerhetskopi', - 'progress.timelineCourses': '{count} resultater etter denne endringen', - 'progress.vocabularyHeading': 'Flere tekstetiketter i karakterutskriften', - 'progress.vocabularyDescription': - 'Legg bare til etiketter når karakterutskriften bruker ord importøren ikke kjenner. Innstillingene gjelder den neste PDF-en i denne nettleserfanen.', - 'progress.vocabularyTerms': 'Semesteretiketter', - 'progress.vocabularyTermsHelp': 'Én per linje som etikett=1 for vår eller etikett=2 for høst.', - 'progress.vocabularyGrades': 'Karakteretiketter', - 'progress.vocabularyGradesHelp': 'Én per linje som etikett=A–F, pass, fail eller recognized.', - 'progress.vocabularyTotals': 'Etiketter for sum studiepoeng', - 'progress.vocabularyTotalsHelp': 'Én etikett per linje, for eksempel «ECTS totalt».', - 'progress.vocabularyInvalid': 'Rett de ekstra tekstetikettene før PDF-en leses.', - 'progress.editResult': 'Rediger emneresultat', -} satisfies MessageCatalogue; +/** + * Dynamic catalogues are indexed by this English baseline. The module that + * loads a locale only carries values, not message keys, so it cannot make the + * production entry chunk retain another language's strings. + */ +export const messageKeys = Object.freeze( + Object.keys(englishMessages) as Array, +) as ReadonlyArray; + +const messageIndexes: Readonly> = Object.freeze( + Object.fromEntries(messageKeys.map((key, index) => [key, index])) as Record, +); + +export const IndexedMessageCatalogueSchema = S.Array(S.String); +export type IndexedMessageCatalogue = typeof IndexedMessageCatalogueSchema.Type; -const catalogues: Readonly> = { en, nb }; +const TokenTranslationSchema = S.Struct({ + token: S.String, + message: S.String, +}); +export const TokenCatalogueSchema = S.Array(TokenTranslationSchema); +export type TokenCatalogue = typeof TokenCatalogueSchema.Type; + +/** + * Locale-specific data remains explicit application state. English needs no + * payload; the optional payload is the loaded Norwegian catalogue. + */ +export const Localization = S.Struct({ + locale: LocaleSchema, + messages: S.optional(IndexedMessageCatalogueSchema), + tokens: S.optional(TokenCatalogueSchema), +}); +export type Localization = typeof Localization.Type; + +export const encodeMessageCatalogue = (catalogue: MessageCatalogue): IndexedMessageCatalogue => + messageKeys.map((key) => catalogue[key]); const interpolationNames = (message: string): ReadonlyArray => [...message.matchAll(/\{([a-z][a-zA-Z0-9]*)\}/g)] @@ -1271,20 +714,24 @@ const interpolationNames = (message: string): ReadonlyArray => .filter((name): name is string => name !== undefined) .sort(); -export const validateMessageCatalogues = (): ReadonlyArray => { +/** + * Kept independent of dynamic imports so the validator can load both complete + * source catalogues directly without pulling Norwegian strings into the app. + */ +export const validateMessageCatalogues = ( + catalogues: Readonly>, +): ReadonlyArray => { const issues: Array = []; - const baselineKeys = Object.keys(en) as ReadonlyArray; - const baselineKeySet = new Set(baselineKeys); for (const locale of supportedLocales) { - const catalogue = catalogues[locale] as Readonly>; - for (const key of baselineKeys) { + const catalogue = catalogues[locale]; + for (const key of messageKeys) { const message = catalogue[key]; if (message === undefined) { issues.push(`${locale}: missing message "${key}"`); continue; } - const expected = interpolationNames(en[key]); + const expected = interpolationNames(englishMessages[key]); const actual = interpolationNames(message); if (expected.join('|') !== actual.join('|')) { issues.push( @@ -1293,7 +740,7 @@ export const validateMessageCatalogues = (): ReadonlyArray => { } } for (const key of Object.keys(catalogue)) { - if (!baselineKeySet.has(key as MessageKey)) { + if (messageIndexes[key as MessageKey] === undefined) { issues.push(`${locale}: unknown message "${key}"`); } } @@ -1308,58 +755,23 @@ export const localeTag = (locale: Locale): 'en-GB' | 'nb-NO' => locale === 'nb' ? 'nb-NO' : 'en-GB'; export const translate = ( - locale: Locale, + localization: Localization, key: MessageKey, parameters: Readonly> = {}, ): string => { - let message: string = catalogues[locale][key] ?? en[key]; + const localizedMessage = + localization.locale === 'nb' ? localization.messages?.[messageIndexes[key]] : undefined; + let message = localizedMessage ?? englishMessages[key]; for (const [name, value] of Object.entries(parameters)) { message = message.replaceAll(`{${name}}`, String(value)); } return message; }; -const tokenCatalogues: Readonly>>> = { - en: {}, - nb: { - autumn: 'Høst', - spring: 'Vår', - summer: 'Sommer', - 'full-year': 'Hele studieåret', - bachelor: 'Bachelor', - master: 'Master', - phd: 'Ph.d.', - 'continuing-education': 'Etter- og videreutdanning', - unknown: 'Ukjent', - unavailable: 'Utilgjengelig', - suppressed: 'Skjermet', - conflicting: 'Motstridende', - available: 'Tilgjengelig', - failed: 'Mislykket', - inference: 'Utledning', - fixture: 'Testdata', - 'source-fact': 'Kildefaktum', - 'in-person': 'Fysisk', - online: 'Digitalt', - hybrid: 'Hybrid', - lectures: 'Forelesninger', - exercises: 'Øvinger', - laboratory: 'Laboratorium', - seminar: 'Seminar', - project: 'Prosjekt', - 'self-study': 'Selvstudium', - other: 'Annet', - 'written-exam': 'Skriftlig eksamen', - 'oral-exam': 'Muntlig eksamen', - 'home-exam': 'Hjemmeeksamen', - portfolio: 'Mappevurdering', - practical: 'Praktisk', - assignment: 'Oppgave', - }, -}; - -export const translateToken = (locale: Locale, value: string): string => - tokenCatalogues[locale][value] ?? +export const translateToken = (localization: Localization, value: string): string => + (localization.locale === 'nb' + ? localization.tokens?.find((translation) => translation.token === value)?.message + : undefined) ?? value .split('-') .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`) diff --git a/apps/student-web/src/navigation.ts b/apps/student-web/src/navigation.ts index 3cbd486..861178b 100644 --- a/apps/student-web/src/navigation.ts +++ b/apps/student-web/src/navigation.ts @@ -1,16 +1,16 @@ import type { Html, HtmlBuilder } from 'foldkit/html'; -import { translate, type Locale } from './i18n'; +import { translate, type Localization } from './i18n'; import { icon, type AppIcon } from './icons'; -export type NavigationRoute = 'explore' | 'list' | 'progress' | 'appearance'; +export type NavigationRoute = 'explore' | 'list' | 'schedule' | 'progress' | 'appearance'; export interface NavigationItem { readonly id: 'list' | 'schedule' | 'explore' | 'progress' | 'appearance'; readonly label: string; readonly accessibleLabel: string; readonly icon: AppIcon; - readonly href: string | null; + readonly href: string; readonly isCurrent: boolean; /** 1 is the most important destination. Rank, never a position. */ readonly priority: number; @@ -49,14 +49,14 @@ export const bottomBarSeating = ( /** * The desktop sidebar and mobile bottom bar intentionally consume the same - * explicit navigation model. A planned destination stays non-interactive until - * its corresponding product surface exists. + * explicit navigation model. */ export const primaryNavigation = ( - locale: Locale, + locale: Localization, route: NavigationRoute, exploreHref: string, listHref: string, + scheduleHref: string, progressHref: string, appearanceHref: string, ): ReadonlyArray => [ @@ -74,8 +74,8 @@ export const primaryNavigation = ( label: translate(locale, 'nav.schedule'), accessibleLabel: translate(locale, 'nav.schedule'), icon: 'schedule', - href: null, - isCurrent: false, + href: scheduleHref, + isCurrent: route === 'schedule', priority: 3, }, { @@ -119,7 +119,6 @@ const desktopItemIcon = 'grid size-6 flex-none place-items-center leading-none [&_svg]:block [&_svg]:w-full [&_svg]:h-full'; const desktopItem = ( - locale: Locale, item: NavigationItem, collapsed: boolean, h: HtmlBuilder, @@ -130,34 +129,22 @@ const desktopItem = ( ]; const layoutClass = collapsed ? 'justify-center px-3' : ''; - return item.href === null - ? h.span( - [ - h.Class( - `${desktopItemBase} ${layoutClass} text-on-surface-variant cursor-not-allowed opacity-[0.52]`, - ), - h.AriaDisabled(true), - h.AriaLabel(translate(locale, 'nav.planned', { label: item.accessibleLabel })), - h.Title(translate(locale, 'nav.plannedTitle', { label: item.accessibleLabel })), - ], - children, - ) - : h.a( - [ - h.Href(item.href), - h.Class( - `${desktopItemBase} ${layoutClass} ${ - item.isCurrent - ? 'bg-secondary-container text-on-secondary-container' - : 'text-on-surface-variant hover:bg-surface-container-high' - }`, - ), - ...(item.isCurrent ? [h.AriaCurrent('page')] : []), - h.AriaLabel(item.accessibleLabel), - ...(collapsed ? [h.Title(item.accessibleLabel)] : []), - ], - children, - ); + return h.a( + [ + h.Href(item.href), + h.Class( + `${desktopItemBase} ${layoutClass} ${ + item.isCurrent + ? 'bg-secondary-container text-on-secondary-container' + : 'text-on-surface-variant hover:bg-surface-container-high' + }`, + ), + ...(item.isCurrent ? [h.AriaCurrent('page')] : []), + h.AriaLabel(item.accessibleLabel), + ...(collapsed ? [h.Title(item.accessibleLabel)] : []), + ], + children, + ); }; /** @@ -187,50 +174,35 @@ const mobileItemIcon = (isCurrent: boolean): string => }`; /** - * Every destination is a link or a planned placeholder. Appearance used to be - * a button opening a dialog, which made it the one item rendered from a - * different element with its own styling — and the one place a stray reset - * could drift away from its peers. It is a page now, so there is no special - * case left. + * Every destination is one ordinary link. Appearance used to be a button + * opening a dialog, which made it the one item rendered from a different + * element with its own styling. It is a page now, so there is no special case. */ -const mobileItem = ( - locale: Locale, - item: NavigationItem, - h: HtmlBuilder, -): Html => { +const mobileItem = (item: NavigationItem, h: HtmlBuilder): Html => { const itemClass = mobileItemLayout(item.isCurrent); const children = [ icon(item.icon, mobileItemIcon(item.isCurrent), h), h.span([h.Class('max-w-full truncate')], [item.label]), ]; - return item.href === null - ? h.span( - [ - h.Class(`${itemClass} cursor-not-allowed opacity-[0.52]`), - h.AriaDisabled(true), - h.AriaLabel(translate(locale, 'nav.planned', { label: item.accessibleLabel })), - h.Title(translate(locale, 'nav.plannedTitle', { label: item.accessibleLabel })), - ], - children, - ) - : h.a( - [ - h.Href(item.href), - h.Class(itemClass), - ...(item.isCurrent ? [h.AriaCurrent('page')] : []), - h.AriaLabel(item.accessibleLabel), - ], - children, - ); + return h.a( + [ + h.Href(item.href), + h.Class(itemClass), + ...(item.isCurrent ? [h.AriaCurrent('page')] : []), + h.AriaLabel(item.accessibleLabel), + ], + children, + ); }; export const desktopNavigation = ( - locale: Locale, + locale: Localization, collapsed: boolean, route: NavigationRoute, exploreHref: string, listHref: string, + scheduleHref: string, progressHref: string, appearanceHref: string, onToggle: Message | undefined, @@ -242,6 +214,7 @@ export const desktopNavigation = ( route, exploreHref, listHref, + scheduleHref, progressHref, appearanceHref, ); @@ -299,7 +272,7 @@ export const desktopNavigation = ( [...items] .filter((item) => item.id !== 'appearance') .sort((left, right) => left.priority - right.priority) - .map((item) => desktopItem(locale, item, collapsed, h)), + .map((item) => desktopItem(item, collapsed, h)), ), h.div( [h.Class(`mt-auto grid gap-4 pt-4 ${collapsed ? '' : 'mx-3'}`)], @@ -347,10 +320,11 @@ export const desktopNavigation = ( }; export const mobileNavigation = ( - locale: Locale, + locale: Localization, route: NavigationRoute, exploreHref: string, listHref: string, + scheduleHref: string, progressHref: string, appearanceHref: string, h: HtmlBuilder, @@ -363,7 +337,15 @@ export const mobileNavigation = ( h.AriaLabel(translate(locale, 'nav.primary')), ], bottomBarSeating( - primaryNavigation(locale, route, exploreHref, listHref, progressHref, appearanceHref), - ).map((item) => mobileItem(locale, item, h)), + primaryNavigation( + locale, + route, + exploreHref, + listHref, + scheduleHref, + progressHref, + appearanceHref, + ), + ).map((item) => mobileItem(item, h)), ); }; diff --git a/apps/student-web/src/scene.test.ts b/apps/student-web/src/scene.test.ts index 661940c..81913b2 100644 --- a/apps/student-web/src/scene.test.ts +++ b/apps/student-web/src/scene.test.ts @@ -2,21 +2,51 @@ import { Scene } from 'foldkit'; import { test } from 'vitest'; -import { fixtureSearchResponse } from './course-client'; +import { fixtureScheduleResponse, fixtureSearchResponse } from './course-client.fixture'; import { partialCourseInsightFixture } from './course-insight.fixture'; +import { courseIdentity } from './course-identity'; import { CatalogueEmpty, CatalogueFailure, CataloguePartial, DetailFailure, + LoadedNorwegianMessages, DetailPartial, + SavedCoursesReady, initForHref, update, view, } from './app'; +import { norwegianIndexedMessages, norwegianTokenCatalogue } from './i18n.nb'; +import { ScheduleSuccess } from './features/schedule'; +import { emptySavedList, saveCourse } from './saved-courses'; const baseModel = () => initForHref('http://course-lens.local/').model; +const scheduleState = ( + courseCode: string, + response = fixtureScheduleResponse([courseCode], '2026-autumn', 45), + locale: 'en' | 'nb' = 'en', +) => { + const identity = courseIdentity(courseCode); + if (identity === null) throw new Error(`Expected valid fixture course code: ${courseCode}`); + const model = initForHref( + `http://course-lens.local/schedule?lang=${locale}&term=2026-autumn&week=45&courses=${courseCode}`, + ).model; + return { + ...model, + savedCourses: SavedCoursesReady({ + state: saveCourse(emptySavedList, identity, '2026-07-24T12:00:00.000Z'), + repairedEntries: 0, + }), + schedule: { + ...model.schedule, + activeRequestKey: '2026-autumn|45|' + courseCode, + result: ScheduleSuccess({ response }), + }, + }; +}; + test('Scene: catalogue loading is announced as a live semantic state', () => { Scene.scene( { update, view }, @@ -26,6 +56,64 @@ test('Scene: catalogue loading is announced as a live semantic state', () => { ); }); +test('Scene: a loaded locale replaces the English fallback without losing app state', () => { + const model = update( + initForHref('http://course-lens.local/?lang=nb').model, + LoadedNorwegianMessages({ + messages: norwegianIndexedMessages, + tokens: norwegianTokenCatalogue, + }), + ).model; + + Scene.scene( + { update, view }, + Scene.given(model), + Scene.expect(Scene.text('Utforsk emner før du velger.')).toExist(), + Scene.expect(Scene.text('Browse courses before you choose.')).toBeAbsent(), + ); +}); + +test('Scene: schedule activity controls use the loaded Bokmål catalogue', () => { + const model = update( + scheduleState('TDT4136', fixtureScheduleResponse(['TDT4136'], '2026-autumn', 45), 'nb'), + LoadedNorwegianMessages({ + messages: norwegianIndexedMessages, + tokens: norwegianTokenCatalogue, + }), + ).model; + + Scene.scene( + { update, view }, + Scene.given(model), + Scene.expect(Scene.role('group', { name: 'Aktiviteter for TDT4136' })).toExist(), + Scene.expect(Scene.text('Alle publiserte aktiviteter er skjult')).toBeAbsent(), + Scene.expect(Scene.text('Activities for TDT4136')).toBeAbsent(), + ); +}); + +test('Scene: activity controls prefer title, then summary, then their published code', () => { + const response = fixtureScheduleResponse(['TDT4136'], '2026-autumn', 45); + const labelledResponse = { + ...response, + items: response.items.map((item) => ({ + ...item, + activityStreams: [ + { activityCode: 'title', title: 'Provider title', summary: 'Ignored summary' }, + { activityCode: 'summary', title: null, summary: 'Provider summary' }, + { activityCode: 'code-only', title: null, summary: null }, + ], + })), + }; + + Scene.scene( + { update, view }, + Scene.given(scheduleState('TDT4136', labelledResponse)), + Scene.expect(Scene.role('checkbox', { name: 'Provider title' })).toExist(), + Scene.expect(Scene.role('checkbox', { name: 'Provider summary' })).toExist(), + Scene.expect(Scene.role('checkbox', { name: 'Activity code-only' })).toExist(), + ); +}); + test('Scene: an empty catalogue is a non-failure outcome with guidance', () => { Scene.scene( { update, view }, @@ -107,3 +195,91 @@ test('Scene: Inspect failure remains distinct from partial evidence', () => { Scene.expect(Scene.text('The detail provider timed out')).toExist(), ); }); + +test('Scene: Schedule exposes keyboard-operable week and saved-course controls with event evidence', () => { + Scene.scene( + { update, view }, + Scene.given(scheduleState('TDT4136')), + Scene.expect(Scene.role('spinbutton', { name: 'Week number' })).toExist(), + Scene.expect(Scene.role('button', { name: 'Previous week' })).toExist(), + Scene.expect(Scene.role('button', { name: 'Next week' })).toExist(), + Scene.expect(Scene.role('group', { name: 'Choose saved courses' })).toExist(), + Scene.expect(Scene.role('checkbox', { name: 'TDT4136' })).toExist(), + Scene.expect(Scene.role('group', { name: 'Activities for TDT4136' })).toExist(), + Scene.expect(Scene.role('checkbox', { name: 'Search and planning' })).toExist(), + Scene.expect(Scene.role('checkbox', { name: 'Constraint satisfaction exercise' })).toExist(), + Scene.expect(Scene.text('Search and planning')).toExist(), + Scene.expect(Scene.text('Source and freshness')).toExist(), + Scene.expect( + Scene.text( + 'Provider-published activities are shown by default. Alternative-session assignment and exception semantics are unavailable.', + ), + ).toExist(), + ); +}); + +test('Scene: hiding every activity is distinct from an empty published week', () => { + const base = scheduleState('TDT4136'); + Scene.scene( + { update, view }, + Scene.given({ + ...base, + schedule: { + ...base.schedule, + hiddenActivityKeys: ['TDT4136:lecture', 'TDT4136:exercise'], + }, + }), + Scene.expect(Scene.text('All published activities are hidden')).toExist(), + Scene.expect(Scene.text('No published activities this week')).toBeAbsent(), + Scene.expect(Scene.text('Weekly timetable')).toBeAbsent(), + ); +}); + +test('Scene: a known empty schedule week is not presented as a source failure', () => { + Scene.scene( + { update, view }, + Scene.given(scheduleState('TDT4109')), + Scene.expect(Scene.role('status')).toExist(), + Scene.expect(Scene.text('No published activities this week')).toExist(), + Scene.expect(Scene.role('alert')).toBeAbsent(), + ); +}); + +test('Scene: a failed source stays distinct while successful neighbouring events remain visible', () => { + const response = fixtureScheduleResponse(['TDT4136', 'TDT4109'], '2026-autumn', 45); + const failedResponse = { + ...response, + items: response.items.map((item) => + item.courseCode === 'TDT4109' + ? { + ...item, + sourceStatus: { + ...item.sourceStatus, + status: 'failed' as const, + warning: 'The fixture source timed out.', + }, + } + : item, + ), + }; + const base = scheduleState('TDT4136', failedResponse); + const secondIdentity = courseIdentity('TDT4109'); + if (secondIdentity === null) throw new Error('Expected a valid fixture course code.'); + const saved = + base.savedCourses._tag === 'SavedCoursesReady' + ? saveCourse(base.savedCourses.state, secondIdentity, '2026-07-24T12:01:00.000Z') + : emptySavedList; + + Scene.scene( + { update, view }, + Scene.given({ + ...base, + savedCourses: SavedCoursesReady({ state: saved, repairedEntries: 0 }), + schedule: { ...base.schedule, selectedCodes: ['TDT4136', 'TDT4109'] }, + }), + Scene.expect(Scene.role('alert')).toExist(), + Scene.expect(Scene.text('Schedule source failed for TDT4109')).toExist(), + Scene.expect(Scene.text('Search and planning')).toExist(), + Scene.expect(Scene.text('No published activities this week')).toBeAbsent(), + ); +}); diff --git a/apps/student-web/src/story.test.ts b/apps/student-web/src/story.test.ts index dc0f650..2e84857 100644 --- a/apps/student-web/src/story.test.ts +++ b/apps/student-web/src/story.test.ts @@ -1,11 +1,13 @@ import fc from 'fast-check'; import { expect, test } from 'vitest'; -import { fixtureSearchResponse } from './course-client'; +import { fixtureScheduleResponse, fixtureSearchResponse } from './course-client.fixture'; import { courseIdentity } from './course-identity'; import { ChangedCampus, ChangedLabelInclusion, + GotScheduleMessage, + LoadedSavedCourses, RequestedRemoveSavedCourse, RequestedSaveCourse, RequestedUndoSavedListAction, @@ -31,6 +33,7 @@ import { type LabelFilter, type SavedListState, } from './saved-courses'; +import { Message as ScheduleMessage } from './features/schedule'; const savedAt = '2026-07-24T12:00:00.000Z'; @@ -71,6 +74,82 @@ test('stale catalogue responses cannot overwrite a newer Explore transition', () expect(staleResponse.commands ?? []).toEqual([]); }); +test('Schedule URL state round-trips through saved-course canonicalization and ignores stale responses', () => { + const saved = saveCourse(emptySavedList, identity('TDT4136'), savedAt); + const initial = initForHref( + 'http://course-lens.local/schedule?lang=en&term=2026-autumn&week=45&courses=TDT4136,UNKNOWN&hideActivity=TDT4136%3Aexercise&hideActivity=TDT4136%3Aexercise&hideActivity=TDT4136%3Apractice%3Aoptional&hideActivity=NOTSELECTED%3Aignored&hideActivity=UNKNOWN%3Aseminar', + ).model; + expect(initial.schedule.hiddenActivityKeys).toEqual([ + 'TDT4136:exercise', + 'TDT4136:practice:optional', + 'UNKNOWN:seminar', + ]); + const loaded = update( + initial, + LoadedSavedCourses({ + load: { _tag: 'SavedListLoaded', state: saved, repairedEntries: 0 }, + }), + ); + + expect(loaded.model.route).toBe('schedule'); + expect(loaded.model.schedule.selectedCodes).toEqual(['TDT4136']); + expect(loaded.model.schedule.hiddenActivityKeys).toEqual([ + 'TDT4136:exercise', + 'TDT4136:practice:optional', + ]); + expect(commandNames(loaded.commands ?? [])).toEqual(['FetchSchedule', 'Navigate']); + expect(normalizedUrl(loaded.model, null, '/schedule')).toBe( + '/schedule?lang=en&term=2026-autumn&week=45&courses=TDT4136&hideActivity=TDT4136%3Aexercise&hideActivity=TDT4136%3Apractice%3Aoptional', + ); + + const canonical = update( + loaded.model, + GotScheduleMessage({ + message: ScheduleMessage.SucceededSchedule({ + requestKey: loaded.model.schedule.activeRequestKey, + response: fixtureScheduleResponse(['TDT4136'], '2026-autumn', 45), + }), + }), + ); + expect(canonical.model.schedule.hiddenActivityKeys).toEqual(['TDT4136:exercise']); + expect(commandNames(canonical.commands ?? [])).toEqual(['Navigate']); + expect(canonical.commands?.[0]?.args).toMatchObject({ mode: 'replace' }); + + const toggled = update( + canonical.model, + GotScheduleMessage({ + message: ScheduleMessage.ToggledActivityVisibility({ + courseCode: 'TDT4136', + activityCode: 'lecture', + isVisible: false, + }), + }), + ); + expect(commandNames(toggled.commands ?? [])).toEqual(['Navigate']); + expect(normalizedUrl(toggled.model, null, '/schedule')).toBe( + '/schedule?lang=en&term=2026-autumn&week=45&courses=TDT4136&hideActivity=TDT4136%3Aexercise&hideActivity=TDT4136%3Alecture', + ); + + const moved = update( + toggled.model, + GotScheduleMessage({ + message: ScheduleMessage.ChangedWeek({ value: '46' }), + }), + ); + const stale = update( + moved.model, + GotScheduleMessage({ + message: ScheduleMessage.SucceededSchedule({ + requestKey: canonical.model.schedule.activeRequestKey, + response: fixtureScheduleResponse(['TDT4136'], '2026-autumn', 45), + }), + }), + ); + + expect(stale.model).toBe(moved.model); + expect(stale.commands ?? []).toEqual([]); +}); + test('saved-list persistence follows explicit save and note commits, never drafts', () => { const saveRequest = update(readyModel(), RequestedSaveCourse({ courseCode: 'TDT4136' })); expect(commandNames(saveRequest.commands ?? [])).toEqual(['StampSavedCourse']); diff --git a/apps/student-web/wrangler.jsonc b/apps/student-web/wrangler.jsonc new file mode 100644 index 0000000..035bedf --- /dev/null +++ b/apps/student-web/wrangler.jsonc @@ -0,0 +1,10 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "course-decision-web-dev", + "compatibility_date": "2026-09-22", + "assets": { + "directory": "./dist", + "not_found_handling": "single-page-application", + }, + "previews": {}, +} diff --git a/docs/generated/course-decision-openapi.json b/docs/generated/course-decision-openapi.json index beb647a..893e794 100644 --- a/docs/generated/course-decision-openapi.json +++ b/docs/generated/course-decision-openapi.json @@ -40,6 +40,7 @@ "gradeSummaries", "decisionSignals", "insight", + "schedule", "openapi", "openapiJson" ], @@ -59,6 +60,9 @@ "insight": { "type": "string" }, + "schedule": { + "type": "string" + }, "openapi": { "type": "string" }, @@ -5095,6 +5099,472 @@ "operationId": "postV1Course-decision-signals" } }, + "/v1/course-schedules": { + "post": { + "summary": "Get published NTNU course schedule occurrences for one week", + "description": "Returns published activities in the requested Oslo-local ISO week with explicit source availability.", + "tags": [ + "Courses" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "courseCodes", + "term", + "week" + ], + "properties": { + "courseCodes": { + "minItems": 1, + "maxItems": 12, + "uniqueItems": true, + "type": "array", + "items": { + "minLength": 2, + "maxLength": 20, + "pattern": "^[A-Za-zÆØÅæøå][A-Za-zÆØÅæøå0-9]*[0-9][A-Za-zÆØÅæøå0-9]*(?:-[0-9]+)?$", + "type": "string" + } + }, + "term": { + "pattern": "^[0-9]{4}-(spring|autumn)$", + "type": "string" + }, + "week": { + "minimum": 1, + "maximum": 53, + "type": "integer" + } + } + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "required": [ + "courseCodes", + "term", + "week" + ], + "properties": { + "courseCodes": { + "minItems": 1, + "maxItems": 12, + "uniqueItems": true, + "type": "array", + "items": { + "minLength": 2, + "maxLength": 20, + "pattern": "^[A-Za-zÆØÅæøå][A-Za-zÆØÅæøå0-9]*[0-9][A-Za-zÆØÅæøå0-9]*(?:-[0-9]+)?$", + "type": "string" + } + }, + "term": { + "pattern": "^[0-9]{4}-(spring|autumn)$", + "type": "string" + }, + "week": { + "minimum": 1, + "maximum": 53, + "type": "integer" + } + } + } + }, + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "courseCodes", + "term", + "week" + ], + "properties": { + "courseCodes": { + "minItems": 1, + "maxItems": 12, + "uniqueItems": true, + "type": "array", + "items": { + "minLength": 2, + "maxLength": 20, + "pattern": "^[A-Za-zÆØÅæøå][A-Za-zÆØÅæøå0-9]*[0-9][A-Za-zÆØÅæøå0-9]*(?:-[0-9]+)?$", + "type": "string" + } + }, + "term": { + "pattern": "^[0-9]{4}-(spring|autumn)$", + "type": "string" + }, + "week": { + "minimum": 1, + "maximum": 53, + "type": "integer" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Response for status 200", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "items", + "meta" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "required": [ + "courseCode", + "sourceStatus", + "activityStreams", + "occurrences" + ], + "properties": { + "courseCode": { + "minLength": 2, + "maxLength": 20, + "type": "string" + }, + "sourceStatus": { + "type": "object", + "required": [ + "provider", + "status", + "observedAt", + "warning" + ], + "properties": { + "provider": { + "minLength": 1, + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "available", + "unavailable", + "failed" + ] + }, + "observedAt": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:[.][0-9]+)?(?:Z|[+-][0-9]{2}:[0-9]{2})$", + "type": [ + "string", + "null" + ] + }, + "warning": { + "minLength": 1, + "type": [ + "string", + "null" + ] + } + } + }, + "activityStreams": { + "type": "array", + "items": { + "type": "object", + "required": [ + "activityCode", + "title", + "summary" + ], + "properties": { + "activityCode": { + "minLength": 1, + "type": "string" + }, + "title": { + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "summary": { + "minLength": 1, + "type": [ + "string", + "null" + ] + } + } + } + }, + "occurrences": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "courseCode", + "activityCode", + "title", + "summary", + "status", + "startsAt", + "endsAt", + "rooms", + "evidence" + ], + "properties": { + "id": { + "minLength": 1, + "type": "string" + }, + "courseCode": { + "minLength": 2, + "maxLength": 20, + "type": "string" + }, + "activityCode": { + "minLength": 1, + "type": "string" + }, + "title": { + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "summary": { + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "status": { + "minLength": 1, + "type": "string" + }, + "startsAt": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:[.][0-9]+)?(?:Z|[+-][0-9]{2}:[0-9]{2})$", + "type": "string" + }, + "endsAt": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:[.][0-9]+)?(?:Z|[+-][0-9]{2}:[0-9]{2})$", + "type": "string" + }, + "rooms": { + "type": "array", + "items": { + "type": "object", + "required": [ + "building", + "room", + "url" + ], + "properties": { + "building": { + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "room": { + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "url": { + "minLength": 1, + "type": [ + "string", + "null" + ] + } + } + } + }, + "evidence": { + "type": "object", + "required": [ + "provider", + "kind", + "sourceRecordId", + "sourceUrl", + "observedAt" + ], + "properties": { + "provider": { + "const": "ntnu-course-schedule", + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "source-fact", + "fixture" + ] + }, + "sourceRecordId": { + "minLength": 1, + "type": "string" + }, + "sourceUrl": { + "minLength": 1, + "type": "string" + }, + "observedAt": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:[.][0-9]+)?(?:Z|[+-][0-9]{2}:[0-9]{2})$", + "type": "string" + } + } + } + } + } + } + } + } + }, + "meta": { + "type": "object", + "required": [ + "count", + "term", + "week", + "timezone", + "limitations" + ], + "properties": { + "count": { + "minimum": 0, + "type": "integer" + }, + "term": { + "pattern": "^[0-9]{4}-(spring|autumn)$", + "type": "string" + }, + "week": { + "minimum": 1, + "maximum": 53, + "type": "integer" + }, + "timezone": { + "const": "Europe/Oslo", + "type": "string" + }, + "limitations": { + "type": "object", + "required": [ + "activitySelection", + "activityGrouping", + "exceptionSemantics" + ], + "properties": { + "activitySelection": { + "const": "all-published-activities", + "type": "string" + }, + "activityGrouping": { + "const": "unavailable", + "type": "string" + }, + "exceptionSemantics": { + "const": "provider-status-unverified", + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Response for status 400", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "type", + "title", + "status", + "detail", + "requestId" + ], + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "detail": { + "type": "string" + }, + "requestId": { + "type": "string" + } + } + } + } + } + }, + "503": { + "description": "Response for status 503", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "type", + "title", + "status", + "detail", + "requestId" + ], + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "detail": { + "type": "string" + }, + "requestId": { + "type": "string" + } + } + } + } + } + } + }, + "operationId": "postV1Course-schedules" + } + }, "/v1/course-grade-summaries": { "post": { "summary": "Summarize grades for visible NTNU courses", diff --git a/packages/course-contracts/src/course-insight.ts b/packages/course-contracts/src/course-insight.ts index bbe7208..6ecc918 100644 --- a/packages/course-contracts/src/course-insight.ts +++ b/packages/course-contracts/src/course-insight.ts @@ -165,7 +165,7 @@ const ExamParticipationDto = Type.Object({ passedAfterRepeat: FactDto(Type.Integer({ minimum: 0 })), }); -const SourceStatusDto = Type.Object({ +export const SourceStatusDto = Type.Object({ provider: Type.String({ minLength: 1 }), status: Type.Union([ Type.Literal('available'), @@ -318,6 +318,74 @@ export const CourseDecisionSignalsResponseDto = Type.Object({ }), }); +export const CourseScheduleRequestDto = Type.Object({ + courseCodes: Type.Array( + Type.String({ + minLength: 2, + maxLength: 20, + pattern: '^[A-Za-zÆØÅæøå][A-Za-zÆØÅæøå0-9]*[0-9][A-Za-zÆØÅæøå0-9]*(?:-[0-9]+)?$', + }), + { minItems: 1, maxItems: 12, uniqueItems: true }, + ), + term: Type.String({ pattern: '^[0-9]{4}-(spring|autumn)$' }), + week: Type.Integer({ minimum: 1, maximum: 53 }), +}); + +export const CourseScheduleEvidenceDto = Type.Object({ + provider: Type.Literal('ntnu-course-schedule'), + kind: Type.Union([Type.Literal('source-fact'), Type.Literal('fixture')]), + sourceRecordId: Type.String({ minLength: 1 }), + sourceUrl: Type.String({ minLength: 1 }), + observedAt: DateTimeStringDto, +}); + +export const CourseScheduleActivityStreamDto = Type.Object({ + activityCode: Type.String({ minLength: 1 }), + title: Type.Union([Type.String({ minLength: 1 }), Type.Null()]), + summary: Type.Union([Type.String({ minLength: 1 }), Type.Null()]), +}); + +export const CourseScheduleOccurrenceDto = Type.Object({ + id: Type.String({ minLength: 1 }), + courseCode: Type.String({ minLength: 2, maxLength: 20 }), + activityCode: Type.String({ minLength: 1 }), + title: Type.Union([Type.String({ minLength: 1 }), Type.Null()]), + summary: Type.Union([Type.String({ minLength: 1 }), Type.Null()]), + status: Type.String({ minLength: 1 }), + startsAt: DateTimeStringDto, + endsAt: DateTimeStringDto, + rooms: Type.Array( + Type.Object({ + building: Type.Union([Type.String({ minLength: 1 }), Type.Null()]), + room: Type.Union([Type.String({ minLength: 1 }), Type.Null()]), + url: Type.Union([Type.String({ minLength: 1 }), Type.Null()]), + }), + ), + evidence: CourseScheduleEvidenceDto, +}); + +export const CourseScheduleItemDto = Type.Object({ + courseCode: Type.String({ minLength: 2, maxLength: 20 }), + sourceStatus: SourceStatusDto, + activityStreams: Type.Array(CourseScheduleActivityStreamDto), + occurrences: Type.Array(CourseScheduleOccurrenceDto), +}); + +export const CourseScheduleResponseDto = Type.Object({ + items: Type.Array(CourseScheduleItemDto), + meta: Type.Object({ + count: Type.Integer({ minimum: 0 }), + term: Type.String({ pattern: '^[0-9]{4}-(spring|autumn)$' }), + week: Type.Integer({ minimum: 1, maximum: 53 }), + timezone: Type.Literal('Europe/Oslo'), + limitations: Type.Object({ + activitySelection: Type.Literal('all-published-activities'), + activityGrouping: Type.Literal('unavailable'), + exceptionSemantics: Type.Literal('provider-status-unverified'), + }), + }), +}); + export const CourseInsightParamsDto = Type.Object({ courseCode: Type.String({ minLength: 2, maxLength: 20 }), }); @@ -372,6 +440,13 @@ export type CourseGradeSummariesResponseDtoType = Static; export type CourseDecisionSignalsDtoType = Static; export type CourseDecisionSignalsResponseDtoType = Static; +export type SourceStatusDtoType = Static; +export type CourseScheduleRequestDtoType = Static; +export type CourseScheduleEvidenceDtoType = Static; +export type CourseScheduleActivityStreamDtoType = Static; +export type CourseScheduleOccurrenceDtoType = Static; +export type CourseScheduleItemDtoType = Static; +export type CourseScheduleResponseDtoType = Static; export type CourseInsightParamsDtoType = Static; export type CourseInsightQueryDtoType = Static; export type CourseInsightDtoType = Static; diff --git a/scripts/dev.ts b/scripts/dev.ts index 4699d5e..97b5080 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -45,7 +45,7 @@ const localWebEnvironment = { ...process.env, VITE_API_URL: process.env.VITE_API_URL ?? localApiUrl, VITE_SOURCE_URL: process.env.VITE_SOURCE_URL ?? 'https://github.com/phibkro/course-data-platform', - VITE_USE_FIXTURE: 'false', + VITE_USE_FIXTURE: process.env.VITE_USE_FIXTURE ?? 'false', }; if (preview) { const build = spawnSync(bun, ['run', 'build'], {