From 16bba463ebc8c72734558e0572fa9ec026742028 Mon Sep 17 00:00:00 2001 From: kilyabin <65072190+kilyabin@users.noreply.github.com> Date: Tue, 2 Dec 2025 01:05:36 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D0=BF=D1=80=D0=B5=D0=B4=D1=83=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=20fallback?= =?UTF-8?q?=20=D0=BA=D1=8D=D1=88=D0=B5=20=D0=B8=20debug=20=D0=BE=D0=BF?= =?UTF-8?q?=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Основные изменения: - Предупреждение о неактуальности расписания: * Добавлен баннер предупреждения при использовании fallback кэша * Добавлено toast уведомление о возможной неактуальности данных * Баннер показывает возраст кэша в удобочитаемом формате * Автоскролл с учетом рендеринга баннера - Debug опции в админ-панели: * Добавлена секция с аккордеоном для debug опций (только в dev режиме) * Опции: принудительное использование кэша, пустое расписание, ошибка, таймаут, информация о кэше * Все опции с тумблерами для удобного управления * API endpoint обновлен для поддержки debug настроек - Структурные изменения: * Создан компонент Accordion для shadcn/ui * Расширены типы AppSettings для поддержки debug опций * Компонент баннера размещен внутри Schedule компонента (следуя правилам проекта) * Добавлен файл .cursorrules с правилами для AI ассистента - Исправления: * Исправлена сериализация undefined значений в getServerSideProps * Улучшена логика автоскролла при использовании fallback кэша * Убраны лишние отступы у баннера предупреждения - Зависимости: * Добавлен @radix-ui/react-accordion для компонента аккордеона - Прочие изменения: * Обновлены настройки в settings.json * Изменения в старых файлах (old/README.md, old/old-schedule.txt) * Обновления в API endpoints админ-панели --- .gitignore | 1 + old/README.md | 1 + old/old-schedule.txt | 1 + package-lock.json | 174 +++++++++++++++++++++++++ package.json | 1 + public/error1.svg | 104 +++++++++++++++ settings.json | 9 +- src/pages/[group].tsx | 109 ++++++++++++++-- src/pages/admin.tsx | 144 +++++++++++++++++++++ src/pages/api/admin/check-auth.ts | 1 + src/pages/api/admin/logout.ts | 1 + src/pages/api/admin/settings.ts | 26 +++- src/shadcn/ui/accordion.tsx | 57 +++++++++ src/shared/data/settings-loader.ts | 28 +++- src/shared/data/settings.json | 9 +- src/widgets/schedule/index.tsx | 199 +++++++++++++++++++++++++---- 16 files changed, 825 insertions(+), 40 deletions(-) create mode 100644 public/error1.svg create mode 100644 src/shadcn/ui/accordion.tsx diff --git a/.gitignore b/.gitignore index 97e9c3c..bb0596a 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ yarn-error.log* next-env.d.ts .vscode/ /.vscode +.cursorrules .env # dependency hash (installation-specific) diff --git a/old/README.md b/old/README.md index 2527393..030004f 100644 --- a/old/README.md +++ b/old/README.md @@ -28,3 +28,4 @@ + diff --git a/old/old-schedule.txt b/old/old-schedule.txt index a2c6f96..b819eca 100644 --- a/old/old-schedule.txt +++ b/old/old-schedule.txt @@ -63,3 +63,4 @@ export async function parseSchedule(groupID: number, groupName: string) { } } + diff --git a/package-lock.json b/package-lock.json index caef81d..647ed0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "kspguti-schedule", "version": "0.1.0", "dependencies": { + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", @@ -1619,6 +1620,93 @@ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", @@ -1710,6 +1798,92 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", diff --git a/package.json b/package.json index b7e5848..65b538c 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "lint": "next lint" }, "dependencies": { + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", diff --git a/public/error1.svg b/public/error1.svg new file mode 100644 index 0000000..d5f2fba --- /dev/null +++ b/public/error1.svg @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + diff --git a/settings.json b/settings.json index 753bc57..4f76642 100644 --- a/settings.json +++ b/settings.json @@ -1,3 +1,10 @@ { - "weekNavigationEnabled": false + "weekNavigationEnabled": false, + "debug": { + "forceCache": true, + "forceEmpty": false, + "forceError": false, + "forceTimeout": false, + "showCacheInfo": false + } } \ No newline at end of file diff --git a/src/pages/[group].tsx b/src/pages/[group].tsx index 001e305..533d289 100644 --- a/src/pages/[group].tsx +++ b/src/pages/[group].tsx @@ -32,10 +32,16 @@ type PageProps = { message: string isTimeout: boolean } + isFromCache?: boolean + cacheAge?: number // возраст кэша в минутах + cacheInfo?: { + size: number + entries: number + } } export default function HomePage(props: NextSerialized) { - const { schedule, group, cacheAvailableFor, parsedAt, groups, currentWk, availableWeeks, settings, error } = nextDeserialized(props) + const { schedule, group, cacheAvailableFor, parsedAt, groups, currentWk, availableWeeks, settings, error, isFromCache, cacheAge, cacheInfo } = nextDeserialized(props) React.useEffect(() => { if (typeof window === 'undefined' || error) return @@ -101,7 +107,17 @@ export default function HomePage(props: NextSerialized) { ) : ( <> {parsedAt && } - {schedule && } + {schedule && ( + + )} )} @@ -149,8 +165,59 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr : undefined if (group && Object.hasOwn(groups, group) && group in groups) { + // Проверяем debug опции + const debug = settings.debug || {} + + // Debug: принудительно показать ошибку + if (debug.forceError) { + 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: 'Debug: принудительная ошибка', + isTimeout: false + } + }) + } + } + + // Debug: принудительно симулировать таймаут + if (debug.forceTimeout) { + 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: 'Debug: принудительный таймаут', + isTimeout: true + } + }) + } + } + let scheduleResult: ScheduleResult let parsedAt + let isFromCache = false + let cacheAge: number | undefined // Очищаем старые записи из кэша перед использованием cleanupCache() @@ -161,7 +228,14 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr const cacheKey = group // Ключ кэша - только группа (текущая неделя) const cachedSchedule = useCache ? cachedSchedules.get(cacheKey) : undefined - if (cachedSchedule?.lastFetched && Date.now() - cachedSchedule.lastFetched.getTime() < maxCacheDurationInMS) { + // Debug: принудительно использовать кэш + if (debug.forceCache && cachedSchedule) { + scheduleResult = cachedSchedule.results + parsedAt = cachedSchedule.lastFetched + isFromCache = true + const cacheAgeMs = Date.now() - cachedSchedule.lastFetched.getTime() + cacheAge = Math.floor(cacheAgeMs / (1000 * 60)) + } else if (cachedSchedule?.lastFetched && Date.now() - cachedSchedule.lastFetched.getTime() < maxCacheDurationInMS) { scheduleResult = cachedSchedule.results parsedAt = cachedSchedule.lastFetched } else { @@ -183,13 +257,14 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr if (cachedSchedule) { scheduleResult = cachedSchedule.results parsedAt = cachedSchedule.lastFetched + isFromCache = true + const cacheAgeMs = Date.now() - cachedSchedule.lastFetched.getTime() + cacheAge = Math.floor(cacheAgeMs / (1000 * 60)) // Логируем использование 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 fallback cache from ${cachedSchedule.lastFetched.toISOString()} (${cacheAgeMinutes} minutes old)`) + console.warn(`Schedule fetch timeout for group ${group}, using fallback cache from ${cachedSchedule.lastFetched.toISOString()} (${cacheAge} minutes old)`) } else { - console.warn(`Schedule fetch error for group ${group}, using fallback cache from ${cachedSchedule.lastFetched.toISOString()} (${cacheAgeMinutes} minutes old)`) + console.warn(`Schedule fetch error for group ${group}, using fallback cache from ${cachedSchedule.lastFetched.toISOString()} (${cacheAge} minutes old)`) } } else { // Если кэша нет, возвращаем страницу с ошибкой вместо throw @@ -223,6 +298,15 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr } } + // Debug: принудительно показать пустое расписание + if (debug.forceEmpty) { + scheduleResult = { + days: [], + currentWk: scheduleResult.currentWk, + availableWeeks: scheduleResult.availableWeeks + } + } + const schedule = scheduleResult.days const getSha256Hash = (input: string) => { @@ -246,6 +330,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr .filter(([, v]) => v.lastFetched.getTime() + maxCacheDurationInMS > Date.now()) .map(([k]) => k.split('_')[0]) // Берем только группу из ключа кэша + // Debug: информация о кэше + const cacheInfo = debug.showCacheInfo ? { + size: cachedSchedules.size, + entries: cachedSchedules.size + } : undefined + context.res.setHeader('ETag', `"${etag}"`) return { props: nextSerialized({ @@ -259,7 +349,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr groups, currentWk: scheduleResult.currentWk ?? null, availableWeeks: scheduleResult.availableWeeks ?? null, - settings + settings, + isFromCache: isFromCache ?? false, + cacheAge: cacheAge ?? null, + cacheInfo: cacheInfo ?? null }) } } else { diff --git a/src/pages/admin.tsx b/src/pages/admin.tsx index 6fe02db..2030d46 100644 --- a/src/pages/admin.tsx +++ b/src/pages/admin.tsx @@ -17,6 +17,12 @@ import { loadSettings, AppSettings } from '@/shared/data/settings-loader' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shadcn/ui/select' import { ToastContainer, Toast } from '@/shared/ui/toast' import Head from 'next/head' +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/shadcn/ui/accordion' type AdminPageProps = { groups: GroupsData @@ -444,6 +450,144 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett )} + + {process.env.NODE_ENV === 'development' && ( + + + + + Debug опции + + + +
+
+
+
Принудительно использовать кэш
+
+ Принудительно использовать кэш, даже если он свежий (симулирует ошибку парсинга) +
+
+ +
+
+
+
Принудительно показать пустое расписание
+
+ Показать пустое расписание независимо от реальных данных +
+
+ +
+
+
+
Принудительно показать ошибку
+
+ Показать страницу ошибки независимо от реальных данных +
+
+ +
+
+
+
Принудительно симулировать таймаут
+
+ Симулировать таймаут при загрузке расписания +
+
+ +
+
+
+
Показать информацию о кэше
+
+ Показать дополнительную информацию о кэше в интерфейсе +
+
+ +
+
+
+
+
+
+
+ )} diff --git a/src/pages/api/admin/check-auth.ts b/src/pages/api/admin/check-auth.ts index 4efd6be..3ca6d61 100644 --- a/src/pages/api/admin/check-auth.ts +++ b/src/pages/api/admin/check-auth.ts @@ -24,3 +24,4 @@ export default function handler( + diff --git a/src/pages/api/admin/logout.ts b/src/pages/api/admin/logout.ts index 2d814dd..a817038 100644 --- a/src/pages/api/admin/logout.ts +++ b/src/pages/api/admin/logout.ts @@ -24,3 +24,4 @@ export default function handler( + diff --git a/src/pages/api/admin/settings.ts b/src/pages/api/admin/settings.ts index 8bbdf52..e3b9328 100644 --- a/src/pages/api/admin/settings.ts +++ b/src/pages/api/admin/settings.ts @@ -21,15 +21,37 @@ async function handler( if (req.method === 'PUT') { // Обновление настроек - const { weekNavigationEnabled } = req.body + const { weekNavigationEnabled, debug } = req.body if (typeof weekNavigationEnabled !== 'boolean') { res.status(400).json({ error: 'weekNavigationEnabled must be a boolean' }) return } + // Валидация debug опций (только в dev режиме) + if (debug !== undefined) { + if (typeof debug !== 'object' || debug === null) { + res.status(400).json({ error: 'debug must be an object' }) + return + } + + if (process.env.NODE_ENV !== 'development') { + res.status(403).json({ error: 'Debug options are only available in development mode' }) + return + } + + const debugKeys = ['forceCache', 'forceEmpty', 'forceError', 'forceTimeout', 'showCacheInfo'] + for (const key of debugKeys) { + if (key in debug && typeof debug[key] !== 'boolean' && debug[key] !== undefined) { + res.status(400).json({ error: `debug.${key} must be a boolean` }) + return + } + } + } + const settings: AppSettings = { - weekNavigationEnabled + weekNavigationEnabled, + ...(debug !== undefined && { debug }) } try { diff --git a/src/shadcn/ui/accordion.tsx b/src/shadcn/ui/accordion.tsx new file mode 100644 index 0000000..91f69e1 --- /dev/null +++ b/src/shadcn/ui/accordion.tsx @@ -0,0 +1,57 @@ +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/shared/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) + +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } + diff --git a/src/shared/data/settings-loader.ts b/src/shared/data/settings-loader.ts index cd2d8cf..a5d97a4 100644 --- a/src/shared/data/settings-loader.ts +++ b/src/shared/data/settings-loader.ts @@ -3,6 +3,13 @@ import path from 'path' export type AppSettings = { weekNavigationEnabled: boolean + debug?: { + forceCache?: boolean + forceEmpty?: boolean + forceError?: boolean + forceTimeout?: boolean + showCacheInfo?: boolean + } } let cachedSettings: AppSettings | null = null @@ -10,7 +17,14 @@ let cachedSettingsPath: string | null = null let cachedSettingsMtime: number | null = null const defaultSettings: AppSettings = { - weekNavigationEnabled: true + weekNavigationEnabled: true, + debug: { + forceCache: false, + forceEmpty: false, + forceError: false, + forceTimeout: false, + showCacheInfo: false + } } /** @@ -60,7 +74,11 @@ export function loadSettings(): AppSettings { // Убеждаемся, что все обязательные поля присутствуют const mergedSettings: AppSettings = { ...defaultSettings, - ...settings + ...settings, + debug: { + ...defaultSettings.debug, + ...settings.debug + } } cachedSettings = mergedSettings @@ -112,7 +130,11 @@ export function saveSettings(settings: AppSettings): void { // Объединяем с настройками по умолчанию для сохранения всех полей const mergedSettings: AppSettings = { ...defaultSettings, - ...settings + ...settings, + debug: { + ...defaultSettings.debug, + ...settings.debug + } } // Ищем существующий файл diff --git a/src/shared/data/settings.json b/src/shared/data/settings.json index 753bc57..4f76642 100644 --- a/src/shared/data/settings.json +++ b/src/shared/data/settings.json @@ -1,3 +1,10 @@ { - "weekNavigationEnabled": false + "weekNavigationEnabled": false, + "debug": { + "forceCache": true, + "forceEmpty": false, + "forceError": false, + "forceTimeout": false, + "showCacheInfo": false + } } \ No newline at end of file diff --git a/src/widgets/schedule/index.tsx b/src/widgets/schedule/index.tsx index a33b4b8..185fa4f 100644 --- a/src/widgets/schedule/index.tsx +++ b/src/widgets/schedule/index.tsx @@ -5,23 +5,115 @@ import { useRouter } from 'next/router' import React from 'react' import { getDayOfWeek } from '@/shared/utils' import { WeekInfo } from '@/app/parser/schedule' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/shadcn/ui/card' +import { CalendarX, AlertTriangle, X } from 'lucide-react' +import { ToastContainer, Toast } from '@/shared/ui/toast' +import { Badge } from '@/shadcn/ui/badge' +import { cn } from '@/shared/utils' export function Schedule({ days, currentWk, availableWeeks, - weekNavigationEnabled = true + weekNavigationEnabled = true, + isFromCache, + cacheAge, + cacheInfo }: { days: DayType[] currentWk: number | null | undefined availableWeeks: WeekInfo[] | null | undefined weekNavigationEnabled?: boolean + isFromCache?: boolean + cacheAge?: number + cacheInfo?: { + size: number + entries: number + } }) { const group = useRouter().query['group'] const hasScrolledRef = React.useRef(false) + const [toasts, setToasts] = React.useState([]) // Определяем текущий номер недели из дней const currentWeekNumber = days.length > 0 ? days[0]?.weekNumber : undefined + + // Показываем toast при использовании кэша + React.useEffect(() => { + if (isFromCache) { + const toastId = Date.now().toString() + const cacheAgeText = cacheAge !== undefined + ? ` (возраст: ${cacheAge} ${cacheAge === 1 ? 'минута' : cacheAge < 5 ? 'минуты' : 'минут'})` + : '' + setToasts([{ + id: toastId, + message: `Показаны данные из кэша${cacheAgeText}. Расписание может быть неактуальным.`, + type: 'error' + }]) + } + }, [isFromCache, cacheAge]) + + const removeToast = (id: string) => { + setToasts((prev) => prev.filter((toast) => toast.id !== id)) + } + + // Компонент баннера предупреждения о кэше + function CacheWarningBanner({ cacheAge, onClose }: { cacheAge?: number; onClose?: () => void }) { + const [isVisible, setIsVisible] = React.useState(true) + + const handleClose = () => { + setIsVisible(false) + onClose?.() + } + + if (!isVisible) return null + + const formatCacheAge = (minutes?: number) => { + if (!minutes) return 'неизвестно' + if (minutes < 60) return `${minutes} ${minutes === 1 ? 'минуту' : minutes < 5 ? 'минуты' : 'минут'}` + const hours = Math.floor(minutes / 60) + const remainingMinutes = minutes % 60 + if (hours < 24) { + if (remainingMinutes === 0) { + return `${hours} ${hours === 1 ? 'час' : hours < 5 ? 'часа' : 'часов'}` + } + return `${hours} ${hours === 1 ? 'час' : hours < 5 ? 'часа' : 'часов'} ${remainingMinutes} ${remainingMinutes === 1 ? 'минуту' : remainingMinutes < 5 ? 'минуты' : 'минут'}` + } + const days = Math.floor(hours / 24) + return `${days} ${days === 1 ? 'день' : days < 5 ? 'дня' : 'дней'}` + } + + return ( +
+
+ +
+

+ Возможна неактуальность расписания +

+

+ Не удалось получить актуальное расписание с официального сайта. + Показаны данные из кэша {cacheAge !== undefined && `(возраст: ${formatCacheAge(cacheAge)})`}. + Расписание может быть устаревшим. Попробуйте обновить страницу позже. +

+
+ +
+
+ ) + } React.useEffect(() => { if (hasScrolledRef.current || typeof window === 'undefined') return @@ -37,8 +129,10 @@ export function Schedule({ }) if (todayDay) { - // Небольшая задержка для завершения рендеринга - const timeoutId = setTimeout(() => { + // Увеличиваем задержку, если используется кэш (баннер может рендериться позже) + const delay = isFromCache ? 300 : 100 + + const scrollToToday = () => { const elementId = getDayOfWeek(todayDay.date) const element = document.getElementById(elementId) @@ -53,33 +147,88 @@ export function Schedule({ behavior: 'smooth' }) hasScrolledRef.current = true + return true } - }, 100) + return false + } - return () => clearTimeout(timeoutId) + // Используем requestAnimationFrame для более точного ожидания рендеринга + let timeoutId: NodeJS.Timeout | null = null + let retryTimeoutId: NodeJS.Timeout | null = null + + const frameId = requestAnimationFrame(() => { + timeoutId = setTimeout(() => { + if (!scrollToToday() && isFromCache) { + // Если не удалось найти элемент и используется кэш, пробуем еще раз через небольшую задержку + retryTimeoutId = setTimeout(scrollToToday, 100) + } + }, delay) + }) + + return () => { + cancelAnimationFrame(frameId) + if (timeoutId) clearTimeout(timeoutId) + if (retryTimeoutId) clearTimeout(retryTimeoutId) + } } - }, [days]) + }, [days, isFromCache]) + + // Проверка на пустое расписание + const isEmpty = days.length === 0 || days.every(day => day.lessons.length === 0) return ( -
- {weekNavigationEnabled && ( - - )} - {days.map((day, i) => ( -
- + <> +
+ {isFromCache && ( + + )} + {cacheInfo && ( +
+ + Debug: Кэш содержит {cacheInfo.entries} {cacheInfo.entries === 1 ? 'запись' : cacheInfo.entries < 5 ? 'записи' : 'записей'} + +
+ )} + {weekNavigationEnabled && ( + + )} + {isEmpty ? ( +
+ + +
+ +
+ + Расписание пусто + +
+ + + Пар нет, либо расписание еще не заполнено + + +
- ))} -
+ ) : ( + days.map((day, i) => ( +
+ +
+ )) + )} +
+ + ) }