- Удалены устаревшие файлы (mock.js, old-schedule.txt, loading-overlay.tsx) - Переработана система аутентификации (login, logout, check-auth) - Добавлен компонент toast для уведомлений - Улучшен контекст загрузки (loading-context) - Обновлен парсер расписания (schedule.ts) - Улучшена админ-панель - Обновлена документация (README.md) - Старые файлы перемещены в директорию old/
205 lines
8.4 KiB
TypeScript
205 lines
8.4 KiB
TypeScript
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<PageProps>) {
|
|
const { schedule, group, cacheAvailableFor, parsedAt, groups, currentWk, availableWeeks, settings } = nextDeserialized<PageProps>(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 (
|
|
<>
|
|
<Head>
|
|
<title>{`Группа ${group.name} — Расписание занятий в Колледже Связи`}</title>
|
|
<link rel="canonical" href={`${SITE_URL}/${group.id}`} />
|
|
<meta name="description" content={`Расписание занятий группы ${group.name} на неделю в Колледже Связи ПГУТИ. Расписание пар, материалы для подготовки и изменения в расписании.`} />
|
|
<meta property="og:title" content={`Группа ${group.name} — Расписание занятий в Колледже Связи`} />
|
|
<meta property="og:description" content={`Расписание занятий группы ${group.name} на неделю в Колледже Связи ПГУТИ. Расписание пар, материалы для подготовки и изменения в расписании.`} />
|
|
</Head>
|
|
<NavBar cacheAvailableFor={cacheAvailableFor} groups={groups} />
|
|
<LastUpdateAt date={parsedAt} />
|
|
<Schedule days={schedule} currentWk={currentWk} availableWeeks={availableWeeks} weekNavigationEnabled={settings.weekNavigationEnabled} />
|
|
</>
|
|
)
|
|
}
|
|
|
|
const cachedSchedules = new Map<string, { lastFetched: Date, results: ScheduleResult }>()
|
|
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<GetServerSidePropsResult<NextSerialized<PageProps>>> {
|
|
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
|
|
}
|
|
}
|
|
} |