import { defineStore } from "pinia"; import { useToast } from "vue-toastification"; import { CalendarEvent, TaughtClass, Module, PersonalEvent, PlanningDump, PlanEntry, } from "../types"; import { useClassesStore } from "./classes"; import { useModulesStore } from "./modules"; import { useClassVersionStore } from "./ClassVersion"; import { useStateStore } from "./state"; import { semesterVersionStringC, waitTimeAfterOldPlanReminder, waitTimeAfterUpgradeRequestModal, } from "../helpers"; const MAX_MODULE_COUNT = 40; const toast = useToast(); function otherPlanModificationToast() { toast.error("Du kannst den Plan eines anderen nicht bearbeiten"); } function reloadChosenModules() { const planningStore = usePlanningStore(); planningStore.loadChosen(); } const DEFAULT_PLAN_NAME = "default"; export const BLOCKCOURSE_DAY_IDX = 6; export const usePlanningStore = defineStore("planning", { state: () => { return { usingModulesFromURL: false, chosen: [] as TaughtClass[], personalEvents: [] as PersonalEvent[], hourStart: 8, hourEnd: 22, hourDivisions: 2, cellHeight: 24, get currentPlanName(): string { if (this.__currentPlanName != null) return this.__currentPlanName; let name = localStorage["lastSelectedCalendarSet"]; if (!name) { name = DEFAULT_PLAN_NAME; localStorage["lastSelectedCalendarSet"] = DEFAULT_PLAN_NAME; } this.__currentPlanName = name; return name; }, set currentPlanName(newName: string) { this.__currentPlanName = newName; localStorage["lastSelectedCalendarSet"] = newName; reloadChosenModules(); }, __currentPlanName: null as string | null, allPlans: [] as PlanEntry[], saveChanges: true, lastPlanRequestTimestamp: undefined as undefined | number, oldPlanReminderTimestamp: undefined as undefined | number, doNotRequestVersionUpgrade: false, }; }, getters: { eventsByDay(): CalendarEvent[][] { // Prefill with 7, because school is from Mo-Sa (6) + 1 day for the block courses const events = new Array(7).fill(null).map(() => []) as CalendarEvent[][]; const allPlacable = [...this.chosen, ...this.personalEvents]; // Sort by longest duration, we want to have them leftmost on the calendar view const ch = allPlacable .filter((el) => el != null) .sort((a, b) => b.to - b.from - (a.to - a.from)); ch.forEach((m, idx) => { const topOffset = (m.from / 3600 - this.hourStart) * this.cellHeight * this.hourDivisions; const length = ((m.to - m.from) / 3600) * this.cellHeight * this.hourDivisions; let overlapCount = 0; let overlapIdx = 0; ch.forEach((om, oidx) => { if (m.weekday != om.weekday || idx == oidx) return; if (om.from >= m.from && om.from <= m.to) { overlapCount++; if (idx > oidx) overlapIdx++; } else if (m.from >= om.from && m.from <= om.to) { overlapCount++; if (idx > oidx) overlapIdx++; } }); const width = 100 / (overlapCount + 1) - overlapIdx; const leftOffset = overlapIdx * width + overlapIdx; let tc = null as null | TaughtClass; let pe = null as null | PersonalEvent; if ((m as TaughtClass).teachers) tc = m as TaughtClass; else pe = m as PersonalEvent; events[m.weekday ?? BLOCKCOURSE_DAY_IDX].push({ taughtClass: tc, personalEvent: pe, topOffset: topOffset, leftOffset: leftOffset, length: length, width: width, }); }); return events; }, sharedDisplayName(): string | null { if (!this.usingModulesFromURL) return null; const pn = this.currentPlanName; if (pn.length == 0 || pn == DEFAULT_PLAN_NAME) return null; return pn; }, totalECTSCount(): { ects: number; unsureModules: string[] } { let ects = 0; const unsureModules = [] as string[]; const countedModules = [] as string[]; this.chosen.forEach((c) => { const module = c.module; if (module == null || module.ects == null) { unsureModules.push(c.name); return; } const moduleName = module.name; if (countedModules.indexOf(moduleName) == -1) { ects += module.ects; countedModules.push(moduleName); } }); this.personalEvents.forEach((pe) => (ects += pe.ects ?? 0)); return { ects, unsureModules }; }, modulesWithMSP(): Module[] { const modulesStore = useModulesStore(); return this.chosen .map((c) => modulesStore.fromModuleShort(c.name)) .filter((m) => m?.hasMSP === true) as Module[]; }, ectsInBBVTClasses(): { bb: number; vt: number } { const res = { bb: 0, vt: 0 }; const seen = { bb: [], vt: [] } as { bb: string[]; vt: string[] }; this.chosen.forEach((c) => { const moduleName = c.name; const ects = c.module?.ects ?? 0; if (c.isBB) { const found = seen.bb.indexOf(moduleName) >= 0; if (found) return; seen.bb.push(moduleName); res.bb += ects; } else { const found = seen.vt.indexOf(moduleName) >= 0; if (found) return; seen.vt.push(moduleName); res.vt += ects; } }); return res; }, ectsInEngClasses(): number { const seen = [] as string[]; return this.chosen .filter((c) => { const moduleName = c.name; const found = seen.indexOf(moduleName) >= 0; if (!found) seen.push(moduleName); return found; }) .reduce((sum, c) => sum + (c.isEnglish ? c.module?.ects ?? 0 : 0), 0); }, ectsInContextClasses(): number { const seen = [] as string[]; return this.chosen .filter((c) => { const moduleName = c.name; const found = seen.indexOf(moduleName) >= 0; if (!found) seen.push(moduleName); return found; }) .reduce((sum, c) => sum + (c.isContext ? c.module?.ects ?? 0 : 0), 0); }, ectsWithMSP(): number { const seen = [] as string[]; return this.chosen .filter((c) => { const moduleName = c.name; const found = seen.indexOf(moduleName) >= 0; if (!found) seen.push(moduleName); return found; }) .reduce( (sum, c) => sum + (c.module?.hasMSP ? c.module?.ects ?? 0 : 0), 0, ); }, }, actions: { /** * Updates the current plan from using the old ids in the format of * `--->` to * `----`. * * This function and it's calls can be safely (and should be) removed by the end of * 2023! In addition to this code, some further code in `loadChosen` can also be * removed (marked). */ checkAndUpdateChosenIDs(modules: string[]): string[] { const newIdRE = /^.*-.*-\d-\d+-\d+$/; const classesStore = useClassesStore(); const currentClasses = [ ...(classesStore.currentData?.taughtClasses ?? []), ...(classesStore.currentData?.blockClasses ?? []), ]; return modules.map((el) => { if (newIdRE.test(el)) return el; const split = el.split("-", 4); const cls = split[0]; const name = split[1]; const weekday = split[2]; const rooms = split[3]; // find the new id const found = currentClasses.filter( (c) => c.class == cls && c.name == name && `${c.weekday}` == weekday && c.rooms.join("_") == rooms, ); if (found.length == 0) { console.warn("Failed to find a replacement ID!"); return el; } return found[0].id; }); }, loadFromStorage() { // Saved plans this.allPlans = [] as PlanEntry[]; for (const key in localStorage) { if (key.startsWith("moduleset-")) { const name = key.replace("moduleset-", ""); if (name.length > 0) { const data = JSON.parse( localStorage[`moduleset-${name}`], ) as PlanningDump; const version = data.version; this.allPlans.push({ name, planVersion: version }); } } } if (this.allPlans.length == 0) { this.createPlan(DEFAULT_PLAN_NAME); this.currentPlanName = DEFAULT_PLAN_NAME; } // Last selected plan this.loadChosen(); }, createPlan(name: string): boolean { if (this.allPlans.some((el) => el.name == name)) { toast.warning( `Der Plan '${name}' existiert bereits. Wähle einen anderen Namen.`, ); return false; } const planVersion = semesterVersionStringC(useClassVersionStore().latestOverall) ?? ""; this.allPlans.push({ name, planVersion }); this.saveChosen(name, true); return true; }, add(m: TaughtClass) { if (this.chosen.includes(m) || this.chosen.length > MAX_MODULE_COUNT) return; if (this.usingModulesFromURL) { otherPlanModificationToast(); return; } this.chosen.push(m); this.saveChosen(); }, remove(m: TaughtClass) { if (this.usingModulesFromURL) { otherPlanModificationToast(); return; } const idx = this.chosen.indexOf(m); if (idx == -1) return; this.chosen.splice(idx, 1); this.saveChosen(); }, removeIdx(idx: number) { if (this.usingModulesFromURL) { otherPlanModificationToast(); return; } this.chosen.splice(idx, 1); this.saveChosen(); }, isModuleChosen(m: TaughtClass) { return this.chosen.includes(m); }, saveChosen(name: string | null = null, forceEmpty = false) { if (!this.saveChanges) return; if (this.usingModulesFromURL) { console.log("Not saving the current plan, as this is a shared one!"); return; } if (name == null) name = this.currentPlanName; localStorage[`moduleset-${name}`] = this.stateToJSON(forceEmpty); }, loadChosen(name: string | null = null, useSavedVersion = true): boolean { if (name == null) name = this.currentPlanName; const params = window.location.href .substr(window.location.href.indexOf("?")) .replace("+", "%2B"); const param = new URLSearchParams(params); let strData; const stateParam = param.get("state"); if (stateParam) { const state = stateParam.replace("%2B", "+"); strData = decodeURIComponent(escape(atob(state))); this.usingModulesFromURL = true; } else { strData = localStorage[`moduleset-${name}`]; if (strData == null) return false; this.usingModulesFromURL = false; } const classVersionStore = useClassVersionStore(); const modulesStore = useClassesStore(); const data = JSON.parse(strData) as PlanningDump; classVersionStore.useFromPDFString( useSavedVersion ? data.version : useClassVersionStore().semVer ?? data.version, ); modulesStore.fetchData(); if (!modulesStore.currentData?.loaded) return false; this.doNotRequestVersionUpgrade = data.doNotRequestVersionUpgrade ?? false; let shouldSave = false; const cTimestamp = new Date().getTime(); if (!this.doNotRequestVersionUpgrade) { this.lastPlanRequestTimestamp = data.lastPlanRequestTimestamp; if ( !classVersionStore.isLatestSemesterVersion && (this.lastPlanRequestTimestamp == undefined || cTimestamp - this.lastPlanRequestTimestamp > waitTimeAfterUpgradeRequestModal) ) { const stateStore = useStateStore(); stateStore.showingClassUpgradeModal = true; this.lastPlanRequestTimestamp = cTimestamp; shouldSave = true; } } this.oldPlanReminderTimestamp = data.oldPlanReminderTimestamp; if ( !classVersionStore.isLatestSemester && (this.oldPlanReminderTimestamp == undefined || cTimestamp - this.oldPlanReminderTimestamp > waitTimeAfterOldPlanReminder) ) { const stateStore = useStateStore(); stateStore.showingOldPlanReminderModal = true; this.oldPlanReminderTimestamp = cTimestamp; shouldSave = true; } /** * The following code can be removed at the end of 2023! */ const updatedIDs = this.checkAndUpdateChosenIDs(data.modules); this.chosen = updatedIDs .map((el) => modulesStore.getById(el)) .filter((el) => el) as TaughtClass[]; /** * End code that can be removed at the end of 2023! */ if (this.usingModulesFromURL) this.__currentPlanName = data.name ?? ""; if (data.personalEvents) this.personalEvents = data.personalEvents; else this.personalEvents = []; /** * The following code can be removed at the end of 2023! */ shouldSave ||= !( updatedIDs.length == data.modules.length && updatedIDs.every(function (element, index) { return element === data.modules[index]; }) ); /** * End code that can be removed at the end of 2023! */ if (shouldSave) { console.log("Saving due to ..."); this.saveChosen(name); } return true; }, copySharedTo(name: string) { const params = window.location.href .substr(window.location.href.indexOf("?")) .replace("+", "%2B"); const param = new URLSearchParams(params); const stateParam = param.get("state"); if (!stateParam) return; const state = stateParam.replace("%2B", "+"); const strData = decodeURIComponent(escape(atob(state))); localStorage[`moduleset-${name}`] = strData; window.history.pushState(null, "", window.location.href.split("?")[0]); this.currentPlanName = name; this.loadChosen(name); }, deleteChosen(name: string | null = null): boolean { if (name == null) name = this.currentPlanName; const pIdx = this.allPlans.findIndex((el) => el.name == name); if (pIdx == -1) return false; this.allPlans.splice(pIdx, 1); localStorage.removeItem(`moduleset-${name}`); if (this.allPlans.length == 0) { const planVersion = semesterVersionStringC(useClassVersionStore().latestOverall) ?? ""; this.allPlans.push({ name: DEFAULT_PLAN_NAME, planVersion }); this.currentPlanName = DEFAULT_PLAN_NAME; this.chosen = []; this.saveChosen(); this.loadChosen(); return true; } if (this.currentPlanName == name) this.currentPlanName = this.allPlans[0].name; this.loadChosen(); return true; }, emptyPlan() { this.chosen = []; this.saveChosen(); }, copyPlan( copyName: string | null = null, copyFrom: string | null = null, ): string | null { if (copyFrom == null) copyFrom = this.currentPlanName; if (copyName == null) { let counter = 2; copyName = `Kopie von ${copyFrom}`; while (this.allPlans.findIndex((el) => el.name == copyName) >= 0) { copyName = `Kopie ${counter} von ${copyFrom}`; counter++; } } else { if (this.allPlans.findIndex((el) => el.name == copyName) >= 0) { // Key already exists toast.error(`Der Name '${copyName}' ist bereits vergeben!`); return null; } } if (localStorage[`moduleset-${copyFrom}`] == null) { // copyFrom name does not exist! toast.error( "Die zu kopierende Modulauswahl wurde nicht im Speicher gefunden!", ); return null; } localStorage[`moduleset-${copyName}`] = localStorage[`moduleset-${copyFrom}`]; const copyData = JSON.parse( localStorage[`moduleset-${copyName}`], ) as PlanningDump; this.allPlans.push({ name: copyName, planVersion: copyData.version, }); this.currentPlanName = copyName; return copyName; }, renamePlan(newName: string, current: string | null = null): boolean { if (current == null) current = this.currentPlanName; if (current == newName) return true; if (localStorage[`moduleset-${newName}`] != null) { // Key already exists toast.error(`Der Name '${newName}' ist bereits vergeben!`); return false; } if (localStorage[`moduleset-${current}`] == null) { // Current name does not exist! toast.error( "Die zu umzubenennende Modulauswahl wurde nicht im Speicher gefunden!", ); return false; } localStorage[`moduleset-${newName}`] = localStorage[`moduleset-${current}`]; localStorage.removeItem(`moduleset-${current}`); this.currentPlanName = newName; const pIdx = this.allPlans.findIndex((el) => el.name == current); if (pIdx >= 0) { const planVersion = useClassVersionStore().semVer ?? ""; this.allPlans.splice(pIdx, 1, { name: newName, planVersion }); } return true; }, stateToJSON(forceEmpty = false, includeName = false): string { let modules = [] as string[]; if (!forceEmpty) modules = this.chosen.map((m) => m.id); const data = { version: useClassVersionStore().semVer, modules: modules, personalEvents: this.personalEvents, lastPlanRequestTimestamp: this.lastPlanRequestTimestamp, oldPlanReminderTimestamp: this.oldPlanReminderTimestamp, doNotRequestVersionUpgrade: this.doNotRequestVersionUpgrade, } as PlanningDump; if (includeName) data.name = this.currentPlanName; return JSON.stringify(data); }, getShareURL(): string { const state = this.stateToJSON(false, true); const encodedState = btoa(unescape(encodeURIComponent(state))); return `${window.location.href}?state=${encodedState}`; }, removePersonalEvent(event: PersonalEvent | null): boolean { if (event == null) return false; const idx = this.personalEvents.indexOf(event); if (idx == -1) return false; this.personalEvents.splice(idx, 1); this.saveChosen(); return true; }, addPersonalEvent(event: PersonalEvent) { this.personalEvents.push(event); this.saveChosen(); }, updatePersonalEvent( oldEvent: PersonalEvent, newEvent: PersonalEvent, ): boolean { const idx = this.personalEvents.indexOf(oldEvent); if (idx == -1) return false; this.personalEvents.splice(idx, 1, newEvent); this.saveChosen(); return true; }, noLongerRequestVersionUpgrade() { this.doNotRequestVersionUpgrade = true; this.saveChosen(); }, }, });