feat: добавлено предупреждение о fallback кэше и debug опции
Основные изменения: - Предупреждение о неактуальности расписания: * Добавлен баннер предупреждения при использовании 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 админ-панели
This commit is contained in:
@@ -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<PageProps>) {
|
||||
const { schedule, group, cacheAvailableFor, parsedAt, groups, currentWk, availableWeeks, settings, error } = nextDeserialized<PageProps>(props)
|
||||
const { schedule, group, cacheAvailableFor, parsedAt, groups, currentWk, availableWeeks, settings, error, isFromCache, cacheAge, cacheInfo } = nextDeserialized<PageProps>(props)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined' || error) return
|
||||
@@ -101,7 +107,17 @@ export default function HomePage(props: NextSerialized<PageProps>) {
|
||||
) : (
|
||||
<>
|
||||
{parsedAt && <LastUpdateAt date={parsedAt} />}
|
||||
{schedule && <Schedule days={schedule} currentWk={currentWk ?? null} availableWeeks={availableWeeks ?? null} weekNavigationEnabled={settings.weekNavigationEnabled} />}
|
||||
{schedule && (
|
||||
<Schedule
|
||||
days={schedule}
|
||||
currentWk={currentWk ?? null}
|
||||
availableWeeks={availableWeeks ?? null}
|
||||
weekNavigationEnabled={settings.weekNavigationEnabled}
|
||||
isFromCache={isFromCache}
|
||||
cacheAge={cacheAge}
|
||||
cacheInfo={cacheInfo}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<Card>
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="debug-options">
|
||||
<AccordionTrigger className="px-6">
|
||||
<CardTitle className="text-base">Debug опции</CardTitle>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<div className="font-semibold">Принудительно использовать кэш</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Принудительно использовать кэш, даже если он свежий (симулирует ошибку парсинга)
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.debug?.forceCache ?? false}
|
||||
onChange={(e) => handleUpdateSettings({
|
||||
...settings,
|
||||
debug: {
|
||||
...settings.debug,
|
||||
forceCache: e.target.checked
|
||||
}
|
||||
})}
|
||||
disabled={loading}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<div className="font-semibold">Принудительно показать пустое расписание</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Показать пустое расписание независимо от реальных данных
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.debug?.forceEmpty ?? false}
|
||||
onChange={(e) => handleUpdateSettings({
|
||||
...settings,
|
||||
debug: {
|
||||
...settings.debug,
|
||||
forceEmpty: e.target.checked
|
||||
}
|
||||
})}
|
||||
disabled={loading}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<div className="font-semibold">Принудительно показать ошибку</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Показать страницу ошибки независимо от реальных данных
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.debug?.forceError ?? false}
|
||||
onChange={(e) => handleUpdateSettings({
|
||||
...settings,
|
||||
debug: {
|
||||
...settings.debug,
|
||||
forceError: e.target.checked
|
||||
}
|
||||
})}
|
||||
disabled={loading}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<div className="font-semibold">Принудительно симулировать таймаут</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Симулировать таймаут при загрузке расписания
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.debug?.forceTimeout ?? false}
|
||||
onChange={(e) => handleUpdateSettings({
|
||||
...settings,
|
||||
debug: {
|
||||
...settings.debug,
|
||||
forceTimeout: e.target.checked
|
||||
}
|
||||
})}
|
||||
disabled={loading}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<div className="font-semibold">Показать информацию о кэше</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Показать дополнительную информацию о кэше в интерфейсе
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.debug?.showCacheInfo ?? false}
|
||||
onChange={(e) => handleUpdateSettings({
|
||||
...settings,
|
||||
debug: {
|
||||
...settings.debug,
|
||||
showCacheInfo: e.target.checked
|
||||
}
|
||||
})}
|
||||
disabled={loading}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -24,3 +24,4 @@ export default function handler(
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -24,3 +24,4 @@ export default function handler(
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
57
src/shadcn/ui/accordion.tsx
Normal file
57
src/shadcn/ui/accordion.tsx
Normal file
@@ -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<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AccordionItem.displayName = "AccordionItem"
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
))
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
))
|
||||
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
// Ищем существующий файл
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
{
|
||||
"weekNavigationEnabled": false
|
||||
"weekNavigationEnabled": false,
|
||||
"debug": {
|
||||
"forceCache": true,
|
||||
"forceEmpty": false,
|
||||
"forceError": false,
|
||||
"forceTimeout": false,
|
||||
"showCacheInfo": false
|
||||
}
|
||||
}
|
||||
@@ -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<Toast[]>([])
|
||||
|
||||
// Определяем текущий номер недели из дней
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full rounded-lg border border-amber-500/50 bg-amber-50/80 dark:bg-amber-950/30 backdrop-blur-sm',
|
||||
'p-4'
|
||||
)}
|
||||
role="alert"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-amber-900 dark:text-amber-100 mb-1">
|
||||
Возможна неактуальность расписания
|
||||
</h3>
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
Не удалось получить актуальное расписание с официального сайта.
|
||||
Показаны данные из кэша {cacheAge !== undefined && `(возраст: ${formatCacheAge(cacheAge)})`}.
|
||||
Расписание может быть устаревшим. Попробуйте обновить страницу позже.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="flex-shrink-0 rounded-sm opacity-70 hover:opacity-100 transition-opacity focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 min-w-[24px] min-h-[24px] flex items-center justify-center text-amber-900 dark:text-amber-100"
|
||||
aria-label="Закрыть предупреждение"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col p-4 md:p-8 lg:p-16 gap-6 md:gap-12 lg:gap-14">
|
||||
{weekNavigationEnabled && (
|
||||
<WeekNavigation
|
||||
currentWk={currentWk}
|
||||
availableWeeks={availableWeeks}
|
||||
currentWeekNumber={currentWeekNumber}
|
||||
/>
|
||||
)}
|
||||
{days.map((day, i) => (
|
||||
<div
|
||||
key={`${group}_day${i}`}
|
||||
className="stagger-card"
|
||||
style={{
|
||||
animationDelay: `${i * 0.1}s`,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<Day day={day} />
|
||||
<>
|
||||
<div className="flex flex-col p-4 md:p-8 lg:p-16 gap-6 md:gap-12 lg:gap-14">
|
||||
{isFromCache && (
|
||||
<CacheWarningBanner cacheAge={cacheAge} />
|
||||
)}
|
||||
{cacheInfo && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Debug: Кэш содержит {cacheInfo.entries} {cacheInfo.entries === 1 ? 'запись' : cacheInfo.entries < 5 ? 'записи' : 'записей'}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{weekNavigationEnabled && (
|
||||
<WeekNavigation
|
||||
currentWk={currentWk}
|
||||
availableWeeks={availableWeeks}
|
||||
currentWeekNumber={currentWeekNumber}
|
||||
/>
|
||||
)}
|
||||
{isEmpty ? (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Card className="stagger-card max-w-md w-full">
|
||||
<CardHeader className="flex flex-col items-center text-center space-y-4">
|
||||
<div className="w-24 h-24 md:w-32 md:h-32 flex items-center justify-center text-muted-foreground">
|
||||
<CalendarX className="w-full h-full" strokeWidth={1.5} />
|
||||
</div>
|
||||
<CardTitle className="text-xl md:text-2xl">
|
||||
Расписание пусто
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
<CardDescription className="text-base md:text-lg">
|
||||
Пар нет, либо расписание еще не заполнено
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
days.map((day, i) => (
|
||||
<div
|
||||
key={`${group}_day${i}`}
|
||||
className="stagger-card"
|
||||
style={{
|
||||
animationDelay: `${i * 0.1}s`,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<Day day={day} />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<ToastContainer toasts={toasts} onClose={removeToast} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user