planning.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620
  1. import { defineStore } from "pinia";
  2. import { useToast } from "vue-toastification";
  3. import {
  4. CalendarEvent,
  5. TaughtClass,
  6. Module,
  7. PersonalEvent,
  8. PlanningDump,
  9. PlanEntry,
  10. } from "../types";
  11. import { useClassesStore } from "./classes";
  12. import { useModulesStore } from "./modules";
  13. import { useClassVersionStore } from "./ClassVersion";
  14. import { useStateStore } from "./state";
  15. import {
  16. semesterVersionStringC,
  17. waitTimeAfterOldPlanReminder,
  18. waitTimeAfterUpgradeRequestModal,
  19. } from "../helpers";
  20. const MAX_MODULE_COUNT = 40;
  21. const toast = useToast();
  22. function otherPlanModificationToast() {
  23. toast.error("Du kannst den Plan eines anderen nicht bearbeiten");
  24. }
  25. function reloadChosenModules() {
  26. const planningStore = usePlanningStore();
  27. planningStore.loadChosen();
  28. }
  29. const DEFAULT_PLAN_NAME = "default";
  30. export const BLOCKCOURSE_DAY_IDX = 6;
  31. export const usePlanningStore = defineStore("planning", {
  32. state: () => {
  33. return {
  34. usingModulesFromURL: false,
  35. chosen: [] as TaughtClass[],
  36. personalEvents: [] as PersonalEvent[],
  37. hourStart: 8,
  38. hourEnd: 22,
  39. hourDivisions: 2,
  40. cellHeight: 24,
  41. get currentPlanName(): string {
  42. if (this.__currentPlanName != null) return this.__currentPlanName;
  43. let name = localStorage["lastSelectedCalendarSet"];
  44. if (!name) {
  45. name = DEFAULT_PLAN_NAME;
  46. localStorage["lastSelectedCalendarSet"] = DEFAULT_PLAN_NAME;
  47. }
  48. this.__currentPlanName = name;
  49. return name;
  50. },
  51. set currentPlanName(newName: string) {
  52. this.__currentPlanName = newName;
  53. localStorage["lastSelectedCalendarSet"] = newName;
  54. reloadChosenModules();
  55. },
  56. __currentPlanName: null as string | null,
  57. allPlans: [] as PlanEntry[],
  58. saveChanges: true,
  59. lastPlanRequestTimestamp: undefined as undefined | number,
  60. oldPlanReminderTimestamp: undefined as undefined | number,
  61. doNotRequestVersionUpgrade: false,
  62. };
  63. },
  64. getters: {
  65. eventsByDay(): CalendarEvent[][] {
  66. // Prefill with 7, because school is from Mo-Sa (6) + 1 day for the block courses
  67. const events = new Array(7).fill(null).map(() => []) as CalendarEvent[][];
  68. const allPlacable = [...this.chosen, ...this.personalEvents];
  69. // Sort by longest duration, we want to have them leftmost on the calendar view
  70. const ch = allPlacable
  71. .filter((el) => el != null)
  72. .sort((a, b) => b.to - b.from - (a.to - a.from));
  73. ch.forEach((m, idx) => {
  74. const topOffset =
  75. (m.from / 3600 - this.hourStart) *
  76. this.cellHeight *
  77. this.hourDivisions;
  78. const length =
  79. ((m.to - m.from) / 3600) * this.cellHeight * this.hourDivisions;
  80. let overlapCount = 0;
  81. let overlapIdx = 0;
  82. ch.forEach((om, oidx) => {
  83. if (m.weekday != om.weekday || idx == oidx) return;
  84. if (om.from >= m.from && om.from <= m.to) {
  85. overlapCount++;
  86. if (idx > oidx) overlapIdx++;
  87. } else if (m.from >= om.from && m.from <= om.to) {
  88. overlapCount++;
  89. if (idx > oidx) overlapIdx++;
  90. }
  91. });
  92. const width = 100 / (overlapCount + 1) - overlapIdx;
  93. const leftOffset = overlapIdx * width + overlapIdx;
  94. let tc = null as null | TaughtClass;
  95. let pe = null as null | PersonalEvent;
  96. if ((m as TaughtClass).teachers) tc = m as TaughtClass;
  97. else pe = m as PersonalEvent;
  98. events[m.weekday ?? BLOCKCOURSE_DAY_IDX].push({
  99. taughtClass: tc,
  100. personalEvent: pe,
  101. topOffset: topOffset,
  102. leftOffset: leftOffset,
  103. length: length,
  104. width: width,
  105. });
  106. });
  107. return events;
  108. },
  109. sharedDisplayName(): string | null {
  110. if (!this.usingModulesFromURL) return null;
  111. const pn = this.currentPlanName;
  112. if (pn.length == 0 || pn == DEFAULT_PLAN_NAME) return null;
  113. return pn;
  114. },
  115. totalECTSCount(): { ects: number; unsureModules: string[] } {
  116. let ects = 0;
  117. const unsureModules = [] as string[];
  118. const countedModules = [] as string[];
  119. this.chosen.forEach((c) => {
  120. const module = c.module;
  121. if (module == null || module.ects == null) {
  122. unsureModules.push(c.name);
  123. return;
  124. }
  125. const moduleName = module.name;
  126. if (countedModules.indexOf(moduleName) == -1) {
  127. ects += module.ects;
  128. countedModules.push(moduleName);
  129. }
  130. });
  131. this.personalEvents.forEach((pe) => (ects += pe.ects ?? 0));
  132. return { ects, unsureModules };
  133. },
  134. modulesWithMSP(): Module[] {
  135. const modulesStore = useModulesStore();
  136. return this.chosen
  137. .map((c) => modulesStore.fromModuleShort(c.name))
  138. .filter((m) => m?.hasMSP === true) as Module[];
  139. },
  140. ectsInBBVTClasses(): { bb: number; vt: number } {
  141. const res = { bb: 0, vt: 0 };
  142. const seen = { bb: [], vt: [] } as { bb: string[]; vt: string[] };
  143. this.chosen.forEach((c) => {
  144. const moduleName = c.name;
  145. const ects = c.module?.ects ?? 0;
  146. if (c.isBB) {
  147. const found = seen.bb.indexOf(moduleName) >= 0;
  148. if (found) return;
  149. seen.bb.push(moduleName);
  150. res.bb += ects;
  151. } else {
  152. const found = seen.vt.indexOf(moduleName) >= 0;
  153. if (found) return;
  154. seen.vt.push(moduleName);
  155. res.vt += ects;
  156. }
  157. });
  158. return res;
  159. },
  160. ectsInEngClasses(): number {
  161. const seen = [] as string[];
  162. return this.chosen
  163. .filter((c) => {
  164. const moduleName = c.name;
  165. const found = seen.indexOf(moduleName) >= 0;
  166. if (!found) seen.push(moduleName);
  167. return found;
  168. })
  169. .reduce((sum, c) => sum + (c.isEnglish ? c.module?.ects ?? 0 : 0), 0);
  170. },
  171. ectsInContextClasses(): number {
  172. const seen = [] as string[];
  173. return this.chosen
  174. .filter((c) => {
  175. const moduleName = c.name;
  176. const found = seen.indexOf(moduleName) >= 0;
  177. if (!found) seen.push(moduleName);
  178. return found;
  179. })
  180. .reduce((sum, c) => sum + (c.isContext ? c.module?.ects ?? 0 : 0), 0);
  181. },
  182. ectsWithMSP(): number {
  183. const seen = [] as string[];
  184. return this.chosen
  185. .filter((c) => {
  186. const moduleName = c.name;
  187. const found = seen.indexOf(moduleName) >= 0;
  188. if (!found) seen.push(moduleName);
  189. return found;
  190. })
  191. .reduce(
  192. (sum, c) => sum + (c.module?.hasMSP ? c.module?.ects ?? 0 : 0),
  193. 0,
  194. );
  195. },
  196. },
  197. actions: {
  198. /**
  199. * Updates the current plan from using the old ids in the format of
  200. * `<className>-<name>-<weekday>-<room1<_room2, ...>>` to
  201. * `<className>-<name>-<weekday>-<from_time>-<to_time>`.
  202. *
  203. * This function and it's calls can be safely (and should be) removed by the end of
  204. * 2023! In addition to this code, some further code in `loadChosen` can also be
  205. * removed (marked).
  206. */
  207. checkAndUpdateChosenIDs(modules: string[]): string[] {
  208. const newIdRE = /^.*-.*-\d-\d+-\d+$/;
  209. const classesStore = useClassesStore();
  210. const currentClasses = [
  211. ...(classesStore.currentData?.taughtClasses ?? []),
  212. ...(classesStore.currentData?.blockClasses ?? []),
  213. ];
  214. return modules.map((el) => {
  215. if (newIdRE.test(el)) return el;
  216. const split = el.split("-", 4);
  217. const cls = split[0];
  218. const name = split[1];
  219. const weekday = split[2];
  220. const rooms = split[3];
  221. // find the new id
  222. const found = currentClasses.filter(
  223. (c) =>
  224. c.class == cls &&
  225. c.name == name &&
  226. `${c.weekday}` == weekday &&
  227. c.rooms.join("_") == rooms,
  228. );
  229. if (found.length == 0) {
  230. console.warn("Failed to find a replacement ID!");
  231. return el;
  232. }
  233. return found[0].id;
  234. });
  235. },
  236. loadFromStorage() {
  237. // Saved plans
  238. this.allPlans = [] as PlanEntry[];
  239. for (const key in localStorage) {
  240. if (key.startsWith("moduleset-")) {
  241. const name = key.replace("moduleset-", "");
  242. if (name.length > 0) {
  243. const data = JSON.parse(
  244. localStorage[`moduleset-${name}`],
  245. ) as PlanningDump;
  246. const version = data.version;
  247. this.allPlans.push({ name, planVersion: version });
  248. }
  249. }
  250. }
  251. if (this.allPlans.length == 0) {
  252. this.createPlan(DEFAULT_PLAN_NAME);
  253. this.currentPlanName = DEFAULT_PLAN_NAME;
  254. }
  255. // Last selected plan
  256. this.loadChosen();
  257. },
  258. createPlan(name: string): boolean {
  259. if (this.allPlans.some((el) => el.name == name)) {
  260. toast.warning(
  261. `Der Plan '${name}' existiert bereits. Wähle einen anderen Namen.`,
  262. );
  263. return false;
  264. }
  265. const planVersion =
  266. semesterVersionStringC(useClassVersionStore().latestOverall) ?? "";
  267. this.allPlans.push({ name, planVersion });
  268. this.saveChosen(name, true);
  269. return true;
  270. },
  271. add(m: TaughtClass) {
  272. if (this.chosen.includes(m) || this.chosen.length > MAX_MODULE_COUNT)
  273. return;
  274. if (this.usingModulesFromURL) {
  275. otherPlanModificationToast();
  276. return;
  277. }
  278. this.chosen.push(m);
  279. this.saveChosen();
  280. },
  281. remove(m: TaughtClass) {
  282. if (this.usingModulesFromURL) {
  283. otherPlanModificationToast();
  284. return;
  285. }
  286. const idx = this.chosen.indexOf(m);
  287. if (idx == -1) return;
  288. this.chosen.splice(idx, 1);
  289. this.saveChosen();
  290. },
  291. removeIdx(idx: number) {
  292. if (this.usingModulesFromURL) {
  293. otherPlanModificationToast();
  294. return;
  295. }
  296. this.chosen.splice(idx, 1);
  297. this.saveChosen();
  298. },
  299. isModuleChosen(m: TaughtClass) {
  300. return this.chosen.includes(m);
  301. },
  302. saveChosen(name: string | null = null, forceEmpty = false) {
  303. if (!this.saveChanges) return;
  304. if (this.usingModulesFromURL) {
  305. console.log("Not saving the current plan, as this is a shared one!");
  306. return;
  307. }
  308. if (name == null) name = this.currentPlanName;
  309. localStorage[`moduleset-${name}`] = this.stateToJSON(forceEmpty);
  310. },
  311. loadChosen(name: string | null = null, useSavedVersion = true): boolean {
  312. if (name == null) name = this.currentPlanName;
  313. const params = window.location.href
  314. .substr(window.location.href.indexOf("?"))
  315. .replace("+", "%2B");
  316. const param = new URLSearchParams(params);
  317. let strData;
  318. const stateParam = param.get("state");
  319. if (stateParam) {
  320. const state = stateParam.replace("%2B", "+");
  321. strData = decodeURIComponent(escape(atob(state)));
  322. this.usingModulesFromURL = true;
  323. } else {
  324. strData = localStorage[`moduleset-${name}`];
  325. if (strData == null) return false;
  326. this.usingModulesFromURL = false;
  327. }
  328. const classVersionStore = useClassVersionStore();
  329. const modulesStore = useClassesStore();
  330. const data = JSON.parse(strData) as PlanningDump;
  331. classVersionStore.useFromPDFString(
  332. useSavedVersion
  333. ? data.version
  334. : useClassVersionStore().semVer ?? data.version,
  335. );
  336. modulesStore.fetchData();
  337. if (!modulesStore.currentData?.loaded) return false;
  338. this.doNotRequestVersionUpgrade =
  339. data.doNotRequestVersionUpgrade ?? false;
  340. let shouldSave = false;
  341. const cTimestamp = new Date().getTime();
  342. if (!this.doNotRequestVersionUpgrade) {
  343. this.lastPlanRequestTimestamp = data.lastPlanRequestTimestamp;
  344. if (
  345. !classVersionStore.isLatestSemesterVersion &&
  346. (this.lastPlanRequestTimestamp == undefined ||
  347. cTimestamp - this.lastPlanRequestTimestamp >
  348. waitTimeAfterUpgradeRequestModal)
  349. ) {
  350. const stateStore = useStateStore();
  351. stateStore.showingClassUpgradeModal = true;
  352. this.lastPlanRequestTimestamp = cTimestamp;
  353. shouldSave = true;
  354. }
  355. }
  356. this.oldPlanReminderTimestamp = data.oldPlanReminderTimestamp;
  357. if (
  358. !classVersionStore.isLatestSemester &&
  359. (this.oldPlanReminderTimestamp == undefined ||
  360. cTimestamp - this.oldPlanReminderTimestamp >
  361. waitTimeAfterOldPlanReminder)
  362. ) {
  363. const stateStore = useStateStore();
  364. stateStore.showingOldPlanReminderModal = true;
  365. this.oldPlanReminderTimestamp = cTimestamp;
  366. shouldSave = true;
  367. }
  368. /**
  369. * The following code can be removed at the end of 2023!
  370. */
  371. const updatedIDs = this.checkAndUpdateChosenIDs(data.modules);
  372. this.chosen = updatedIDs
  373. .map((el) => modulesStore.getById(el))
  374. .filter((el) => el) as TaughtClass[];
  375. /**
  376. * End code that can be removed at the end of 2023!
  377. */
  378. if (this.usingModulesFromURL) this.__currentPlanName = data.name ?? "";
  379. if (data.personalEvents) this.personalEvents = data.personalEvents;
  380. else this.personalEvents = [];
  381. /**
  382. * The following code can be removed at the end of 2023!
  383. */
  384. shouldSave ||= !(
  385. updatedIDs.length == data.modules.length &&
  386. updatedIDs.every(function (element, index) {
  387. return element === data.modules[index];
  388. })
  389. );
  390. /**
  391. * End code that can be removed at the end of 2023!
  392. */
  393. if (shouldSave) {
  394. console.log("Saving due to ...");
  395. this.saveChosen(name);
  396. }
  397. return true;
  398. },
  399. copySharedTo(name: string) {
  400. const params = window.location.href
  401. .substr(window.location.href.indexOf("?"))
  402. .replace("+", "%2B");
  403. const param = new URLSearchParams(params);
  404. const stateParam = param.get("state");
  405. if (!stateParam) return;
  406. const state = stateParam.replace("%2B", "+");
  407. const strData = decodeURIComponent(escape(atob(state)));
  408. localStorage[`moduleset-${name}`] = strData;
  409. window.history.pushState(null, "", window.location.href.split("?")[0]);
  410. this.currentPlanName = name;
  411. this.loadChosen(name);
  412. },
  413. deleteChosen(name: string | null = null): boolean {
  414. if (name == null) name = this.currentPlanName;
  415. const pIdx = this.allPlans.findIndex((el) => el.name == name);
  416. if (pIdx == -1) return false;
  417. this.allPlans.splice(pIdx, 1);
  418. localStorage.removeItem(`moduleset-${name}`);
  419. if (this.allPlans.length == 0) {
  420. const planVersion =
  421. semesterVersionStringC(useClassVersionStore().latestOverall) ?? "";
  422. this.allPlans.push({ name: DEFAULT_PLAN_NAME, planVersion });
  423. this.currentPlanName = DEFAULT_PLAN_NAME;
  424. this.chosen = [];
  425. this.saveChosen();
  426. this.loadChosen();
  427. return true;
  428. }
  429. if (this.currentPlanName == name)
  430. this.currentPlanName = this.allPlans[0].name;
  431. this.loadChosen();
  432. return true;
  433. },
  434. emptyPlan() {
  435. this.chosen = [];
  436. this.saveChosen();
  437. },
  438. copyPlan(
  439. copyName: string | null = null,
  440. copyFrom: string | null = null,
  441. ): string | null {
  442. if (copyFrom == null) copyFrom = this.currentPlanName;
  443. if (copyName == null) {
  444. let counter = 2;
  445. copyName = `Kopie von ${copyFrom}`;
  446. while (this.allPlans.findIndex((el) => el.name == copyName) >= 0) {
  447. copyName = `Kopie ${counter} von ${copyFrom}`;
  448. counter++;
  449. }
  450. } else {
  451. if (this.allPlans.findIndex((el) => el.name == copyName) >= 0) {
  452. // Key already exists
  453. toast.error(`Der Name '${copyName}' ist bereits vergeben!`);
  454. return null;
  455. }
  456. }
  457. if (localStorage[`moduleset-${copyFrom}`] == null) {
  458. // copyFrom name does not exist!
  459. toast.error(
  460. "Die zu kopierende Modulauswahl wurde nicht im Speicher gefunden!",
  461. );
  462. return null;
  463. }
  464. localStorage[`moduleset-${copyName}`] =
  465. localStorage[`moduleset-${copyFrom}`];
  466. const copyData = JSON.parse(
  467. localStorage[`moduleset-${copyName}`],
  468. ) as PlanningDump;
  469. this.allPlans.push({
  470. name: copyName,
  471. planVersion: copyData.version,
  472. });
  473. this.currentPlanName = copyName;
  474. return copyName;
  475. },
  476. renamePlan(newName: string, current: string | null = null): boolean {
  477. if (current == null) current = this.currentPlanName;
  478. if (current == newName) return true;
  479. if (localStorage[`moduleset-${newName}`] != null) {
  480. // Key already exists
  481. toast.error(`Der Name '${newName}' ist bereits vergeben!`);
  482. return false;
  483. }
  484. if (localStorage[`moduleset-${current}`] == null) {
  485. // Current name does not exist!
  486. toast.error(
  487. "Die zu umzubenennende Modulauswahl wurde nicht im Speicher gefunden!",
  488. );
  489. return false;
  490. }
  491. localStorage[`moduleset-${newName}`] =
  492. localStorage[`moduleset-${current}`];
  493. localStorage.removeItem(`moduleset-${current}`);
  494. this.currentPlanName = newName;
  495. const pIdx = this.allPlans.findIndex((el) => el.name == current);
  496. if (pIdx >= 0) {
  497. const planVersion = useClassVersionStore().semVer ?? "";
  498. this.allPlans.splice(pIdx, 1, { name: newName, planVersion });
  499. }
  500. return true;
  501. },
  502. stateToJSON(forceEmpty = false, includeName = false): string {
  503. let modules = [] as string[];
  504. if (!forceEmpty) modules = this.chosen.map((m) => m.id);
  505. const data = {
  506. version: useClassVersionStore().semVer,
  507. modules: modules,
  508. personalEvents: this.personalEvents,
  509. lastPlanRequestTimestamp: this.lastPlanRequestTimestamp,
  510. oldPlanReminderTimestamp: this.oldPlanReminderTimestamp,
  511. doNotRequestVersionUpgrade: this.doNotRequestVersionUpgrade,
  512. } as PlanningDump;
  513. if (includeName) data.name = this.currentPlanName;
  514. return JSON.stringify(data);
  515. },
  516. getShareURL(): string {
  517. const state = this.stateToJSON(false, true);
  518. const encodedState = btoa(unescape(encodeURIComponent(state)));
  519. return `${window.location.href}?state=${encodedState}`;
  520. },
  521. removePersonalEvent(event: PersonalEvent | null): boolean {
  522. if (event == null) return false;
  523. const idx = this.personalEvents.indexOf(event);
  524. if (idx == -1) return false;
  525. this.personalEvents.splice(idx, 1);
  526. this.saveChosen();
  527. return true;
  528. },
  529. addPersonalEvent(event: PersonalEvent) {
  530. this.personalEvents.push(event);
  531. this.saveChosen();
  532. },
  533. updatePersonalEvent(
  534. oldEvent: PersonalEvent,
  535. newEvent: PersonalEvent,
  536. ): boolean {
  537. const idx = this.personalEvents.indexOf(oldEvent);
  538. if (idx == -1) return false;
  539. this.personalEvents.splice(idx, 1, newEvent);
  540. this.saveChosen();
  541. return true;
  542. },
  543. noLongerRequestVersionUpgrade() {
  544. this.doNotRequestVersionUpgrade = true;
  545. this.saveChosen();
  546. },
  547. },
  548. });