Files
kspguti-schedule/src/pages/[group].tsx
kilyabin 9df04745df Рефакторинг: улучшение системы аутентификации и UI компонентов
- Удалены устаревшие файлы (mock.js, old-schedule.txt, loading-overlay.tsx)
- Переработана система аутентификации (login, logout, check-auth)
- Добавлен компонент toast для уведомлений
- Улучшен контекст загрузки (loading-context)
- Обновлен парсер расписания (schedule.ts)
- Улучшена админ-панель
- Обновлена документация (README.md)
- Старые файлы перемещены в директорию old/
2025-11-28 00:29:46 +04:00

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
}
}
}