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:
kilyabin
2025-12-02 01:05:36 +04:00
parent 166c73aff4
commit 16bba463eb
16 changed files with 825 additions and 40 deletions

View File

@@ -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 {

View File

@@ -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>

View File

@@ -24,3 +24,4 @@ export default function handler(

View File

@@ -24,3 +24,4 @@ export default function handler(

View File

@@ -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 {