feat: улучшение кэширования и обработки ошибок расписания
- Изменен TTL кэша с 1 часа на 15 минут для нормального использования - Добавлен fallback кэш на 24 часа для использования при ошибках парсинга - Улучшена обработка ошибок: при отсутствии кэша показывается страница с ошибкой вместо 500 - Добавлена анимация появления сообщения об ошибке - Улучшено логирование fallback кэша с указанием возраста - Добавлены новые сообщения загрузки и логика избежания повторений
This commit is contained in:
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 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)
|
||||
|
||||
Reference in New Issue
Block a user