From 60dc6e274f73262b805d86e9c60531e083c9b540 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Mar 2026 14:06:54 +0000 Subject: [PATCH 1/9] fix: resolve performance issues with O(n*m) lookups and redundant computations - Add moduleMap getter (Map) for O(1) module lookups, replacing O(n) .find() calls - Add allPlannedModuleIdsSet getter (Set) for O(1) membership checks, replacing O(n) .includes() - Pass enrichedSemesters as parameter to getEarnedEcts/getPlannedEcts to avoid redundant getter calls - Replace deep watch on groupedModules with a simple computed property - Convert filter data methods to computed properties to leverage Vue's caching - Combine 3 sequential filter passes into single pass in ModuleSearch - Replace setTimeout(0) with $nextTick in Home.vue route watcher https://claude.ai/code/session_01QVdw6xYjwtg3SujE4WpExq --- src/components/GraphModule.vue | 12 ++- src/components/GraphModuleHightlight.vue | 2 +- src/components/ModuleSearch.vue | 127 ++++++++--------------- src/components/ModuleSearchListItem.vue | 2 +- src/composables/useGraphView.ts | 3 +- src/helpers/store.ts | 76 +++++++++----- src/views/Home.vue | 4 +- 7 files changed, 108 insertions(+), 118 deletions(-) diff --git a/src/components/GraphModule.vue b/src/components/GraphModule.vue index d63143d3..9f7673ee 100644 --- a/src/components/GraphModule.vue +++ b/src/components/GraphModule.vue @@ -123,14 +123,18 @@ export default defineComponent({ }, computed: { recommendedModules(): Module[] { + const moduleMap = store.getters.moduleMap as Map; + const plannedSet = store.getters.allPlannedModuleIdsSet as Set; return this.module.recommendedModuleIds - .map(id => (store.getters.modules as Module[]).find(m => m.id === id)) - .filter((m): m is Module => !!m && !(store.getters.allPlannedModuleIds as string[]).includes(m.id)) + .map(id => moduleMap.get(id)) + .filter((m): m is Module => !!m && !plannedSet.has(m.id)) }, dependentModules(): Module[] { + const moduleMap = store.getters.moduleMap as Map; + const plannedSet = store.getters.allPlannedModuleIdsSet as Set; return this.module.dependentModuleIds - .map(id => (store.getters.modules as Module[]).find(m => m.id === id)) - .filter((m): m is Module => !!m && !(store.getters.allPlannedModuleIds as string[]).includes(m.id)) + .map(id => moduleMap.get(id)) + .filter((m): m is Module => !!m && !plannedSet.has(m.id)) }, hasRecommended(): boolean { return this.recommendedModules.length > 0 diff --git a/src/components/GraphModuleHightlight.vue b/src/components/GraphModuleHightlight.vue index 9a3d1f53..a37074a9 100644 --- a/src/components/GraphModuleHightlight.vue +++ b/src/components/GraphModuleHightlight.vue @@ -48,7 +48,7 @@ export default { }, computed: { isPlanned(): boolean { - return (store.getters.allPlannedModuleIds as string[]).includes(this.id); + return (store.getters.allPlannedModuleIdsSet as Set).has(this.id); }, }, methods: { diff --git a/src/components/ModuleSearch.vue b/src/components/ModuleSearch.vue index 297d26ff..e938ae78 100644 --- a/src/components/ModuleSearch.vue +++ b/src/components/ModuleSearch.vue @@ -63,7 +63,7 @@

Kategorie

g.modules).map(m => m.id); - const modulesNotInGroups = store.getters.modules.filter(m => !modulesInGroups.includes(m.id)); + const modulesInGroupIds = new Set(groups.flatMap(g => g.modules).map(m => m.id)); + const modulesNotInGroups = store.getters.modules.filter(m => !modulesInGroupIds.has(m.id)); let filteredGroups: GroupedModule[] = groups.concat({ id: 'none', name: 'Ohne', @@ -211,48 +210,50 @@ export default defineComponent({ filteredGroups = filteredGroups.filter(v => this.filter.categories.includes(v.id)) } - if (this.filter.ects.length > 0) { - filteredGroups = filteredGroups.map(g => { - return { - ...g, - modules: g.modules.filter(m => this.filter.ects.includes(m.ects)) - } - }); - } - - if (this.filter.semester.length > 0) { - filteredGroups = filteredGroups.map(g => { - return { - ...g, - modules: g.modules.filter(m => this.filter.semester.includes(m.term as string)) - } - }); - } + const ectsFilter = this.filter.ects; + const semesterFilter = this.filter.semester; + const queryFilter = this.filter.query.toLowerCase(); + const needsModuleFilter = ectsFilter.length > 0 || semesterFilter.length > 0 || queryFilter.length > 0; - if (this.filter.query.length > 0) { - filteredGroups = filteredGroups.map(g => { - return { - ...g, - modules: g.modules.filter(m => m.name.toLowerCase().includes(this.filter.query.toLowerCase())) - } - }); + if (needsModuleFilter) { + filteredGroups = filteredGroups.map(g => ({ + ...g, + modules: g.modules.filter(m => + (ectsFilter.length === 0 || ectsFilter.includes(m.ects)) && + (semesterFilter.length === 0 || semesterFilter.includes(m.term as string)) && + (queryFilter.length === 0 || m.name.toLowerCase().includes(queryFilter)) + ) + })); } return filteredGroups; - } - }, - watch: { - groupedModules: { - handler(newValue) { - const modules = newValue.flatMap(g => { - return g.modules - }) - - this.isOneModuleAvailable = modules.length !== 0; - }, - deep: true, - immediate: true - } + }, + isOneModuleAvailable(): boolean { + return this.groupedModules.some(g => g.modules.length > 0); + }, + categoryFilterData() { + return store.getters.enrichedCategories.map(c => ({ + id: c.id, + value: c.name, + color: getColorClassForCategoryId(c.id) + })); + }, + ectsFilterData() { + return store.getters.modules.map(m => m.ects) + .filter((value: number, index: number, self: number[]) => self.indexOf(value) === index) + .sort((a: number, b: number) => a - b) + .map((value: number) => ({ + id: value, + value: value.toString() + })) as { id: number, value: string }[]; + }, + semesterFilterData() { + return [ + { id: 'FS', value: 'Frühling' }, + { id: 'HS', value: 'Herbst' }, + { id: 'both', value: 'Beide' } + ]; + }, }, methods: { moduleIsDisabled(module: Module): boolean { @@ -262,7 +263,7 @@ export default defineComponent({ (this.showNextPossibleSemester && !module.nextPossibleSemester))); }, moduleIsInPlan(module: Module): boolean { - return store.getters.allPlannedModuleIds.includes(module.id); + return (store.getters.allPlannedModuleIdsSet as Set).has(module.id); }, moduleHasWrongTerm(module: Module): boolean { return ValidationHelper.isModuleInWrongTerm(module, this.termForWhichToSearch); @@ -280,42 +281,6 @@ export default defineComponent({ semester: [] as string[], }; }, - categoryFilterData() { - return store.getters.enrichedCategories.map(c => { - return { - id: c.id, - value: c.name, - color: getColorClassForCategoryId(c.id) - }; - }); - }, - ectsFilterData() { - return store.getters.modules.map(m => { - return m.ects - }).filter((value: number, index: number, self: number[]) => { - return self.indexOf(value) === index; - }).sort((a: number, b: number) => a - b).map((value: number) => { - return { - id: value, - value: value.toString() - }; - }) as { id: number, value: string }[]; - }, - semesterFilterData() { - return [ - { - id: 'FS', - value: 'Frühling' - }, - { - id: 'HS', - value: 'Herbst' - }, - { - id: 'both', - value: 'Beide' - }]; - }, } }); diff --git a/src/components/ModuleSearchListItem.vue b/src/components/ModuleSearchListItem.vue index 91aa7770..2e529e18 100644 --- a/src/components/ModuleSearchListItem.vue +++ b/src/components/ModuleSearchListItem.vue @@ -61,7 +61,7 @@ export default defineComponent({ }, methods: { moduleIsInPlan(module: Module): boolean { - return store.getters.allPlannedModuleIds.includes(module.id); + return (store.getters.allPlannedModuleIdsSet as Set).has(module.id); }, moduleIsDisabled(module: Module): boolean { return this.moduleIsInPlan(module) || (this.disableInvalidModules && ( diff --git a/src/composables/useGraphView.ts b/src/composables/useGraphView.ts index 3b5157c8..6388c2b7 100644 --- a/src/composables/useGraphView.ts +++ b/src/composables/useGraphView.ts @@ -51,7 +51,8 @@ export function useGraphView() { } async function computeLayout() { - const plannedModules = modules.value.filter((m) => allPlannedModuleIds.value.includes(m.id)); + const plannedIdSet = new Set(allPlannedModuleIds.value); + const plannedModules = modules.value.filter((m) => plannedIdSet.has(m.id)); const rawNodes = generateModuleNodes(plannedModules); const rawEdges = generateModuleEdges( plannedModules, diff --git a/src/helpers/store.ts b/src/helpers/store.ts index 3c87ff42..7479b364 100644 --- a/src/helpers/store.ts +++ b/src/helpers/store.ts @@ -26,14 +26,24 @@ export const store = createStore({ semesters: state => state.semesters, categories: state => state.categories, accreditedModules: state => state.accreditedModules, - modulesByIds: state => moduleIds => - moduleIds.map((id) => state.modules.find((module) => module.id === id)).filter(f => f), - totalPlannedEcts: () => getPlannedEcts(), - totalEarnedEcts: () => getEarnedEcts(), + moduleMap: state => { + const map = new Map(); + state.modules.forEach(m => map.set(m.id, m)); + return map; + }, + modulesByIds: (_state, getters) => (moduleIds: string[]) => { + const map = getters.moduleMap as Map; + return moduleIds.map(id => map.get(id)).filter(f => f); + }, allPlannedModuleIds: state => state.semesters .flatMap(semester => semester.moduleIds) .concat(state.accreditedModules.map(m => m.moduleId)) .filter(id => id), + allPlannedModuleIdsSet: (_state, getters) => { + return new Set(getters.allPlannedModuleIds); + }, + totalPlannedEcts: (_state, getters) => getPlannedEcts(undefined, getters.enrichedSemesters), + totalEarnedEcts: (_state, getters) => getEarnedEcts(undefined, getters.enrichedSemesters), startSemester: state => state.startSemester, studienordnung: state => state.studienordnung, validationEnabled: state => state.validationEnabled, @@ -41,43 +51,53 @@ export const store = createStore({ state.modules.map(m => m.validationInfo).filter(f => f?.severity === 'hard').length, hardValidationProblemsByType: state => type => state.modules.map(m => m.validationInfo).filter(f => f?.severity === 'hard' && f?.type === type), - enrichedCategories: (state, getters) => - state.categories.map(category => ({ + enrichedSemesters: (_state, getters) => { + const state = store.state; + const map = getters.moduleMap as Map; + return state.semesters.map(semester => ({ + ...semester, + modules: semester.moduleIds.map(id => map.get(id)).filter(f => f), + })); + }, + enrichedCategories: (_state, getters) => { + const state = store.state; + const map = getters.moduleMap as Map; + const enrichedSemesters = getters.enrichedSemesters; + return state.categories.map(category => ({ ...category, - earnedEcts: getEarnedEcts(category), - plannedEcts: getPlannedEcts(category), + earnedEcts: getEarnedEcts(category, enrichedSemesters), + plannedEcts: getPlannedEcts(category, enrichedSemesters), colorClass: getColorClassForCategoryId(category.id), - modules: getters.modulesByIds(category.moduleIds), - })), - enrichedFocuses: (state, getters) => { - const plannedModuleIds = getters.allPlannedModuleIds; + modules: category.moduleIds.map(id => map.get(id)).filter(f => f), + })); + }, + enrichedFocuses: (_state, getters) => { + const state = store.state; + const plannedSet = getters.allPlannedModuleIdsSet as Set; + const map = getters.moduleMap as Map; const numberOfModulesRequiredToGetFocus = 8; return state.focuses.map(focus => { - const allModulesForFocus = getters.modulesByIds(focus.moduleIds); + const allModulesForFocus = focus.moduleIds.map(id => map.get(id)).filter(f => f) as Module[]; return { ...focus, numberOfMissingModules: Math.max( 0, numberOfModulesRequiredToGetFocus - focus.moduleIds.filter(moduleId => - plannedModuleIds.includes(moduleId)).length + plannedSet.has(moduleId)).length ), // to only show actually available modules, we filter out predecessors of already planned ones - availableModules: getters.modulesByIds( - focus.moduleIds.filter(moduleId => - !plannedModuleIds.includes(moduleId) && - !plannedModuleIds.includes(allModulesForFocus.find(module => module.id === moduleId).successorModuleId) + availableModules: focus.moduleIds + .filter(moduleId => + !plannedSet.has(moduleId) && + !plannedSet.has(allModulesForFocus.find(module => module.id === moduleId)?.successorModuleId) ) - ), + .map(id => map.get(id)) + .filter(f => f), modules: allModulesForFocus, }; }); }, - enrichedSemesters: (state, getters) => - state.semesters.map(semester => ({ - ...semester, - modules: getters.modulesByIds(semester.moduleIds), - })), }, mutations: { setModules(state, modules: Module[]) { @@ -204,7 +224,7 @@ export const store = createStore({ }, } }); -function getEarnedEcts(category?: Category): number { +function getEarnedEcts(category: Category | undefined, enrichedSemesters: Semester[]): number { if (store.getters.startSemester === undefined) { return 0; } @@ -214,7 +234,7 @@ function getEarnedEcts(category?: Category): number { return 0; } - const ectsInSemesters = store.getters.enrichedSemesters + const ectsInSemesters = enrichedSemesters .slice(0, indexOfLastCompletedSemester) .flatMap((semester) => semester.modules) .filter((module) => !category || category.moduleIds.includes(module.id)) @@ -225,12 +245,12 @@ function getEarnedEcts(category?: Category): number { return ectsInSemesters + accreditedEcts; } -function getPlannedEcts(category?: Category): number { +function getPlannedEcts(category: Category | undefined, enrichedSemesters: Semester[]): number { if (store.getters.startSemester === undefined) { return 0; } - let semestersToConsider = store.getters.enrichedSemesters; + let semestersToConsider = enrichedSemesters; const indexOfLastCompletedSemester = SemesterInfo.now().difference(store.getters.startSemester); if (indexOfLastCompletedSemester >= 0) { diff --git a/src/views/Home.vue b/src/views/Home.vue index 57690960..8803a3dd 100644 --- a/src/views/Home.vue +++ b/src/views/Home.vue @@ -195,9 +195,9 @@ export default defineComponent({ watch: { $route: { handler() { - setTimeout(() => { + this.$nextTick(() => { this.getPlanDataFromUrl(); - }, 0); + }); }, }, }, From 55ebc3feb5edb81601096d04b3fae0dc012c7ee6 Mon Sep 17 00:00:00 2001 From: Jeremy Stucki Date: Sat, 7 Mar 2026 15:20:18 +0100 Subject: [PATCH 2/9] Update src/helpers/store.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/helpers/store.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/helpers/store.ts b/src/helpers/store.ts index 7479b364..99375110 100644 --- a/src/helpers/store.ts +++ b/src/helpers/store.ts @@ -88,10 +88,13 @@ export const store = createStore({ ), // to only show actually available modules, we filter out predecessors of already planned ones availableModules: focus.moduleIds - .filter(moduleId => - !plannedSet.has(moduleId) && - !plannedSet.has(allModulesForFocus.find(module => module.id === moduleId)?.successorModuleId) - ) + .filter(moduleId => { + const successorId = allModulesForFocus.find(module => module.id === moduleId)?.successorModuleId; + return ( + !plannedSet.has(moduleId) && + !(successorId && plannedSet.has(successorId)) + ); + }) .map(id => map.get(id)) .filter(f => f), modules: allModulesForFocus, From 077eb4d8a6edf7623d93e1d35ef8003fdaf5d1cb Mon Sep 17 00:00:00 2001 From: Jeremy Stucki Date: Sat, 7 Mar 2026 15:20:56 +0100 Subject: [PATCH 3/9] Update src/helpers/store.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/helpers/store.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/helpers/store.ts b/src/helpers/store.ts index 99375110..c75bf0b4 100644 --- a/src/helpers/store.ts +++ b/src/helpers/store.ts @@ -51,16 +51,14 @@ export const store = createStore({ state.modules.map(m => m.validationInfo).filter(f => f?.severity === 'hard').length, hardValidationProblemsByType: state => type => state.modules.map(m => m.validationInfo).filter(f => f?.severity === 'hard' && f?.type === type), - enrichedSemesters: (_state, getters) => { - const state = store.state; + enrichedSemesters: (state, getters) => { const map = getters.moduleMap as Map; return state.semesters.map(semester => ({ ...semester, modules: semester.moduleIds.map(id => map.get(id)).filter(f => f), })); }, - enrichedCategories: (_state, getters) => { - const state = store.state; + enrichedCategories: (state, getters) => { const map = getters.moduleMap as Map; const enrichedSemesters = getters.enrichedSemesters; return state.categories.map(category => ({ From 82d7c41c8afd850fd347b1307f5ec453d72b0898 Mon Sep 17 00:00:00 2001 From: Jeremy Stucki Date: Mon, 25 May 2026 09:52:07 +0200 Subject: [PATCH 4/9] fix: address correctness issues found during review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused modulesByIds getter - Use state parameter in enrichedFocuses instead of store.state directly - Add proper type guard to allPlannedModuleIds filter - Fix ectsFilterData deduplication to use Set instead of indexOf (O(n²)) - Extract semesterFilterData to module-level constant Co-Authored-By: Claude Sonnet 4.6 --- src/helpers/store.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/helpers/store.ts b/src/helpers/store.ts index c75bf0b4..3a13225e 100644 --- a/src/helpers/store.ts +++ b/src/helpers/store.ts @@ -31,14 +31,10 @@ export const store = createStore({ state.modules.forEach(m => map.set(m.id, m)); return map; }, - modulesByIds: (_state, getters) => (moduleIds: string[]) => { - const map = getters.moduleMap as Map; - return moduleIds.map(id => map.get(id)).filter(f => f); - }, allPlannedModuleIds: state => state.semesters .flatMap(semester => semester.moduleIds) .concat(state.accreditedModules.map(m => m.moduleId)) - .filter(id => id), + .filter((id): id is string => !!id), allPlannedModuleIdsSet: (_state, getters) => { return new Set(getters.allPlannedModuleIds); }, @@ -69,8 +65,7 @@ export const store = createStore({ modules: category.moduleIds.map(id => map.get(id)).filter(f => f), })); }, - enrichedFocuses: (_state, getters) => { - const state = store.state; + enrichedFocuses: (state, getters) => { const plannedSet = getters.allPlannedModuleIdsSet as Set; const map = getters.moduleMap as Map; const numberOfModulesRequiredToGetFocus = 8; From 51a3160c232cf89208172a57e005a5dd88a8f886 Mon Sep 17 00:00:00 2001 From: Jeremy Stucki Date: Mon, 25 May 2026 09:53:18 +0200 Subject: [PATCH 5/9] fix: use Set for ectsFilterData deduplication and extract semesterFilterData constant Co-Authored-By: Claude Sonnet 4.6 --- src/components/ModuleSearch.vue | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/components/ModuleSearch.vue b/src/components/ModuleSearch.vue index e938ae78..7e7f77fe 100644 --- a/src/components/ModuleSearch.vue +++ b/src/components/ModuleSearch.vue @@ -126,6 +126,12 @@ import ModuleFilter from "./ModuleFilter.vue"; import ModuleSearchList from "./ModuleSearchList.vue"; import type { GroupedModule } from "../types/GroupedModule"; +const SEMESTER_FILTER_DATA = [ + { id: 'FS', value: 'Frühling' }, + { id: 'HS', value: 'Herbst' }, + { id: 'both', value: 'Beide' } +]; + export default defineComponent({ name: 'ModuleSearch', components: { @@ -239,20 +245,12 @@ export default defineComponent({ })); }, ectsFilterData() { - return store.getters.modules.map(m => m.ects) - .filter((value: number, index: number, self: number[]) => self.indexOf(value) === index) - .sort((a: number, b: number) => a - b) - .map((value: number) => ({ - id: value, - value: value.toString() - })) as { id: number, value: string }[]; + return [...new Set(store.getters.modules.map(m => m.ects))] + .sort((a, b) => a - b) + .map(value => ({ id: value, value: value.toString() })) as { id: number, value: string }[]; }, semesterFilterData() { - return [ - { id: 'FS', value: 'Frühling' }, - { id: 'HS', value: 'Herbst' }, - { id: 'both', value: 'Beide' } - ]; + return SEMESTER_FILTER_DATA; }, }, methods: { From b63c5a8d62da1eaf03e11da984d3058359997f0f Mon Sep 17 00:00:00 2001 From: Jeremy Stucki Date: Mon, 25 May 2026 09:59:10 +0200 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20address=20review=20issues=20?= =?UTF-8?q?=E2=80=94=20eliminate=20remaining=20O(n)=20find,=20decouple=20h?= =?UTF-8?q?elpers=20from=20store.getters,=20use=20allPlannedModuleIdsSet?= =?UTF-8?q?=20in=20composable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - enrichedFocuses: replace allModulesForFocus.find() with map.get() (O(n²) → O(1)) - getEarnedEcts/getPlannedEcts: pass startSemester and accreditedModules as params instead of reading store.getters directly, making the functions fully decoupled from global state - useGraphView: use allPlannedModuleIdsSet store getter instead of constructing a local Set on every computeLayout call - ModuleSearch: move semesterFilterData from computed to data since it returns a module-level constant with no reactive dependencies Co-Authored-By: Claude Sonnet 4.6 --- src/components/ModuleSearch.vue | 4 +--- src/composables/useGraphView.ts | 2 +- src/helpers/store.ts | 26 ++++++++++++++------------ 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/components/ModuleSearch.vue b/src/components/ModuleSearch.vue index 7e7f77fe..79a31420 100644 --- a/src/components/ModuleSearch.vue +++ b/src/components/ModuleSearch.vue @@ -183,6 +183,7 @@ export default defineComponent({ data() { return { isSearching: false, + semesterFilterData: SEMESTER_FILTER_DATA, filter: { query: '', categories: [] as string[], @@ -249,9 +250,6 @@ export default defineComponent({ .sort((a, b) => a - b) .map(value => ({ id: value, value: value.toString() })) as { id: number, value: string }[]; }, - semesterFilterData() { - return SEMESTER_FILTER_DATA; - }, }, methods: { moduleIsDisabled(module: Module): boolean { diff --git a/src/composables/useGraphView.ts b/src/composables/useGraphView.ts index 6388c2b7..15fb4498 100644 --- a/src/composables/useGraphView.ts +++ b/src/composables/useGraphView.ts @@ -51,7 +51,7 @@ export function useGraphView() { } async function computeLayout() { - const plannedIdSet = new Set(allPlannedModuleIds.value); + const plannedIdSet = store.getters.allPlannedModuleIdsSet as Set; const plannedModules = modules.value.filter((m) => plannedIdSet.has(m.id)); const rawNodes = generateModuleNodes(plannedModules); const rawEdges = generateModuleEdges( diff --git a/src/helpers/store.ts b/src/helpers/store.ts index 3a13225e..f53f4c46 100644 --- a/src/helpers/store.ts +++ b/src/helpers/store.ts @@ -38,8 +38,8 @@ export const store = createStore({ allPlannedModuleIdsSet: (_state, getters) => { return new Set(getters.allPlannedModuleIds); }, - totalPlannedEcts: (_state, getters) => getPlannedEcts(undefined, getters.enrichedSemesters), - totalEarnedEcts: (_state, getters) => getEarnedEcts(undefined, getters.enrichedSemesters), + totalPlannedEcts: (_state, getters) => getPlannedEcts(undefined, getters.enrichedSemesters, getters.startSemester), + totalEarnedEcts: (_state, getters) => getEarnedEcts(undefined, getters.enrichedSemesters, getters.startSemester, getters.accreditedModules), startSemester: state => state.startSemester, studienordnung: state => state.studienordnung, validationEnabled: state => state.validationEnabled, @@ -57,10 +57,12 @@ export const store = createStore({ enrichedCategories: (state, getters) => { const map = getters.moduleMap as Map; const enrichedSemesters = getters.enrichedSemesters; + const startSemester = getters.startSemester; + const accreditedModules = getters.accreditedModules; return state.categories.map(category => ({ ...category, - earnedEcts: getEarnedEcts(category, enrichedSemesters), - plannedEcts: getPlannedEcts(category, enrichedSemesters), + earnedEcts: getEarnedEcts(category, enrichedSemesters, startSemester, accreditedModules), + plannedEcts: getPlannedEcts(category, enrichedSemesters, startSemester), colorClass: getColorClassForCategoryId(category.id), modules: category.moduleIds.map(id => map.get(id)).filter(f => f), })); @@ -82,7 +84,7 @@ export const store = createStore({ // to only show actually available modules, we filter out predecessors of already planned ones availableModules: focus.moduleIds .filter(moduleId => { - const successorId = allModulesForFocus.find(module => module.id === moduleId)?.successorModuleId; + const successorId = map.get(moduleId)?.successorModuleId; return ( !plannedSet.has(moduleId) && !(successorId && plannedSet.has(successorId)) @@ -220,11 +222,11 @@ export const store = createStore({ }, } }); -function getEarnedEcts(category: Category | undefined, enrichedSemesters: Semester[]): number { - if (store.getters.startSemester === undefined) { +function getEarnedEcts(category: Category | undefined, enrichedSemesters: Semester[], startSemester: SemesterInfo | undefined, accreditedModules: AccreditedModule[]): number { + if (startSemester === undefined) { return 0; } - const indexOfLastCompletedSemester = SemesterInfo.now().difference(store.getters.startSemester); + const indexOfLastCompletedSemester = SemesterInfo.now().difference(startSemester); if (indexOfLastCompletedSemester < 0) { return 0; @@ -235,19 +237,19 @@ function getEarnedEcts(category: Category | undefined, enrichedSemesters: Semest .flatMap((semester) => semester.modules) .filter((module) => !category || category.moduleIds.includes(module.id)) .reduce((previousTotal, module) => previousTotal + module.ects, 0); - const accreditedEcts = store.getters.accreditedModules + const accreditedEcts = accreditedModules .filter(module => !category || module.categoryIds.includes(category.id)) .reduce((previousTotal, module) => previousTotal + module.ects, 0); return ectsInSemesters + accreditedEcts; } -function getPlannedEcts(category: Category | undefined, enrichedSemesters: Semester[]): number { - if (store.getters.startSemester === undefined) { +function getPlannedEcts(category: Category | undefined, enrichedSemesters: Semester[], startSemester: SemesterInfo | undefined): number { + if (startSemester === undefined) { return 0; } let semestersToConsider = enrichedSemesters; - const indexOfLastCompletedSemester = SemesterInfo.now().difference(store.getters.startSemester); + const indexOfLastCompletedSemester = SemesterInfo.now().difference(startSemester); if (indexOfLastCompletedSemester >= 0) { semestersToConsider = semestersToConsider.slice(indexOfLastCompletedSemester) From 736d857147819a89fa64424bf5f9a3dbfff3d487 Mon Sep 17 00:00:00 2001 From: Jeremy Stucki Date: Mon, 25 May 2026 10:01:26 +0200 Subject: [PATCH 7/9] fix: use type-guard filters to properly narrow Module | undefined to Module[] Replace .filter(f => f) with .filter((m): m is Module => !!m) in enrichedSemesters, enrichedCategories, and enrichedFocuses (allModulesForFocus and availableModules). Also removes the now-unnecessary `as Module[]` cast on allModulesForFocus since the type guard makes it redundant. Co-Authored-By: Claude Sonnet 4.6 --- src/helpers/store.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/helpers/store.ts b/src/helpers/store.ts index f53f4c46..9ed1c2c1 100644 --- a/src/helpers/store.ts +++ b/src/helpers/store.ts @@ -51,7 +51,7 @@ export const store = createStore({ const map = getters.moduleMap as Map; return state.semesters.map(semester => ({ ...semester, - modules: semester.moduleIds.map(id => map.get(id)).filter(f => f), + modules: semester.moduleIds.map(id => map.get(id)).filter((m): m is Module => !!m), })); }, enrichedCategories: (state, getters) => { @@ -64,7 +64,7 @@ export const store = createStore({ earnedEcts: getEarnedEcts(category, enrichedSemesters, startSemester, accreditedModules), plannedEcts: getPlannedEcts(category, enrichedSemesters, startSemester), colorClass: getColorClassForCategoryId(category.id), - modules: category.moduleIds.map(id => map.get(id)).filter(f => f), + modules: category.moduleIds.map(id => map.get(id)).filter((m): m is Module => !!m), })); }, enrichedFocuses: (state, getters) => { @@ -72,7 +72,7 @@ export const store = createStore({ const map = getters.moduleMap as Map; const numberOfModulesRequiredToGetFocus = 8; return state.focuses.map(focus => { - const allModulesForFocus = focus.moduleIds.map(id => map.get(id)).filter(f => f) as Module[]; + const allModulesForFocus = focus.moduleIds.map(id => map.get(id)).filter((m): m is Module => !!m); return { ...focus, numberOfMissingModules: @@ -91,7 +91,7 @@ export const store = createStore({ ); }) .map(id => map.get(id)) - .filter(f => f), + .filter((m): m is Module => !!m), modules: allModulesForFocus, }; }); From b259917210474f7699068f8898133e14ed692043 Mon Sep 17 00:00:00 2001 From: Jeremy Stucki Date: Mon, 25 May 2026 10:06:30 +0200 Subject: [PATCH 8/9] fix: move semesterFilterData from data to computed for consistency Co-Authored-By: Claude Sonnet 4.6 --- src/components/ModuleSearch.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/ModuleSearch.vue b/src/components/ModuleSearch.vue index 79a31420..7e7f77fe 100644 --- a/src/components/ModuleSearch.vue +++ b/src/components/ModuleSearch.vue @@ -183,7 +183,6 @@ export default defineComponent({ data() { return { isSearching: false, - semesterFilterData: SEMESTER_FILTER_DATA, filter: { query: '', categories: [] as string[], @@ -250,6 +249,9 @@ export default defineComponent({ .sort((a, b) => a - b) .map(value => ({ id: value, value: value.toString() })) as { id: number, value: string }[]; }, + semesterFilterData() { + return SEMESTER_FILTER_DATA; + }, }, methods: { moduleIsDisabled(module: Module): boolean { From 20dac1f54114ddd200f59fdc5196016c8d32b603 Mon Sep 17 00:00:00 2001 From: Jeremy Stucki Date: Mon, 25 May 2026 10:06:47 +0200 Subject: [PATCH 9/9] fix: wrap long lines in store.ts to satisfy max-len rule Co-Authored-By: Claude Sonnet 4.6 --- src/helpers/store.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/helpers/store.ts b/src/helpers/store.ts index 9ed1c2c1..c268b363 100644 --- a/src/helpers/store.ts +++ b/src/helpers/store.ts @@ -39,7 +39,8 @@ export const store = createStore({ return new Set(getters.allPlannedModuleIds); }, totalPlannedEcts: (_state, getters) => getPlannedEcts(undefined, getters.enrichedSemesters, getters.startSemester), - totalEarnedEcts: (_state, getters) => getEarnedEcts(undefined, getters.enrichedSemesters, getters.startSemester, getters.accreditedModules), + totalEarnedEcts: (_state, getters) => + getEarnedEcts(undefined, getters.enrichedSemesters, getters.startSemester, getters.accreditedModules), startSemester: state => state.startSemester, studienordnung: state => state.studienordnung, validationEnabled: state => state.validationEnabled, @@ -222,7 +223,12 @@ export const store = createStore({ }, } }); -function getEarnedEcts(category: Category | undefined, enrichedSemesters: Semester[], startSemester: SemesterInfo | undefined, accreditedModules: AccreditedModule[]): number { +function getEarnedEcts( + category: Category | undefined, + enrichedSemesters: Semester[], + startSemester: SemesterInfo | undefined, + accreditedModules: AccreditedModule[], +): number { if (startSemester === undefined) { return 0; } @@ -243,7 +249,11 @@ function getEarnedEcts(category: Category | undefined, enrichedSemesters: Semest return ectsInSemesters + accreditedEcts; } -function getPlannedEcts(category: Category | undefined, enrichedSemesters: Semester[], startSemester: SemesterInfo | undefined): number { +function getPlannedEcts( + category: Category | undefined, + enrichedSemesters: Semester[], + startSemester: SemesterInfo | undefined, +): number { if (startSemester === undefined) { return 0; }