feat: улучшения системы расписания и UI
- Добавлены таймауты (8 сек) для запросов расписания - Реализована навигация по неделям с поддержкой параметра wk - Улучшено кэширование с автоматической очисткой старых записей - Добавлена валидация параметров в getSchedule - Улучшен UI загрузки с анимированными сообщениями и предупреждениями - Оптимизирована обработка ошибок и очистка памяти JSDOM - Обновлены зависимости проекта - Добавлена документация для старых файлов
This commit is contained in:
@@ -27,3 +27,4 @@
|
|||||||
- При необходимости можно удалить эту папку без влияния на работу приложения
|
- При необходимости можно удалить эту папку без влияния на работу приложения
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -62,3 +62,4 @@ export async function parseSchedule(groupID: number, groupName: string) {
|
|||||||
throw new Error('Error while fetching lk.ks.psuti.ru')
|
throw new Error('Error while fetching lk.ks.psuti.ru')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -44,6 +44,7 @@
|
|||||||
"@types/react-dom": "19.2.0",
|
"@types/react-dom": "19.2.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.7.3",
|
"@typescript-eslint/eslint-plugin": "^6.7.3",
|
||||||
"autoprefixer": "10.4.20",
|
"autoprefixer": "10.4.20",
|
||||||
|
"baseline-browser-mapping": "^2.8.32",
|
||||||
"eslint": "8.57.0",
|
"eslint": "8.57.0",
|
||||||
"eslint-config-next": "16.0.3",
|
"eslint-config-next": "16.0.3",
|
||||||
"postcss": "8.4.47",
|
"postcss": "8.4.47",
|
||||||
@@ -4335,9 +4336,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.8.29",
|
"version": "2.8.32",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.29.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz",
|
||||||
"integrity": "sha512-sXdt2elaVnhpDNRDz+1BDx1JQoJRuNk7oVlAlbGiFkLikHCAQiccexF/9e91zVi6RCgqspl04aP+6Cnl9zRLrA==",
|
"integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
"@types/react-dom": "19.2.0",
|
"@types/react-dom": "19.2.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.7.3",
|
"@typescript-eslint/eslint-plugin": "^6.7.3",
|
||||||
"autoprefixer": "10.4.20",
|
"autoprefixer": "10.4.20",
|
||||||
|
"baseline-browser-mapping": "^2.8.32",
|
||||||
"eslint": "8.57.0",
|
"eslint": "8.57.0",
|
||||||
"eslint-config-next": "16.0.3",
|
"eslint-config-next": "16.0.3",
|
||||||
"postcss": "8.4.47",
|
"postcss": "8.4.47",
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ export type ScheduleResult = {
|
|||||||
availableWeeks?: WeekInfo[]
|
availableWeeks?: WeekInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ScheduleTimeoutError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ScheduleTimeoutError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSchedule(groupID: number, groupName: string, wk?: number, parseWeekNavigation: boolean = true): Promise<ScheduleResult> {
|
export async function getSchedule(groupID: number, groupName: string, wk?: number, parseWeekNavigation: boolean = true): Promise<ScheduleResult> {
|
||||||
// Валидация параметров
|
// Валидация параметров
|
||||||
if (!Number.isInteger(groupID) || groupID <= 0) {
|
if (!Number.isInteger(groupID) || groupID <= 0) {
|
||||||
@@ -23,9 +30,9 @@ export async function getSchedule(groupID: number, groupName: string, wk?: numbe
|
|||||||
|
|
||||||
const url = `${PROXY_URL}/?mn=2&obj=${groupID}${wk ? `&wk=${wk}` : ''}`
|
const url = `${PROXY_URL}/?mn=2&obj=${groupID}${wk ? `&wk=${wk}` : ''}`
|
||||||
|
|
||||||
// Добавляем таймаут 30 секунд для fetch запроса
|
// Добавляем таймаут 8 секунд для fetch запроса
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 30000) // 30 секунд
|
const timeoutId = setTimeout(() => controller.abort(), 8000) // 8 секунд
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const page = await fetch(url, { signal: controller.signal })
|
const page = await fetch(url, { signal: controller.signal })
|
||||||
@@ -65,7 +72,7 @@ export async function getSchedule(groupID: number, groupName: string, wk?: numbe
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
if (error instanceof Error && error.name === 'AbortError') {
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
throw new Error(`Request timeout while fetching ${PROXY_URL}`)
|
throw new ScheduleTimeoutError(`Request timeout while fetching ${PROXY_URL}`)
|
||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Schedule } from '@/widgets/schedule'
|
import { Schedule } from '@/widgets/schedule'
|
||||||
import { Day } from '@/shared/model/day'
|
import { Day } from '@/shared/model/day'
|
||||||
import { GetServerSidePropsContext, GetServerSidePropsResult } from 'next'
|
import { GetServerSidePropsContext, GetServerSidePropsResult } from 'next'
|
||||||
import { getSchedule, ScheduleResult } from '@/app/agregator/schedule'
|
import { getSchedule, ScheduleResult, ScheduleTimeoutError } from '@/app/agregator/schedule'
|
||||||
import { NextSerialized, nextDeserialized, nextSerialized } from '@/app/utils/date-serializer'
|
import { NextSerialized, nextDeserialized, nextSerialized } from '@/app/utils/date-serializer'
|
||||||
import { NavBar } from '@/widgets/navbar'
|
import { NavBar } from '@/widgets/navbar'
|
||||||
import { LastUpdateAt } from '@/entities/last-update-at'
|
import { LastUpdateAt } from '@/entities/last-update-at'
|
||||||
@@ -149,9 +149,14 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr
|
|||||||
cleanupCache()
|
cleanupCache()
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
|
// При таймауте или любой другой ошибке используем кэш, если он доступен
|
||||||
if (cachedSchedule?.lastFetched) {
|
if (cachedSchedule?.lastFetched) {
|
||||||
scheduleResult = cachedSchedule.results
|
scheduleResult = cachedSchedule.results
|
||||||
parsedAt = cachedSchedule.lastFetched
|
parsedAt = cachedSchedule.lastFetched
|
||||||
|
// Логируем использование кэша при таймауте
|
||||||
|
if (e instanceof ScheduleTimeoutError) {
|
||||||
|
console.warn(`Schedule fetch timeout for group ${group}, using cached data from ${cachedSchedule.lastFetched.toISOString()}`)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,3 +23,4 @@ export default function handler(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,3 +23,4 @@ export default function handler(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export default function HomePage({ groups, groupsByCourse }: HomePageProps) {
|
|||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>Расписание занятий — Колледж Связи ПГУТИ</title>
|
<title>Расписание занятий — Колледж Связи ПГУТИ</title>
|
||||||
<meta name="description" content="Расписание занятий для всех групп Колледжа Связи ПГУТИ. Выберите группу для просмотра расписания." />
|
<meta name="description" content="Расписание занятий для всех групп Колледжа Связи ПГУТИ" />
|
||||||
</Head>
|
</Head>
|
||||||
<div className="min-h-screen p-4 md:p-8">
|
<div className="min-h-screen p-4 md:p-8">
|
||||||
<div className="max-w-4xl mx-auto space-y-4">
|
<div className="max-w-4xl mx-auto space-y-4">
|
||||||
|
|||||||
@@ -61,11 +61,15 @@ interface LoadingOverlayProps {
|
|||||||
export function LoadingOverlay({ isLoading }: LoadingOverlayProps) {
|
export function LoadingOverlay({ isLoading }: LoadingOverlayProps) {
|
||||||
const [currentMessage, setCurrentMessage] = React.useState<string>('')
|
const [currentMessage, setCurrentMessage] = React.useState<string>('')
|
||||||
const [messageOpacity, setMessageOpacity] = React.useState(0)
|
const [messageOpacity, setMessageOpacity] = React.useState(0)
|
||||||
|
const [showError, setShowError] = React.useState(false)
|
||||||
|
const [errorOpacity, setErrorOpacity] = React.useState(0)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isLoading) {
|
if (!isLoading) {
|
||||||
setCurrentMessage('')
|
setCurrentMessage('')
|
||||||
setMessageOpacity(0)
|
setMessageOpacity(0)
|
||||||
|
setShowError(false)
|
||||||
|
setErrorOpacity(0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +83,15 @@ export function LoadingOverlay({ isLoading }: LoadingOverlayProps) {
|
|||||||
setCurrentMessage(getRandomMessage())
|
setCurrentMessage(getRandomMessage())
|
||||||
setMessageOpacity(1)
|
setMessageOpacity(1)
|
||||||
|
|
||||||
|
// Таймер для показа сообщения об ошибке после 5 секунд
|
||||||
|
const errorTimeout = setTimeout(() => {
|
||||||
|
setShowError(true)
|
||||||
|
// Плавное появление с небольшой задержкой для анимации
|
||||||
|
setTimeout(() => {
|
||||||
|
setErrorOpacity(1)
|
||||||
|
}, 50)
|
||||||
|
}, 5000)
|
||||||
|
|
||||||
// Меняем сообщение каждые 2 секунды
|
// Меняем сообщение каждые 2 секунды
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
// Fade out
|
// Fade out
|
||||||
@@ -93,6 +106,7 @@ export function LoadingOverlay({ isLoading }: LoadingOverlayProps) {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(interval)
|
clearInterval(interval)
|
||||||
|
clearTimeout(errorTimeout)
|
||||||
}
|
}
|
||||||
}, [isLoading])
|
}, [isLoading])
|
||||||
|
|
||||||
@@ -109,17 +123,32 @@ export function LoadingOverlay({ isLoading }: LoadingOverlayProps) {
|
|||||||
aria-hidden={!isLoading}
|
aria-hidden={!isLoading}
|
||||||
>
|
>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="flex flex-col items-center gap-4">
|
<>
|
||||||
<div className="w-16 h-16">
|
<div className="flex flex-col items-center gap-4">
|
||||||
<Spinner size="large" />
|
<div className="w-16 h-16">
|
||||||
|
<Spinner size="large" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="min-h-[1.5rem] text-center transition-opacity duration-300"
|
||||||
|
style={{ opacity: messageOpacity }}
|
||||||
|
>
|
||||||
|
{currentMessage}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
{showError && (
|
||||||
className="min-h-[1.5rem] text-center transition-opacity duration-300"
|
<div
|
||||||
style={{ opacity: messageOpacity }}
|
className="fixed bottom-8 left-1/2 -translate-x-1/2 bg-background/10 backdrop-blur-sm border border-border/30 rounded-lg p-4 max-w-md mx-4 transition-all duration-500 ease-out"
|
||||||
>
|
style={{
|
||||||
{currentMessage}
|
opacity: errorOpacity,
|
||||||
</div>
|
transform: `translateX(-50%) translateY(${errorOpacity === 1 ? '0' : '100px'})`
|
||||||
</div>
|
}}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-foreground text-center">
|
||||||
|
⚠️ Не удается получить актуальное расписание с официального сайта. Возможно, сервер временно недоступен. Будут показаны данные из кэша. Попробуйте обновить страницу позже.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user