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

1
.gitignore vendored
View File

@@ -35,6 +35,7 @@ yarn-error.log*
next-env.d.ts next-env.d.ts
.vscode/ .vscode/
/.vscode /.vscode
.cursorrules
.env .env
# dependency hash (installation-specific) # dependency hash (installation-specific)

View File

@@ -28,3 +28,4 @@

View File

@@ -63,3 +63,4 @@ export async function parseSchedule(groupID: number, groupName: string) {
} }
} }

174
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "kspguti-schedule", "name": "kspguti-schedule",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-dropdown-menu": "^2.0.6",
@@ -1619,6 +1620,93 @@
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT" "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": { "node_modules/@radix-ui/react-arrow": {
"version": "1.1.7", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", "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": { "node_modules/@radix-ui/react-collection": {
"version": "1.1.7", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",

View File

@@ -13,6 +13,7 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-dropdown-menu": "^2.0.6",

104
public/error1.svg Normal file
View File

@@ -0,0 +1,104 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="1408.000000pt" height="736.000000pt" viewBox="0 0 1408.000000 736.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,736.000000) scale(0.100000,-0.100000)"
fill="currentColor" stroke="none" class="svg-icon">
<path d="M6683 6519 c-91 -11 -205 -48 -308 -102 -58 -30 -104 -66 -166 -127
-127 -125 -186 -228 -230 -400 -28 -106 -27 -295 1 -405 77 -298 296 -521 597
-607 64 -19 102 -23 213 -22 158 1 243 20 372 84 112 56 155 88 235 174 155
166 227 353 226 586 -3 423 -311 766 -734 820 -92 11 -102 11 -206 -1z m489
-436 c43 -43 78 -83 78 -89 0 -6 -10 -19 -21 -30 -12 -10 -75 -77 -142 -147
l-120 -128 142 -143 c77 -79 141 -150 141 -158 0 -7 -36 -49 -80 -93 l-80 -80
-150 150 -150 150 -150 -150 -150 -150 -80 80 c-44 44 -80 85 -80 91 0 7 62
76 138 154 75 78 137 145 137 150 0 5 -60 72 -132 148 -73 77 -133 146 -133
154 0 17 141 158 158 158 7 0 75 -63 151 -139 l140 -140 143 145 c79 79 147
144 153 144 5 0 45 -35 87 -77z"/>
<path d="M6553 4575 c-80 -22 -146 -60 -189 -108 -22 -24 -52 -47 -68 -51 -63
-15 -123 -112 -152 -246 -14 -59 -16 -100 -10 -205 4 -71 10 -148 14 -170 5
-25 3 -53 -5 -75 -27 -77 -2 -166 57 -205 16 -11 30 -23 30 -26 0 -3 -30 -19
-66 -34 -259 -110 -453 -349 -588 -723 -55 -154 -77 -237 -113 -422 -22 -117
-26 -169 -30 -396 -4 -207 -2 -281 10 -360 32 -199 92 -328 207 -445 170 -172
415 -233 660 -166 71 20 126 27 220 30 219 7 329 63 512 258 l104 109 84 -152
85 -153 48 -1 c45 -1 49 -3 76 -46 16 -24 41 -54 56 -66 28 -22 29 -22 534
-22 381 0 510 3 519 12 16 16 15 83 -2 116 -21 40 -61 74 -151 130 -68 42 -80
53 -77 74 2 13 -40 161 -92 329 -52 167 -122 396 -156 508 -82 271 -95 304
-147 368 l-44 54 20 62 c29 90 40 172 56 431 18 286 19 262 -4 283 -16 15 -19
36 -23 124 -3 95 -7 112 -36 172 -81 165 -281 228 -398 125 -39 -34 -64 -76
-64 -107 0 -11 -6 -24 -14 -28 -21 -12 -36 -48 -36 -85 1 -48 31 -107 83 -157
37 -36 44 -48 37 -65 -4 -12 -11 -65 -15 -117 -4 -53 -11 -102 -15 -109 -8
-13 -18 -8 -227 121 -40 24 -73 48 -73 54 0 5 20 30 44 55 159 162 219 435
151 693 -8 32 -15 64 -15 72 0 7 29 36 64 63 80 63 114 127 123 228 9 118 -27
201 -107 242 -57 30 -111 25 -200 -17 -83 -40 -148 -41 -251 -5 -183 63 -317
78 -426 49z m210 -55 c37 -6 118 -26 180 -47 143 -46 207 -48 292 -9 115 54
155 55 205 5 44 -44 58 -120 37 -204 -40 -160 -159 -235 -373 -235 -115 0
-180 12 -387 70 -163 45 -194 48 -230 20 -31 -24 -43 -62 -51 -155 -7 -73 -31
-161 -47 -171 -6 -3 -21 2 -35 11 -26 17 -89 17 -125 1 -21 -9 -25 7 -41 147
-14 135 -2 234 43 324 30 62 62 93 74 73 14 -23 44 -8 69 35 39 66 118 115
216 134 62 12 94 12 173 1z m-48 -486 c259 -71 363 -84 496 -63 38 7 72 9 75
6 17 -17 44 -171 44 -252 0 -179 -58 -325 -174 -441 -161 -160 -370 -196 -573
-98 -95 45 -181 126 -224 208 l-33 64 22 19 c36 28 20 50 -40 55 -43 5 -57 11
-85 42 -47 51 -47 104 1 152 39 39 60 42 115 13 35 -18 43 -19 68 -7 44 21 81
106 93 217 6 52 16 101 22 108 16 20 53 16 193 -23z m1015 -387 c90 -46 140
-148 140 -287 l0 -77 -133 -7 -134 -7 -67 63 c-70 64 -96 102 -96 137 0 48 52
36 80 -20 23 -44 53 -52 70 -18 10 17 7 27 -14 58 -14 20 -39 44 -56 54 -16
10 -30 23 -30 28 0 29 35 73 73 90 50 23 101 19 167 -14z m-1442 -254 c15 -49
90 -148 144 -191 54 -44 165 -97 243 -117 117 -30 250 -15 376 41 l56 26 94
-58 c52 -32 98 -61 103 -65 8 -7 -102 -344 -123 -378 -6 -10 -21 -4 -63 23
l-55 37 18 87 c21 99 18 122 -16 122 -20 0 -26 -7 -33 -37 -48 -198 -64 -258
-72 -268 -5 -6 -32 -17 -59 -24 -52 -13 -137 -67 -208 -132 -24 -22 -45 -39
-47 -39 -5 0 -33 91 -101 335 -27 94 -52 176 -58 183 -14 17 -46 15 -54 -4 -3
-9 7 -61 24 -116 17 -54 26 -101 22 -104 -20 -12 -384 -114 -394 -110 -6 2
-24 44 -39 94 -29 90 -42 107 -71 96 -23 -9 -19 -28 34 -195 96 -301 145 -433
178 -481 18 -28 33 -55 33 -60 0 -6 -37 -52 -82 -102 -46 -50 -99 -110 -118
-134 -33 -39 -43 -44 -155 -77 -66 -19 -174 -52 -240 -74 l-120 -40 -9 32
c-12 45 0 423 18 543 51 341 157 652 293 856 65 98 182 219 257 266 53 34 190
101 207 102 3 0 11 -17 17 -37z m1608 -330 c-14 -323 -34 -459 -82 -540 -61
-104 -176 -128 -309 -65 -92 44 -265 141 -265 150 0 12 123 357 131 366 4 4
26 -3 50 -17 l43 -26 -3 -59 c-2 -44 2 -62 14 -72 31 -26 43 7 59 168 9 84 16
168 16 187 0 18 5 37 10 40 9 6 178 19 304 24 l39 1 -7 -157z m-1350 -555 l39
-141 50 -22 c28 -12 173 -64 323 -115 312 -106 288 -86 251 -219 -24 -87 -62
-166 -82 -174 -14 -5 -173 26 -327 64 -122 30 -387 117 -441 144 -53 27 -109
86 -140 147 -23 44 -120 330 -114 336 3 3 386 121 396 122 3 0 23 -64 45 -142z
m654 16 c87 -39 151 -111 209 -237 70 -149 69 -137 8 -137 -29 0 -68 -3 -87
-6 -30 -5 -36 -2 -45 18 -13 30 -20 33 -310 133 -136 47 -251 88 -254 91 -9 9
84 88 139 118 113 62 232 69 340 20z m663 -111 c35 -37 81 -140 113 -249 14
-49 79 -262 144 -474 65 -212 120 -394 122 -405 3 -18 -8 -21 -117 -36 -160
-23 -184 -23 -192 -1 -13 38 -110 268 -194 461 -62 142 -89 215 -89 243 0 48
-21 91 -67 139 -19 20 -50 72 -68 115 -18 44 -47 110 -64 148 -26 56 -28 67
-14 61 173 -70 239 -77 315 -32 24 14 50 33 58 41 17 22 22 20 53 -11z m-351
-350 c74 -43 95 -127 52 -208 -43 -83 -139 -100 -257 -47 -35 16 -67 35 -71
41 -4 6 4 44 17 83 13 40 28 88 32 106 4 22 14 33 28 37 12 2 31 6 42 9 38 9
126 -3 157 -21z m-1188 -72 c44 -27 234 -91 409 -137 l149 -39 39 -78 40 -78
-163 -197 c-214 -260 -321 -367 -415 -414 -292 -146 -627 -42 -792 245 -32 57
-85 225 -79 254 2 13 49 32 193 79 105 34 192 60 193 58 2 -1 -7 -16 -19 -34
-18 -24 -21 -35 -13 -48 21 -32 39 -23 98 50 103 126 309 358 319 358 5 0 24
-9 41 -19z m1422 -456 c58 -137 107 -261 107 -274 2 -23 -7 -28 -133 -68 -159
-51 -352 -103 -362 -97 -3 2 -23 35 -44 72 -20 37 -106 190 -191 340 -85 150
-152 276 -149 279 3 4 29 1 57 -5 68 -16 110 -15 136 3 21 14 26 13 75 -14 58
-32 147 -61 187 -61 53 0 105 23 150 65 39 38 47 42 53 27 4 -9 56 -129 114
-267z m-691 -35 c29 -53 44 -92 41 -105 -8 -31 -180 -209 -250 -259 -33 -24
-79 -51 -101 -60 -60 -23 -164 -46 -215 -46 l-45 0 63 57 c67 59 183 190 346
391 55 67 103 119 107 115 4 -5 28 -46 54 -93z m1192 -314 c4 -8 46 -38 93
-66 98 -60 126 -84 146 -123 12 -22 12 -28 1 -32 -7 -3 -89 -4 -181 -3 l-169
3 -13 47 c-11 34 -28 59 -70 97 -31 29 -55 53 -53 56 2 2 54 10 114 18 61 8
113 15 117 16 4 0 11 -5 15 -13z m-313 -71 c81 -49 136 -104 136 -135 0 -19
-8 -20 -261 -20 l-261 0 -35 35 c-19 19 -33 40 -31 46 4 12 318 116 355 118
12 1 56 -19 97 -44z"/>
<path d="M7019 3864 c-28 -34 38 -82 125 -91 47 -5 55 -3 65 15 14 27 -1 37
-63 46 -27 3 -63 15 -81 26 -29 18 -34 19 -46 4z"/>
<path d="M6736 3855 c-13 -10 -42 -15 -82 -15 -49 0 -63 -4 -68 -17 -12 -31
19 -47 85 -46 75 2 124 28 124 64 0 29 -30 36 -59 14z"/>
<path d="M6917 3734 c-12 -12 -8 -59 8 -104 21 -59 19 -70 -10 -70 -20 0 -25
-5 -25 -25 0 -32 42 -45 79 -24 35 20 38 66 8 154 -22 64 -42 87 -60 69z"/>
<path d="M6659 3706 c-25 -53 25 -110 66 -76 18 15 20 71 3 88 -21 21 -57 14
-69 -12z"/>
<path d="M7084 3705 c-22 -33 -11 -73 21 -81 33 -8 55 11 55 47 0 30 -23 59
-45 59 -8 0 -22 -11 -31 -25z"/>
<path d="M6767 3446 c-48 -18 -67 -34 -67 -58 0 -26 27 -34 59 -15 61 36 158
29 196 -13 21 -24 51 -26 59 -5 9 25 -4 44 -50 71 -54 32 -142 41 -197 20z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

@@ -1,3 +1,10 @@
{ {
"weekNavigationEnabled": false "weekNavigationEnabled": false,
"debug": {
"forceCache": true,
"forceEmpty": false,
"forceError": false,
"forceTimeout": false,
"showCacheInfo": false
}
} }

View File

@@ -32,10 +32,16 @@ type PageProps = {
message: string message: string
isTimeout: boolean isTimeout: boolean
} }
isFromCache?: boolean
cacheAge?: number // возраст кэша в минутах
cacheInfo?: {
size: number
entries: number
}
} }
export default function HomePage(props: NextSerialized<PageProps>) { 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(() => { React.useEffect(() => {
if (typeof window === 'undefined' || error) return if (typeof window === 'undefined' || error) return
@@ -101,7 +107,17 @@ export default function HomePage(props: NextSerialized<PageProps>) {
) : ( ) : (
<> <>
{parsedAt && <LastUpdateAt date={parsedAt} />} {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 : undefined
if (group && Object.hasOwn(groups, group) && group in groups) { 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 scheduleResult: ScheduleResult
let parsedAt let parsedAt
let isFromCache = false
let cacheAge: number | undefined
// Очищаем старые записи из кэша перед использованием // Очищаем старые записи из кэша перед использованием
cleanupCache() cleanupCache()
@@ -161,7 +228,14 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr
const cacheKey = group // Ключ кэша - только группа (текущая неделя) const cacheKey = group // Ключ кэша - только группа (текущая неделя)
const cachedSchedule = useCache ? cachedSchedules.get(cacheKey) : undefined 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 scheduleResult = cachedSchedule.results
parsedAt = cachedSchedule.lastFetched parsedAt = cachedSchedule.lastFetched
} else { } else {
@@ -183,13 +257,14 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr
if (cachedSchedule) { if (cachedSchedule) {
scheduleResult = cachedSchedule.results scheduleResult = cachedSchedule.results
parsedAt = cachedSchedule.lastFetched parsedAt = cachedSchedule.lastFetched
isFromCache = true
const cacheAgeMs = Date.now() - cachedSchedule.lastFetched.getTime()
cacheAge = Math.floor(cacheAgeMs / (1000 * 60))
// Логируем использование fallback кэша с указанием возраста // Логируем использование fallback кэша с указанием возраста
const cacheAge = Date.now() - cachedSchedule.lastFetched.getTime()
const cacheAgeMinutes = Math.floor(cacheAge / (1000 * 60))
if (e instanceof ScheduleTimeoutError) { 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 { } 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 { } else {
// Если кэша нет, возвращаем страницу с ошибкой вместо throw // Если кэша нет, возвращаем страницу с ошибкой вместо 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 schedule = scheduleResult.days
const getSha256Hash = (input: string) => { const getSha256Hash = (input: string) => {
@@ -246,6 +330,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr
.filter(([, v]) => v.lastFetched.getTime() + maxCacheDurationInMS > Date.now()) .filter(([, v]) => v.lastFetched.getTime() + maxCacheDurationInMS > Date.now())
.map(([k]) => k.split('_')[0]) // Берем только группу из ключа кэша .map(([k]) => k.split('_')[0]) // Берем только группу из ключа кэша
// Debug: информация о кэше
const cacheInfo = debug.showCacheInfo ? {
size: cachedSchedules.size,
entries: cachedSchedules.size
} : undefined
context.res.setHeader('ETag', `"${etag}"`) context.res.setHeader('ETag', `"${etag}"`)
return { return {
props: nextSerialized({ props: nextSerialized({
@@ -259,7 +349,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr
groups, groups,
currentWk: scheduleResult.currentWk ?? null, currentWk: scheduleResult.currentWk ?? null,
availableWeeks: scheduleResult.availableWeeks ?? null, availableWeeks: scheduleResult.availableWeeks ?? null,
settings settings,
isFromCache: isFromCache ?? false,
cacheAge: cacheAge ?? null,
cacheInfo: cacheInfo ?? null
}) })
} }
} else { } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shadcn/ui/select'
import { ToastContainer, Toast } from '@/shared/ui/toast' import { ToastContainer, Toast } from '@/shared/ui/toast'
import Head from 'next/head' import Head from 'next/head'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/shadcn/ui/accordion'
type AdminPageProps = { type AdminPageProps = {
groups: GroupsData groups: GroupsData
@@ -444,6 +450,144 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
)} )}
</CardContent> </CardContent>
</Card> </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>
</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') { if (req.method === 'PUT') {
// Обновление настроек // Обновление настроек
const { weekNavigationEnabled } = req.body const { weekNavigationEnabled, debug } = req.body
if (typeof weekNavigationEnabled !== 'boolean') { if (typeof weekNavigationEnabled !== 'boolean') {
res.status(400).json({ error: 'weekNavigationEnabled must be a boolean' }) res.status(400).json({ error: 'weekNavigationEnabled must be a boolean' })
return 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 = { const settings: AppSettings = {
weekNavigationEnabled weekNavigationEnabled,
...(debug !== undefined && { debug })
} }
try { try {

View 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 }

View File

@@ -3,6 +3,13 @@ import path from 'path'
export type AppSettings = { export type AppSettings = {
weekNavigationEnabled: boolean weekNavigationEnabled: boolean
debug?: {
forceCache?: boolean
forceEmpty?: boolean
forceError?: boolean
forceTimeout?: boolean
showCacheInfo?: boolean
}
} }
let cachedSettings: AppSettings | null = null let cachedSettings: AppSettings | null = null
@@ -10,7 +17,14 @@ let cachedSettingsPath: string | null = null
let cachedSettingsMtime: number | null = null let cachedSettingsMtime: number | null = null
const defaultSettings: AppSettings = { 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 = { const mergedSettings: AppSettings = {
...defaultSettings, ...defaultSettings,
...settings ...settings,
debug: {
...defaultSettings.debug,
...settings.debug
}
} }
cachedSettings = mergedSettings cachedSettings = mergedSettings
@@ -112,7 +130,11 @@ export function saveSettings(settings: AppSettings): void {
// Объединяем с настройками по умолчанию для сохранения всех полей // Объединяем с настройками по умолчанию для сохранения всех полей
const mergedSettings: AppSettings = { const mergedSettings: AppSettings = {
...defaultSettings, ...defaultSettings,
...settings ...settings,
debug: {
...defaultSettings.debug,
...settings.debug
}
} }
// Ищем существующий файл // Ищем существующий файл

View File

@@ -1,3 +1,10 @@
{ {
"weekNavigationEnabled": false "weekNavigationEnabled": false,
"debug": {
"forceCache": true,
"forceEmpty": false,
"forceError": false,
"forceTimeout": false,
"showCacheInfo": false
}
} }

View File

@@ -5,24 +5,116 @@ import { useRouter } from 'next/router'
import React from 'react' import React from 'react'
import { getDayOfWeek } from '@/shared/utils' import { getDayOfWeek } from '@/shared/utils'
import { WeekInfo } from '@/app/parser/schedule' 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({ export function Schedule({
days, days,
currentWk, currentWk,
availableWeeks, availableWeeks,
weekNavigationEnabled = true weekNavigationEnabled = true,
isFromCache,
cacheAge,
cacheInfo
}: { }: {
days: DayType[] days: DayType[]
currentWk: number | null | undefined currentWk: number | null | undefined
availableWeeks: WeekInfo[] | null | undefined availableWeeks: WeekInfo[] | null | undefined
weekNavigationEnabled?: boolean weekNavigationEnabled?: boolean
isFromCache?: boolean
cacheAge?: number
cacheInfo?: {
size: number
entries: number
}
}) { }) {
const group = useRouter().query['group'] const group = useRouter().query['group']
const hasScrolledRef = React.useRef(false) const hasScrolledRef = React.useRef(false)
const [toasts, setToasts] = React.useState<Toast[]>([])
// Определяем текущий номер недели из дней // Определяем текущий номер недели из дней
const currentWeekNumber = days.length > 0 ? days[0]?.weekNumber : undefined 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(() => { React.useEffect(() => {
if (hasScrolledRef.current || typeof window === 'undefined') return if (hasScrolledRef.current || typeof window === 'undefined') return
@@ -37,8 +129,10 @@ export function Schedule({
}) })
if (todayDay) { if (todayDay) {
// Небольшая задержка для завершения рендеринга // Увеличиваем задержку, если используется кэш (баннер может рендериться позже)
const timeoutId = setTimeout(() => { const delay = isFromCache ? 300 : 100
const scrollToToday = () => {
const elementId = getDayOfWeek(todayDay.date) const elementId = getDayOfWeek(todayDay.date)
const element = document.getElementById(elementId) const element = document.getElementById(elementId)
@@ -53,33 +147,88 @@ export function Schedule({
behavior: 'smooth' behavior: 'smooth'
}) })
hasScrolledRef.current = true 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 ( return (
<div className="flex flex-col p-4 md:p-8 lg:p-16 gap-6 md:gap-12 lg:gap-14"> <>
{weekNavigationEnabled && ( <div className="flex flex-col p-4 md:p-8 lg:p-16 gap-6 md:gap-12 lg:gap-14">
<WeekNavigation {isFromCache && (
currentWk={currentWk} <CacheWarningBanner cacheAge={cacheAge} />
availableWeeks={availableWeeks} )}
currentWeekNumber={currentWeekNumber} {cacheInfo && (
/> <div className="flex items-center gap-2 mb-2">
)} <Badge variant="outline" className="text-xs">
{days.map((day, i) => ( Debug: Кэш содержит {cacheInfo.entries} {cacheInfo.entries === 1 ? 'запись' : cacheInfo.entries < 5 ? 'записи' : 'записей'}
<div </Badge>
key={`${group}_day${i}`} </div>
className="stagger-card" )}
style={{ {weekNavigationEnabled && (
animationDelay: `${i * 0.1}s`, <WeekNavigation
} as React.CSSProperties} currentWk={currentWk}
> availableWeeks={availableWeeks}
<Day day={day} /> 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>
))} ) : (
</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} />
</>
) )
} }