1 Commits

Author SHA1 Message Date
batmanisko 774be3df6d feat: večeře (extra meal slot)
CI / Generate TypeScript types (pull_request) Successful in 11s
CI / Generate TypeScript types (push) Successful in 36s
CI / Server unit tests (pull_request) Successful in 25s
CI / Build client (pull_request) Successful in 37s
CI / Server unit tests (push) Successful in 22s
CI / Build server (push) Successful in 1m0s
CI / Build client (push) Successful in 37s
CI / Build server (pull_request) Successful in 3m14s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Build and push Docker image (push) Has been skipped
CI / Notify (push) Successful in 2s
CI / Playwright E2E tests (pull_request) Successful in 10m34s
CI / Build and push Docker image (pull_request) Has been skipped
CI / Notify (pull_request) Has been skipped
- Nová stránka /vecere pro evidenci extra jídla (večeře/pozdní oběd)
- MealSlot enum (obed/extra), oddělený storage namespace YYYY-MM-DD_extra
- slot parametr na všech food endpointech a GET /api/data
- Push reminder: přechod na 60min cooldown, login v payloadu místo endpointu
- server: slot?: string → slot?: MealSlot, enum konstanty místo literálů
- Jest testy izolace extra/obed storage namespace
- Aktualizace changelogů (saláty, SINGLE_PAYMENT, večeře)
2026-05-06 21:06:25 +02:00
22 changed files with 441 additions and 107 deletions
+6 -7
View File
@@ -7,6 +7,7 @@ self.addEventListener('push', (event) => {
body: data.body,
icon: '/favicon.ico',
tag: 'lunch-reminder',
data: { login: data.login },
actions: [
{ action: 'neobedvam', title: 'Mám vlastní/neobědvám' },
],
@@ -18,28 +19,26 @@ self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'neobedvam') {
const login = event.notification.data?.login;
if (login) {
event.waitUntil(
self.registration.pushManager.getSubscription().then((subscription) => {
if (!subscription) return;
return fetch('/api/notifications/push/quickChoice', {
fetch('/api/notifications/push/quickChoice', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: subscription.endpoint }),
});
body: JSON.stringify({ login }),
})
);
}
return;
}
event.waitUntil(
self.clients.matchAll({ type: 'window' }).then((clientList) => {
// Pokud je již otevřené okno, zaostříme na něj
for (const client of clientList) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
return client.focus();
}
}
// Jinak otevřeme nové
return self.clients.openWindow('/');
})
);
+2 -1
View File
@@ -19,7 +19,7 @@ import { getHumanDateTime, isInTheFuture, formatDateString } from './Utils';
import NoteModal from './components/modals/NoteModal';
import PayForAllModal from './components/modals/PayForAllModal';
import { useEasterEgg } from './context/eggs';
import { ClientData, Food, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LocationLunchChoicesMap, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, dismissQr, generateQr } from '../../types';
import { ClientData, Food, MealSlot, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LocationLunchChoicesMap, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, dismissQr, generateQr } from '../../types';
import { getLunchChoiceName } from './enums';
// import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
// import './FallingLeaves.scss';
@@ -126,6 +126,7 @@ function App() {
});
socket.on(EVENT_MESSAGE, (newData: ClientData) => {
// console.log("Přijata nová data ze socketu", newData);
if (newData.slot === MealSlot.EXTRA) return;
// Aktualizujeme pouze, pokud jsme dostali data pro den, který máme aktuálně zobrazený
if (dayIndexRef.current == null || newData.dayIndex === dayIndexRef.current) {
setData(newData);
+10
View File
@@ -5,14 +5,24 @@ import { SnowOverlay } from 'react-snow-overlay';
import { ToastContainer } from "react-toastify";
import { SocketContext, socket } from "./context/socket";
import StatsPage from "./pages/StatsPage";
import ExtraPage from "./pages/ExtraPage";
import App from "./App";
export const STATS_URL = '/stats';
export const VECERE_URL = '/vecere';
export default function AppRoutes() {
return (
<Routes>
<Route path={STATS_URL} element={<StatsPage />} />
<Route path={VECERE_URL} element={
<ProvideSettings>
<SocketContext.Provider value={socket}>
<ExtraPage />
<ToastContainer />
</SocketContext.Provider>
</ProvideSettings>
} />
<Route path="/" element={
<ProvideSettings>
<SocketContext.Provider value={socket}>
+2 -1
View File
@@ -10,7 +10,7 @@ import GenerateQrModal from "./modals/GenerateQrModal";
import GenerateMockDataModal from "./modals/GenerateMockDataModal";
import ClearMockDataModal from "./modals/ClearMockDataModal";
import { useNavigate } from "react-router";
import { STATS_URL } from "../AppRoutes";
import { STATS_URL, VECERE_URL } from "../AppRoutes";
import { FeatureRequest, getVotes, updateVote, LunchChoices, getChangelogs } from "../../../types";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
@@ -207,6 +207,7 @@ export default function Header({ choices, dayIndex }: Props) {
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
<NavDropdown.Item onClick={handleQrMenuClick}>Generování QR kódů</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(VECERE_URL)}>Večeře</NavDropdown.Item>
<NavDropdown.Item onClick={() => {
getChangelogs().then(response => {
const entries = response.data ?? {};
+209
View File
@@ -0,0 +1,209 @@
import { useContext, useEffect, useRef, useState } from 'react';
import { Button, Table } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCircleCheck, faNoteSticky, faTrashCan } from '@fortawesome/free-regular-svg-icons';
import { faBasketShopping, faSearch } from '@fortawesome/free-solid-svg-icons';
import {
ClientData, LunchChoice, MealSlot, UserLunchChoice,
addChoice, removeChoices, updateNote, setBuyer, getData,
} from '../../../types';
import { EVENT_MESSAGE, SocketContext } from '../context/socket';
import { useAuth } from '../context/auth';
import Login from '../Login';
import Header from '../components/Header';
import Footer from '../components/Footer';
import Loader from '../components/Loader';
import NoteModal from '../components/modals/NoteModal';
const SLOT = MealSlot.EXTRA;
export default function ExtraPage() {
const auth = useAuth();
const socket = useContext(SocketContext);
const [data, setData] = useState<ClientData | undefined>();
const [failure, setFailure] = useState(false);
const [noteModalOpen, setNoteModalOpen] = useState(false);
const fetchData = async () => {
try {
const r = await getData({ query: { slot: SLOT } });
if (r.data) setData(r.data);
} catch {
setFailure(true);
}
};
useEffect(() => {
if (!auth?.login) return;
fetchData();
}, [auth?.login]);
useEffect(() => {
socket.on(EVENT_MESSAGE, (newData: ClientData) => {
if (newData.slot === SLOT) setData(newData);
});
return () => { socket.off(EVENT_MESSAGE); };
}, [socket]);
const myChoice = data?.choices?.OBJEDNAVAM?.[auth?.login ?? ''];
const isIn = !!myChoice;
const isBuyer = myChoice?.isBuyer ?? false;
const joinOrder = async () => {
if (!auth?.login) return;
await addChoice({ body: { locationKey: LunchChoice.OBJEDNAVAM, slot: SLOT } });
await fetchData();
};
const joinAndBuy = async () => {
if (!auth?.login) return;
await addChoice({ body: { locationKey: LunchChoice.OBJEDNAVAM, slot: SLOT } });
await setBuyer({ body: { slot: SLOT } });
await fetchData();
};
const leaveOrder = async () => {
if (!auth?.login) return;
await removeChoices({ body: { locationKey: LunchChoice.OBJEDNAVAM, slot: SLOT } });
await fetchData();
};
const toggleBuyer = async () => {
if (!auth?.login) return;
await setBuyer({ body: { slot: SLOT } });
await fetchData();
};
const saveNote = async (note?: string) => {
if (!auth?.login) return;
await updateNote({ body: { note, slot: SLOT } });
setNoteModalOpen(false);
await fetchData();
};
if (!auth?.login) return <Login />;
if (failure) return (
<Loader icon={faSearch} description="Nepodařilo se načíst data" animation="fa-beat" />
);
if (!data) return (
<Loader icon={faSearch} description="Načítám..." animation="fa-bounce" />
);
const orderEntries = Object.entries(data.choices?.OBJEDNAVAM ?? {}) as [string, UserLunchChoice][];
return (
<div className="app-container">
<Header choices={data.choices} />
<div className="wrapper">
<h1 className="title">Večeře</h1>
<p style={{ color: 'var(--luncher-text-muted)' }}>Extra jídlo pro ty, kdo zůstávají déle</p>
<div className="content-wrapper">
<div className="content">
<div className="choice-section fade-in">
{!isIn ? (
<div className="d-flex gap-2 flex-wrap">
<Button variant="primary" onClick={joinOrder}>
Přidám se
</Button>
<Button variant="outline-primary" onClick={joinAndBuy}>
<FontAwesomeIcon icon={faBasketShopping} className="me-2" />
Budu objednávat
</Button>
</div>
) : (
<div className="d-flex gap-2 flex-wrap align-items-center">
<span style={{ color: 'var(--luncher-text-secondary)' }}>
{isBuyer ? 'Objednáváš.' : 'Jsi přidán/a k objednávce.'}
</span>
<Button variant="outline-secondary" size="sm" onClick={toggleBuyer}>
<FontAwesomeIcon icon={faBasketShopping} className="me-1" />
{isBuyer ? 'Odebrat roli objednávajícího' : 'Označit se jako objednávající'}
</Button>
<Button variant="outline-secondary" size="sm" onClick={() => setNoteModalOpen(true)}>
<FontAwesomeIcon icon={faNoteSticky} className="me-1" />
Poznámka
</Button>
<Button variant="outline-danger" size="sm" onClick={leaveOrder}>
<FontAwesomeIcon icon={faTrashCan} className="me-1" />
Odhlásit se
</Button>
</div>
)}
</div>
{orderEntries.length > 0 && (
<Table className="choices-table mt-4 fade-in">
<tbody>
<tr>
<td>Budu objednávat / Přidám se</td>
<td className="p-0">
<Table className="nested-table">
<tbody>
{orderEntries.map(([login, payload]) => (
<tr key={login}>
<td>
<div className="user-row">
<div className="user-info">
{payload.trusted && (
<span className="trusted-icon" title="Ověřený uživatel">
<FontAwesomeIcon icon={faCircleCheck} style={{ cursor: 'help' }} />
</span>
)}
<strong>{login}</strong>
{payload.note && (
<span className="ms-2" style={{ fontSize: 'small', color: 'var(--luncher-text-secondary)' }}>
({payload.note})
</span>
)}
</div>
<div className="user-actions">
{payload.isBuyer && (
<span title="Objednávající">
<FontAwesomeIcon icon={faBasketShopping} className="buyer-icon" />
</span>
)}
{login === auth.login && (
<>
<span title="Upravit poznámku">
<FontAwesomeIcon
onClick={() => setNoteModalOpen(true)}
className="action-icon"
icon={faNoteSticky}
/>
</span>
<span title="Odhlásit se z objednávky">
<FontAwesomeIcon
onClick={leaveOrder}
className="action-icon"
icon={faTrashCan}
/>
</span>
</>
)}
</div>
</div>
</td>
</tr>
))}
</tbody>
</Table>
</td>
</tr>
</tbody>
</Table>
)}
</div>
</div>
</div>
<Footer />
<NoteModal
isOpen={noteModalOpen}
onClose={() => setNoteModalOpen(false)}
onSave={saveNote}
/>
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
[
"Zobrazení nabídky salátů z Pizza Chefie"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Možnost úhrady celého účtu jednou osobou s rozesláním QR kódů ostatním (na Pizza day)"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Evidence večeří a pozdních obědů na samostatné stránce (/vecere)"
]
+6 -1
View File
@@ -2,6 +2,7 @@ import express from "express";
import bodyParser from "body-parser";
import cors from 'cors';
import { getData, getDateForWeekIndex, getToday } from "./service";
import { MealSlot } from "../../types/gen/types.gen";
import dotenv from 'dotenv';
import path from 'path';
import { getQr } from "./qr";
@@ -151,7 +152,11 @@ app.get("/api/data", async (req, res) => {
// Na víkendu zobrazíme pátek místo hlášky "Užívejte víkend"
date = getDateForWeekIndex(4);
}
const data = await getData(date);
const slotParam = typeof req.query.slot === 'string' ? req.query.slot as MealSlot : undefined;
if (slotParam && slotParam !== MealSlot.OBED && slotParam !== MealSlot.EXTRA) {
return res.status(400).json({ error: 'Neplatný slot' });
}
const data = await getData(date, slotParam);
// Připojíme nevyřízené QR kódy pro přihlášeného uživatele
try {
const login = getLogin(parseToken(req));
+9 -21
View File
@@ -14,13 +14,10 @@ interface RegistryEntry {
type Registry = Record<string, RegistryEntry>;
/** Mapa login → datum (YYYY-MM-DD), kdy byl uživatel naposledy upozorněn. */
const remindedToday = new Map<string, string>();
/** Mapa login → timestamp (ms) posledního odeslání připomínky. */
const lastReminded = new Map<string, number>();
function getTodayDateString(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
const REMINDER_COOLDOWN_MS = 60 * 60 * 1000; // 60 minut mezi připomínkami
function getCurrentTimeHHMM(): string {
const now = new Date();
@@ -59,7 +56,7 @@ export async function unsubscribePush(login: string): Promise<void> {
const registry = await getRegistry();
delete registry[login];
await saveRegistry(registry);
remindedToday.delete(login);
lastReminded.delete(login);
console.log(`Push reminder: uživatel ${login} odhlášen z připomínek`);
}
@@ -68,16 +65,6 @@ export function getVapidPublicKey(): string | undefined {
return process.env.VAPID_PUBLIC_KEY;
}
/** Najde login uživatele podle push subscription endpointu. */
export async function findLoginByEndpoint(endpoint: string): Promise<string | undefined> {
const registry = await getRegistry();
for (const [login, entry] of Object.entries(registry)) {
if (entry.subscription.endpoint === endpoint) {
return login;
}
}
return undefined;
}
/** Zkontroluje a odešle připomínky uživatelům, kteří si nezvolili oběd. */
async function checkAndSendReminders(): Promise<void> {
@@ -93,7 +80,6 @@ async function checkAndSendReminders(): Promise<void> {
}
const currentTime = getCurrentTimeHHMM();
const todayStr = getTodayDateString();
// Získáme data pro dnešek jednou pro všechny uživatele
let clientData;
@@ -110,8 +96,9 @@ async function checkAndSendReminders(): Promise<void> {
continue;
}
// Už jsme dnes připomenuli
if (remindedToday.get(login) === todayStr) {
// Cooldown — nepřipomínat častěji než jednou za hodinu
const last = lastReminded.get(login) ?? 0;
if (Date.now() - last < REMINDER_COOLDOWN_MS) {
continue;
}
@@ -127,9 +114,10 @@ async function checkAndSendReminders(): Promise<void> {
JSON.stringify({
title: 'Luncher',
body: 'Ještě nemáte zvolený oběd!',
login,
})
);
remindedToday.set(login, todayStr);
lastReminded.set(login, Date.now());
console.log(`Push reminder: odeslána připomínka uživateli ${login}`);
} catch (error: any) {
if (error.statusCode === 410 || error.statusCode === 404) {
+1 -1
View File
@@ -189,7 +189,7 @@ router.post("/testPush", async (req, res, next) => {
webpush.setVapidDetails(subject, publicKey, privateKey);
await webpush.sendNotification(
entry.subscription,
JSON.stringify({ title: 'Luncher test', body: 'Push notifikace fungují!' })
JSON.stringify({ title: 'Luncher test', body: 'Push notifikace fungují!', login })
);
res.status(200).json({ ok: true });
} catch (e: any) { next(e) }
+24 -6
View File
@@ -4,7 +4,7 @@ import { addChoice, getDateForWeekIndex, getToday, removeChoice, removeChoices,
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";
import { AddChoiceData, ChangeDepartureTimeData, MealSlot, RemoveChoiceData, RemoveChoicesData, UdalostEnum, UpdateNoteData } from "../../../types/gen/types.gen";
// RateLimit na refresh endpoint
@@ -69,11 +69,21 @@ const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"]
return dayIndex;
}
const parseSlot = (body: Record<string, any>): MealSlot | undefined => {
const slot = body?.slot;
if (slot != null && slot !== MealSlot.OBED && slot !== MealSlot.EXTRA) {
throw Error(`Neplatný slot: ${slot}`);
}
return slot ?? undefined;
};
const router = express.Router();
router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
let slot: MealSlot | undefined;
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
let date = undefined;
if (req.body.dayIndex != null) {
let dayIndex;
@@ -85,7 +95,7 @@ router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, r
date = getDateForWeekIndex(dayIndex);
}
try {
const data = await addChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date);
const data = await addChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date, slot);
getWebsocket().emit("message", data);
return res.status(200).json(data);
} catch (e: any) { next(e) }
@@ -94,6 +104,8 @@ router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, r
router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["body"]>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
let slot: MealSlot | undefined;
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
let date = undefined;
if (req.body.dayIndex != null) {
let dayIndex;
@@ -105,7 +117,7 @@ router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["bo
date = getDateForWeekIndex(dayIndex);
}
try {
const data = await removeChoices(login, trusted, req.body.locationKey, date);
const data = await removeChoices(login, trusted, req.body.locationKey, date, slot);
getWebsocket().emit("message", data);
res.status(200).json(data);
} catch (e: any) { next(e) }
@@ -114,6 +126,8 @@ router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["bo
router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceData["body"]>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
let slot: MealSlot | undefined;
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
let date = undefined;
if (req.body.dayIndex != null) {
let dayIndex;
@@ -125,7 +139,7 @@ router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceData["body
date = getDateForWeekIndex(dayIndex);
}
try {
const data = await removeChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date);
const data = await removeChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date, slot);
getWebsocket().emit("message", data);
res.status(200).json(data);
} catch (e: any) { next(e) }
@@ -135,6 +149,8 @@ router.post("/updateNote", async (req: Request<{}, any, UpdateNoteData["body"]>,
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
const note = req.body.note;
let slot: MealSlot | undefined;
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
try {
if (note && note.length > 70) {
throw Error("Poznámka může mít maximálně 70 znaků");
@@ -149,7 +165,7 @@ router.post("/updateNote", async (req: Request<{}, any, UpdateNoteData["body"]>,
}
date = getDateForWeekIndex(dayIndex);
}
const data = await updateNote(login, trusted, note, date);
const data = await updateNote(login, trusted, note, date, slot);
getWebsocket().emit("message", data);
res.status(200).json(data);
} catch (e: any) { next(e) }
@@ -184,8 +200,10 @@ router.post("/jdemeObed", async (req, res, next) => {
router.post("/updateBuyer", async (req, res, next) => {
const login = getLogin(parseToken(req));
let slot: MealSlot | undefined;
try { slot = parseSlot(req.body ?? {}); } catch (e: any) { return res.status(400).json({ error: e.message }); }
try {
const data = await updateBuyer(login);
const data = await updateBuyer(login, slot);
getWebsocket().emit("message", data);
res.status(200).json({});
} catch (e: any) { next(e) }
+3 -10
View File
@@ -2,7 +2,7 @@ import express, { Request } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { getNotificationSettings, saveNotificationSettings } from "../notifikace";
import { subscribePush, unsubscribePush, getVapidPublicKey, findLoginByEndpoint } from "../pushReminder";
import { subscribePush, unsubscribePush, getVapidPublicKey } from "../pushReminder";
import { addChoice } from "../service";
import { getWebsocket } from "../websocket";
import { UpdateNotificationSettingsData } from "../../../types";
@@ -66,17 +66,10 @@ router.post("/push/unsubscribe", async (req, res, next) => {
} catch (e: any) { next(e) }
});
/** Rychlá akce z push notifikace — nastaví volbu bez JWT (identita přes subscription endpoint). */
/** Rychlá akce z push notifikace — nastaví volbu NEOBEDVAM pro přihlášeného uživatele. */
router.post("/push/quickChoice", async (req, res, next) => {
try {
const { endpoint } = req.body;
if (!endpoint) {
return res.status(400).json({ error: "Nebyl předán endpoint" });
}
const login = await findLoginByEndpoint(endpoint);
if (!login) {
return res.status(404).json({ error: "Subscription nenalezena" });
}
const login = getLogin(parseToken(req));
const data = await addChoice(login, false, 'NEOBEDVAM', undefined, undefined);
getWebsocket().emit("message", data);
res.status(200).json({});
+41 -33
View File
@@ -3,11 +3,16 @@ import getStorage from "./storage";
import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova, StaleWeekError } from "./restaurants";
import { getTodayMock } from "./mock";
import { removeAllUserPizzas } from "./pizza";
import { ClientData, DepartureTime, LunchChoice, PizzaDayState, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types/gen/types.gen";
import { ClientData, DepartureTime, LunchChoice, MealSlot, PizzaDayState, Restaurant, RestaurantDayMenu, 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') {
@@ -43,8 +48,9 @@ export function getEmptyData(date?: Date): ClientData {
/**
* 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): Promise<ClientData> {
const clientData = await getClientData(date);
export async function getData(date?: Date, slot?: MealSlot): Promise<ClientData> {
const clientData = await getClientData(date, slot);
if (slot !== MealSlot.EXTRA) {
clientData.menus = {
SLADOVNICKA: await getRestaurantMenu('SLADOVNICKA', date),
// UMOTLIKU: await getRestaurantMenu('UMOTLIKU', date),
@@ -52,6 +58,7 @@ export async function getData(date?: Date): Promise<ClientData> {
ZASTAVKAUMICHALA: await getRestaurantMenu('ZASTAVKAUMICHALA', date),
SENKSERIKOVA: await getRestaurantMenu('SENKSERIKOVA', date),
}
}
return clientData;
}
@@ -290,8 +297,8 @@ function generateMenuWarnings(menu: RestaurantDayMenu): string[] {
*
* @param date datum
*/
export async function initIfNeeded(date?: Date) {
const usedDate = formatDate(date ?? getToday());
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()));
@@ -307,9 +314,9 @@ export async function initIfNeeded(date?: Date) {
* @param date datum, ke kterému se volba vztahuje
* @returns
*/
export async function removeChoices(login: string, trusted: boolean, locationKey: LunchChoice, date?: Date) {
const selectedDay = formatDate(date ?? getToday());
let data = await getClientData(date);
export async function removeChoices(login: string, trusted: boolean, locationKey: LunchChoice, date?: Date, slot?: MealSlot) {
const selectedDay = getDataKey(date ?? getToday(), slot);
let data = await getClientData(date, slot);
validateTrusted(data, login, trusted);
if (locationKey in data.choices) {
if (data.choices[locationKey] && login in data.choices[locationKey]) {
@@ -334,9 +341,9 @@ export async function removeChoices(login: string, trusted: boolean, locationKey
* @param date datum, ke kterému se volba vztahuje
* @returns
*/
export async function removeChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex: number, date?: Date) {
const selectedDay = formatDate(date ?? getToday());
let data = await getClientData(date);
export async function removeChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex: number, date?: Date, slot?: MealSlot) {
const selectedDay = getDataKey(date ?? getToday(), slot);
let data = await getClientData(date, slot);
validateTrusted(data, login, trusted);
if (locationKey in data.choices) {
if (data.choices[locationKey] && login in data.choices[locationKey]) {
@@ -357,9 +364,9 @@ export async function removeChoice(login: string, trusted: boolean, locationKey:
* @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) {
async function removeChoiceIfPresent(login: string, date?: Date, ignoredLocationKey?: LunchChoice, slot?: MealSlot) {
const usedDate = date ?? getToday();
let data = await getClientData(usedDate);
let data = await getClientData(usedDate, slot);
for (const key of Object.keys(data.choices)) {
const locationKey = key as LunchChoice;
if (ignoredLocationKey != null && ignoredLocationKey == locationKey) {
@@ -370,7 +377,7 @@ async function removeChoiceIfPresent(login: string, date?: Date, ignoredLocation
if (Object.keys(data.choices[locationKey]).length === 0) {
delete data.choices[locationKey];
}
await storage.setData(formatDate(usedDate), data);
await storage.setData(getDataKey(usedDate, slot), data);
}
}
return data;
@@ -409,13 +416,14 @@ function validateTrusted(data: ClientData, login: string, trusted: boolean) {
* @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) {
export async function addChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex?: number, date?: Date, slot?: MealSlot) {
const usedDate = date ?? getToday();
await initIfNeeded(usedDate);
let data = await getClientData(usedDate);
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) {
@@ -435,15 +443,16 @@ export async function addChoice(login: string, trusted: boolean, locationKey: Lu
// nebo byl již smazán frontendem)
await removeAllUserPizzas(login, usedDate);
// Znovu načteme data, protože removeAllUserPizzas je upravila
data = await getClientData(usedDate);
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);
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);
data = await removeChoiceIfPresent(login, usedDate, locationKey, slot);
}
// TODO vytáhnout inicializaci "prázdné struktury" do vlastní funkce
data.choices[locationKey] ??= {};
@@ -459,8 +468,7 @@ export async function addChoice(login: string, trusted: boolean, locationKey: Lu
if (foodIndex != null && !data.choices[locationKey][login].selectedFoods?.includes(foodIndex)) {
data.choices[locationKey][login].selectedFoods?.push(foodIndex);
}
const selectedDate = formatDate(usedDate);
await storage.setData(selectedDate, data);
await storage.setData(getDataKey(usedDate, slot), data);
return data;
}
@@ -498,10 +506,10 @@ async function validateFoodIndex(locationKey: LunchChoice, foodIndex?: number, d
* @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) {
export async function updateNote(login: string, trusted: boolean, note?: string, date?: Date, slot?: MealSlot) {
const usedDate = date ?? getToday();
await initIfNeeded(usedDate);
let data = await getClientData(usedDate);
await initIfNeeded(usedDate, slot);
let data = await getClientData(usedDate, slot);
validateTrusted(data, login, trusted);
const userEntry = data.choices != null && Object.entries(data.choices).find(entry => entry[1][login] != null);
if (userEntry) {
@@ -510,8 +518,7 @@ export async function updateNote(login: string, trusted: boolean, note?: string,
} else {
userEntry[1][login].note = note;
}
const selectedDate = formatDate(usedDate);
await storage.setData(selectedDate, data);
await storage.setData(getDataKey(usedDate, slot), data);
}
return data;
}
@@ -537,7 +544,7 @@ export async function updateDepartureTime(login: string, time?: string, date?: D
}
found[login].departureTime = time;
}
await storage.setData(formatDate(usedDate), clientData);
await storage.setData(getDataKey(usedDate), clientData);
}
return clientData;
}
@@ -548,15 +555,15 @@ export async function updateDepartureTime(login: string, time?: string, date?: D
*
* @param login přihlašovací jméno uživatele
*/
export async function updateBuyer(login: string) {
export async function updateBuyer(login: string, slot?: MealSlot) {
const usedDate = getToday();
let clientData = await getClientData(usedDate);
let clientData = await getClientData(usedDate, slot);
const userEntry = clientData.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);
await storage.setData(formatDate(usedDate), clientData);
await storage.setData(getDataKey(usedDate, slot), clientData);
return clientData;
}
@@ -566,12 +573,13 @@ export async function updateBuyer(login: string) {
* @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): Promise<ClientData> {
export async function getClientData(date?: Date, slot?: MealSlot): Promise<ClientData> {
const targetDate = date ?? getToday();
const dateString = formatDate(targetDate);
const dateString = getDataKey(targetDate, slot);
const clientData = await storage.getData<ClientData>(dateString) || getEmptyData(date);
return {
...clientData,
todayDayIndex: getDayOfWeekIndex(getToday()),
...(slot === MealSlot.EXTRA ? { slot: MealSlot.EXTRA } : {}),
}
}
+60
View File
@@ -0,0 +1,60 @@
const mockStorageData = new Map<string, any>();
jest.mock('../storage', () => ({
__esModule: true,
default: () => ({
hasData: async (key: string) => mockStorageData.has(key),
getData: async <T>(key: string) => mockStorageData.get(key) as T,
setData: async <T>(key: string, val: T) => void mockStorageData.set(key, val),
}),
storageReady: Promise.resolve(),
}));
import { addChoice, getData } from '../service';
import { LunchChoice, MealSlot } from '../../../types/gen/types.gen';
const TODAY = new Date('2025-01-10');
const TODAY_STR = '2025-01-10';
const TODAY_EXTRA_STR = '2025-01-10_extra';
describe('MealSlot storage isolation', () => {
beforeEach(() => {
mockStorageData.clear();
jest.useFakeTimers();
jest.setSystemTime(TODAY);
});
afterEach(() => {
jest.useRealTimers();
});
test('addChoice slot=extra writes only to _extra key, not to obed key, and returns slot=EXTRA', async () => {
const result = await addChoice('user1', false, LunchChoice.OBJEDNAVAM, undefined, TODAY, MealSlot.EXTRA);
expect(result.slot).toBe(MealSlot.EXTRA);
expect(mockStorageData.has(TODAY_EXTRA_STR)).toBe(true);
expect(mockStorageData.has(TODAY_STR)).toBe(false);
const extraData = mockStorageData.get(TODAY_EXTRA_STR);
expect(extraData.choices.OBJEDNAVAM?.['user1']).toBeDefined();
});
test('getData slot=extra returns slot===MealSlot.EXTRA and no menus', async () => {
await addChoice('user1', false, LunchChoice.OBJEDNAVAM, undefined, TODAY, MealSlot.EXTRA);
const result = await getData(TODAY, MealSlot.EXTRA);
expect(result.slot).toBe(MealSlot.EXTRA);
expect(result.menus).toBeUndefined();
});
test('addChoice slot=extra does not modify obed data even when obed has PIZZA choice', async () => {
mockStorageData.set(TODAY_STR, {
choices: { PIZZA: { user1: { selectedFoods: [0], trusted: false } } },
todayDayIndex: 4,
date: '10. 1. 2025',
isWeekend: false,
dayIndex: 4,
});
await addChoice('user1', false, LunchChoice.OBJEDNAVAM, undefined, TODAY, MealSlot.EXTRA);
const obed = mockStorageData.get(TODAY_STR);
expect(obed.choices.PIZZA?.['user1']).toBeDefined();
});
});
+2
View File
@@ -15,6 +15,8 @@ post:
$ref: "../../schemas/_index.yml#/DayIndex"
foodIndex:
$ref: "../../schemas/_index.yml#/FoodIndex"
slot:
$ref: "../../schemas/_index.yml#/MealSlot"
responses:
"200":
$ref: "../../api.yml#/components/responses/ClientDataResponse"
+2
View File
@@ -16,6 +16,8 @@ post:
$ref: "../../schemas/_index.yml#/LunchChoice"
dayIndex:
$ref: "../../schemas/_index.yml#/DayIndex"
slot:
$ref: "../../schemas/_index.yml#/MealSlot"
responses:
"200":
$ref: "../../api.yml#/components/responses/ClientDataResponse"
+2
View File
@@ -13,6 +13,8 @@ post:
$ref: "../../schemas/_index.yml#/LunchChoice"
dayIndex:
$ref: "../../schemas/_index.yml#/DayIndex"
slot:
$ref: "../../schemas/_index.yml#/MealSlot"
responses:
"200":
$ref: "../../api.yml#/components/responses/ClientDataResponse"
+8
View File
@@ -1,6 +1,14 @@
post:
operationId: setBuyer
summary: Nastavení/odnastavení aktuálně přihlášeného uživatele jako objednatele pro stav "Budu objednávat" pro aktuální den.
requestBody:
required: false
content:
application/json:
schema:
properties:
slot:
$ref: "../../schemas/_index.yml#/MealSlot"
responses:
"200":
description: Stav byl úspěšně změněn.
+2
View File
@@ -11,6 +11,8 @@ post:
$ref: "../../schemas/_index.yml#/DayIndex"
note:
type: string
slot:
$ref: "../../schemas/_index.yml#/MealSlot"
responses:
"200":
$ref: "../../api.yml#/components/responses/ClientDataResponse"
+5
View File
@@ -9,6 +9,11 @@ get:
type: integer
minimum: 0
maximum: 4
- in: query
name: slot
description: Slot jídla. Pokud není předán, je použit výchozí slot (oběd).
schema:
$ref: "../schemas/_index.yml#/MealSlot"
responses:
"200":
$ref: "../api.yml#/components/responses/ClientDataResponse"
+12
View File
@@ -63,6 +63,9 @@ ClientData:
type: array
items:
$ref: "#/PendingQr"
slot:
description: Slot jídla, ke kterému se tato data vztahují
$ref: "#/MealSlot"
# --- OBĚDY ---
UserLunchChoice:
@@ -135,6 +138,15 @@ LunchChoice:
- OBJEDNAVAM
- NEOBEDVAM
- ROZHODUJI
MealSlot:
description: Slot jídla - oběd nebo extra jídlo (večeře, pozdní oběd)
type: string
enum:
- obed
- extra
x-enum-varnames:
- OBED
- EXTRA
DayIndex:
description: Index dne v týdnu (0 = pondělí, 4 = pátek)
type: integer