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]);
+ });
+});