feat: улучшение кэширования и обработки ошибок расписания

- Изменен TTL кэша с 1 часа на 15 минут для нормального использования
- Добавлен fallback кэш на 24 часа для использования при ошибках парсинга
- Улучшена обработка ошибок: при отсутствии кэша показывается страница с ошибкой вместо 500
- Добавлена анимация появления сообщения об ошибке
- Улучшено логирование fallback кэша с указанием возраста
- Добавлены новые сообщения загрузки и логика избежания повторений
This commit is contained in:
kilyabin
2025-11-30 22:50:23 +04:00
parent 3345eb2e3f
commit 86715eaf66
2 changed files with 119 additions and 27 deletions

View File

@@ -13,26 +13,32 @@ import React from 'react'
import { getDayOfWeek } from '@/shared/utils'
import Head from 'next/head'
import { WeekInfo } from '@/app/parser/schedule'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/shadcn/ui/card'
import { AlertCircle } from 'lucide-react'
type PageProps = {
schedule: Day[]
schedule?: Day[]
group: {
id: string
name: string
}
parsedAt: Date
parsedAt?: Date
cacheAvailableFor: string[]
groups: GroupsData
currentWk: number | null
availableWeeks: WeekInfo[] | null
currentWk?: number | null
availableWeeks?: WeekInfo[] | null
settings: AppSettings
error?: {
message: string
isTimeout: boolean
}
}
export default function HomePage(props: NextSerialized<PageProps>) {
const { schedule, group, cacheAvailableFor, parsedAt, groups, currentWk, availableWeeks, settings } = nextDeserialized<PageProps>(props)
const { schedule, group, cacheAvailableFor, parsedAt, groups, currentWk, availableWeeks, settings, error } = nextDeserialized<PageProps>(props)
React.useEffect(() => {
if (typeof window === 'undefined') return
if (typeof window === 'undefined' || error) return
// Используем 'auto' для нормальной работы обновления страницы
if ('scrollRestoration' in history) {
@@ -62,26 +68,49 @@ export default function HomePage(props: NextSerialized<PageProps>) {
return () => {
clearInterval(interval)
}
}, [schedule])
}, [schedule, error])
return (
<>
<Head>
<title>{`Группа ${group.name} — Расписание занятий в Колледже Связи`}</title>
<title>{error ? `Ошибка — Расписание группы ${group.name}` : `Группа ${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} на неделю в Колледже Связи ПГУТИ. Расписание пар, материалы для подготовки и изменения в расписании.`} />
<meta name="description" content={error ? `Не удалось загрузить расписание группы ${group.name}` : `Расписание занятий группы ${group.name} на неделю в Колледже Связи ПГУТИ. Расписание пар, материалы для подготовки и изменения в расписании.`} />
<meta property="og:title" content={error ? `Ошибка — Расписание группы ${group.name}` : `Группа ${group.name} — Расписание занятий в Колледже Связи`} />
<meta property="og:description" content={error ? `Не удалось загрузить расписание группы ${group.name}` : `Расписание занятий группы ${group.name} на неделю в Колледже Связи ПГУТИ. Расписание пар, материалы для подготовки и изменения в расписании.`} />
</Head>
<NavBar cacheAvailableFor={cacheAvailableFor} groups={groups} />
<LastUpdateAt date={parsedAt} />
<Schedule days={schedule} currentWk={currentWk} availableWeeks={availableWeeks} weekNavigationEnabled={settings.weekNavigationEnabled} />
{error ? (
<div className="container mx-auto px-4 py-8 max-w-2xl">
<Card className="stagger-card">
<CardHeader>
<div className="flex items-center gap-3">
<AlertCircle className="h-6 w-6 text-destructive" />
<CardTitle>Не удалось загрузить расписание</CardTitle>
</div>
</CardHeader>
<CardContent>
<CardDescription className="text-base">
{error.isTimeout
? 'Превышено время ожидания ответа от сервера. Пожалуйста, попробуйте обновить страницу через несколько минут.'
: 'Произошла ошибка при загрузке расписания. Пожалуйста, попробуйте обновить страницу позже.'}
</CardDescription>
</CardContent>
</Card>
</div>
) : (
<>
{parsedAt && <LastUpdateAt date={parsedAt} />}
{schedule && <Schedule days={schedule} currentWk={currentWk ?? null} availableWeeks={availableWeeks ?? null} weekNavigationEnabled={settings.weekNavigationEnabled} />}
</>
)}
</>
)
}
const cachedSchedules = new Map<string, { lastFetched: Date, results: ScheduleResult }>()
const maxCacheDurationInMS = 1000 * 60 * 60
const maxCacheDurationInMS = 1000 * 60 * 15 // 15 минут для нормального использования кэша
const fallbackCacheDurationInMS = 1000 * 60 * 60 * 24 // 24 часа для fallback кэша при ошибках парсинга
const maxCacheSize = 50 // Максимальное количество записей в кэше (только текущие недели)
// Очистка старых записей из кэша
@@ -89,9 +118,9 @@ function cleanupCache() {
const now = Date.now()
const entriesToDelete: string[] = []
// Находим устаревшие записи
// Находим устаревшие записи (используем fallback TTL для сохранения кэша при ошибках)
for (const [key, value] of cachedSchedules.entries()) {
if (now - value.lastFetched.getTime() >= maxCacheDurationInMS) {
if (now - value.lastFetched.getTime() >= fallbackCacheDurationInMS) {
entriesToDelete.push(key)
}
}
@@ -149,16 +178,47 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr
cleanupCache()
}
} catch(e) {
// При таймауте или любой другой ошибке используем кэш, если он доступен
if (cachedSchedule?.lastFetched) {
// При таймауте или любой другой ошибке используем кэш, если он доступен (fallback кэш)
// Используем кэш независимо от возраста при ошибке парсинга
if (cachedSchedule) {
scheduleResult = cachedSchedule.results
parsedAt = cachedSchedule.lastFetched
// Логируем использование кэша при таймауте
// Логируем использование fallback кэша с указанием возраста
const cacheAge = Date.now() - cachedSchedule.lastFetched.getTime()
const cacheAgeMinutes = Math.floor(cacheAge / (1000 * 60))
if (e instanceof ScheduleTimeoutError) {
console.warn(`Schedule fetch timeout for group ${group}, using cached data from ${cachedSchedule.lastFetched.toISOString()}`)
console.warn(`Schedule fetch timeout for group ${group}, using fallback cache from ${cachedSchedule.lastFetched.toISOString()} (${cacheAgeMinutes} minutes old)`)
} else {
console.warn(`Schedule fetch error for group ${group}, using fallback cache from ${cachedSchedule.lastFetched.toISOString()} (${cacheAgeMinutes} minutes old)`)
}
} else {
throw e
// Если кэша нет, возвращаем страницу с ошибкой вместо throw
const isTimeout = e instanceof ScheduleTimeoutError
const errorMessage = isTimeout
? 'Превышено время ожидания ответа от сервера'
: 'Произошла ошибка при загрузке расписания'
console.error(`Schedule fetch failed for group ${group}, no cache available:`, e)
const cacheAvailableFor = Array.from(cachedSchedules.entries())
.filter(([, v]) => v.lastFetched.getTime() + maxCacheDurationInMS > Date.now())
.map(([k]) => k.split('_')[0])
return {
props: nextSerialized({
group: {
id: group,
name: groups[group].name
},
cacheAvailableFor,
groups,
settings,
error: {
message: errorMessage,
isTimeout
}
})
}
}
}
}

View File

@@ -52,8 +52,16 @@ const loadingMessages = [
'Объезжаем пробки…',
'Ищем замены…',
'Ждем выходных…',
'Прописываем сетевые настройки...',
'Настраиваем антенны...',
'Обновляем кэш...',
'Готовим кофе...',
'Подкручиваем позитив...',
]
// Размер истории последних сообщений для избежания повторений
const MAX_HISTORY_SIZE = Math.min(3, Math.floor(loadingMessages.length / 2))
interface LoadingOverlayProps {
isLoading: boolean
}
@@ -63,6 +71,8 @@ export function LoadingOverlay({ isLoading }: LoadingOverlayProps) {
const [messageOpacity, setMessageOpacity] = React.useState(0)
const [showError, setShowError] = React.useState(false)
const [errorOpacity, setErrorOpacity] = React.useState(0)
// Храним историю последних показанных сообщений для избежания повторений
const messageHistoryRef = React.useRef<string[]>([])
React.useEffect(() => {
if (!isLoading) {
@@ -70,17 +80,31 @@ export function LoadingOverlay({ isLoading }: LoadingOverlayProps) {
setMessageOpacity(0)
setShowError(false)
setErrorOpacity(0)
messageHistoryRef.current = []
return
}
// Выбираем случайное сообщение при старте загрузки
const getRandomMessage = () => {
const randomIndex = Math.floor(Math.random() * loadingMessages.length)
return loadingMessages[randomIndex]
// Выбираем случайное сообщение, исключая последние показанные
const getRandomMessage = (excludeMessages: string[] = []) => {
const availableMessages = loadingMessages.filter(
msg => !excludeMessages.includes(msg)
)
// Если все сообщения были недавно показаны, сбрасываем историю
if (availableMessages.length === 0) {
messageHistoryRef.current = []
const randomIndex = Math.floor(Math.random() * loadingMessages.length)
return loadingMessages[randomIndex]
}
const randomIndex = Math.floor(Math.random() * availableMessages.length)
return availableMessages[randomIndex]
}
// Устанавливаем первое сообщение
setCurrentMessage(getRandomMessage())
const firstMessage = getRandomMessage()
setCurrentMessage(firstMessage)
messageHistoryRef.current = [firstMessage]
setMessageOpacity(1)
// Таймер для показа сообщения об ошибке после 5 секунд
@@ -99,7 +123,15 @@ export function LoadingOverlay({ isLoading }: LoadingOverlayProps) {
// После fade out меняем сообщение и fade in
setTimeout(() => {
setCurrentMessage(getRandomMessage())
const newMessage = getRandomMessage(messageHistoryRef.current)
setCurrentMessage(newMessage)
// Обновляем историю: добавляем новое сообщение и ограничиваем размер истории
messageHistoryRef.current = [
...messageHistoryRef.current.slice(-(MAX_HISTORY_SIZE - 1)),
newMessage
]
setMessageOpacity(1)
}, 300) // Длительность fade анимации
}, 2000)