From 7adcab25e2cfffe2eabe4740ee63b8803d947673 Mon Sep 17 00:00:00 2001 From: cupoftea4 Date: Mon, 9 Sep 2024 11:35:02 +0300 Subject: [PATCH] [feat] saturday lessons and some formatting --- .env | 2 + dev.env | 2 + hope.env | 2 + package.json | 2 +- src/features/timetable/Timetable.tsx | 181 +++++++++++++--------- src/pages/TimetablePage.tsx | 2 +- src/utils/constants.ts | 8 + src/utils/data/LPNUData.ts | 148 +++++++++++------- src/utils/data/TimetableManager.ts | 220 ++++++++++++++------------- src/utils/date.ts | 29 +++- src/utils/timetable.ts | 25 +++ 11 files changed, 383 insertions(+), 238 deletions(-) diff --git a/.env b/.env index 4dca29a..cba95ef 100644 --- a/.env +++ b/.env @@ -1 +1,3 @@ VITE_PROXY=https://lpnu.pp.ua/get.php?query= +VITE_ENABLE_WORKING_SATURADAYS=true +VITE_FIRST_CLASS_DATE=2024-09-09 diff --git a/dev.env b/dev.env index 6032635..7830655 100644 --- a/dev.env +++ b/dev.env @@ -1 +1,3 @@ VITE_PROXY=https://hope.if.ua/get.php?query= +VITE_ENABLE_WORKING_SATURADAYS=true +VITE_FIRST_CLASS_DATE=2024-09-09 \ No newline at end of file diff --git a/hope.env b/hope.env index 6032635..1451187 100644 --- a/hope.env +++ b/hope.env @@ -1 +1,3 @@ VITE_PROXY=https://hope.if.ua/get.php?query= +VITE_ENABLE_WORKING_SATURADAYS=true +VITE_FIRST_CLASS_DATE=2024-09-09 diff --git a/package.json b/package.json index bd485ba..616a433 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timetable", - "version": "2.6.2", + "version": "2.6.5", "homepage": "/", "private": true, "dependencies": { diff --git a/src/features/timetable/Timetable.tsx b/src/features/timetable/Timetable.tsx index 3560d06..9da66e2 100644 --- a/src/features/timetable/Timetable.tsx +++ b/src/features/timetable/Timetable.tsx @@ -1,7 +1,7 @@ import { type FC, useCallback, useEffect, useMemo, useState } from 'react'; import TimetableLesson from './components/TimetableLesson'; import useWindowDimensions from '@/hooks/useWindowDimensions'; -import { lessonsTimes, unique } from '@/utils/timetable'; +import { generateSaturdayLessons, lessonsTimes, unique } from '@/utils/timetable'; import { getCurrentUADate, stringToDate } from '@/utils/date'; import { DEVELOP, TIMETABLE_SCREEN_BREAKPOINT } from '@/utils/constants'; import type { TimetableItem } from '@/types/timetable'; @@ -17,28 +17,45 @@ type OwnProps = { const MINUTE = 60 * 1000; -const Timetable: FC = ({ timetable, isSecondSubgroup, isSecondWeek, hasCellSubgroups }) => { +const Timetable: FC = ({ + timetable: originalTimetable, + isSecondSubgroup, + isSecondWeek, + hasCellSubgroups +}) => { const { width } = useWindowDimensions(); const isMobile = width < TIMETABLE_SCREEN_BREAKPOINT; - const maxLessonNumber = useMemo(() => - timetable?.reduce((max, item) => item.number > max ? item.number : max, 0) || 0, - [timetable]); - const maxDayNumber = useMemo(() => - timetable?.reduce((max, item) => item.day > max ? item.day : max, 0) || 0, - [timetable]); + const timetable = useMemo(() => { + return [ + ...originalTimetable, + ...generateSaturdayLessons(originalTimetable) + ]; + }, [originalTimetable]); - const timetableLessonsTimes = useMemo(() => - lessonsTimes.slice(0, maxLessonNumber), - [maxLessonNumber]); + const maxLessonNumber = useMemo( + () => timetable?.reduce((max, item) => (item.number > max ? item.number : max), 0) || 0, + [timetable] + ); + const maxDayNumber = useMemo( + () => timetable?.reduce((max, item) => (item.day > max ? item.day : max), 0) || 0, + [timetable] + ); - const days = useMemo(() => - [null, 'Понеділок', 'Вівторок', 'Середа', 'Четвер', "П'ятниця", 'Субота', 'Неділя'].slice(0, maxDayNumber + 1), - [maxDayNumber]); + const timetableLessonsTimes = useMemo(() => lessonsTimes.slice(0, maxLessonNumber), [maxLessonNumber]); + + const days = useMemo( + () => + [null, 'Понеділок', 'Вівторок', 'Середа', 'Четвер', "П'ятниця", 'Субота', 'Неділя'].slice( + 0, + maxDayNumber + 1 + ), + [maxDayNumber] + ); const getCurrentLessonNumber = useCallback(() => { const curDate = getCurrentUADate(); - return timetableLessonsTimes.findIndex(time => curDate <= stringToDate(time.end)); + return timetableLessonsTimes.findIndex((time) => curDate <= stringToDate(time.end)); }, [timetableLessonsTimes]); const currentDay = getCurrentUADate().getDay(); @@ -48,60 +65,76 @@ const Timetable: FC = ({ timetable, isSecondSubgroup, isSecondWeek, ha const id = setInterval(() => { setCurrentLessonNumber(getCurrentLessonNumber()); }, MINUTE); - return () => { clearInterval(id); }; + return () => { + clearInterval(id); + }; }, [getCurrentLessonNumber]); const forBothSubgroups = (item: TimetableItem) => item.isFirstSubgroup && item.isSecondSubgroup; const forAllWeeks = (item: TimetableItem) => item.isFirstWeek && item.isSecondWeek; - const getLessonsByDayAndTime = useCallback((number: number, day: number) => { - if (!timetable) return null; - const result = timetable.filter(item => - item.day === day && - item.number === number && - (hasCellSubgroups || item.isSecondSubgroup === isSecondSubgroup || forBothSubgroups(item)) && - (item.isSecondWeek === isSecondWeek || forAllWeeks(item)) - ); - return result.length === 0 ? null : unique(result); - }, [timetable, isSecondSubgroup, isSecondWeek, hasCellSubgroups]); + const getLessonsByDayAndTime = useCallback( + (number: number, day: number) => { + if (!timetable) return null; + const result = timetable.filter( + (item) => + item.day === day && + item.number === number && + (hasCellSubgroups || item.isSecondSubgroup === isSecondSubgroup || forBothSubgroups(item)) && + ((item.isSecondWeek === isSecondWeek || forAllWeeks(item)) || item.day === 6) + ); + return result.length === 0 ? null : unique(result); + }, + [timetable, isSecondSubgroup, isSecondWeek, hasCellSubgroups] + ); const tableContent = useMemo(() => { if (isMobile) return null; if (DEVELOP) console.log('Running scary useMemo'); - const table = timetableLessonsTimes.map((time, i) => - { - days.map((day, j) => + const table = timetableLessonsTimes.map((time, i) => ( + + {days.map((day, j) => day === null - ? - {time.start} - {i + 1} - {time.end} - - : - ) - } - ); + ? ( + + {time.start} + {i + 1} + {time.end} + + ) + : ( + + ) + )} + + )); return table; }, [ - isMobile, timetableLessonsTimes, timetable, days, - getLessonsByDayAndTime, currentLessonNumber, currentDay, hasCellSubgroups + isMobile, + timetableLessonsTimes, + timetable, + days, + getLessonsByDayAndTime, + currentLessonNumber, + currentDay, + hasCellSubgroups ]); const listsContent = useMemo(() => { if (!isMobile) return null; if (DEVELOP) console.log('Running scary useMemo'); - const lists = days.filter(Boolean).map((day, i) => + const lists = days.filter(Boolean).map((day, i) => (

{day}

-
    { - timetableLessonsTimes.map((time, j) => +
      + {timetableLessonsTimes.map((time, j) => ( = ({ timetable, isSecondSubgroup, isSecondWeek, ha key={time.start + day} cellSubgroup={hasCellSubgroups} /> - ) - }
    + ))} +
- ); + )); return lists; }, [ - isMobile, timetableLessonsTimes, timetable, days, - getLessonsByDayAndTime, currentLessonNumber, currentDay, hasCellSubgroups + isMobile, + timetableLessonsTimes, + timetable, + days, + getLessonsByDayAndTime, + currentLessonNumber, + currentDay, + hasCellSubgroups ]); - return ( - isMobile - ?
- {listsContent} -
- : - - - {days.map((day, index) => )} - - - - {tableContent} - -
{day}
- ); + return isMobile + ? ( +
{listsContent}
+ ) + : ( + + + + {days.map((day, index) => ( + + ))} + + + {tableContent} +
{day}
+ ); }; export default Timetable; diff --git a/src/pages/TimetablePage.tsx b/src/pages/TimetablePage.tsx index 8bb0c32..45e7ad0 100644 --- a/src/pages/TimetablePage.tsx +++ b/src/pages/TimetablePage.tsx @@ -1,5 +1,5 @@ import { type FC, useEffect, useMemo, useRef, useState } from 'react'; -import { Navigate, useNavigate, useParams } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import LoadingPage from './LoadingPage'; import TimetableHeader from '@/features/header/TimetableHeader'; import ExamsTimetable from '@/features/timetable/ExamsTimetable'; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 8fcc38c..f10f7aa 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -6,3 +6,11 @@ export const NARROW_SCREEN_BREAKPOINT = 300; export const TOAST_AUTO_CLOSE_TIME = 5000; export const DEVELOP = import.meta.env.MODE === 'development'; + +export const ENABLE_SATURDAYS = import.meta.env.VITE_ENABLE_WORKING_SATURADAYS === 'true'; + +if (ENABLE_SATURDAYS && !import.meta.env.VITE_FIRST_CLASS_DATE) { + throw new Error('VITE_FIRST_CLASS_DATE is required when ENABLE_WORKING_SATURADAYS is true'); +} + +export const FIRST_CLASS_DATE = new Date(import.meta.env.VITE_FIRST_CLASS_DATE); diff --git a/src/utils/data/LPNUData.ts b/src/utils/data/LPNUData.ts index 28b8933..7aad461 100644 --- a/src/utils/data/LPNUData.ts +++ b/src/utils/data/LPNUData.ts @@ -4,9 +4,9 @@ import type { LPNUTimetableType, TimetableType } from '@/types/timetable'; import { getCurrentSemester } from '../date'; // const NULP_STUDENTS = 'https://student.lpnu.ua/'; -const NULP_STUDENTS_2023 = 'https://student2023.lpnu.ua/'; +const NULP_STUDENTS_2023 = 'https://student.lpnu.ua/'; // const NULP_STAFF = 'https://staff.lpnu.ua/'; -const NULP_STAFF_2023 = 'https://staff2023.lpnu.ua/'; +const NULP_STAFF_2023 = 'https://staff.lpnu.ua/'; const CURRENT_SEMESTER = getCurrentSemester(); const PROXY: string = import.meta.env.VITE_PROXY; @@ -20,11 +20,11 @@ const TIMETABLE_EXAMS_SUFFIX = 'students_exam'; const LECTURER_EXAMS_SUFFIX = 'lecturer_exam'; type LPNURequestSuffix = - typeof TIMETABLE_SUFFIX -| typeof SELECTIVE_SUFFIX -| typeof LECTURER_SUFFIX -| typeof TIMETABLE_EXAMS_SUFFIX -| typeof LECTURER_EXAMS_SUFFIX; + | typeof TIMETABLE_SUFFIX + | typeof SELECTIVE_SUFFIX + | typeof LECTURER_SUFFIX + | typeof TIMETABLE_EXAMS_SUFFIX + | typeof LECTURER_EXAMS_SUFFIX; const timetableSuffixes: Record = { timetable: 'students_schedule', @@ -41,7 +41,7 @@ type StudentParams = { }; type LecturerParams = { - department_name_selective: string + department_name_selective?: string assetbuilding_name_selective?: 'весь семестр' teachername?: string @@ -59,10 +59,20 @@ type ExamsLecturerParams = { namedepartment_selective?: string }; -type LPNURequestParams = StudentParams | LecturerParams | ExamsStudentParams | ExamsLecturerParams | null; +type SelectiveParams = { + studygroup_abbrname: string +}; + +type LPNURequestParams = + | StudentParams + | LecturerParams + | ExamsStudentParams + | ExamsLecturerParams + | SelectiveParams + | null; const getKeysWithTypes = (obj: Record) => { - return Object.keys(obj) as (T)[]; + return Object.keys(obj) as T[]; }; const isLecturer = (suffix: LPNURequestSuffix) => { @@ -72,99 +82,119 @@ const isLecturer = (suffix: LPNURequestSuffix) => { const buildURL = (base: string, params: Record | null) => { const originalUrl = new URL(base); if (!params) return originalUrl.href; - (getKeysWithTypes(params)).forEach(key => originalUrl.searchParams.set(key, params[key]!)); + getKeysWithTypes(params).forEach((key) => originalUrl.searchParams.set(key, params[key]!)); return originalUrl.href; }; export default class LPNUData { - private static fetchHTML ( - params: LPNURequestParams = null, - suffix: LPNURequestSuffix = TIMETABLE_SUFFIX - ) { - const origin = isLecturer(suffix) - ? NULP_STAFF_2023 - : NULP_STUDENTS_2023; + private static fetchHTML(params: LPNURequestParams = null, suffix: LPNURequestSuffix = TIMETABLE_SUFFIX) { + const origin = isLecturer(suffix) ? NULP_STAFF_2023 : NULP_STUDENTS_2023; const built = buildURL(origin + suffix, params); const proxiedUrl = PROXY + built; - return timeout(TIMEOUT, fetch(proxiedUrl)).then(response => { + return timeout(TIMEOUT, fetch(proxiedUrl)).then((response) => { if (!response.ok) throw Error(response.statusText); return response.text(); }); } - static getSelectiveGroups () { - return this.fetchHTML(null, SELECTIVE_SUFFIX) - .then(Parser.parseSelectiveGroups.bind(Parser)); + static getSelectiveGroups() { + return this.fetchHTML(null, SELECTIVE_SUFFIX).then(Parser.parseSelectiveGroups.bind(Parser)); } - static getInstitutes () { - return this.fetchHTML() - .then(Parser.parseInstitutes.bind(Parser)); + static getInstitutes() { + return this.fetchHTML().then(Parser.parseInstitutes.bind(Parser)); } - static getLecturers (department?: string) { + static getLecturers(department?: string) { return this.fetchHTML( department ? { department_name_selective: department } : null, LECTURER_SUFFIX ).then(Parser.parseLecturers.bind(Parser)); } - static getLecturerDepartments () { - return this.fetchHTML(null, LECTURER_SUFFIX) - .then(Parser.parseLecturerDepartments.bind(Parser)); + static getLecturerDepartments() { + return this.fetchHTML(null, LECTURER_SUFFIX).then(Parser.parseLecturerDepartments.bind(Parser)); } - static getGroups (institute = 'All') { + static getGroups(institute = 'All') { return this.fetchHTML({ semestr: CURRENT_SEMESTER, departmentparent_abbrname_selective: institute }).then(Parser.parseGroups.bind(Parser)); } - static getPartialGroups (semesterHalf: 1 | 2, institute = 'All') { + static getPartialGroups(semesterHalf: 1 | 2, institute = 'All') { const semesterParam = semesterHalf === 1 ? '2' : '3'; - return this.fetchHTML({ - departmentparent_abbrname_selective: institute, - semestr: CURRENT_SEMESTER, - semestrduration: semesterParam - }, TIMETABLE_SUFFIX).then(Parser.parsePartialGroups.bind(Parser)); + return this.fetchHTML( + { + departmentparent_abbrname_selective: institute, + semestr: CURRENT_SEMESTER, + semestrduration: semesterParam + }, + TIMETABLE_SUFFIX + ).then(Parser.parsePartialGroups.bind(Parser)); } - static getTimetable (type: LPNUTimetableType, timetableName = 'All', timetableCategory = 'All') { + static getTimetable(type: LPNUTimetableType, timetableName = 'All', timetableCategory = 'All') { const suffix = timetableSuffixes[type]; if (suffix === LECTURER_SUFFIX) { - return this.fetchHTML({ - teachername: timetableName, + return this.fetchHTML( + { + teachername: timetableName, + semestr: CURRENT_SEMESTER, + semestrduration: '1' // Why, NULP? + }, + LECTURER_SUFFIX + ).then(Parser.parseTimetable.bind(Parser)); + } + + if (suffix === SELECTIVE_SUFFIX) { + return this.fetchHTML( + { + studygroup_abbrname: timetableName.toLowerCase() + }, + SELECTIVE_SUFFIX + ).then(Parser.parseTimetable.bind(Parser)); + } + + return this.fetchHTML( + { + studygroup_abbrname: timetableName.toLowerCase(), semestr: CURRENT_SEMESTER, semestrduration: '1' // Why, NULP? - }, LECTURER_SUFFIX).then(Parser.parseTimetable.bind(Parser)); - } - return this.fetchHTML({ - studygroup_abbrname: timetableName.toUpperCase(), - semestr: CURRENT_SEMESTER, - semestrduration: '1' // Why, NULP? - }, suffix).then(Parser.parseTimetable.bind(Parser)); + }, + suffix + ).then(Parser.parseTimetable.bind(Parser)); } - static getPartialTimetable (timetableName: string, semesterHalf: 1 | 2) { + static getPartialTimetable(timetableName: string, semesterHalf: 1 | 2) { const semesterParam = semesterHalf === 1 ? '2' : '3'; // TODO: fix support for lecturer partial timetable - return this.fetchHTML({ - departmentparent_abbrname_selective: 'All', - studygroup_abbrname: timetableName, - semestrduration: semesterParam - }, TIMETABLE_SUFFIX).then(Parser.parseTimetable.bind(Parser)); + return this.fetchHTML( + { + departmentparent_abbrname_selective: 'All', + studygroup_abbrname: timetableName, + semestrduration: semesterParam + }, + TIMETABLE_SUFFIX + ).then(Parser.parseTimetable.bind(Parser)); } - static getExamsTimetable (type: TimetableType, group = 'All', institute = 'All') { + static getExamsTimetable(type: TimetableType, group = 'All', institute = 'All') { if (type === 'lecturer') { - return this.fetchHTML({ - teachername: group - }, LECTURER_EXAMS_SUFFIX).then(Parser.parseExamsTimetable.bind(Parser)); + return this.fetchHTML( + { + teachername: group + }, + LECTURER_EXAMS_SUFFIX + ).then(Parser.parseExamsTimetable.bind(Parser)); } - return this.fetchHTML({ - studygroup_abbrname: group - }, TIMETABLE_EXAMS_SUFFIX).then(Parser.parseExamsTimetable.bind(Parser)); + return this.fetchHTML( + { + studygroup_abbrname: group + }, + TIMETABLE_EXAMS_SUFFIX + ).then(Parser.parseExamsTimetable.bind(Parser)); } } diff --git a/src/utils/data/TimetableManager.ts b/src/utils/data/TimetableManager.ts index c0af55c..91ded02 100644 --- a/src/utils/data/TimetableManager.ts +++ b/src/utils/data/TimetableManager.ts @@ -54,11 +54,13 @@ class TimetableManager { private departments: string[] = []; private lecturers: string[] = []; - async init () { + async init() { // cache only data this.timetables = (await storage.getItem(TIMETABLES)) ?? []; this.mergedTimetable = (await storage.getItem(MERGED_TIMETABLE)) ?? null; - storage.getItem(EXAMS_TIMETABLES).then(data => { this.examsTimetables = data ?? []; }); + storage.getItem(EXAMS_TIMETABLES).then((data) => { + this.examsTimetables = data ?? []; + }); // request data from server if needed this.groups = await this.getData(GROUPS, FallbackData.getGroups); @@ -66,11 +68,11 @@ class TimetableManager { this.lecturers = await this.getData(LECTURERS, FallbackData.getLecturers); } - isInited () { + isInited() { return this.institutes.length > 0 && this.groups.length > 0 && this.selectiveGroups.length > 0; } - async getFirstLayerSelectionByType (type: TimetableType) { + async getFirstLayerSelectionByType(type: TimetableType) { switch (type) { case 'selective': { const data = await this.getSelectiveGroups(); @@ -84,15 +86,15 @@ class TimetableManager { } } - firstLayerItemExists (type: TimetableType, query: string) { + firstLayerItemExists(type: TimetableType, query: string) { const isInstitute = this.institutes.includes(query); switch (type) { case 'selective': - return !isInstitute && - this.selectiveGroups.some((group) => Util.startsWithLetters(group, query)); + return !isInstitute && this.selectiveGroups.some((group) => Util.startsWithLetters(group, query)); case 'lecturer': - return !isInstitute && - this.departments.some((department) => Util.startsWithLetters(department, query)); + return ( + !isInstitute && this.departments.some((department) => Util.startsWithLetters(department, query)) + ); case 'timetable': return isInstitute; default: @@ -100,7 +102,7 @@ class TimetableManager { } } - async getSecondLayerByType (type: TimetableType, query: string) { + async getSecondLayerByType(type: TimetableType, query: string) { switch (type) { case 'selective': { const data = await this.getSelectiveGroups(); @@ -116,49 +118,51 @@ class TimetableManager { } } - async getThirdLayerByType (type: TimetableType, query: string) { + async getThirdLayerByType(type: TimetableType, query: string) { switch (type) { case 'selective': { const data = await this.getSelectiveGroups(); - return data.filter(group => Util.getGroupName(group, type) === query); + return data.filter((group) => Util.getGroupName(group, type) === query); } case 'lecturer': return await this.getLecturers(query); default: { const groups = await this.getTimetableGroups(); - return groups.filter(group => Util.getGroupName(group, type) === query); + return groups.filter((group) => Util.getGroupName(group, type) === query); } } } - async getLastOpenedInstitute (): Promise { + async getLastOpenedInstitute(): Promise { if (this.lastOpenedInstitute) return this.lastOpenedInstitute; - return await (storage.getItem(LAST_OPENED_INSTITUTE)); + return await storage.getItem(LAST_OPENED_INSTITUTE); } - async getLastOpenedTimetable (): Promise { + async getLastOpenedTimetable(): Promise { if (this.lastOpenedTimetable) return this.lastOpenedTimetable; - return await (storage.getItem(LAST_OPENED_TIMETABLE)); + return await storage.getItem(LAST_OPENED_TIMETABLE); } - async updateLastOpenedInstitute (institute: string) { + async updateLastOpenedInstitute(institute: string) { this.lastOpenedInstitute = institute; return await storage.setItem(LAST_OPENED_INSTITUTE, institute); } - async updateLastOpenedTimetable (timetable: string) { + async updateLastOpenedTimetable(timetable: string) { this.lastOpenedTimetable = timetable; return await storage.setItem(LAST_OPENED_TIMETABLE, timetable); } - async getPartialTimetable (group: string, halfTerm: 1 | 2, checkCache = true) { + async getPartialTimetable(group: string, halfTerm: 1 | 2, checkCache = true) { if (!(await this.ifPartialTimetableExists(group, halfTerm))) { throw new Error(`Group ${group} does not exist or does't have ${halfTerm} term partial timetable`); } const key = PARTIAL_TIMETABLE + group + '_' + halfTerm; - if (checkCache) { return await this.getData(key, LPNUData.getPartialTimetable, group, halfTerm); } + if (checkCache) { + return await this.getData(key, LPNUData.getPartialTimetable, group, halfTerm); + } const data = await LPNUData.getPartialTimetable(group, halfTerm); if (!data) throw new Error(`Failed to get partial timetable! Group: ${group}, halfTerm: ${halfTerm}`); @@ -166,15 +170,16 @@ class TimetableManager { return data; } - async getPartials (group: string): Promise { - const temp = await Promise.allSettled([HalfTerm.First, HalfTerm.Second] - .map((halfTerm) => this.ifPartialTimetableExists(group, halfTerm))); + async getPartials(group: string): Promise { + const temp = await Promise.allSettled( + [HalfTerm.First, HalfTerm.Second].map((halfTerm) => this.ifPartialTimetableExists(group, halfTerm)) + ); return temp - .map((el, i) => el.status === 'fulfilled' && el.value ? i + 1 : false) - .filter(el => el) as HalfTerm[]; + .map((el, i) => (el.status === 'fulfilled' && el.value ? i + 1 : false)) + .filter((el) => el) as HalfTerm[]; } - getTimetable (group: string, type?: TimetableType, checkCache = true): RenderPromises { + getTimetable(group: string, type?: TimetableType, checkCache = true): RenderPromises { const groupName = group.trim(); const timetableType = type ?? this.tryToGetType(groupName); if (!timetableType) throw Error(`Couldn't define a type! Group: ${groupName}`); @@ -182,60 +187,64 @@ class TimetableManager { if (timetableType === 'merged') return this.getMergedTimetable(); let cacheData: OptimisticPromise; - const data = this.timetables.find(el => el.group.toUpperCase() === groupName.toUpperCase()); + const data = this.timetables.find((el) => el.group.toLowerCase() === groupName.toLowerCase()); if (checkCache && data && !Util.needsUpdate(data.time)) { cacheData = storage.getItem(TIMETABLE + groupName); } else { - cacheData = FallbackData.getTimetable(timetableType, groupName) - .catch(() => storage.getItem(TIMETABLE + groupName)); + cacheData = FallbackData.getTimetable(timetableType, groupName).catch(() => + storage.getItem(TIMETABLE + groupName) + ); } const fetchData: ActualPromise = LPNUData.getTimetable(timetableType, groupName) // doesn't work - /* new Promise((resolve, reject) => { - reject(new Error('LPNU API is not working!')); - }) */.catch(() => { - cacheData.then(t => this.saveTimetableLocally(groupName, t, data?.subgroup)); + .catch(() => { + cacheData.then((t) => this.saveTimetableLocally(groupName, t, data?.subgroup)); Toast.warn('Data is possibly outdated!'); return null; }); - fetchData.then(t => this.saveTimetableLocally(groupName, t, data?.subgroup)); + fetchData.then((t) => this.saveTimetableLocally(groupName, t, data?.subgroup)); return [cacheData, fetchData as any] as const; } - getExamsTimetable (group: string, type?: TimetableType, checkCache = true): RenderPromises { + getExamsTimetable( + group: string, + type?: TimetableType, + checkCache = true + ): RenderPromises { const groupName = group.trim(); const timetableType = type ?? this.tryToGetType(groupName); if (!timetableType) throw Error(`Couldn't define a type! Group: ${groupName}`); let cacheData: OptimisticPromise; - const data = this.examsTimetables.find(el => el.group.toUpperCase() === groupName.toUpperCase()); + const data = this.examsTimetables.find((el) => el.group.toLowerCase() === groupName.toLowerCase()); if (checkCache && data && !Util.needsUpdate(data.time)) { cacheData = storage.getItem(EXAMS_TIMETABLE + groupName); } else { - cacheData = FallbackData.getExamsTimetable(timetableType, groupName) - .catch(() => storage.getItem(EXAMS_TIMETABLE + groupName)); + cacheData = FallbackData.getExamsTimetable(timetableType, groupName).catch(() => + storage.getItem(EXAMS_TIMETABLE + groupName) + ); } - const fetchData: ActualPromise = LPNUData.getExamsTimetable(timetableType, groupName) - .catch((e) => { - console.warn('LPNU API is not working!', e); - cacheData.then(t => this.saveExamsLocally(groupName, t)); - Toast.warn('Data is possibly outdated!'); - return null; - }); + const fetchData: ActualPromise = LPNUData.getExamsTimetable( + timetableType, + groupName + ).catch((e) => { + console.warn('LPNU API is not working!', e); + cacheData.then((t) => this.saveExamsLocally(groupName, t)); + Toast.warn('Data is possibly outdated!'); + return null; + }); - fetchData.then(t => this.saveExamsLocally(groupName, t)); + fetchData.then((t) => this.saveExamsLocally(groupName, t)); return [cacheData, fetchData] as const; } - saveTimetableLocally (group: string, timetable?: TimetableItem[] | null, subgroup?: 1 | 2) { + saveTimetableLocally(group: string, timetable?: TimetableItem[] | null, subgroup?: 1 | 2) { if (!timetable) return; - this.timetables = this.timetables.filter( - el => el.group.toUpperCase() !== group.toUpperCase() - ); + this.timetables = this.timetables.filter((el) => el.group.toLowerCase() !== group.toLowerCase()); this.timetables.push({ group, time: Date.now(), @@ -246,10 +255,10 @@ class TimetableManager { return timetable; } - saveExamsLocally (group: string, timetable?: ExamsTimetableItem[] | null) { + saveExamsLocally(group: string, timetable?: ExamsTimetableItem[] | null) { if (!timetable) return; this.examsTimetables = this.examsTimetables.filter( - el => el.group.toUpperCase() !== group.toUpperCase() + (el) => el.group.toLowerCase() !== group.toLowerCase() ); this.examsTimetables.push({ group, time: Date.now() }); storage.setItem(EXAMS_TIMETABLE + group, timetable); @@ -257,31 +266,38 @@ class TimetableManager { return timetable; } - getMergedTimetable (timetablesToMerge?: string[]): RenderPromises { - const timetableNames = timetablesToMerge ?? this.mergedTimetable?.timetables ?? - (this.mergedTimetable as any)?.timetableNames as string[]; // for backward compatibility + getMergedTimetable(timetablesToMerge?: string[]): RenderPromises { + const timetableNames = + timetablesToMerge ?? + this.mergedTimetable?.timetables ?? + ((this.mergedTimetable as any)?.timetableNames as string[]); // for backward compatibility if (!timetableNames) throw Error("Merge doesn't exist!"); - const timetables = timetableNames.map(el => ({ name: el, data: this.getTimetable(el) })); - const cachePromises = timetables.map(({ name, data }) => data[0].then(timetable => ({ timetable, name }))); - const fetchPromises = timetables.map(({ name, data }) => data[1].then(timetable => ({ timetable, name }))); - const cacheData: OptimisticPromise = Promise.all(cachePromises) - .then(timetables => Util.mergeTimetables(timetables)); - - const fetchData: ActualPromise = Promise.all(fetchPromises).then(timetables => { - const merged = Util.mergeTimetables(timetables); - this.saveMergedTimetable(timetableNames); - return merged; - }).catch(() => { - cacheData.then(merged => - (merged && this.saveMergedTimetable(timetableNames)) - ); - Toast.warn('Data is possibly outdated!'); - return null; - }); + const timetables = timetableNames.map((el) => ({ name: el, data: this.getTimetable(el) })); + const cachePromises = timetables.map(({ name, data }) => + data[0].then((timetable) => ({ timetable, name })) + ); + const fetchPromises = timetables.map(({ name, data }) => + data[1].then((timetable) => ({ timetable, name })) + ); + const cacheData: OptimisticPromise = Promise.all(cachePromises).then((timetables) => + Util.mergeTimetables(timetables) + ); + + const fetchData: ActualPromise = Promise.all(fetchPromises) + .then((timetables) => { + const merged = Util.mergeTimetables(timetables); + this.saveMergedTimetable(timetableNames); + return merged; + }) + .catch(() => { + cacheData.then((merged) => merged && this.saveMergedTimetable(timetableNames)); + Toast.warn('Data is possibly outdated!'); + return null; + }); return [cacheData, fetchData] as const; } - updateSubgroup (group?: string, subgroup: 1 | 2 = 1) { + updateSubgroup(group?: string, subgroup: 1 | 2 = 1) { if (!group) return; group = group.trim(); @@ -294,11 +310,11 @@ class TimetableManager { return storage.setItem(MERGED_TIMETABLE, this.mergedTimetable); } - const data = this.timetables.find(el => el.group === group); + const data = this.timetables.find((el) => el.group === group); if (!data) throw Error(`Failed to update subgroup! Group: ${group}`); if (data.subgroup === subgroup) return; - this.timetables = this.timetables.filter(el => el.group !== group); // remove previous timetable + this.timetables = this.timetables.filter((el) => el.group !== group); // remove previous timetable this.timetables.push({ group, time: data.time, @@ -307,7 +323,7 @@ class TimetableManager { return storage.setItem(TIMETABLES, this.timetables); } - getSubgroup (group?: string) { + getSubgroup(group?: string) { if (!group) return; if (Util.isMerged(group)) { @@ -319,23 +335,23 @@ class TimetableManager { } group = group.trim(); - const data = this.timetables.find(el => el.group === group); + const data = this.timetables.find((el) => el.group === group); if (!data) return; return data.subgroup; } - deleteTimetable (group: string) { + deleteTimetable(group: string) { if (Util.isMerged(group)) { this.mergedTimetable = null; return storage.deleteItem(MERGED_TIMETABLE); } group = group.trim(); - this.timetables = this.timetables.filter(el => el.group !== group); + this.timetables = this.timetables.filter((el) => el.group !== group); storage.deleteItem(TIMETABLE + group); return storage.setItem(TIMETABLES, this.timetables); } - saveMergedTimetable (timetablesToMerge: string[]) { + saveMergedTimetable(timetablesToMerge: string[]) { this.mergedTimetable = { group: 'Мій розклад', time: Date.now(), @@ -345,9 +361,9 @@ class TimetableManager { return storage.setItem(MERGED_TIMETABLE, this.mergedTimetable); } - tryToGetType (timetable: string): TimetableType | undefined { + tryToGetType(timetable: string): TimetableType | undefined { timetable = timetable.trim(); - const compare = (el: string) => el.toUpperCase() === timetable.toUpperCase(); + const compare = (el: string) => el.toLowerCase() === timetable.toLowerCase(); if (this.groups.some(compare)) return 'timetable'; if (this.selectiveGroups.some(compare)) return 'selective'; if (this.lecturers.some(compare)) return 'lecturer'; @@ -364,51 +380,51 @@ class TimetableManager { return 'timetable'; // FIXME temporary allow to fetch unknown groups } - getCachedTimetables () { + getCachedTimetables() { return this.timetables; } - getCachedTime (group: string, isExams = false) { + getCachedTime(group: string, isExams = false) { if (isExams) { - return this.examsTimetables.find(el => el.group === group)?.time; + return this.examsTimetables.find((el) => el.group === group)?.time; } - return this.timetables.find(el => el.group === group)?.time; + return this.timetables.find((el) => el.group === group)?.time; } - get cachedInstitutes () { + get cachedInstitutes() { return this.institutes; } - get cachedGroups () { + get cachedGroups() { return this.groups; } - get cachedSelectiveGroups () { + get cachedSelectiveGroups() { return this.selectiveGroups; } - get cachedLecturers () { + get cachedLecturers() { return this.lecturers; } - get cachedMergedTimetable () { + get cachedMergedTimetable() { return this.mergedTimetable; } - private async getInstitutes (): Promise { + private async getInstitutes(): Promise { if (this.institutes.length > 0) return this.institutes; this.institutes = await this.getData(INSTITUTES, FallbackData.getInstitutes); return this.institutes; } - private async ifPartialTimetableExists (group: string, halfTerm: 1 | 2) { + private async ifPartialTimetableExists(group: string, halfTerm: 1 | 2) { // if (!this.groups.includes(group)) return false; // const partialGroups = await this.getPartialGroups(halfTerm); // return partialGroups.includes(group); return false; } - private async getPartialGroups (halfTerm: 1 | 2) { + private async getPartialGroups(halfTerm: 1 | 2) { if (halfTerm === 1) { if (this.firstHalfTermGroups.length !== 0) return this.firstHalfTermGroups; const data = await this.getData(PARTIAL_GROUPS_1, LPNUData.getPartialGroups, halfTerm); @@ -422,8 +438,8 @@ class TimetableManager { } } - private async getTimetableGroups (institute?: string): Promise { - const key = GROUPS + (institute ? ('_' + institute) : ''); + private async getTimetableGroups(institute?: string): Promise { + const key = GROUPS + (institute ? '_' + institute : ''); if (!institute && this.groups.length > 0) return this.groups; const data = await this.getData(key, FallbackData.getGroups, institute); @@ -431,13 +447,13 @@ class TimetableManager { return data; } - private async getSelectiveGroups (): Promise { + private async getSelectiveGroups(): Promise { if (this.selectiveGroups.length > 0) return this.selectiveGroups; this.selectiveGroups = await this.getData(SELECTIVE_GROUPS, FallbackData.getSelectiveGroups); return this.selectiveGroups; } - private async getLecturers (department?: string): Promise { + private async getLecturers(department?: string): Promise { if (this.lecturers.length > 0 && !department) return this.lecturers; const lecturers = await FallbackData.getLecturers(department); if (!department) { @@ -447,7 +463,7 @@ class TimetableManager { return lecturers; } - private async getLecturerDepartments (): Promise { + private async getLecturerDepartments(): Promise { if (this.departments.length > 0) return this.departments; this.departments = await this.getData(DEPARTMENTS, FallbackData.getLecturerDepartments); return this.departments; @@ -459,7 +475,7 @@ class TimetableManager { if (!Array.isArray(cached) && cached) return cached; if (DEVELOP) console.log('Getting data from server', cacheKey); - const binding = (cacheKey.includes('partial')) ? LPNUData : FallbackData; // TODO: fix this + const binding = cacheKey.includes('partial') ? LPNUData : FallbackData; // TODO: fix this const data: T = await fn.call(binding, ...args).catch(async () => { if (DEVELOP) console.log('Failed to get data from server', cacheKey); const cached = await this.getFromCache(cacheKey, true); @@ -476,7 +492,7 @@ class TimetableManager { if (cached && updated && (!Util.needsUpdate(updated) || force)) return cached as T; } - private updateCache (key: string, data: any) { + private updateCache(key: string, data: any) { storage.setItem(key, data); storage.setItem(key + UPDATED, Date.now()); } diff --git a/src/utils/date.ts b/src/utils/date.ts index e234906..1caf31f 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -25,16 +25,15 @@ export const stringToDate = (time: string) => { return date; }; -export function getNULPWeek () { +export function getNULPWeek() { const date = getCurrentUADate(); date.setHours(0, 0, 0, 0); // Thursday in current week decides the year. - date.setDate(date.getDate() + 3 - (date.getDay() + 6) % 7); + date.setDate(date.getDate() + 3 - ((date.getDay() + 6) % 7)); // January 4 is always in week 1. const week1 = new Date(date.getFullYear(), 0, 4); // Adjust to Thursday in week 1 and count number of weeks from date to week1. - return 1 + Math.round(((date.getTime() - week1.getTime()) / 86400000 - - 3 + (week1.getDay() + 6) % 7) / 7); + return 1 + Math.round(((date.getTime() - week1.getTime()) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7); } export function getCurrentSemester(): '1' | '2' { @@ -49,3 +48,25 @@ export function getCurrentSemester(): '1' | '2' { return '1'; // First semester (assuming other months) } } + +export function countDaysFrom(startDate: Date): number { + const currentDate = new Date(); + let workingDaysCount = 0; + + // Clone the start date to avoid mutating the original one + const date = new Date(startDate); + + if (date > currentDate) { + return -1; + } + + // Loop through each day between the start date and current date + // eslint-disable-next-line no-unmodified-loop-condition + while (date <= currentDate) { + workingDaysCount++; + // Move to the next day + date.setDate(date.getDate() + 1); + } + + return workingDaysCount; +} diff --git a/src/utils/timetable.ts b/src/utils/timetable.ts index 24dad63..ed80b4a 100644 --- a/src/utils/timetable.ts +++ b/src/utils/timetable.ts @@ -1,4 +1,6 @@ +import { ENABLE_SATURDAYS, FIRST_CLASS_DATE } from './constants'; import TimetableManager from './data/TimetableManager'; +import { countDaysFrom } from './date'; import { findAndConvertRomanNumeral, hashCode } from './general'; import type { CachedTimetable, @@ -149,3 +151,26 @@ const timetableItemDisplayTypes: Record = { export function getDisplayType (type: TimetableItemType) { return timetableItemDisplayTypes[type]; } + +export function generateSaturdayLessons(originalTimetable: TimetableItem[]) { + if (ENABLE_SATURDAYS) { + const dayCount = countDaysFrom(new Date(FIRST_CLASS_DATE)); + const alreadyHasSaturdayLessons = originalTimetable.some((item) => item.day === 6); + if (dayCount < 0 || alreadyHasSaturdayLessons) return []; + + const weeksFromStart = dayCount / 7; + const saturdayLessonsDay = (Math.floor(weeksFromStart) % 5) + 1; + const shouldHaveLessonsFromEvenWeek = weeksFromStart > 5; // Second 5 weeks + if (weeksFromStart > 10) return []; // No Saturday lessons after 10 weeks + + const saturdayLessons = originalTimetable.filter( + (item) => + item.day === saturdayLessonsDay && + (shouldHaveLessonsFromEvenWeek ? item.isSecondWeek : item.isFirstWeek) + ); + + return saturdayLessons.map((item) => ({ ...item, day: 6 })); + } else { + return []; + } +}