import { Schedule } from '@/widgets/schedule' import { Day } from '@/shared/model/day' import { GetServerSidePropsContext, GetServerSidePropsResult } from 'next' import { getSchedule, ScheduleResult } from '@/app/agregator/schedule' import { NextSerialized, nextDeserialized, nextSerialized } from '@/app/utils/date-serializer' import { NavBar } from '@/widgets/navbar' import { LastUpdateAt } from '@/entities/last-update-at' import { loadGroups, GroupsData } from '@/shared/data/groups-loader' import { loadSettings, AppSettings } from '@/shared/data/settings-loader' import { SITE_URL } from '@/shared/constants/urls' import crypto from 'crypto' import React from 'react' import { getDayOfWeek } from '@/shared/utils' import Head from 'next/head' import { WeekInfo } from '@/app/parser/schedule' type PageProps = { schedule: Day[] group: { id: string name: string } parsedAt: Date cacheAvailableFor: string[] groups: GroupsData currentWk: number | null availableWeeks: WeekInfo[] | null settings: AppSettings } export default function HomePage(props: NextSerialized) { const { schedule, group, cacheAvailableFor, parsedAt, groups, currentWk, availableWeeks, settings } = nextDeserialized(props) React.useEffect(() => { if (typeof window === 'undefined') return // Используем 'auto' для нормальной работы обновления страницы if ('scrollRestoration' in history) { history.scrollRestoration = 'auto' } let attempts = 0 const MAX_ATTEMPTS = 50 // Максимум 5 секунд (50 * 100ms) const interval = setInterval(() => { attempts++ const today = getDayOfWeek(new Date()) const todayBlock = document.getElementById(today) if (todayBlock) { const GAP = 48 const HEADER_HEIGHT = 64 window.scrollTo({ top: todayBlock.offsetTop - GAP - HEADER_HEIGHT, behavior: 'smooth' }) clearInterval(interval) } else if (attempts >= MAX_ATTEMPTS) { // Прекращаем попытки после максимального количества clearInterval(interval) } }, 100) // Cleanup функция для очистки интервала при размонтировании return () => { clearInterval(interval) } }, [schedule]) return ( <> {`Группа ${group.name} — Расписание занятий в Колледже Связи`} ) } const cachedSchedules = new Map() const maxCacheDurationInMS = 1000 * 60 * 60 const maxCacheSize = 50 // Максимальное количество записей в кэше (только текущие недели) // Очистка старых записей из кэша function cleanupCache() { const now = Date.now() const entriesToDelete: string[] = [] // Находим устаревшие записи for (const [key, value] of cachedSchedules.entries()) { if (now - value.lastFetched.getTime() >= maxCacheDurationInMS) { entriesToDelete.push(key) } } // Удаляем устаревшие записи entriesToDelete.forEach(key => cachedSchedules.delete(key)) // Если кэш все еще слишком большой, удаляем самые старые записи if (cachedSchedules.size > maxCacheSize) { const sortedEntries = Array.from(cachedSchedules.entries()) .sort((a, b) => a[1].lastFetched.getTime() - b[1].lastFetched.getTime()) const toRemove = sortedEntries.slice(0, cachedSchedules.size - maxCacheSize) toRemove.forEach(([key]) => cachedSchedules.delete(key)) } } export async function getServerSideProps(context: GetServerSidePropsContext<{ group: string }>): Promise>> { const groups = loadGroups() const settings = loadSettings() const group = context.params?.group const wkParam = context.query.wk // Валидация wk параметра: проверка на валидное число (не NaN, не Infinity) const wk = wkParam && !isNaN(Number(wkParam)) && isFinite(Number(wkParam)) && Number.isInteger(Number(wkParam)) && Number(wkParam) > 0 ? Number(wkParam) : undefined if (group && Object.hasOwn(groups, group) && group in groups) { let scheduleResult: ScheduleResult let parsedAt // Очищаем старые записи из кэша перед использованием cleanupCache() // Кэшируем только текущую неделю (без параметра wk) // Если запрашивается конкретная неделя (wk указан), не используем кэш const useCache = !wk const cacheKey = group // Ключ кэша - только группа (текущая неделя) const cachedSchedule = useCache ? cachedSchedules.get(cacheKey) : undefined if (cachedSchedule?.lastFetched && Date.now() - cachedSchedule.lastFetched.getTime() < maxCacheDurationInMS) { scheduleResult = cachedSchedule.results parsedAt = cachedSchedule.lastFetched } else { try { const groupInfo = groups[group] // Передаем настройки в getSchedule для условного парсинга навигации scheduleResult = await getSchedule(groupInfo.parseId, groupInfo.name, wk, settings.weekNavigationEnabled) parsedAt = new Date() // Кэшируем только текущую неделю if (useCache) { cachedSchedules.set(cacheKey, { lastFetched: new Date(), results: scheduleResult }) // Очищаем кэш после добавления новой записи, если он стал слишком большим cleanupCache() } } catch(e) { if (cachedSchedule?.lastFetched) { scheduleResult = cachedSchedule.results parsedAt = cachedSchedule.lastFetched } else { throw e } } } const schedule = scheduleResult.days const getSha256Hash = (input: string) => { const hash = crypto.createHash('sha256') hash.update(input) return hash.digest('hex') } const etag = getSha256Hash(JSON.stringify(nextSerialized(schedule))) const ifNoneMatch = context.req.headers['if-none-match'] if (ifNoneMatch === etag) { context.res.writeHead(304, { ETag: `"${etag}"` }) context.res.end() // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore Content has not changed return { props: {} } } const cacheAvailableFor = Array.from(cachedSchedules.entries()) .filter(([, v]) => v.lastFetched.getTime() + maxCacheDurationInMS > Date.now()) .map(([k]) => k.split('_')[0]) // Берем только группу из ключа кэша context.res.setHeader('ETag', `"${etag}"`) return { props: nextSerialized({ schedule: schedule, parsedAt: parsedAt, group: { id: group, name: groups[group].name }, cacheAvailableFor, groups, currentWk: scheduleResult.currentWk ?? null, availableWeeks: scheduleResult.availableWeeks ?? null, settings }) } } else { return { notFound: true } } }