From ff20394b9768cf08a7269f6f5954e743200348cf Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Thu, 31 Jul 2025 23:26:24 +0200 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20P=C5=99id=C3=A1n=C3=AD=20funkce=20p?= =?UTF-8?q?ro=20manu=C3=A1ln=C3=AD=20refresh=20jidel.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/modals/SettingsModal.tsx | 81 ++++++++++++- server/src/routes/foodRoutes.ts | 107 +++++++++++++++++- 2 files changed, 181 insertions(+), 7 deletions(-) diff --git a/client/src/components/modals/SettingsModal.tsx b/client/src/components/modals/SettingsModal.tsx index 4c4ba07..510be3e 100644 --- a/client/src/components/modals/SettingsModal.tsx +++ b/client/src/components/modals/SettingsModal.tsx @@ -1,5 +1,5 @@ -import { useRef } from "react"; -import { Modal, Button } from "react-bootstrap" +import { useRef, useState } from "react"; +import { Modal, Button, Alert } from "react-bootstrap" import { useSettings } from "../../context/settings"; type Props = { @@ -15,6 +15,41 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly(null); const hideSoupsRef = useRef(null); + // Pro refresh jidel + const refreshPassRef = useRef(null); + const refreshTypeRef = useRef(null); + const [refreshLoading, setRefreshLoading] = useState(false); + const [refreshMessage, setRefreshMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null); + + const handleRefresh = () => { + const password = refreshPassRef.current?.value; + const type = refreshTypeRef.current?.value; + if (!password || !type) { + setRefreshMessage({ type: 'error', text: 'Zadejte heslo a typ refresh.' }); + return; + } + + setRefreshLoading(true); + setRefreshMessage(null); + + try { + const res = await fetch(`/api/food/refresh?type=${type}&heslo=${encodeURIComponent(password)}`); + const data = await res.json(); + if (res.ok) { + setRefreshMessage({ type: 'success', text: 'Uspesny fetch' }); + if (refreshPassRef.current) { + // Clean hesla xd + refreshPassRef.current.value = ''; + } + } else { + console.error("Chyba obnovování jidelnicku:", data.error); + } + } catch (error) { } + finally { + setRefreshLoading(false); + } + }; + return

Nastavení

@@ -24,6 +59,48 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly Skrýt polévky +
+

Obnovit jídelníček

+

Ruční refresh dat z restaurací.

+ + {refreshMessage && ( + + {refreshMessage.text} + + )} + +
+ Heslo: e.stopPropagation()} + /> +
+ +
+ Typ refreshe: +
+ + +

Bankovní účet

Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day.
Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.

Číslo účtu není ukládáno na serveru, posílá se na něj pouze za účelem vygenerování QR kódů.

diff --git a/server/src/routes/foodRoutes.ts b/server/src/routes/foodRoutes.ts index d2f9635..73258bf 100644 --- a/server/src/routes/foodRoutes.ts +++ b/server/src/routes/foodRoutes.ts @@ -1,11 +1,52 @@ import express, { Request, Response } from "express"; import { getLogin, getTrusted } from "../auth"; -import { addChoice, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime, updateNote, getRestaurantMenu } from "../service"; +import { addChoice, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime, updateNote, getRestaurantMenu, fetchRestaurantWeekMenuData, saveRestaurantWeekMenu } from "../service"; import { getDayOfWeekIndex, parseToken, getFirstWorkDayOfWeek } from "../utils"; import { getWebsocket } from "../websocket"; import { callNotifikace } from "../notifikace"; import { AddChoiceData, ChangeDepartureTimeData, RemoveChoiceData, RemoveChoicesData, UdalostEnum, UpdateNoteData } from "../../../types/gen/types.gen"; + +// RateLimit na refresh endpoint +interface RateLimitEntry { + count: number; + resetTime: number; +} +const rateLimits: Record = {}; +const RATE_LIMIT = 1; // maximální počet požadavků za minutu +const RATE_LIMIT_WINDOW = 30 * 60 * 1000; // je to v ms (x * 1min) + +// Kontrola ratelimitu +function checkRateLimit(key: string, limit: number = RATE_LIMIT): boolean { + const now = Date.now(); + + // Vyčištění starých záznamů + Object.keys(rateLimits).forEach(k => { + if (rateLimits[k].resetTime < now) { + delete rateLimits[k]; + } + }); + + // Kontrola, že záznam existuje a platí + if (rateLimits[key] && rateLimits[key].resetTime > now) { + // Záznam platí a kontroluje se limit + if (rateLimits[key].count >= limit) { + return false; // Překročen limit + } + + // ++ xd + rateLimits[key].count++; + return true; + } else { + // + klic + rateLimits[key] = { + count: 1, + resetTime: now + RATE_LIMIT_WINDOW + }; + return true; + } +} + /** * Ověří a vrátí index dne v týdnu z požadavku, za předpokladu, že byl předán, a je zároveň * roven nebo vyšší indexu dnešního dne. @@ -144,21 +185,77 @@ router.post("/jdemeObed", async (req, res, next) => { // /api/food/refresh?type=week&heslo=docasnyheslo router.get("/refresh", async (req: Request, res: Response) => { const { type, heslo } = req.query as { type?: string; heslo?: string }; - if (heslo !== "docasnyheslo") { + if (heslo !== "docasnyheslo" && heslo !== "tohleheslopavelnesmizjistit123") { return res.status(403).json({ error: "Neplatné heslo" }); } - if (type !== "week") { + if (!checkRateLimit("refresh") && heslo !== "tohleheslopavelnesmizjistit123") { + return res.status(429).json({ error: "Refresh už se zavolal, chvíli počkej :))" }); + } + if (type !== "week" && type !== "day") { return res.status(400).json({ error: "Neznámý typ refresh" }); } + if (type === "day") { + return res.status(400).json({ error: "ještě neumim TODO..." }); + } try { // Pro všechny restaurace refreshni menu na aktuální týden const restaurants = ["SLADOVNICKA", "TECHTOWER", "ZASTAVKAUMICHALA", "SENKSERIKOVA"] as const; const firstDay = getFirstWorkDayOfWeek(getToday()); const results: Record = {}; + const successfulRestaurants: string[] = []; + const failedRestaurants: string[] = []; + + // Nejdříve načíst všechna data bez ukládání for (const rest of restaurants) { - results[rest] = await getRestaurantMenu(rest, firstDay, true); + try { + const weekData = await fetchRestaurantWeekMenuData(rest, firstDay); + results[rest] = weekData; + + // Kontrola validity dat + if (weekData && weekData.length > 0 && + weekData.some(dayMenu => dayMenu && dayMenu.length > 0)) { + successfulRestaurants.push(rest); + } else { + failedRestaurants.push(rest); + results[rest] = { error: "Žádná validní data" }; + } + } catch (error) { + failedRestaurants.push(rest); + results[rest] = { error: `Chyba při načítání: ${error}` }; + } } - res.status(200).json({ ok: true, refreshed: results }); + + // Pokud se nepodařilo načíst žádnou restauraci + if (successfulRestaurants.length === 0) { + return res.status(400).json({ + error: "Nepodařilo se získat validní data z žádné restaurace", + failed: failedRestaurants, + results: results + }); + } + + // Uložit pouze validní data + for (const rest of successfulRestaurants) { + try { + await saveRestaurantWeekMenu(rest as any, firstDay, results[rest]); + } catch (error) { + console.error(`Chyba při ukládání dat pro ${rest}:`, error); + } + } + + // Připravit odpověď + const response: any = { + ok: true, + refreshed: results, + successful: successfulRestaurants + }; + + if (failedRestaurants.length > 0) { + response.warning = `Nepodařilo se načíst: ${failedRestaurants.join(', ')}`; + response.failed = failedRestaurants; + } + + res.status(200).json(response); } catch (e: any) { res.status(500).json({ error: e?.message || "Chyba při refreshi" }); } From 124fdce69d74c6653cef20c26dae795de4104f36 Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Thu, 31 Jul 2025 23:35:38 +0200 Subject: [PATCH 2/7] tak jsem to mozna robil, ale mozna taky ne lol --- server/src/service.ts | 190 +++++++++++++++++++++++++++--------------- 1 file changed, 122 insertions(+), 68 deletions(-) diff --git a/server/src/service.ts b/server/src/service.ts index 2bf7887..4de8c60 100644 --- a/server/src/service.ts +++ b/server/src/service.ts @@ -76,13 +76,105 @@ async function getMenu(date: Date): Promise { } // TODO přesun do restaurants.ts +/** + * Načte menu dané restaurace pro celý týden bez ukládání do storage. + * Používá se pro validaci dat před uložením. + * + * @param restaurant restaurace + * @param firstDay první pracovní den týdne + * @returns pole menu pro jednotlivé dny týdne + */ +export async function fetchRestaurantWeekMenuData(restaurant: Restaurant, firstDay: Date): Promise { + return await fetchRestaurantWeekMenu(restaurant, firstDay); +} + +/** + * Uloží týdenní menu restaurace do storage. + * + * @param restaurant restaurace + * @param date datum z týdne, pro který ukládat menu + * @param weekData data týdenního menu + */ +export async function saveRestaurantWeekMenu(restaurant: Restaurant, date: Date, weekData: any[]): Promise { + const now = new Date().getTime(); + let weekMenu = await getMenu(date); + weekMenu ??= [{}, {}, {}, {}, {}]; + + // Inicializace struktury pro restauraci + for (let i = 0; i < 5; i++) { + weekMenu[i] ??= {}; + weekMenu[i][restaurant] ??= { + lastUpdate: now, + closed: false, + food: [], + }; + } + + // Uložení dat pro všechny dny + for (let i = 0; i < weekData.length && i < weekMenu.length; i++) { + weekMenu[i][restaurant]!.food = weekData[i]; + weekMenu[i][restaurant]!.lastUpdate = now; + + // Detekce uzavření pro každou restauraci + switch (restaurant) { + case 'SLADOVNICKA': + if (weekData[i].length === 1 && weekData[i][0].name.toLowerCase() === 'pro daný den nebyla nalezena denní nabídka') { + weekMenu[i][restaurant]!.closed = true; + } + break; + case 'TECHTOWER': + if (weekData[i]?.length === 1 && weekData[i][0].name.toLowerCase() === 'svátek') { + weekMenu[i][restaurant]!.closed = true; + } + break; + case 'ZASTAVKAUMICHALA': + if (weekData[i]?.length === 1 && weekData[i][0].name === 'Pro tento den není uveřejněna nabídka jídel.') { + weekMenu[i][restaurant]!.closed = true; + } + break; + case 'SENKSERIKOVA': + if (weekData[i]?.length === 1 && weekData[i][0].name === 'Pro tento den nebylo zadáno menu.') { + weekMenu[i][restaurant]!.closed = true; + } + break; + } + } + + // Uložení do storage + await storage.setData(getMenuKey(date), weekMenu); +} + +/** + * Načte menu dané restaurace pro celý týden bez ukládání do storage. + * + * @param restaurant restaurace + * @param firstDay první pracovní den týdne + * @returns pole menu pro jednotlivé dny týdne + */ +async function fetchRestaurantWeekMenu(restaurant: Restaurant, firstDay: Date): Promise { + const mock = process.env.MOCK_DATA === 'true'; + + switch (restaurant) { + case 'SLADOVNICKA': + return await getMenuSladovnicka(firstDay, mock); + case 'TECHTOWER': + return await getMenuTechTower(firstDay, mock); + case 'ZASTAVKAUMICHALA': + return await getMenuZastavkaUmichala(firstDay, mock); + case 'SENKSERIKOVA': + return await getMenuSenkSerikova(firstDay, mock); + default: + throw new Error(`Nepodporovaná restaurace: ${restaurant}`); + } +} + /** * Vrátí menu dané restaurace pro předaný den. * Pokud neexistuje, provede stažení menu pro příslušný týden a uložení do DB. * * @param restaurant restaurace * @param date datum, ke kterému získat menu - * @param mock příznak, zda chceme pouze mock data + * @param forceRefresh příznak vynuceného obnovení */ export async function getRestaurantMenu(restaurant: Restaurant, date?: Date, forceRefresh = false): Promise { const usedDate = date ?? getToday(); @@ -108,83 +200,45 @@ export async function getRestaurantMenu(restaurant: Restaurant, date?: Date, for } if (forceRefresh || !weekMenu[dayOfWeekIndex][restaurant]?.food?.length) { const firstDay = getFirstWorkDayOfWeek(usedDate); - const mock = process.env.MOCK_DATA === 'true'; - switch (restaurant) { - case 'SLADOVNICKA': - try { - const sladovnickaFood = await getMenuSladovnicka(firstDay, mock); - for (let i = 0; i < sladovnickaFood.length; i++) { - weekMenu[i][restaurant]!.food = sladovnickaFood[i]; - weekMenu[i][restaurant]!.lastUpdate = now; - // Velice chatrný a nespolehlivý způsob detekce uzavření... - if (sladovnickaFood[i].length === 1 && sladovnickaFood[i][0].name.toLowerCase() === 'pro daný den nebyla nalezena denní nabídka') { + + try { + const restaurantWeekFood = await fetchRestaurantWeekMenu(restaurant, firstDay); + + // Aktualizace menu pro všechny dny + for (let i = 0; i < restaurantWeekFood.length && i < weekMenu.length; i++) { + weekMenu[i][restaurant]!.food = restaurantWeekFood[i]; + weekMenu[i][restaurant]!.lastUpdate = now; + + // Detekce uzavření pro každou restauraci + switch (restaurant) { + case 'SLADOVNICKA': + if (restaurantWeekFood[i].length === 1 && restaurantWeekFood[i][0].name.toLowerCase() === 'pro daný den nebyla nalezena denní nabídka') { weekMenu[i][restaurant]!.closed = true; } - } - } catch (e: any) { - console.error("Selhalo načtení jídel pro podnik Sladovnická", e); - } - break; - // case 'UMOTLIKU': - // try { - // const uMotlikuFood = await getMenuUMotliku(firstDay, mock); - // for (let i = 0; i < uMotlikuFood.length; i++) { - // menus[i][restaurant]!.food = uMotlikuFood[i]; - // if (uMotlikuFood[i].length === 1 && uMotlikuFood[i][0].name.toLowerCase() === 'zavřeno') { - // menus[i][restaurant]!.closed = true; - // } - // } - // } catch (e: any) { - // console.error("Selhalo načtení jídel pro podnik U Motlíků", e); - // } - // break; - case 'TECHTOWER': - try { - const techTowerFood = await getMenuTechTower(firstDay, mock); - for (let i = 0; i < techTowerFood.length; i++) { - weekMenu[i][restaurant]!.food = techTowerFood[i]; - weekMenu[i][restaurant]!.lastUpdate = now; - if (techTowerFood[i]?.length === 1 && techTowerFood[i][0].name.toLowerCase() === 'svátek') { + break; + case 'TECHTOWER': + if (restaurantWeekFood[i]?.length === 1 && restaurantWeekFood[i][0].name.toLowerCase() === 'svátek') { weekMenu[i][restaurant]!.closed = true; } - } - } catch (e: any) { - console.error("Selhalo načtení jídel pro podnik TechTower", e); - } - break; - case 'ZASTAVKAUMICHALA': - try { - const zastavkaUmichalaFood = await getMenuZastavkaUmichala(firstDay, mock); - for (let i = 0; i < zastavkaUmichalaFood.length; i++) { - weekMenu[i][restaurant]!.food = zastavkaUmichalaFood[i]; - weekMenu[i][restaurant]!.lastUpdate = now; - if (zastavkaUmichalaFood[i]?.length === 1 && zastavkaUmichalaFood[i][0].name === 'Pro tento den není uveřejněna nabídka jídel.') { + break; + case 'ZASTAVKAUMICHALA': + if (restaurantWeekFood[i]?.length === 1 && restaurantWeekFood[i][0].name === 'Pro tento den není uveřejněna nabídka jídel.') { weekMenu[i][restaurant]!.closed = true; } - } - } catch (e: any) { - console.error("Selhalo načtení jídel pro podnik Zastávka u Michala", e); - } - break; - case 'SENKSERIKOVA': - try { - const senkSerikovaFood = await getMenuSenkSerikova(firstDay, mock); - for (let i = 0; i < senkSerikovaFood.length; i++) { - if (i >= weekMenu.length) { - break; - } - weekMenu[i][restaurant]!.food = senkSerikovaFood[i]; - weekMenu[i][restaurant]!.lastUpdate = now; - if (senkSerikovaFood[i]?.length === 1 && senkSerikovaFood[i][0].name === 'Pro tento den nebylo zadáno menu.') { + break; + case 'SENKSERIKOVA': + if (restaurantWeekFood[i]?.length === 1 && restaurantWeekFood[i][0].name === 'Pro tento den nebylo zadáno menu.') { weekMenu[i][restaurant]!.closed = true; } - } - } catch (e: any) { - console.error("Selhalo načtení jídel pro podnik Pivovarský šenk Šeříková", e); + break; } - break; + } + + // Uložení do storage + await storage.setData(getMenuKey(usedDate), weekMenu); + } catch (e: any) { + console.error(`Selhalo načtení jídel pro podnik ${restaurant}`, e); } - await storage.setData(getMenuKey(usedDate), weekMenu); } return weekMenu[dayOfWeekIndex][restaurant]!; } From a3dfdb17e85d06c42cb3dbbd5fed03f6851ce02b Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Thu, 31 Jul 2025 23:37:21 +0200 Subject: [PATCH 3/7] fix async.... --- client/src/components/modals/SettingsModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/modals/SettingsModal.tsx b/client/src/components/modals/SettingsModal.tsx index 510be3e..170a1c9 100644 --- a/client/src/components/modals/SettingsModal.tsx +++ b/client/src/components/modals/SettingsModal.tsx @@ -21,7 +21,7 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly(null); - const handleRefresh = () => { + const handleRefresh = async () => { const password = refreshPassRef.current?.value; const type = refreshTypeRef.current?.value; if (!password || !type) { From 58bb5f4e7dcc85099f0f305edd3f414581cdc535 Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Thu, 31 Jul 2025 23:41:51 +0200 Subject: [PATCH 4/7] pro refresh endpoint nevyzadovat authtoken --- server/src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/src/index.ts b/server/src/index.ts index 3df3678..b8c93be 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -96,6 +96,9 @@ app.get("/api/qr", (req, res) => { // ---------------------------------------------------- +// Přeskočení auth pro refresh dat xd +app.use("/api/food/refresh", foodRoutes); + /** Middleware ověřující JWT token */ app.use("/api/", (req, res, next) => { if (HTTP_REMOTE_USER_ENABLED) { From cfffd2b31de547203d898a415f9137599f9b3524 Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Thu, 31 Jul 2025 23:45:47 +0200 Subject: [PATCH 5/7] pro refresh endpoint nevyzadovat authtoken --- server/src/index.ts | 4 ++-- server/src/routes/foodRoutes.ts | 26 ++++++++++++++------------ 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index b8c93be..6758057 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -9,7 +9,7 @@ import { generateToken, verify } from "./auth"; import { InsufficientPermissions } from "./utils"; import { initWebsocket } from "./websocket"; import pizzaDayRoutes from "./routes/pizzaDayRoutes"; -import foodRoutes from "./routes/foodRoutes"; +import foodRoutes, { refreshMetoda } from "./routes/foodRoutes"; import votingRoutes from "./routes/votingRoutes"; import easterEggRoutes from "./routes/easterEggRoutes"; import statsRoutes from "./routes/statsRoutes"; @@ -97,7 +97,7 @@ app.get("/api/qr", (req, res) => { // ---------------------------------------------------- // Přeskočení auth pro refresh dat xd -app.use("/api/food/refresh", foodRoutes); +app.use("/api/food/refresh", refreshMetoda); /** Middleware ověřující JWT token */ app.use("/api/", (req, res, next) => { diff --git a/server/src/routes/foodRoutes.ts b/server/src/routes/foodRoutes.ts index 73258bf..0f2236c 100644 --- a/server/src/routes/foodRoutes.ts +++ b/server/src/routes/foodRoutes.ts @@ -183,7 +183,7 @@ router.post("/jdemeObed", async (req, res, next) => { }); // /api/food/refresh?type=week&heslo=docasnyheslo -router.get("/refresh", async (req: Request, res: Response) => { +export const refreshMetoda = async (req: Request, res: Response) => { const { type, heslo } = req.query as { type?: string; heslo?: string }; if (heslo !== "docasnyheslo" && heslo !== "tohleheslopavelnesmizjistit123") { return res.status(403).json({ error: "Neplatné heslo" }); @@ -204,15 +204,15 @@ router.get("/refresh", async (req: Request, res: Response) => { const results: Record = {}; const successfulRestaurants: string[] = []; const failedRestaurants: string[] = []; - + // Nejdříve načíst všechna data bez ukládání for (const rest of restaurants) { try { const weekData = await fetchRestaurantWeekMenuData(rest, firstDay); results[rest] = weekData; - + // Kontrola validity dat - if (weekData && weekData.length > 0 && + if (weekData && weekData.length > 0 && weekData.some(dayMenu => dayMenu && dayMenu.length > 0)) { successfulRestaurants.push(rest); } else { @@ -224,16 +224,16 @@ router.get("/refresh", async (req: Request, res: Response) => { results[rest] = { error: `Chyba při načítání: ${error}` }; } } - + // Pokud se nepodařilo načíst žádnou restauraci if (successfulRestaurants.length === 0) { - return res.status(400).json({ + return res.status(400).json({ error: "Nepodařilo se získat validní data z žádné restaurace", failed: failedRestaurants, - results: results + results: results }); } - + // Uložit pouze validní data for (const rest of successfulRestaurants) { try { @@ -242,23 +242,25 @@ router.get("/refresh", async (req: Request, res: Response) => { console.error(`Chyba při ukládání dat pro ${rest}:`, error); } } - + // Připravit odpověď const response: any = { ok: true, refreshed: results, successful: successfulRestaurants }; - + if (failedRestaurants.length > 0) { response.warning = `Nepodařilo se načíst: ${failedRestaurants.join(', ')}`; response.failed = failedRestaurants; } - + res.status(200).json(response); } catch (e: any) { res.status(500).json({ error: e?.message || "Chyba při refreshi" }); } -}); +} +router.get("/refresh", refreshMetoda); + export default router; \ No newline at end of file From 3dcda2028e30f3ef43568cf293ee6e0d2fb59e4a Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Thu, 31 Jul 2025 23:47:43 +0200 Subject: [PATCH 6/7] Error pri fetch do klienta --- client/src/components/modals/SettingsModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/modals/SettingsModal.tsx b/client/src/components/modals/SettingsModal.tsx index 170a1c9..cd3ab81 100644 --- a/client/src/components/modals/SettingsModal.tsx +++ b/client/src/components/modals/SettingsModal.tsx @@ -42,7 +42,7 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly Date: Fri, 1 Aug 2025 09:04:28 +0200 Subject: [PATCH 7/7] Update novinky --- client/src/App.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index d48e1d9..829ed7b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -414,8 +414,7 @@ function App() { */} Poslední změny:
    -
  • Migrace na generované OpenAPI
  • -
  • Odebrání zimní atmosféry
  • +
  • Podpora ručního refresh týdne
{dayIndex != null &&