From 146c31b775c974da2c93f03a661f5e34d995f8af Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Mon, 20 Jul 2026 11:33:14 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20oprava=20p=C3=A1du=20p=C5=99i=20zdvojen?= =?UTF-8?q?=C3=A9m=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/App.tsx | 6 ++- server/src/service.ts | 46 +++++++++++++++- server/src/tests/service.pruneChoices.test.ts | 53 +++++++++++++++++++ 3 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 server/src/tests/service.pruneChoices.test.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index 0ce8e02..3dfb51d 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -778,9 +778,11 @@ function App() { {userChoices && userChoices.length > 0 && food && (
- {userChoices.map(foodIndex => { + {userChoices + .filter(foodIndex => food[key as Restaurant]?.food?.[foodIndex] != null) + .map(foodIndex => { const restaurantKey = key as Restaurant; - const foodName = food[restaurantKey]?.food?.[foodIndex].name; + const foodName = food[restaurantKey]?.food?.[foodIndex]?.name; return
{foodName} {login === auth.login && canChangeChoice && diff --git a/server/src/service.ts b/server/src/service.ts index 2d34721..b60a1d5 100644 --- a/server/src/service.ts +++ b/server/src/service.ts @@ -4,7 +4,7 @@ import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuS import { getTodayMock } from "./mock"; import { removeAllUserPizzas } from "./pizza"; import { getStores } from "./stores"; -import { ClientData, DepartureTime, LunchChoice, MealSlot, PizzaDayState, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types/gen/types.gen"; +import { ClientData, DepartureTime, LunchChoice, LunchChoices, MealSlot, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, WeekMenu } from "../../types/gen/types.gen"; const storage = getStorage(); const MENU_PREFIX = 'menu'; @@ -62,10 +62,54 @@ export async function getData(date?: Date, slot?: MealSlot): Promise ZASTAVKAUMICHALA: await getRestaurantMenu('ZASTAVKAUMICHALA', date), SENKSERIKOVA: await getRestaurantMenu('SENKSERIKOVA', date), } + // Očistí volby jídel odkazující na indexy, které v aktuálním menu již neexistují. + // Pokud se v odpovědi něco změnilo, promítne očistu i do uložených dat (self-heal). + if (pruneStaleFoodChoices(clientData.choices, clientData.menus)) { + await storage.updateData(getDataKey(date ?? getToday(), slot), (current) => { + const data = current ?? getEmptyData(date); + pruneStaleFoodChoices(data.choices, clientData.menus!); + return data; + }); + } } return clientData; } +/** + * Odstraní z voleb uživatelů indexy jídel, které v aktuálním menu daného podniku již neexistují. + * + * Nastává typicky poté, co podnik ze svého webu odebere menu předchozího týdne: aplikace pak + * pro daný den vyparsuje méně jídel než dříve a dříve uložený index (např. z „budoucího“ týdne) + * se stane neplatným. Bez očištění by klient spadl při přístupu na neexistující jídlo a data + * by zůstala nekonzistentní. + * + * Očista se provádí pouze u podniků, které mají v menu alespoň jedno jídlo – prázdné menu + * (zavřeno / neúspěšné načtení) volby záměrně nemaže, aby nedošlo ke ztrátě platných voleb. + * + * @param choices volby uživatelů + * @param menus menu podniků pro daný den + * @returns true, pokud došlo k odebrání alespoň jednoho indexu + */ +export function pruneStaleFoodChoices(choices: LunchChoices, menus: RestaurantDayMenuMap): boolean { + let mutated = false; + for (const restaurant of Object.keys(Restaurant) as Restaurant[]) { + const foodCount = menus[restaurant]?.food?.length ?? 0; + if (foodCount === 0) continue; // prázdné menu volby nemažeme + const locationChoices = choices[restaurant]; + if (!locationChoices) continue; + for (const userChoice of Object.values(locationChoices)) { + const selected = userChoice.selectedFoods; + if (!selected?.length) continue; + const filtered = selected.filter(index => index < foodCount); + if (filtered.length !== selected.length) { + userChoice.selectedFoods = filtered; + mutated = true; + } + } + } + return mutated; +} + /** * Vrátí klíč, pod kterým je uloženo menu pro týden příslušící předanému datu. * diff --git a/server/src/tests/service.pruneChoices.test.ts b/server/src/tests/service.pruneChoices.test.ts new file mode 100644 index 0000000..9fc0000 --- /dev/null +++ b/server/src/tests/service.pruneChoices.test.ts @@ -0,0 +1,53 @@ +import { pruneStaleFoodChoices } from '../service'; +import { LunchChoices, RestaurantDayMenuMap } from '../../../types/gen/types.gen'; + +// Pomocná fabrika menu s daným počtem jídel pro TECHTOWER. +const menusWithTechTowerFoods = (count: number): RestaurantDayMenuMap => ({ + TECHTOWER: { + lastUpdate: 0, + closed: false, + food: Array.from({ length: count }, (_, i) => ({ name: `Jídlo ${i}` })), + }, +} as unknown as RestaurantDayMenuMap); + +describe('pruneStaleFoodChoices', () => { + test('odebere index jídla, který v aktuálním menu již neexistuje', () => { + const choices: LunchChoices = { + TECHTOWER: { alice: { selectedFoods: [0, 9] } }, + }; + const mutated = pruneStaleFoodChoices(choices, menusWithTechTowerFoods(5)); + expect(mutated).toBe(true); + expect(choices.TECHTOWER!.alice.selectedFoods).toEqual([0]); + }); + + test('nechá platné indexy beze změny a vrátí false', () => { + const choices: LunchChoices = { + TECHTOWER: { alice: { selectedFoods: [0, 4] } }, + }; + const mutated = pruneStaleFoodChoices(choices, menusWithTechTowerFoods(5)); + expect(mutated).toBe(false); + expect(choices.TECHTOWER!.alice.selectedFoods).toEqual([0, 4]); + }); + + test('u prázdného menu (zavřeno / neúspěšné načtení) volby nemaže', () => { + const choices: LunchChoices = { + TECHTOWER: { alice: { selectedFoods: [3] } }, + }; + const mutated = pruneStaleFoodChoices(choices, menusWithTechTowerFoods(0)); + expect(mutated).toBe(false); + expect(choices.TECHTOWER!.alice.selectedFoods).toEqual([3]); + }); + + test('očistí volby více uživatelů najednou', () => { + const choices: LunchChoices = { + TECHTOWER: { + alice: { selectedFoods: [7] }, + bob: { selectedFoods: [1, 2] }, + }, + }; + const mutated = pruneStaleFoodChoices(choices, menusWithTechTowerFoods(5)); + expect(mutated).toBe(true); + expect(choices.TECHTOWER!.alice.selectedFoods).toEqual([]); + expect(choices.TECHTOWER!.bob.selectedFoods).toEqual([1, 2]); + }); +});