Files
Luncher/server/src/service.ts
T
mates 146c31b775
CI / Generate TypeScript types (push) Successful in 13s
CI / Server unit tests (push) Successful in 23s
CI / Build server (push) Successful in 26s
CI / Build client (push) Successful in 38s
CI / Playwright E2E tests (push) Successful in 1m29s
CI / Build and push Docker image (push) Successful in 43s
CI / Notify (push) Successful in 2s
fix: oprava pádu při zdvojeném menu
2026-07-20 11:33:14 +02:00

626 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { InsufficientPermissions, PizzaDayConflictError, formatDate, getDayOfWeekIndex, getFirstWorkDayOfWeek, getHumanDate, getIsWeekend, getWeekNumber } from "./utils";
import getStorage from "./storage";
import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova, StaleWeekError } from "./restaurants";
import { getTodayMock } from "./mock";
import { removeAllUserPizzas } from "./pizza";
import { getStores } from "./stores";
import { ClientData, DepartureTime, LunchChoice, LunchChoices, MealSlot, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, WeekMenu } from "../../types/gen/types.gen";
const storage = getStorage();
const MENU_PREFIX = 'menu';
function getDataKey(date: Date, slot?: MealSlot): string {
const base = formatDate(date);
return slot === MealSlot.EXTRA ? `${base}_extra` : base;
}
/** Vrátí dnešní datum, případně fiktivní datum pro účely vývoje a testování. */
export function getToday(): Date {
if (process.env.MOCK_DATA === 'true') {
return getTodayMock();
}
return new Date();
}
/** Vrátí datum v aktuálním týdnu na základě předaného indexu (0 = pondělí). */
export const getDateForWeekIndex = (index: number) => {
if (index < 0 || index > 4) {
// Nechceme shodit server, vrátíme dnešek
console.log('Neplatný index dne v týdnu: ' + index);
return getToday();
}
const date = getToday();
date.setDate(date.getDate() - getDayOfWeekIndex(date) + index);
return date;
}
/** Vrátí "prázdná" (implicitní) data pro předaný den. */
export function getEmptyData(date?: Date): ClientData {
const usedDate = date || getToday();
return {
todayDayIndex: getDayOfWeekIndex(getToday()),
date: getHumanDate(usedDate),
isoDate: formatDate(usedDate),
isWeekend: getIsWeekend(usedDate),
dayIndex: getDayOfWeekIndex(usedDate),
choices: {},
};
}
/**
* Vrátí veškerá klientská data pro předaný den, nebo aktuální den, pokud není předán.
*/
export async function getData(date?: Date, slot?: MealSlot): Promise<ClientData> {
const clientData = await getClientData(date, slot);
if (slot === MealSlot.EXTRA) {
clientData.stores = await getStores();
} else {
clientData.menus = {
SLADOVNICKA: await getRestaurantMenu('SLADOVNICKA', date),
// UMOTLIKU: await getRestaurantMenu('UMOTLIKU', date),
TECHTOWER: await getRestaurantMenu('TECHTOWER', date),
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<ClientData>(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.
*
* @param date datum
* @returns databázový klíč
*/
export function getMenuKey(date: Date) {
const weekNumber = getWeekNumber(date);
return `${MENU_PREFIX}_${date.getFullYear()}_${weekNumber}`;
}
/**
* Vrátí menu všech podniků pro celý týden do kterého spadá předané datum, pokud již existují.
*
* @param date datum
* @returns menu restaurací pro týden příslušící předanému datu
*/
async function getMenu(date: Date): Promise<WeekMenu | undefined> {
return await storage.getData<WeekMenu | undefined>(getMenuKey(date));
}
// 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<any[]> {
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<void> {
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<any[]> {
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 forceRefresh příznak vynuceného obnovení
*/
export async function getRestaurantMenu(restaurant: Restaurant, date?: Date, forceRefresh = false): Promise<RestaurantDayMenu> {
const usedDate = date ?? getToday();
const dayOfWeekIndex = getDayOfWeekIndex(usedDate);
const now = new Date().getTime();
if (getIsWeekend(usedDate)) {
return {
lastUpdate: now,
closed: true,
food: [],
};
}
let weekMenu = await getMenu(usedDate);
weekMenu ??= [{}, {}, {}, {}, {}];
for (let i = 0; i < 5; i++) {
weekMenu[i] ??= {};
weekMenu[i][restaurant] ??= {
lastUpdate: now,
closed: false,
food: [],
};
}
const MENU_REFETCH_TTL_MS = 60 * 60 * 1000; // 1 hour
const existingMenu = weekMenu[dayOfWeekIndex][restaurant];
const lastFetchExpired = !existingMenu?.lastUpdate ||
existingMenu.lastUpdate === now || // freshly initialized, never fetched
(now - existingMenu.lastUpdate) > MENU_REFETCH_TTL_MS;
const shouldFetch = forceRefresh ||
(!existingMenu?.food?.length && !existingMenu?.closed && lastFetchExpired);
if (shouldFetch) {
const firstDay = getFirstWorkDayOfWeek(usedDate);
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;
weekMenu[i][restaurant]!.isStale = false;
// 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;
}
break;
case 'TECHTOWER':
if (restaurantWeekFood[i]?.length === 1 && restaurantWeekFood[i][0].name.toLowerCase() === 'svátek') {
weekMenu[i][restaurant]!.closed = true;
}
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;
}
break;
case 'SENKSERIKOVA':
if (restaurantWeekFood[i]?.length === 1 && restaurantWeekFood[i][0].name === 'Pro tento den nebylo zadáno menu.') {
weekMenu[i][restaurant]!.closed = true;
}
break;
}
}
// Uložení do storage
await storage.setData(getMenuKey(usedDate), weekMenu);
} catch (e: any) {
if (e instanceof StaleWeekError) {
for (let i = 0; i < e.food.length && i < weekMenu.length; i++) {
weekMenu[i][restaurant]!.food = e.food[i];
weekMenu[i][restaurant]!.lastUpdate = now;
weekMenu[i][restaurant]!.isStale = true;
}
await storage.setData(getMenuKey(usedDate), weekMenu);
} else {
console.error(`Selhalo načtení jídel pro podnik ${restaurant}`, e);
}
}
}
const result = weekMenu[dayOfWeekIndex][restaurant]!;
result.warnings = generateMenuWarnings(result);
return result;
}
/**
* Generuje varování o kvalitě/úplnosti dat menu restaurace.
*/
function generateMenuWarnings(menu: RestaurantDayMenu): string[] {
const warnings: string[] = [];
if (!menu.food?.length || menu.closed) {
return warnings;
}
if (menu.isStale) {
warnings.push('Data jsou z minulého týdne');
}
const hasSoup = menu.food.some(f => f.isSoup);
if (!hasSoup) {
warnings.push('Chybí polévka');
}
const missingPrice = menu.food.some(f => !f.isSoup && (!f.price || f.price.trim() === ''));
if (missingPrice) {
warnings.push('U některých jídel chybí cena');
}
return warnings;
}
/**
* Inicializuje výchozí data pro předané datum, nebo dnešek, pokud není datum předáno.
*
* @param date datum
*/
export async function initIfNeeded(date?: Date, slot?: MealSlot) {
const usedDate = getDataKey(date ?? getToday(), slot);
const hasData = await storage.hasData(usedDate);
if (!hasData) {
await storage.setData(usedDate, getEmptyData(date || getToday()));
}
}
/**
* Odstraní kompletně volbu uživatele (včetně případných podřízených jídel).
*
* @param login login uživatele
* @param trusted příznak, zda se jedná o ověřeného uživatele
* @param locationKey vybrané "umístění"
* @param date datum, ke kterému se volba vztahuje
* @returns
*/
export async function removeChoices(login: string, trusted: boolean, locationKey: LunchChoice, date?: Date, slot?: MealSlot) {
const selectedDay = getDataKey(date ?? getToday(), slot);
// Validate trusted flag against current data before atomic update
const snapshot = await getClientData(date, slot);
validateTrusted(snapshot, login, trusted);
return storage.updateData<ClientData>(selectedDay, (current) => {
const data = current ?? getEmptyData(date);
if (locationKey in data.choices && data.choices[locationKey] && login in data.choices[locationKey]) {
delete data.choices[locationKey][login];
if (Object.keys(data.choices[locationKey]).length === 0) delete data.choices[locationKey];
}
return data;
});
}
/**
* Odstraní konkrétní volbu jídla uživatele.
* Neodstraňuje volbu samotnou, k tomu slouží {@link removeChoices}.
*
* @param login login uživatele
* @param trusted příznak, zda se jedná o ověřeného uživatele
* @param locationKey vybrané "umístění"
* @param foodIndex index jídla v jídelním lístku daného umístění, pokud existuje
* @param date datum, ke kterému se volba vztahuje
* @returns
*/
export async function removeChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex: number, date?: Date, slot?: MealSlot) {
const selectedDay = getDataKey(date ?? getToday(), slot);
const snapshot = await getClientData(date, slot);
validateTrusted(snapshot, login, trusted);
return storage.updateData<ClientData>(selectedDay, (current) => {
const data = current ?? getEmptyData(date);
if (locationKey in data.choices && data.choices[locationKey] && login in data.choices[locationKey]) {
const index = data.choices[locationKey][login].selectedFoods?.indexOf(foodIndex);
if (index != null && index > -1) data.choices[locationKey][login].selectedFoods?.splice(index, 1);
}
return data;
});
}
/**
* Odstraní kompletně volbu uživatele, vyjma ignoredLocationKey (pokud byla předána a existuje).
*
* @param login login uživatele
* @param date datum, ke kterému se volby vztahují
* @param ignoredLocationKey volba, která nebude odstraněna, pokud existuje
*/
async function removeChoiceIfPresent(login: string, date?: Date, ignoredLocationKey?: LunchChoice, slot?: MealSlot) {
const usedDate = date ?? getToday();
let data = await getClientData(usedDate, slot);
for (const key of Object.keys(data.choices)) {
const locationKey = key as LunchChoice;
if (ignoredLocationKey != null && ignoredLocationKey == locationKey) {
continue;
}
if (data.choices[locationKey] && login in data.choices[locationKey]) {
delete data.choices[locationKey][login];
if (Object.keys(data.choices[locationKey]).length === 0) {
delete data.choices[locationKey];
}
await storage.setData(getDataKey(usedDate, slot), data);
}
}
return data;
}
/**
* Ověří, zda se neověřený uživatel nepokouší přepsat údaje ověřeného a případně vyhodí chybu.
*
* @param data aktuální klientská data
* @param login přihlašovací jméno uživatele
* @param trusted příznak, zda se jedná o ověřeného uživatele
*/
function validateTrusted(data: ClientData, login: string, trusted: boolean) {
const locations = Object.values(data?.choices);
let found = false;
if (!trusted) {
for (const location of locations) {
if (Object.keys(location).includes(login) && location[login].trusted) {
found = true;
}
}
}
if (!trusted && found) {
throw new InsufficientPermissions("Nelze změnit volbu ověřeného uživatele");
}
}
/**
* Přidá volbu uživatele.
*
* @param login login uživatele
* @param trusted příznak, zda se jedná o ověřeného uživatele
* @param locationKey vybrané "umístění"
* @param foodIndex volitelný index jídla v daném umístění
* @param trusted příznak, zda se jedná o ověřeného uživatele
* @param date datum, ke kterému se volba vztahuje
* @returns aktuální data
*/
export async function addChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex?: number, date?: Date, slot?: MealSlot) {
const usedDate = date ?? getToday();
await initIfNeeded(usedDate, slot);
let data = await getClientData(usedDate, slot);
validateTrusted(data, login, trusted);
await validateFoodIndex(locationKey, foodIndex, date);
if (!slot || slot === MealSlot.OBED) {
// Pokud uživatel měl vybranou PIZZA a mění na něco jiného
const hadPizzaChoice = data.choices.PIZZA && login in data.choices.PIZZA;
if (hadPizzaChoice && locationKey !== LunchChoice.PIZZA && foodIndex == null) {
// Kontrola, zda existuje Pizza day a uživatel je jeho zakladatel
if (data.pizzaDay && data.pizzaDay.creator === login) {
// Pokud Pizza day není ve stavu CREATED, nelze změnit volbu
if (data.pizzaDay.state !== PizzaDayState.CREATED) {
throw new PizzaDayConflictError(
`Nelze změnit volbu. Pizza day je ve stavu "${data.pizzaDay.state}" a musí být nejprve dokončen nebo smazán.`
);
}
// Pizza day je ve stavu CREATED - bude smazán frontendem po potvrzení uživatelem
// (frontend volá nejprve deletePizzaDay, pak teprve addChoice)
}
// Smažeme pizzy uživatele (pokud Pizza day nebyl založen tímto uživatelem,
// nebo byl již smazán frontendem)
await removeAllUserPizzas(login, usedDate);
// Znovu načteme data, protože removeAllUserPizzas je upravila
data = await getClientData(usedDate, slot);
}
}
// Pokud měníme pouze lokaci, mažeme případné předchozí
if (foodIndex == null) {
data = await removeChoiceIfPresent(login, usedDate, undefined, slot);
} else {
// Mažeme případné ostatní volby (měla by být maximálně jedna)
data = await removeChoiceIfPresent(login, usedDate, locationKey, slot);
}
// TODO vytáhnout inicializaci "prázdné struktury" do vlastní funkce
data.choices[locationKey] ??= {};
if (!(login in data.choices[locationKey])) {
if (!data.choices[locationKey]) {
data.choices[locationKey] = {}
}
data.choices[locationKey][login] = {
trusted,
selectedFoods: []
};
}
if (foodIndex != null && !data.choices[locationKey][login].selectedFoods?.includes(foodIndex)) {
data.choices[locationKey][login].selectedFoods?.push(foodIndex);
}
await storage.setData(getDataKey(usedDate, slot), data);
return data;
}
/**
* Zvaliduje platnost indexu jídla pro vybranou lokalitu a datum.
*
* @param locationKey vybraná lokalita
* @param foodIndex index jídla pro danou lokalitu
* @param date datum, pro které je validace prováděna
*/
async function validateFoodIndex(locationKey: LunchChoice, foodIndex?: number, date?: Date) {
if (foodIndex != null) {
if (typeof foodIndex !== 'number') {
throw new Error(`Neplatný index ${foodIndex} typu ${typeof foodIndex}`);
}
if (foodIndex < 0) {
throw new Error(`Neplatný index ${foodIndex}`);
}
if (!Object.keys(Restaurant).includes(locationKey)) {
throw new Error(`Neplatný index ${foodIndex} pro lokalitu ${locationKey} nepodporující indexy`);
}
const usedDate = date ?? getToday();
const menu = await getRestaurantMenu(locationKey as Restaurant, usedDate);
if (menu.food?.length && foodIndex > (menu.food.length - 1)) {
throw new Error(`Neplatný index ${foodIndex} pro lokalitu ${locationKey}`);
}
}
}
/**
* Aktualizuje poznámku k aktuálně vybrané možnosti.
*
* @param login login uživatele
* @param trusted příznak, zda se jedná o ověřeného uživatele
* @param note poznámka
* @param date datum, ke kterému se volba vztahuje
*/
export async function updateNote(login: string, trusted: boolean, note?: string, date?: Date, slot?: MealSlot) {
const usedDate = date ?? getToday();
await initIfNeeded(usedDate, slot);
const snapshot = await getClientData(usedDate, slot);
validateTrusted(snapshot, login, trusted);
return storage.updateData<ClientData>(getDataKey(usedDate, slot), (current) => {
const data = current ?? getEmptyData(date);
const userEntry = data.choices != null && Object.entries(data.choices).find(entry => entry[1][login] != null);
if (userEntry) {
if (!note?.length) delete userEntry[1][login].note;
else userEntry[1][login].note = note;
}
return data;
});
}
/**
* Aktualizuje preferovaný čas odchodu strávníka.
*
* @param login login uživatele
* @param time preferovaný čas odchodu
* @param date datum, ke kterému se čas vztahuje
*/
export async function updateDepartureTime(login: string, time?: string, date?: Date) {
const usedDate = date ?? getToday();
if (time?.length && !Object.values<string>(DepartureTime).includes(time)) {
throw Error(`Neplatný čas odchodu ${time}`);
}
return storage.updateData<ClientData>(getDataKey(usedDate), (current) => {
const data = current ?? getEmptyData(date);
const found = Object.values(data.choices).find(location => login in location);
if (found) {
if (!time?.length) delete found[login].departureTime;
else found[login].departureTime = time;
}
return data;
});
}
/**
* Nastaví/odnastaví uživatele jako objednatele pro dnešní den.
* Objednatelů může být více.
*
* @param login přihlašovací jméno uživatele
*/
export async function updateBuyer(login: string, slot?: MealSlot) {
const usedDate = getToday();
return storage.updateData<ClientData>(getDataKey(usedDate, slot), (current) => {
const data = current ?? getEmptyData();
const userEntry = data.choices?.['OBJEDNAVAM']?.[login];
if (!userEntry) throw new Error("Nelze nastavit objednatele pro uživatele s jinou volbou než \"Budu objednávat\"");
userEntry.isBuyer = !(userEntry.isBuyer || false);
return data;
});
}
/**
* Vrátí data pro klienta pro předaný nebo aktuální den.
*
* @param date datum pro který vrátit data, pokud není vyplněno, je použit dnešní den
* @returns data pro klienta
*/
export async function getClientData(date?: Date, slot?: MealSlot): Promise<ClientData> {
const targetDate = date ?? getToday();
const dateString = getDataKey(targetDate, slot);
const clientData = await storage.getData<ClientData>(dateString) || getEmptyData(date);
return {
...clientData,
todayDayIndex: getDayOfWeekIndex(getToday()),
isoDate: formatDate(targetDate),
...(slot === MealSlot.EXTRA ? { slot: MealSlot.EXTRA } : {}),
}
}