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)
This commit is contained in:
2026-05-06 20:37:39 +02:00
parent 5f903797f1
commit 774be3df6d
22 changed files with 441 additions and 107 deletions
+9 -10
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') {
event.waitUntil(
self.registration.pushManager.getSubscription().then((subscription) => {
if (!subscription) return;
return fetch('/api/notifications/push/quickChoice', {
const login = event.notification.data?.login;
if (login) {
event.waitUntil(
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>
);
}