From 86715eaf663c655c780d547c7aab26cadbd9faa5 Mon Sep 17 00:00:00 2001 From: kilyabin <65072190+kilyabin@users.noreply.github.com> Date: Sun, 30 Nov 2025 22:50:23 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BA=D1=8D=D1=88=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B8=20=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=BA=D0=B8=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE?= =?UTF-8?q?=D0=BA=20=D1=80=D0=B0=D1=81=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Изменен TTL кэша с 1 часа на 15 минут для нормального использования - Добавлен fallback кэш на 24 часа для использования при ошибках парсинга - Улучшена обработка ошибок: при отсутствии кэша показывается страница с ошибкой вместо 500 - Добавлена анимация появления сообщения об ошибке - Улучшено логирование fallback кэша с указанием возраста - Добавлены новые сообщения загрузки и логика избежания повторений --- src/pages/[group].tsx | 102 ++++++++++++++++++++----- src/shared/context/loading-context.tsx | 44 +++++++++-- 2 files changed, 119 insertions(+), 27 deletions(-) diff --git a/src/pages/[group].tsx b/src/pages/[group].tsx index 6813ed5..001e305 100644 --- a/src/pages/[group].tsx +++ b/src/pages/[group].tsx @@ -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) { - const { schedule, group, cacheAvailableFor, parsedAt, groups, currentWk, availableWeeks, settings } = nextDeserialized(props) + const { schedule, group, cacheAvailableFor, parsedAt, groups, currentWk, availableWeeks, settings, error } = nextDeserialized(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) { return () => { clearInterval(interval) } - }, [schedule]) + }, [schedule, error]) return ( <> - {`Группа ${group.name} — Расписание занятий в Колледже Связи`} + {error ? `Ошибка — Расписание группы ${group.name}` : `Группа ${group.name} — Расписание занятий в Колледже Связи`} - - - + + + - - + {error ? ( +
+ + +
+ + Не удалось загрузить расписание +
+
+ + + {error.isTimeout + ? 'Превышено время ожидания ответа от сервера. Пожалуйста, попробуйте обновить страницу через несколько минут.' + : 'Произошла ошибка при загрузке расписания. Пожалуйста, попробуйте обновить страницу позже.'} + + +
+
+ ) : ( + <> + {parsedAt && } + {schedule && } + + )} ) } const cachedSchedules = new Map() -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 + } + }) + } } } } diff --git a/src/shared/context/loading-context.tsx b/src/shared/context/loading-context.tsx index 8c3eb09..c0dabc7 100644 --- a/src/shared/context/loading-context.tsx +++ b/src/shared/context/loading-context.tsx @@ -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([]) 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)