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 { getDayOfWeek } from '@/shared/utils'
|
||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import { WeekInfo } from '@/app/parser/schedule'
|
import { WeekInfo } from '@/app/parser/schedule'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/shadcn/ui/card'
|
||||||
|
import { AlertCircle } from 'lucide-react'
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
schedule: Day[]
|
schedule?: Day[]
|
||||||
group: {
|
group: {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
parsedAt: Date
|
parsedAt?: Date
|
||||||
cacheAvailableFor: string[]
|
cacheAvailableFor: string[]
|
||||||
groups: GroupsData
|
groups: GroupsData
|
||||||
currentWk: number | null
|
currentWk?: number | null
|
||||||
availableWeeks: WeekInfo[] | null
|
availableWeeks?: WeekInfo[] | null
|
||||||
settings: AppSettings
|
settings: AppSettings
|
||||||
|
error?: {
|
||||||
|
message: string
|
||||||
|
isTimeout: boolean
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HomePage(props: NextSerialized<PageProps>) {
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined' || error) return
|
||||||
|
|
||||||
// Используем 'auto' для нормальной работы обновления страницы
|
// Используем 'auto' для нормальной работы обновления страницы
|
||||||
if ('scrollRestoration' in history) {
|
if ('scrollRestoration' in history) {
|
||||||
@@ -62,26 +68,49 @@ export default function HomePage(props: NextSerialized<PageProps>) {
|
|||||||
return () => {
|
return () => {
|
||||||
clearInterval(interval)
|
clearInterval(interval)
|
||||||
}
|
}
|
||||||
}, [schedule])
|
}, [schedule, error])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{`Группа ${group.name} — Расписание занятий в Колледже Связи`}</title>
|
<title>{error ? `Ошибка — Расписание группы ${group.name}` : `Группа ${group.name} — Расписание занятий в Колледже Связи`}</title>
|
||||||
<link rel="canonical" href={`${SITE_URL}/${group.id}`} />
|
<link rel="canonical" href={`${SITE_URL}/${group.id}`} />
|
||||||
<meta name="description" content={`Расписание занятий группы ${group.name} на неделю в Колледже Связи ПГУТИ. Расписание пар, материалы для подготовки и изменения в расписании.`} />
|
<meta name="description" content={error ? `Не удалось загрузить расписание группы ${group.name}` : `Расписание занятий группы ${group.name} на неделю в Колледже Связи ПГУТИ. Расписание пар, материалы для подготовки и изменения в расписании.`} />
|
||||||
<meta property="og:title" content={`Группа ${group.name} — Расписание занятий в Колледже Связи`} />
|
<meta property="og:title" content={error ? `Ошибка — Расписание группы ${group.name}` : `Группа ${group.name} — Расписание занятий в Колледже Связи`} />
|
||||||
<meta property="og:description" content={`Расписание занятий группы ${group.name} на неделю в Колледже Связи ПГУТИ. Расписание пар, материалы для подготовки и изменения в расписании.`} />
|
<meta property="og:description" content={error ? `Не удалось загрузить расписание группы ${group.name}` : `Расписание занятий группы ${group.name} на неделю в Колледже Связи ПГУТИ. Расписание пар, материалы для подготовки и изменения в расписании.`} />
|
||||||
</Head>
|
</Head>
|
||||||
<NavBar cacheAvailableFor={cacheAvailableFor} groups={groups} />
|
<NavBar cacheAvailableFor={cacheAvailableFor} groups={groups} />
|
||||||
<LastUpdateAt date={parsedAt} />
|
{error ? (
|
||||||
<Schedule days={schedule} currentWk={currentWk} availableWeeks={availableWeeks} weekNavigationEnabled={settings.weekNavigationEnabled} />
|
<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 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 // Максимальное количество записей в кэше (только текущие недели)
|
const maxCacheSize = 50 // Максимальное количество записей в кэше (только текущие недели)
|
||||||
|
|
||||||
// Очистка старых записей из кэша
|
// Очистка старых записей из кэша
|
||||||
@@ -89,9 +118,9 @@ function cleanupCache() {
|
|||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const entriesToDelete: string[] = []
|
const entriesToDelete: string[] = []
|
||||||
|
|
||||||
// Находим устаревшие записи
|
// Находим устаревшие записи (используем fallback TTL для сохранения кэша при ошибках)
|
||||||
for (const [key, value] of cachedSchedules.entries()) {
|
for (const [key, value] of cachedSchedules.entries()) {
|
||||||
if (now - value.lastFetched.getTime() >= maxCacheDurationInMS) {
|
if (now - value.lastFetched.getTime() >= fallbackCacheDurationInMS) {
|
||||||
entriesToDelete.push(key)
|
entriesToDelete.push(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,16 +178,47 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr
|
|||||||
cleanupCache()
|
cleanupCache()
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
// При таймауте или любой другой ошибке используем кэш, если он доступен
|
// При таймауте или любой другой ошибке используем кэш, если он доступен (fallback кэш)
|
||||||
if (cachedSchedule?.lastFetched) {
|
// Используем кэш независимо от возраста при ошибке парсинга
|
||||||
|
if (cachedSchedule) {
|
||||||
scheduleResult = cachedSchedule.results
|
scheduleResult = cachedSchedule.results
|
||||||
parsedAt = cachedSchedule.lastFetched
|
parsedAt = cachedSchedule.lastFetched
|
||||||
// Логируем использование кэша при таймауте
|
// Логируем использование fallback кэша с указанием возраста
|
||||||
|
const cacheAge = Date.now() - cachedSchedule.lastFetched.getTime()
|
||||||
|
const cacheAgeMinutes = Math.floor(cacheAge / (1000 * 60))
|
||||||
if (e instanceof ScheduleTimeoutError) {
|
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 {
|
} 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 {
|
interface LoadingOverlayProps {
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
}
|
}
|
||||||
@@ -63,6 +71,8 @@ export function LoadingOverlay({ isLoading }: LoadingOverlayProps) {
|
|||||||
const [messageOpacity, setMessageOpacity] = React.useState(0)
|
const [messageOpacity, setMessageOpacity] = React.useState(0)
|
||||||
const [showError, setShowError] = React.useState(false)
|
const [showError, setShowError] = React.useState(false)
|
||||||
const [errorOpacity, setErrorOpacity] = React.useState(0)
|
const [errorOpacity, setErrorOpacity] = React.useState(0)
|
||||||
|
// Храним историю последних показанных сообщений для избежания повторений
|
||||||
|
const messageHistoryRef = React.useRef<string[]>([])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isLoading) {
|
if (!isLoading) {
|
||||||
@@ -70,17 +80,31 @@ export function LoadingOverlay({ isLoading }: LoadingOverlayProps) {
|
|||||||
setMessageOpacity(0)
|
setMessageOpacity(0)
|
||||||
setShowError(false)
|
setShowError(false)
|
||||||
setErrorOpacity(0)
|
setErrorOpacity(0)
|
||||||
|
messageHistoryRef.current = []
|
||||||
return
|
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)
|
const randomIndex = Math.floor(Math.random() * loadingMessages.length)
|
||||||
return loadingMessages[randomIndex]
|
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)
|
setMessageOpacity(1)
|
||||||
|
|
||||||
// Таймер для показа сообщения об ошибке после 5 секунд
|
// Таймер для показа сообщения об ошибке после 5 секунд
|
||||||
@@ -99,7 +123,15 @@ export function LoadingOverlay({ isLoading }: LoadingOverlayProps) {
|
|||||||
|
|
||||||
// После fade out меняем сообщение и fade in
|
// После fade out меняем сообщение и fade in
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setCurrentMessage(getRandomMessage())
|
const newMessage = getRandomMessage(messageHistoryRef.current)
|
||||||
|
setCurrentMessage(newMessage)
|
||||||
|
|
||||||
|
// Обновляем историю: добавляем новое сообщение и ограничиваем размер истории
|
||||||
|
messageHistoryRef.current = [
|
||||||
|
...messageHistoryRef.current.slice(-(MAX_HISTORY_SIZE - 1)),
|
||||||
|
newMessage
|
||||||
|
]
|
||||||
|
|
||||||
setMessageOpacity(1)
|
setMessageOpacity(1)
|
||||||
}, 300) // Длительность fade анимации
|
}, 300) // Длительность fade анимации
|
||||||
}, 2000)
|
}, 2000)
|
||||||
|
|||||||
Reference in New Issue
Block a user