79 Commits

Author SHA1 Message Date
61f13d2132 Validace TypeScript typů při sestavení klienta
Some checks failed
ci/woodpecker/push/workflow Pipeline failed
2025-03-05 21:05:40 +01:00
d69e09afee Migrace na OpenAPI - TypeScript typy 2025-03-05 21:05:21 +01:00
d144c55bf7 feat: #11 je tohle feat?, pridani poctu lidi k restauraci
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-03-05 18:56:21 +01:00
999a517404 Oprava lokalizace datumu
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-03-03 10:20:41 +01:00
68bafa808c Oprava #8
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-27 21:46:50 +01:00
a34614c8db Oprava #6
Some checks failed
ci/woodpecker/push/workflow Pipeline failed
2025-02-27 21:36:21 +01:00
f4e31cea36 Oprava #4, #5
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-27 21:29:43 +01:00
8dda6b1014 Oprava #7
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-27 21:19:38 +01:00
f9c7d647f7 Migrace Node v18 -> v22
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-27 00:28:14 +01:00
ca400638d1 Přidání základních statistik
Some checks failed
ci/woodpecker/push/workflow Pipeline failed
2025-02-27 00:22:34 +01:00
0af78e72d9 Nastavení časové zóny
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-24 12:06:29 +01:00
Michal Hájek
8137ca6fc0 Teamsová notifikace "Jdeme na oběd"
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-22 20:43:34 +01:00
Michal Hájek
3817126ac0 Výběr restaurace kliknutím na její název
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-19 20:39:27 +01:00
Michal Hájek
c1856b2eee Pokud bylo v osobním nastavení vypnuto zobraování polévky, předával se do funkce doAddClickFoodChoice spatně foodIndex
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-19 20:21:17 +01:00
Michal Hájek
eaf0bc353d Výběr obědu kliknutím
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-18 10:07:35 +01:00
ff650ec3b8 rm db.json
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-17 09:32:23 +01:00
f8aa293413 fix
Some checks are pending
ci/woodpecker/push/workflow Pipeline is running
2025-02-17 09:26:03 +01:00
cafcd0a467 Log username a email pri kazdem dotazu pouze pro neproduction env
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-17 09:19:28 +01:00
9e247eb2a1 Podpora sestavování přes Woodpecker CI
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-09 00:34:59 +01:00
469a6b9031 Oprava .gitignore 2025-02-08 23:28:20 +01:00
Michal Hájek
89dec1c194 Založení složky server/data, pokud neexistuje, do které je vytvořen soubor db.json 2025-02-02 19:46:20 +01:00
Michal Hájek
f3af64923c Přesun json databaze (souboru db.json) do složky data, související úpravy v Dockerfile 2025-02-02 16:09:07 +01:00
Michal Hájek
44b09a9d1a Začištění souborů .gitignore 2025-02-02 16:06:52 +01:00
Michal Hájek
c311cc2fd7 Oprava importů klienta do složky types, aby nebylo potřeba složku kokírovat 2025-02-02 16:01:21 +01:00
Michal Hájek
a9fe369abc Oprava možnosti vybrat V kolik hodin preferuješ odchod pro následující dny 2025-01-29 08:48:43 +01:00
Michal Hájek
ea9fe980f0 U restaurace Pivovarský šenk Šeříková nahrazena mezera mezi cenou a Kč pevnou mezerou, aby nedocházelo k zalomení 2025-01-29 01:29:51 +01:00
Michal Hájek
d367826ce0 Přidání restaurace Pivovarský šenk Šeříková 2025-01-29 01:14:03 +01:00
Michal Hájek
fdf1ae938f Načtení menu celého týdne restaurace Zastávka u Michala 2025-01-28 22:20:44 +01:00
57c22958be Oprava chybné detekce některých jídel TechTower jako polévka 2025-01-20 14:41:18 +01:00
Michal Hájek
fe9cee3a80 Odfiltrovat ze selectboxu pro výběr preferovaného odchodu časy z minulosti 2025-01-15 00:35:17 +01:00
Michal Hájek
1d995faf8e Odfiltrovat ze selectboxu pro výběr preferovaného odchodu časy z minulosti 2025-01-15 00:34:47 +01:00
Michal Hájek
62fff22a12 Přidání restaurace Zastávka u Michala do výběru "Jak to dnes vidíš s obědem" 2025-01-15 00:03:15 +01:00
Michal Hájek
0fd1482810 Přidání restaurace Zastávka u Michala 2025-01-14 23:45:06 +01:00
02de6691a8 Migrace z pořadových indexů na unikátní klíče 2025-01-09 22:05:20 +01:00
774cb4f9d2 Oprava syntaxe - zapomenutá migrace interface 2025-01-09 21:04:12 +01:00
fd9aa547e2 Migrace "interface" na "type" 2025-01-08 20:53:48 +01:00
e611d36995 Otypování requestů na API 2025-01-08 17:58:49 +01:00
414664b2d7 Úprava API pro podporu TypeScript 2025-01-08 17:43:47 +01:00
a2167038da Redukce velikosti obrázku 2025-01-07 15:52:10 +01:00
219f7ffbc8 Zimní atmosféra 2025-01-07 15:49:22 +01:00
4d2ec529bb Skrytí podniku U Motlíků 2025-01-07 15:44:39 +01:00
86af490e94 Oprava parsování TechTower 2025-01-07 15:21:10 +01:00
e21da059c6 Aktualizace posledních změn 2024-12-11 23:31:55 +01:00
e990108140 Migrace na React 19 2024-12-11 23:04:03 +01:00
18f2b72133 Migrace sestavování klienta na Vite 2024-12-11 22:54:57 +01:00
b0d8a1a830 Povýšení závislostí 2024-12-11 20:24:06 +01:00
7e4fa236b1 Podpora easter eggů 2024-12-11 20:09:45 +01:00
98f2b2a1e0 Přidání vánočních prvků 2024-12-06 16:53:24 +01:00
9b7abb0703 throw error 2024-11-19 12:10:02 +01:00
5678e4a606 Snad fix timeout 2024-11-19 12:01:59 +01:00
582216015c Přidání easter-eggs.json do .gitignore 2024-11-13 23:30:54 +01:00
31daf4fb36 Začištění Dockerfile a compose.yml 2024-10-30 13:05:05 +01:00
4f858a19d8 Oprava parsování TechTower 2024-10-30 13:01:32 +01:00
91ea07a539 Oprava parsování TechTower 2024-07-08 20:45:09 +02:00
101bd60ddb Oprava case-sensitive parsování TechTower 2024-06-10 12:56:20 +02:00
7e061aa890 Nové možnosti hlasování 2024-04-11 22:00:34 +02:00
ff2d9e4fdb Vylepšení mobilního zobrazení 2024-04-09 17:40:13 +02:00
e261d32170 Úprava zápatí 2024-03-24 18:53:51 +01:00
731fd2eeb9 Oprava validace délky poznámky 2024-03-24 18:51:38 +01:00
93ba8def03 Oprava posunů mezi dny v inputech 2024-03-05 23:10:38 +01:00
1e280e9d05 Možnost zadání obecné poznámky k volbě 2024-03-04 23:35:58 +01:00
44187bc316 Oprava vyhodnocení nastavení trusted headers 2024-03-04 23:33:22 +01:00
4bd825fbcf Aktualizace TODO 2024-02-26 20:34:14 +01:00
b087c790ad Povýšení závislostí 2024-02-26 20:23:14 +01:00
e4a146995f Oprava nodemon hotreload 2024-02-26 20:16:11 +01:00
2883e80658 urcite neco rozbije a pavel to najde jako prvni 2024-02-05 20:02:21 +01:00
5830cde9ac Aktualizace frází pro detekci polévek v TechTower 2024-02-02 22:05:54 +01:00
52c4a53b9e Aktualizace changelogu 2024-01-28 21:15:38 +01:00
e9ea42c636 Oprava varování linteru 2024-01-28 20:46:38 +01:00
e735af4fc1 Neuskakování šipek 2024-01-25 19:18:05 +01:00
56125eea2e Přidání možnosti "Rozhoduji se" 2024-01-24 19:29:09 +01:00
61b6ec04f4 Rozšíření výčtu polévek pro TechTower 2024-01-24 19:11:21 +01:00
b954374425 Aktualizace mock dat 2024-01-24 19:07:49 +01:00
72c7bfe80c Možnost skrytí polévek 2024-01-24 18:54:07 +01:00
2633d445cc Doplnění chybějící nedělitelné mezery 2024-01-23 08:00:21 +01:00
3fd6b7dfcb Text "na váhu" v případě neznámé ceny u TechTower 2024-01-22 21:32:04 +01:00
8e075dd904 Základní Pizza kalkulačka 2024-01-08 23:39:12 +01:00
74f6e1ab69 Oprava parsování Sladovnická
Opravena chyba, kdy docházelo k posunu nabídky o den, pokud nabídka nezačínala pondělím.
2024-01-03 14:03:38 +01:00
fcad338921 Oprava parsování U Motlíků
Nyní je počítáno s případy, kdy neexistuje nabídka pro první den v daném týdnu.
2024-01-03 14:01:45 +01:00
74 changed files with 9196 additions and 12032 deletions

23
.gitignore vendored
View File

@@ -1,23 +1,2 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
types/gen

56
.woodpecker/workflow.yaml Normal file
View File

@@ -0,0 +1,56 @@
variables:
- &node_image 'node:22-alpine'
- &branch 'master'
when:
- event: push
branch: *branch
steps:
- name: Install server dependencies
image: *node_image
commands:
- cd server
- yarn install --frozen-lockfile
- name: Install client dependencies
image: *node_image
commands:
- cd client
- yarn install --frozen-lockfile
- name: Build server
depends_on: [Install server dependencies]
image: *node_image
commands:
- cd server
- yarn build
- name: Build client
depends_on: [Install client dependencies]
image: *node_image
commands:
- cd client
- yarn build
- name: Build Docker image
depends_on: [Build server, Build client]
image: woodpeckerci/plugin-docker-buildx
settings:
dockerfile: Dockerfile-Woodpecker
platforms: linux/amd64
registry:
from_secret: REPO_URL
username:
from_secret: REPO_USERNAME
password:
from_secret: REPO_PASSWORD
repo:
from_secret: REPO_NAME
- name: Discord notification - build
image: appleboy/drone-discord
depends_on: [Build Docker image]
when:
- status: [success, failure]
settings:
webhook_id:
from_secret: DISCORD_WEBHOOK_ID
webhook_token:
from_secret: DISCORD_WEBHOOK_TOKEN
message: "{{#success build.status}}✅ Sestavení {{build.number}} proběhlo úspěšně.{{else}}❌ Sestavení {{build.number}} selhalo.{{/success}}\n\nPipeline: {{build.link}}\nPoslední commit: {{commit.message}}Autor: {{commit.author}}"

View File

@@ -1,43 +1,92 @@
ARG NODE_VERSION="node:22-alpine"
# Builder
FROM node:18-alpine3.18 as builder
FROM ${NODE_VERSION} AS builder
WORKDIR /build
COPY package.json .
COPY yarn.lock .
# Zkopírování závislostí - OpenAPI generátor
COPY types/package.json ./types/
COPY types/yarn.lock ./types/
COPY types/api.yml ./types/
COPY types/openapi-ts.config.ts ./types/
# Zkopírování závislostí - server
COPY server/package.json ./server/
COPY client/package.json ./client/
COPY server/yarn.lock ./server/
# Zkopírování závislostí - klient
COPY client/package.json ./client/
COPY client/yarn.lock ./client/
# Instalace závislostí - OpenAPI generátor
WORKDIR /build/types
RUN yarn install --frozen-lockfile
# Instalace závislostí - server
WORKDIR /build/server
RUN yarn install --frozen-lockfile
# Instalace závislostí - klient
WORKDIR /build/client
RUN yarn install --frozen-lockfile
WORKDIR /build
# Zkopírování build závislostí - server
COPY server/tsconfig.json ./server/
COPY server/src ./server/src/
# Zkopírování build závislostí - klient
COPY client/tsconfig.json ./client/
COPY client/vite.config.ts ./client/
COPY client/vite-env.d.ts ./client/
COPY client/index.html ./client/
COPY client/src ./client/src
COPY client/public ./client/public
COPY types ./types/
# Zkopírování společných typů
COPY types/RequestTypes.ts ./types/
COPY types/index.ts ./types/
# Vygenerování společných typů z OpenAPI
WORKDIR /build/types
RUN yarn openapi-ts
# Sestavení serveru
WORKDIR /build/server
RUN yarn build
# Sestavení klienta
WORKDIR /build/client
RUN yarn build
# Runner
FROM node:18-alpine3.18
ENV LANG cs_CZ.UTF-8
ENV NODE_ENV production
FROM ${NODE_VERSION}
RUN apk add --no-cache tzdata
ENV TZ=Europe/Prague \
LC_ALL=cs_CZ.UTF-8 \
NODE_ENV=production
WORKDIR /app
COPY --from=builder /build/node_modules ./node_modules
# Vykopírování sestaveného serveru
COPY --from=builder /build/server/node_modules ./server/node_modules
COPY --from=builder /build/server/dist ./
COPY --from=builder /build/client/build ./public
# Vykopírování sestaveného klienta
COPY --from=builder /build/client/dist ./public
# Zkopírování produkčních .env serveru
COPY /server/.env.production ./server/src
# Zkopírování konfigurace easter eggů
RUN if [ -f /server/.easter-eggs.json ]; then cp /server/.easter-eggs.json ./server/; fi
# Export /data/db.json do složky /data
VOLUME ["/data"]
EXPOSE 3000
CMD [ "node", "./server/src/index.js" ]

26
Dockerfile-Woodpecker Normal file
View File

@@ -0,0 +1,26 @@
ARG NODE_VERSION="node:22-alpine"
FROM ${NODE_VERSION}
RUN apk add --no-cache tzdata
ENV TZ=Europe/Prague \
LC_ALL=cs_CZ.UTF-8 \
NODE_ENV=production
WORKDIR /app
# Vykopírování sestaveného serveru
COPY ./server/node_modules ./server/node_modules
COPY ./server/dist ./
# TODO tohle není dobře, má to být součástí serveru
# COPY ./server/resources ./resources
# Vykopírování sestaveného klienta
COPY ./client/dist ./public
# Zkopírování konfigurace easter eggů
RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi
EXPOSE 3000
CMD [ "node", "./server/src/index.js" ]

View File

@@ -1,7 +1,9 @@
# Luncher
Aplikace pro profesionální management obědů.
Aplikace sestává ze dvou modulů + společných TypeScript definic (adresář `types`).
Aplikace sestává ze tří modulů.
- types
- OpenAPI definice společných typů, generované přes [openapi-ts](https://github.com/hey-api/openapi-ts)
- server
- backend psaný v [node.js](https://nodejs.dev)
- client
@@ -10,19 +12,27 @@ Aplikace sestává ze dvou modulů + společných TypeScript definic (adresář
## Spuštění pro vývoj
### Závislosti
#### Klient/server
- [Node.js 18.x](https://nodejs.dev)
- [Node.js 22.x (>= 22.11)](https://nodejs.dev)
- [Yarn 1.22.x (Classic)](https://classic.yarnpkg.com)
### Spuštění na *nix platformách
- Nainstalovat závislosti viz předchozí bod
- Zkopírovat `server/.env.template` do `server/.env.development` a upravit dle potřeby
- Spustit `./run_dev.sh`. Na jiných platformách se lze inspirovat jeho obsahem, postup by měl být víceméně stejný.
- Vygenerovat společné TypeScript typy
- `cd types && yarn install && yarn openapi-ts`
- Server
- `cd server && yarn install && export NODE_ENV=development && yarn startReload`
- Klient
- `cd client && yarn install && yarn start`
## Sestavení a spuštění produkční verze v Docker
### Závislosti
- [Docker](https://www.docker.com)
- [Docker Compose](https://docs.docker.com/compose)
### Spuštění
- `docker compose up --build -d`
### Spuštení s traefik
- `docker compose -f compose-traefik.yml up --build -d`

View File

@@ -1,4 +1,7 @@
# TODO
- [ ] HTTP_REMOTE_TRUSTED_IPS se nikde nevalidují, hlavičky jsou přijímány odkudkoli
- [ ] V případě zapnutí přihlašování přes trusted headers nefunguje standardní přihlášení (nevrátí žádnou odpověď)
- [ ] Nemělo by se jít dostat na přihlašovací formulář (měla by tam být nanejvýš hláška nebo přesměrování)
- [ ] Možnost úhrady celé útraty jednou osobou
- Základní myšlenka: jedna osoba uhradí celou útratu (v zájmu rychlosti odbavení), ostatním se automaticky vygeneruje QR kód, kterým následně uhradí svoji část útraty
- Obecně to bude problém např. pokud si někdo objedná něco navíc (pití apod.)

2
client/.gitignore vendored
View File

@@ -1,2 +1,2 @@
build
src/types
dist

21
client/index.html Normal file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Moderní webová aplikace pro lepší správu obědových preferencí" />
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" />
<title>Luncher</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

View File

@@ -3,39 +3,43 @@
"version": "0.1.0",
"license": "MIT",
"private": true,
"type": "module",
"homepage": ".",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-regular-svg-icons": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/react-fontawesome": "^0.2.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.23",
"@types/react": "^18.0.33",
"@types/react-dom": "^18.0.11",
"@types/jest": "^29.5.12",
"@types/node": "^20.11.20",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"bootstrap": "^5.2.3",
"react": "^18.2.0",
"react": "^19.0.0",
"react-bootstrap": "^2.7.2",
"react-dom": "^18.2.0",
"react-dom": "^19.0.0",
"react-jwt": "^1.2.0",
"react-modal": "^3.16.1",
"react-scripts": "5.0.1",
"react-router": "^7.2.0",
"react-router-dom": "^7.2.0",
"react-select-search": "^4.1.6",
"react-toastify": "^9.1.3",
"react-snowfall": "^2.2.0",
"react-toastify": "^10.0.4",
"recharts": "^2.15.1",
"sass": "^1.80.6",
"socket.io-client": "^4.6.1",
"typescript": "^4.9.5"
"typescript": "^5.3.3",
"vite": "^6.0.3",
"vite-tsconfig-paths": "^5.1.4"
},
"scripts": {
"copy-types": "cp -r ../types ./src",
"start": "yarn copy-types && react-scripts start",
"build": "yarn copy-types && react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
"start": "yarn vite",
"build": "tsc --noEmit && yarn vite build"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
"react-app"
]
},
"browserslist": {
@@ -52,6 +56,6 @@
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"prettier": "^2.8.8"
"prettier": "^3.2.5"
}
}

BIN
client/public/hat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Moderní webová aplikace pro lepší správu obědových preferencí" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Luncher</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

BIN
client/public/snowman.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -56,7 +56,7 @@
}
.title {
margin: 50px 30px;
margin: 50px 20px;
}
.food-tables {
@@ -124,3 +124,45 @@
align-items: center;
font-size: xx-large;
}
@keyframes bounce-in {
0% {
left: var(--start-left);
right: var(--start-right);
top: var(--start-top);
bottom: var(--start-bottom);
}
25% {
left: var(--end-left);
right: var(--end-right);
top: var(--end-top);
bottom: var(--end-bottom);
}
50% {
left: var(--start-left);
right: var(--start-right);
top: var(--start-top);
bottom: var(--start-bottom);
}
75% {
left: var(--end-left);
right: var(--end-right);
top: var(--end-top);
bottom: var(--end-bottom);
}
100% {
left: var(--start-left);
right: var(--start-right);
top: var(--start-top);
bottom: var(--start-bottom);
}
}
// TODO zjistit, zda to nedokážeme lépe - tohle je kvůli overflow easter egg obrázků, ale skrývá to úplně scrollbar
html {
overflow-x: hidden;
}

View File

@@ -8,39 +8,57 @@ import { Alert, Button, Col, Form, Row, Table } from 'react-bootstrap';
import Header from './components/Header';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import PizzaOrderList from './components/PizzaOrderList';
import SelectSearch, { SelectedOptionValue } from 'react-select-search';
import SelectSearch, { SelectedOptionValue, SelectSearchOption } from 'react-select-search';
import 'react-select-search/style.css';
import './App.css';
import { SelectSearchOption } from 'react-select-search';
import { faCircleCheck, faTrashCan } from '@fortawesome/free-regular-svg-icons';
import { useBank } from './context/bank';
import { ClientData, Restaurants, Food, Order, Locations, PizzaOrder, PizzaDayState, FoodChoices, DayMenu, DepartureTime } from './types';
import './App.scss';
import { faCircleCheck, faNoteSticky, faTrashCan } from '@fortawesome/free-regular-svg-icons';
import { useSettings } from './context/settings';
import Footer from './components/Footer';
import { faChainBroken, faChevronLeft, faChevronRight, faGear, faSatelliteDish, faSearch } from '@fortawesome/free-solid-svg-icons';
import Loader from './components/Loader';
import { getData, errorHandler, getQrUrl } from './api/Api';
import { addChoice, removeChoices, removeChoice, changeDepartureTime, jdemeObed } from './api/FoodApi';
import { getHumanDateTime } from './Utils';
import { addChoice, removeChoices, removeChoice, changeDepartureTime, jdemeObed, updateNote } from './api/FoodApi';
import { getHumanDateTime, isInTheFuture } from './Utils';
import NoteModal from './components/modals/NoteModal';
import { useEasterEgg } from './context/eggs';
import { getImage } from './api/EasterEggApi';
import { Link } from 'react-router';
import { STATS_URL } from './AppRoutes';
import { ClientData, Food, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LunchChoices, UserLunchChoice, PizzaVariant } from '../../types';
const EVENT_CONNECT = "connect"
// Fixní styl pro všechny easter egg obrázky
const EASTER_EGG_STYLE = {
zIndex: 1,
animationName: "bounce-in",
animationTimingFunction: "ease"
}
// Výchozí doba trvání animace v sekundách, pokud není přetíženo v konfiguračním JSONu
const EASTER_EGG_DEFAULT_DURATION = 0.75;
function App() {
const auth = useAuth();
const bank = useBank();
const settings = useSettings();
const [easterEgg, easterEggLoading] = useEasterEgg(auth);
const [isConnected, setIsConnected] = useState<boolean>(false);
const [data, setData] = useState<ClientData>();
const [food, setFood] = useState<{ [key in Restaurants]?: DayMenu }>();
const [myOrder, setMyOrder] = useState<Order>();
const [food, setFood] = useState<RestaurantDayMenuMap>();
const [myOrder, setMyOrder] = useState<PizzaOrder>();
const [foodChoiceList, setFoodChoiceList] = useState<Food[]>();
const [closed, setClosed] = useState<boolean>(false);
const socket = useContext(SocketContext);
const choiceRef = useRef<HTMLSelectElement>(null);
const foodChoiceRef = useRef<HTMLSelectElement>(null);
const departureChoiceRef = useRef<HTMLSelectElement>(null);
const poznamkaRef = useRef<HTMLInputElement>(null);
const pizzaPoznamkaRef = useRef<HTMLInputElement>(null);
const [failure, setFailure] = useState<boolean>(false);
const [dayIndex, setDayIndex] = useState<number>();
const [loadingPizzaDay, setLoadingPizzaDay] = useState<boolean>(false);
const [noteModalOpen, setNoteModalOpen] = useState<boolean>(false);
const [eggImage, setEggImage] = useState<Blob>();
const eggRef = useRef<HTMLImageElement>(null);
// Prazvláštní workaround, aby socket.io listener viděl aktuální hodnotu
// https://medium.com/@kishorkrishna/cant-access-latest-state-inside-socket-io-listener-heres-how-to-fix-it-1522a5abebdb
const dayIndexRef = useRef<number | undefined>(dayIndex);
@@ -50,11 +68,13 @@ function App() {
if (!auth || !auth.login) {
return
}
getData().then((data: ClientData) => {
getData().then(({ data }) => {
if (data) {
setData(data);
setDayIndex(data.weekIndex);
dayIndexRef.current = data.weekIndex;
setFood(data.menus);
}
}).catch(e => {
setFailure(true);
})
@@ -86,7 +106,7 @@ function App() {
socket.on(EVENT_MESSAGE, (newData: ClientData) => {
// console.log("Přijata nová data ze socketu", newData);
// Aktualizujeme pouze, pokud jsme dostali data pro den, který máme aktuálně zobrazený
if (dayIndexRef.current == null || newData.weekIndex === dayIndexRef.current) {
if (dayIndexRef.current == null || newData.dayIndex === dayIndexRef.current) {
setData(newData);
}
});
@@ -124,13 +144,10 @@ function App() {
useEffect(() => {
if (choiceRef?.current?.value && choiceRef.current.value !== "") {
// TODO: wtf, cos pil, když jsi tohle psal?
const key = choiceRef?.current?.value;
const locationIndex = Object.keys(Locations).indexOf(key as unknown as Locations);
const locationsKey = Object.keys(Locations)[locationIndex];
const restaurantKey = Object.keys(Restaurants).indexOf(locationsKey);
const locationKey = choiceRef.current.value as keyof typeof LunchChoice;
const restaurantKey = Object.keys(Restaurant).indexOf(locationKey);
if (restaurantKey > -1 && food) {
const restaurant = Object.values(Restaurants)[restaurantKey];
const restaurant = Object.keys(Restaurant)[restaurantKey] as keyof typeof Restaurant;
setFoodChoiceList(food[restaurant]?.food);
setClosed(food[restaurant]?.closed ?? false);
} else {
@@ -159,10 +176,35 @@ function App() {
}
}, [handleKeyDown]);
const doAddChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => {
const index = Object.keys(Locations).indexOf(event.target.value as unknown as Locations);
// Stažení a nastavení easter egg obrázku
useEffect(() => {
if (auth?.login && easterEgg?.url && !eggImage) {
getImage(easterEgg.url).then(data => {
if (data) {
setEggImage(data);
// Smazání obrázku z DOMu po animaci
setTimeout(() => {
if (eggRef?.current) {
eggRef.current.remove();
}
}, (easterEgg.duration || EASTER_EGG_DEFAULT_DURATION) * 1000);
}
});
}
}, [auth?.login, easterEgg?.duration, easterEgg?.url, eggImage]);
const doAddClickFoodChoice = async (location: keyof typeof LunchChoice, foodIndex?: number) => {
if (document.getSelection()?.type !== 'Range') { // pouze pokud se nejedná o výběr textu
if (auth?.login) {
await errorHandler(() => addChoice(index, undefined, dayIndex));
await errorHandler(() => addChoice(location, foodIndex, dayIndex));
}
}
}
const doAddChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => {
const locationKey = event.target.value as keyof typeof LunchChoice;
if (auth?.login) {
await errorHandler(() => addChoice(locationKey, undefined, dayIndex));
if (foodChoiceRef.current?.value) {
foodChoiceRef.current.value = "";
}
@@ -177,17 +219,16 @@ function App() {
const doAddFoodChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => {
if (event.target.value && foodChoiceList?.length && choiceRef.current?.value) {
const restaurantKey = choiceRef.current.value;
const locationKey = choiceRef.current.value as keyof typeof LunchChoice;
if (auth?.login) {
const locationIndex = Object.keys(Locations).indexOf(restaurantKey as unknown as Locations);
await errorHandler(() => addChoice(locationIndex, Number(event.target.value), dayIndex));
await errorHandler(() => addChoice(locationKey, Number(event.target.value), dayIndex));
}
}
}
const doRemoveChoices = async (locationKey: string) => {
const doRemoveChoices = async (locationKey: keyof typeof LunchChoice) => {
if (auth?.login) {
await errorHandler(() => removeChoices(Number(locationKey), dayIndex));
await errorHandler(() => removeChoices(locationKey, dayIndex));
// Vyresetujeme výběr, aby bylo jasné pro který případně vybíráme jídlo
if (choiceRef?.current?.value) {
choiceRef.current.value = "";
@@ -198,9 +239,9 @@ function App() {
}
}
const doRemoveFoodChoice = async (locationKey: string, foodIndex: number) => {
const doRemoveFoodChoice = async (locationKey: keyof typeof LunchChoice, foodIndex: number) => {
if (auth?.login) {
await errorHandler(() => removeChoice(Number(locationKey), foodIndex, dayIndex));
await errorHandler(() => removeChoice(locationKey, foodIndex, dayIndex));
if (choiceRef?.current?.value) {
choiceRef.current.value = "";
}
@@ -210,6 +251,13 @@ function App() {
}
}
const saveNote = async (note?: string) => {
if (auth?.login) {
await errorHandler(() => updateNote(note, dayIndex));
setNoteModalOpen(false);
}
}
const pizzaSuggestions = useMemo(() => {
if (!data?.pizzaList) {
return [];
@@ -239,16 +287,16 @@ function App() {
}
}
const handlePizzaDelete = async (pizzaOrder: PizzaOrder) => {
const handlePizzaDelete = async (pizzaOrder: PizzaVariant) => {
await removePizza(pizzaOrder);
}
const handlePoznamkaChange = async () => {
if (poznamkaRef.current?.value && poznamkaRef.current.value.length > 100) {
alert("Poznámka může mít maximálně 100 znaků");
const handlePizzaPoznamkaChange = async () => {
if (pizzaPoznamkaRef.current?.value && pizzaPoznamkaRef.current.value.length > 70) {
alert("Poznámka může mít maximálně 70 znaků");
return;
}
updatePizzaDayNote(poznamkaRef.current?.value);
updatePizzaDayNote(pizzaPoznamkaRef.current?.value);
}
// const addToCart = async () => {
@@ -295,15 +343,17 @@ function App() {
}
}
const renderFoodTable = (name: string, menu: DayMenu) => {
const renderFoodTable = (location: keyof typeof Restaurant, menu: RestaurantDayMenu) => {
let content;
if (menu?.closed) {
content = <h3>Zavřeno</h3>
} else if (menu?.food?.length > 0) {
} else if (menu?.food?.length && menu.food.length > 0) {
const hideSoups = settings?.hideSoups;
content = <Table striped bordered hover>
<tbody>
{menu.food.map((f: any, index: number) =>
<tr key={index}>
<tbody style={{ cursor: 'pointer' }}>
{menu.food.map((f: Food, index: number) =>
(!hideSoups || !f.isSoup) &&
<tr key={f.name} onClick={() => doAddClickFoodChoice(location, index)}>
<td>{f.amount}</td>
<td>{f.name}</td>
<td>{f.price}</td>
@@ -314,8 +364,8 @@ function App() {
} else {
content = <h3>Chyba načtení dat</h3>
}
return <Col md={12} lg={4}>
<h3>{name}</h3>
return <Col md={12} lg={3} className='mt-3'>
<h3 style={{ cursor: 'pointer' }} onClick={() => doAddClickFoodChoice(location, undefined)}>{location}</h3>
{menu?.lastUpdate && <small>Poslední aktualizace: {getHumanDateTime(new Date(menu.lastUpdate))}</small>}
{content}
</Col>
@@ -350,46 +400,50 @@ function App() {
}
const noOrders = data?.pizzaDay?.orders?.length === 0;
const canChangeChoice = dayIndex == null || data.todayWeekIndex == null || dayIndex >= data.todayWeekIndex;
const canChangeChoice = dayIndex == null || data.todayDayIndex == null || dayIndex >= data.todayDayIndex;
const { path, url, startOffset, endOffset, duration, ...style } = easterEgg || {};
return (
<>
{easterEgg && eggImage && <img ref={eggRef} alt='' src={URL.createObjectURL(eggImage)} style={{ position: 'absolute', ...EASTER_EGG_STYLE, ...style, animationDuration: `${duration ?? EASTER_EGG_DEFAULT_DURATION}s` }} />}
<Header />
<div className='wrapper'>
{data.isWeekend ? <h4>Užívejte víkend :)</h4> : <>
<Alert variant={'primary'}>
<img src='hat.png' style={{ position: "absolute", width: "70px", rotate: "-45deg", left: -40, top: -58 }} />
<img src='snowman.png' style={{ position: "absolute", height: "110px", right: 10, top: 5 }} />
Poslední změny:
<ul>
<li>Tolerance existence menu na více týdnů pro restauraci U Motlíků</li>
<li>Možnost výběru restaurace a jídel kliknutím v tabulce</li>
<li><Link to={STATS_URL}>Statistiky</Link></li>
</ul>
</Alert>
{dayIndex != null &&
<div className='day-navigator'>
{dayIndex > 0 && <FontAwesomeIcon title="Předchozí den" icon={faChevronLeft} style={{ cursor: "pointer" }} onClick={() => handleDayChange(dayIndex - 1)} />}
<h1 className='title' style={{ color: dayIndex === data.todayWeekIndex ? 'black' : 'gray' }}>{data.date}</h1>
{dayIndex < 4 && <FontAwesomeIcon title="Následující den" icon={faChevronRight} style={{ cursor: "pointer" }} onClick={() => handleDayChange(dayIndex + 1)} />}
<FontAwesomeIcon title="Předchozí den" icon={faChevronLeft} style={{ cursor: "pointer", visibility: dayIndex > 0 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex - 1)} />
<h1 className='title' style={{ color: dayIndex === data.todayDayIndex ? 'black' : 'gray' }}>{data.date}</h1>
<FontAwesomeIcon title="Následující den" icon={faChevronRight} style={{ cursor: "pointer", visibility: dayIndex < 4 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex + 1)} />
</div>
}
<Row className='food-tables'>
{food[Restaurants.SLADOVNICKA] && renderFoodTable('Sladovnická', food[Restaurants.SLADOVNICKA])}
{food[Restaurants.UMOTLIKU] && renderFoodTable('U Motlíků', food[Restaurants.UMOTLIKU])}
{food[Restaurants.TECHTOWER] && renderFoodTable('TechTower', food[Restaurants.TECHTOWER])}
{/* TODO zjednodušit, stačí iterovat klíče typu Restaurant */}
{food['SLADOVNICKA'] && renderFoodTable('SLADOVNICKA', food['SLADOVNICKA'])}
{/* {food['UMOTLIKU'] && renderFoodTable('UMOTLIKU', food['UMOTLIKU'])} */}
{food['TECHTOWER'] && renderFoodTable('TECHTOWER', food['TECHTOWER'])}
{food['ZASTAVKAUMICHALA'] && renderFoodTable('ZASTAVKAUMICHALA', food['ZASTAVKAUMICHALA'])}
{food['SENKSERIKOVA'] && renderFoodTable('SENKSERIKOVA', food['SENKSERIKOVA'])}
</Row>
<div className='content-wrapper'>
<div className='content'>
{canChangeChoice && <>
<p>{`Jak to ${dayIndex == null || dayIndex === data.todayWeekIndex ? 'dnes' : 'tento den'} vidíš s obědem?`}</p>
<p>{`Jak to ${dayIndex == null || dayIndex === data.todayDayIndex ? 'dnes' : 'tento den'} vidíš s obědem?`}</p>
<Form.Select ref={choiceRef} onChange={doAddChoice}>
<option></option>
{Object.entries(Locations)
{Object.keys(Restaurant)
.filter(entry => {
// TODO: wtf, cos pil, když jsi tohle psal? v2
const key = entry[0];
const locationIndex = Object.keys(Locations).indexOf(key as unknown as Locations);
const locationsKey = Object.keys(Locations)[locationIndex];
const restaurantKey = Object.keys(Restaurants).indexOf(locationsKey);
const v = Object.values(Restaurants)[restaurantKey];
return v == null || !food[v]?.closed;
const locationKey = entry as keyof typeof Restaurant;
return !food[locationKey]?.closed;
})
.map(entry => <option key={entry[0]} value={entry[0]}>{entry[1]}</option>)}
</Form.Select>
@@ -405,49 +459,62 @@ function App() {
<p style={{ marginTop: "10px" }}>V kolik hodin preferuješ odchod?</p>
<Form.Select ref={departureChoiceRef} onChange={handleChangeDepartureTime}>
<option></option>
{Object.values(DepartureTime).map(time => <option key={time} value={time}>{time}</option>)}
{Object.values(DepartureTime)
.filter(time => isInTheFuture(time))
.map(time => <option key={time} value={time}>{time}</option>)}
</Form.Select>
</>}
</>}
{Object.keys(data.choices).length > 0 ?
<Table bordered className='mt-5'>
<tbody>
{Object.keys(data.choices).map((locationKey: string) => {
const locationName = Object.values(Locations)[Number(locationKey)];
const locationLoginList = Object.entries(data.choices[Number(locationKey)]);
{Object.keys(data.choices).map(key => {
const locationKey = key as keyof typeof LunchChoice;
const locationName = LunchChoice[locationKey];
const loginObject = data.choices[locationKey];
if (!loginObject) {
return;
}
const locationLoginList = Object.entries(loginObject);
const locationPickCount = locationLoginList.length
return (
<tr key={locationKey}>
<td>{locationName}</td>
<tr key={key}>
{(locationPickCount ?? 0) > 1 ? (
<td>{locationName} ({locationPickCount})</td>
) : (
<td>{locationName}</td>)}
<td className='p-0'>
<Table>
<tbody>
{locationLoginList.map((entry: [string, FoodChoices], index) => {
{locationLoginList.map((entry: [string, UserLunchChoice], index) => {
const login = entry[0];
const userPayload = entry[1];
const userChoices = userPayload?.options;
const userChoices = userPayload?.selectedFoods;
const trusted = userPayload?.trusted || false;
return <tr key={index}>
<td className='text-nowrap'>
<td>
{trusted && <span className='trusted-icon'>
<FontAwesomeIcon title='Uživatel ověřený doménovým přihlášením' icon={faCircleCheck} style={{ cursor: "help" }} />
</span>}
{login}
{userPayload.departureTime && <small> ({userPayload.departureTime})</small>}
{userPayload.note && <small style={{ overflowWrap: 'anywhere' }}> ({userPayload.note})</small>}
{login === auth.login && canChangeChoice && <FontAwesomeIcon onClick={() => {
doRemoveChoices(locationKey);
setNoteModalOpen(true);
}} title='Upravit poznámku' className='action-icon' icon={faNoteSticky} />}
{login === auth.login && canChangeChoice && <FontAwesomeIcon onClick={() => {
doRemoveChoices(key as keyof typeof LunchChoice);
}} title={`Odstranit volbu ${locationName}, včetně případných zvolených jídel`} className='action-icon' icon={faTrashCan} />}
</td>
{userChoices?.length && food ? <td className='w-100'>
{userChoices?.length && food ? <td>
<ul>
{userChoices?.map(foodIndex => {
const locationsKey = Object.keys(Locations)[Number(locationKey)]
const restaurantKey = Object.keys(Restaurants).indexOf(locationsKey);
const restaurant = Object.values(Restaurants)[restaurantKey];
const foodName = food[restaurant]?.food[foodIndex].name;
const restaurantKey = key as keyof typeof Restaurant;
const foodName = food[restaurantKey]?.food?.[foodIndex].name;
return <li key={foodIndex}>
{foodName}
{login === auth.login && canChangeChoice && <FontAwesomeIcon onClick={() => {
doRemoveFoodChoice(locationKey, foodIndex);
doRemoveFoodChoice(restaurantKey, foodIndex);
}} title={`Odstranit ${foodName}`} className='action-icon' icon={faTrashCan} />}
</li>
})}
@@ -467,7 +534,7 @@ function App() {
: <div className='mt-5'><i>Zatím nikdo nehlasoval...</i></div>
}
</div>
{dayIndex === data.todayWeekIndex &&
{dayIndex === data.todayDayIndex &&
<div className='mt-5'>
{!data.pizzaDay &&
<div style={{ textAlign: 'center' }}>
@@ -540,7 +607,7 @@ function App() {
await lockPizzaDay();
}}>Vrátit do "uzamčeno"</Button>
<Button className='danger mb-3' style={{ marginLeft: '20px' }} title="Nastaví stav na 'Doručeno' - koncový stav." onClick={async () => {
await finishDelivery(bank?.bankAccount, bank?.holderName);
await finishDelivery(settings?.bankAccount, settings?.holderName);
}}>Doručeno</Button>
</div>
}
@@ -560,27 +627,30 @@ function App() {
options={pizzaSuggestions}
placeholder='Vyhledat pizzu...'
onChange={handlePizzaChange}
onBlur={_ => { }}
onFocus={_ => { }}
/>
Poznámka: <input ref={poznamkaRef} className='mt-3' type="text" onKeyDown={event => {
Poznámka: <input ref={pizzaPoznamkaRef} className='mt-3' type="text" onKeyDown={event => {
if (event.key === 'Enter') {
handlePoznamkaChange();
handlePizzaPoznamkaChange();
}
event.stopPropagation();
}} />
<Button
style={{ marginLeft: '20px' }}
disabled={!myOrder?.pizzaList?.length}
onClick={handlePoznamkaChange}>
onClick={handlePizzaPoznamkaChange}>
Uložit
</Button>
</div>
}
<PizzaOrderList state={data.pizzaDay.state} orders={data.pizzaDay.orders} onDelete={handlePizzaDelete} creator={data.pizzaDay.creator} />
<PizzaOrderList state={data.pizzaDay.state!} orders={data.pizzaDay.orders!} onDelete={handlePizzaDelete} creator={data.pizzaDay.creator!} />
{
data.pizzaDay.state === PizzaDayState.DELIVERED && myOrder?.hasQr &&
data.pizzaDay.state === PizzaDayState.DELIVERED && myOrder?.hasQr ?
<div className='qr-code'>
<h3>QR platba</h3>
<img src={getQrUrl(auth.login)} alt='QR kód' />
</div>
</div> : null
}
</div>
}
@@ -590,6 +660,7 @@ function App() {
</>}
</div>
<Footer />
<NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} />
</>
);
}

33
client/src/AppRoutes.tsx Normal file
View File

@@ -0,0 +1,33 @@
import { Routes, Route } from "react-router-dom";
import { ProvideSettings } from "./context/settings";
import Snowfall from "react-snowfall";
import { ToastContainer } from "react-toastify";
import { SocketContext, socket } from "./context/socket";
import StatsPage from "./pages/StatsPage";
import App from "./App";
export const STATS_URL = '/stats';
export default function AppRoutes() {
return (
<Routes>
<Route path={STATS_URL} element={<StatsPage />} />
<Route path="/" element={
<ProvideSettings>
<SocketContext.Provider value={socket}>
<>
<Snowfall style={{
zIndex: 2,
position: 'fixed',
width: '100vw',
height: '100vh'
}} />
<App />
</>
<ToastContainer />
</SocketContext.Provider>
</ProvideSettings>
} />
</Routes>
);
}

View File

@@ -28,7 +28,7 @@ export default function Login() {
const length = loginRef?.current?.value && loginRef?.current?.value.length && loginRef.current.value.replace(/\s/g, '').length
if (length) {
// TODO odchytávat cokoliv mimo 200
const token = await login(loginRef.current.value);
const token = await login(loginRef.current?.value);
if (token) {
auth?.setToken(token);
}

View File

@@ -1,14 +1,4 @@
/**
* Vrátí kořenovou URL serveru na základě aktuálního prostředí (vývojovou či produkční).
*
* @returns kořenová URL serveru
*/
export const getBaseUrl = (): string => {
if (process.env.PUBLIC_URL) {
return process.env.PUBLIC_URL;
}
return 'http://127.0.0.1:3001';
}
import { DepartureTime } from "../../types";
const TOKEN_KEY = "token";
@@ -26,8 +16,8 @@ export const storeToken = (token: string) => {
*
* @returns token nebo null
*/
export const getToken = (): string | null => {
return localStorage.getItem(TOKEN_KEY);
export const getToken = (): string | undefined => {
return localStorage.getItem(TOKEN_KEY) ?? undefined;
}
/**
@@ -55,3 +45,62 @@ export function getHumanDateTime(datetime: Date) {
return `${day}.${month}.${year} ${hours}:${minutes}`;
}
}
/**
* Vrátí true, pokud je předaný čas větší než aktuální čas.
*/
export function isInTheFuture(time: DepartureTime) {
const now = new Date();
const currentHours = now.getHours();
const currentMinutes = now.getMinutes();
const currentDate = now.toDateString();
const [hours, minutes] = time.split(':').map(Number);
if (currentDate === now.toDateString()) {
return hours > currentHours || (hours === currentHours && minutes > currentMinutes);
}
return true;
}
/**
* Vrátí index dne v týdnu, kde pondělí=0, neděle=6
*
* @param date datum
* @returns index dne v týdnu
*/
export const getDayOfWeekIndex = (date: Date) => {
// https://stackoverflow.com/a/4467559
return (((date.getDay() - 1) % 7) + 7) % 7;
}
/** Vrátí první pracovní den v týdnu předaného data. */
export function getFirstWorkDayOfWeek(date: Date) {
const firstDay = new Date(date.getTime());
firstDay.setDate(date.getDate() - getDayOfWeekIndex(date));
return firstDay;
}
/** Vrátí poslední pracovní den v týdnu předaného data. */
export function getLastWorkDayOfWeek(date: Date) {
const lastDay = new Date(date.getTime());
lastDay.setDate(date.getDate() + (4 - getDayOfWeekIndex(date)));
return lastDay;
}
/** Vrátí datum v ISO formátu. */
export function formatDate(date: Date, format?: string) {
let day = String(date.getDate()).padStart(2, '0');
let month = String(date.getMonth() + 1).padStart(2, "0");
let year = String(date.getFullYear());
const f = (format === undefined) ? 'YYYY-MM-DD' : format;
return f.replace('DD', day).replace('MM', month).replace('YYYY', year);
}
/** Vrátí human-readable reprezentaci předaného data pro zobrazení. */
export function getHumanDate(date: Date) {
let currentDay = String(date.getDate()).padStart(2, '0');
let currentMonth = String(date.getMonth() + 1).padStart(2, "0");
let currentYear = date.getFullYear();
return `${currentDay}.${currentMonth}.${currentYear}`;
}

View File

@@ -1,5 +1,5 @@
import { toast } from "react-toastify";
import { getBaseUrl, getToken } from "../Utils";
import { getToken } from "../Utils";
/**
* Wrapper pro volání API, u kterých chceme automaticky zobrazit toaster s chybou ze serveru.
@@ -23,13 +23,38 @@ async function request<TResponse>(
config.headers = config?.headers ? new Headers(config.headers) : new Headers();
config.headers.set("Authorization", `Bearer ${getToken()}`);
try {
const response = await fetch(getBaseUrl() + url, config);
const response = await fetch(url, config);
if (!response.ok) {
// TODO tohle je blbě, jelikož automaticky očekáváme, že v případě chyby přijde vždy JSON, což není pravda
const json = await response.json();
// Vyhodíme samotnou hlášku z odpovědi, odchytí si jí errorHandler
throw new Error(json.error);
}
const contentType = response.headers.get("content-type");
if (contentType && contentType.indexOf("application/json") !== -1) {
return response.json() as TResponse;
} else {
return response.text() as TResponse;
}
} catch (e) {
return Promise.reject(e);
}
}
async function blobRequest(
url: string,
config: RequestInit = {}
): Promise<Blob> {
config.headers = config?.headers ? new Headers(config.headers) : new Headers();
config.headers.set("Authorization", `Bearer ${getToken()}`);
try {
const response = await fetch(url, config);
if (!response.ok) {
const json = await response.json();
// Vyhodíme samotnou hlášku z odpovědi, odchytí si jí errorHandler
throw new Error(json.error);
}
return response.json() as TResponse;
return response.blob()
} catch (e) {
return Promise.reject(e);
}
@@ -37,11 +62,12 @@ async function request<TResponse>(
export const api = {
get: <TResponse>(url: string) => request<TResponse>(url),
post: <TBody extends BodyInit, TResponse>(url: string, body: TBody) => request<TResponse>(url, { method: 'POST', body, headers: { 'Content-Type': 'application/json' } }),
blobGet: (url: string) => blobRequest(url),
post: <TBody, TResponse>(url: string, body?: TBody) => request<TResponse>(url, { method: 'POST', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } }),
}
export const getQrUrl = (login: string) => {
return `${getBaseUrl()}/api/qr?login=${login}`;
return `/api/qr?login=${login}`;
}
export const getData = async (dayIndex?: number) => {
@@ -53,5 +79,5 @@ export const getData = async (dayIndex?: number) => {
}
export const login = async (login?: string) => {
return await api.post<any, any>('/api/login', JSON.stringify({ login }));
return await api.post<any, any>('/api/login', { login });
}

8
client/src/api/Client.ts Normal file
View File

@@ -0,0 +1,8 @@
import { client } from '../../../types/gen/client.gen';
import { getToken } from '../Utils';
client.setConfig({
auth: () => getToken(),
});
export default client

View File

@@ -0,0 +1,12 @@
import { EasterEgg } from "../../../types";
import { api } from "./Api";
const EASTER_EGGS_API_PREFIX = '/api/easterEggs';
export const getEasterEgg = async (): Promise<EasterEgg | undefined> => {
return await api.get<EasterEgg>(`${EASTER_EGGS_API_PREFIX}`);
}
export const getImage = async (url: string) => {
return await api.blobGet(`${EASTER_EGGS_API_PREFIX}/${url}`);
}

View File

@@ -1,23 +1,29 @@
import { AddChoiceRequest, ChangeDepartureTimeRequest, RemoveChoiceRequest, RemoveChoicesRequest, UpdateNoteRequest } from "../../../types";
import { LunchChoice } from "../../../types";
import { api } from "./Api";
const FOOD_API_PREFIX = '/api/food';
export const addChoice = async (locationIndex: number, foodIndex?: number, dayIndex?: number) => {
return await api.post<any, any>(`${FOOD_API_PREFIX}/addChoice`, JSON.stringify({ locationIndex, foodIndex, dayIndex }));
export const addChoice = async (locationKey: keyof typeof LunchChoice, foodIndex?: number, dayIndex?: number) => {
return await api.post<AddChoiceRequest, void>(`${FOOD_API_PREFIX}/addChoice`, { locationKey, foodIndex, dayIndex });
}
export const removeChoices = async (locationIndex: number, dayIndex?: number) => {
return await api.post<any, any>(`${FOOD_API_PREFIX}/removeChoices`, JSON.stringify({ locationIndex, dayIndex }));
export const removeChoices = async (locationKey: keyof typeof LunchChoice, dayIndex?: number) => {
return await api.post<RemoveChoicesRequest, void>(`${FOOD_API_PREFIX}/removeChoices`, { locationKey, dayIndex });
}
export const removeChoice = async (locationIndex: number, foodIndex: number, dayIndex?: number) => {
return await api.post<any, any>(`${FOOD_API_PREFIX}/removeChoice`, JSON.stringify({ locationIndex, foodIndex, dayIndex }));
export const removeChoice = async (locationKey: keyof typeof LunchChoice, foodIndex: number, dayIndex?: number) => {
return await api.post<RemoveChoiceRequest, void>(`${FOOD_API_PREFIX}/removeChoice`, { locationKey, foodIndex, dayIndex });
}
export const updateNote = async (note?: string, dayIndex?: number) => {
return await api.post<UpdateNoteRequest, void>(`${FOOD_API_PREFIX}/updateNote`, { note, dayIndex });
}
export const changeDepartureTime = async (time: string, dayIndex?: number) => {
return await api.post<any, any>(`${FOOD_API_PREFIX}/changeDepartureTime`, JSON.stringify({ time, dayIndex }));
return await api.post<ChangeDepartureTimeRequest, void>(`${FOOD_API_PREFIX}/changeDepartureTime`, { time, dayIndex });
}
export const jdemeObed = async () => {
return await api.post<any, any>(`${FOOD_API_PREFIX}/jdemeObed`, JSON.stringify({}));
return await api.post<undefined, void>(`${FOOD_API_PREFIX}/jdemeObed`);
}

View File

@@ -1,44 +1,45 @@
import { PizzaOrder } from "../types";
import { AddPizzaRequest, FinishDeliveryRequest, RemovePizzaRequest, UpdatePizzaDayNoteRequest, UpdatePizzaFeeRequest } from "../../../types";
import { PizzaVariant } from "../../../types";
import { api } from "./Api";
const PIZZADAY_API_PREFIX = '/api/pizzaDay';
export const createPizzaDay = async () => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/create`, undefined);
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/create`);
}
export const deletePizzaDay = async () => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/delete`, undefined);
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/delete`);
}
export const lockPizzaDay = async () => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/lock`, undefined);
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/lock`);
}
export const unlockPizzaDay = async () => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/unlock`, undefined);
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/unlock`);
}
export const finishOrder = async () => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/finishOrder`, undefined);
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/finishOrder`);
}
export const finishDelivery = async (bankAccount?: string, bankAccountHolder?: string) => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/finishDelivery`, JSON.stringify({ bankAccount, bankAccountHolder }));
return await api.post<FinishDeliveryRequest, void>(`${PIZZADAY_API_PREFIX}/finishDelivery`, { bankAccount, bankAccountHolder });
}
export const addPizza = async (pizzaIndex: number, pizzaSizeIndex: number) => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/add`, JSON.stringify({ pizzaIndex, pizzaSizeIndex }));
return await api.post<AddPizzaRequest, void>(`${PIZZADAY_API_PREFIX}/add`, { pizzaIndex, pizzaSizeIndex });
}
export const removePizza = async (pizzaOrder: PizzaOrder) => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/remove`, JSON.stringify({ pizzaOrder }));
export const removePizza = async (pizzaOrder: PizzaVariant) => {
return await api.post<RemovePizzaRequest, void>(`${PIZZADAY_API_PREFIX}/remove`, { pizzaOrder });
}
export const updatePizzaDayNote = async (note?: string) => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/updatePizzaDayNote`, JSON.stringify({ note }));
return await api.post<UpdatePizzaDayNoteRequest, void>(`${PIZZADAY_API_PREFIX}/updatePizzaDayNote`, { note });
}
export const updatePizzaFee = async (login: string, text?: string, price?: number) => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/updatePizzaFee`, JSON.stringify({ login, text, price }));
return await api.post<UpdatePizzaFeeRequest, void>(`${PIZZADAY_API_PREFIX}/updatePizzaFee`, { login, text, price });
}

View File

@@ -0,0 +1,8 @@
import { WeeklyStats } from "../../../types";
import { api } from "./Api";
const STATS_API_PREFIX = '/api/stats';
export const getStats = async (startDate: string, endDate: string) => {
return await api.get<WeeklyStats>(`${STATS_API_PREFIX}?startDate=${startDate}&endDate=${endDate}`);
}

View File

@@ -1,12 +1,13 @@
import { FeatureRequest } from "../types";
import { UpdateFeatureVoteRequest } from "../../../types";
import { FeatureRequest } from "../../../types";
import { api } from "./Api";
const VOTING_API_PREFIX = '/api/voting';
export const getFeatureVotes = async () => {
return await api.get<any>(`${VOTING_API_PREFIX}/getVotes`);
return await api.get<FeatureRequest[]>(`${VOTING_API_PREFIX}/getVotes`);
}
export const updateFeatureVote = async (option: FeatureRequest, active: boolean) => {
return await api.post<any, any>(`${VOTING_API_PREFIX}/updateVote`, JSON.stringify({ option, active }));
return await api.post<UpdateFeatureVoteRequest, void>(`${VOTING_API_PREFIX}/updateVote`, { option, active });
}

View File

@@ -2,6 +2,6 @@ import { Navbar } from "react-bootstrap";
export default function Footer() {
return <Navbar className="text-light" variant='dark' expand="lg" style={{ display: "flex", justifyContent: "center" }}>
<span>🄯 Kancelář 51, Marbes s.r.o. Žádná práva nevyhrazena. TODO a zdrojové kódy dostupné <a href="https://gitea.melancholik.eu/mates/Luncher">zde</a>.</span>
<span>🄯 Žádná práva nevyhrazena. TODO a zdrojové kódy dostupné <a href="https://gitea.melancholik.eu/mates/Luncher">zde</a>.</span>
</Navbar >
}

View File

@@ -1,19 +1,23 @@
import { useEffect, useState } from "react";
import { Navbar, Nav, NavDropdown } from "react-bootstrap";
import { useAuth } from "../context/auth";
import BankAccountModal from "./modals/BankAccountModal";
import { useBank } from "../context/bank";
import SettingsModal from "./modals/SettingsModal";
import { useSettings } from "../context/settings";
import FeaturesVotingModal from "./modals/FeaturesVotingModal";
import { FeatureRequest } from "../types";
import { errorHandler } from "../api/Api";
import { getFeatureVotes, updateFeatureVote } from "../api/VotingApi";
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
import { useNavigate } from "react-router";
import { STATS_URL } from "../AppRoutes";
import { FeatureRequest } from "../../../types";
export default function Header() {
const auth = useAuth();
const bank = useBank();
const [bankModalOpen, setBankModalOpen] = useState<boolean>(false);
const settings = useSettings();
const navigate = useNavigate();
const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false);
const [votingModalOpen, setVotingModalOpen] = useState<boolean>(false);
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[]>([]);
useEffect(() => {
@@ -24,14 +28,18 @@ export default function Header() {
}
}, [auth?.login]);
const closeBankModal = () => {
setBankModalOpen(false);
const closeSettingsModal = () => {
setSettingsModalOpen(false);
}
const closeVotingModal = () => {
setVotingModalOpen(false);
}
const closePizzaModal = () => {
setPizzaModalOpen(false);
}
const isValidInteger = (str: string) => {
str = str.trim();
if (!str) {
@@ -42,7 +50,7 @@ export default function Header() {
return n !== Infinity && String(n) === str && n >= 0;
}
const saveBankAccount = (bankAccountNumber?: string, bankAccountHolderName?: string) => {
const saveSettings = (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean) => {
if (bankAccountNumber) {
try {
// Validace kódu banky
@@ -84,9 +92,10 @@ export default function Header() {
return
}
}
bank?.setBankAccountNumber(bankAccountNumber);
bank?.setBankAccountHolderName(bankAccountHolderName);
closeBankModal();
settings?.setBankAccountNumber(bankAccountNumber);
settings?.setBankAccountHolderName(bankAccountHolderName);
settings?.setHideSoupsOption(hideSoupsOption);
closeSettingsModal();
}
const saveFeatureVote = async (option: FeatureRequest, active: boolean) => {
@@ -101,18 +110,22 @@ export default function Header() {
}
return <Navbar variant='dark' expand="lg">
<Navbar.Brand>Luncher</Navbar.Brand>
<Navbar.Brand href="/">Luncher</Navbar.Brand>
<Navbar.Toggle aria-controls="basic-navbar-nav" />
<Navbar.Collapse id="basic-navbar-nav">
<Nav className="nav">
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
<NavDropdown.Item onClick={() => setBankModalOpen(true)}>Nastavit číslo účtu</NavDropdown.Item>
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
<NavDropdown.Item onClick={() => setVotingModalOpen(true)}>Hlasovat o nových funkcích</NavDropdown.Item>
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
<NavDropdown.Divider />
<NavDropdown.Item onClick={auth?.logout}>Odhlásit se</NavDropdown.Item>
</NavDropdown>
</Nav>
</Navbar.Collapse>
<BankAccountModal isOpen={bankModalOpen} onClose={closeBankModal} onSave={saveBankAccount} />
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
<FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} />
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
</Navbar>
}

View File

@@ -1,12 +1,12 @@
import { Table } from "react-bootstrap";
import { Order, PizzaDayState, PizzaOrder } from "../types";
import PizzaOrderRow from "./PizzaOrderRow";
import { updatePizzaFee } from "../api/PizzaDayApi";
import { PizzaDayState, PizzaOrder, PizzaVariant } from "../../../types";
type Props = {
state: PizzaDayState,
orders: Order[],
onDelete: (pizzaOrder: PizzaOrder) => void,
orders: PizzaOrder[],
onDelete: (pizzaOrder: PizzaVariant) => void,
creator: string,
}

View File

@@ -2,14 +2,14 @@ import React, { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faMoneyBill1, faTrashCan } from "@fortawesome/free-regular-svg-icons";
import { useAuth } from "../context/auth";
import { Order, PizzaDayState, PizzaOrder } from "../types";
import PizzaAdditionalFeeModal from "./modals/PizzaAdditionalFeeModal";
import { PizzaDayState, PizzaOrder, PizzaVariant } from "../../../types";
type Props = {
creator: string,
order: Order,
order: PizzaOrder,
state: PizzaDayState,
onDelete: (order: PizzaOrder) => void,
onDelete: (order: PizzaVariant) => void,
onFeeModalSave: (customer: string, name?: string, price?: number) => void,
}
@@ -24,7 +24,7 @@ export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeMo
return <>
<td>{order.customer}</td>
<td>{order.pizzaList.map<React.ReactNode>((pizzaOrder, index) =>
<td>{order.pizzaList!.map<React.ReactNode>((pizzaOrder, index) =>
<span key={index}>
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`}
{auth?.login === order.customer && state === PizzaDayState.CREATED &&

View File

@@ -1,5 +1,5 @@
import { Modal, Button, Form } from "react-bootstrap"
import { FeatureRequest } from "../../types";
import { FeatureRequest } from "../../../../types";
type Props = {
isOpen: boolean,
@@ -19,7 +19,7 @@ export default function FeaturesVotingModal({ isOpen, onClose, onChange, initial
<Modal.Header closeButton>
<Modal.Title>
Hlasujte pro nové funkce
<p style={{ fontSize: '12px' }}>Je možno vybrat maximálně 3 možnosti</p>
<p style={{ fontSize: '12px' }}>Je možno vybrat maximálně 4 možnosti</p>
</Modal.Title>
</Modal.Header>
<Modal.Body>

View File

@@ -0,0 +1,36 @@
import { useRef } from "react";
import { Modal, Button, Form } from "react-bootstrap"
type Props = {
isOpen: boolean,
onClose: () => void,
onSave: (note?: string) => void,
}
/** Modální dialog pro úpravu obecné poznámky. */
export default function NoteModal({ isOpen, onClose, onSave }: Props) {
const note = useRef<HTMLInputElement>(null);
const save = () => {
onSave(note?.current?.value);
}
return <Modal show={isOpen} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title>Úprava poznámky</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form.Control ref={note} autoFocus={true} type="text" id="note" onKeyDown={event => {
if (event.key === 'Enter') {
save();
}
event.stopPropagation();
}} />
</Modal.Body>
<Modal.Footer>
<Button variant="primary" onClick={save}>
Uložit
</Button>
</Modal.Footer>
</Modal>
}

View File

@@ -0,0 +1,141 @@
import { useRef, useState } from "react";
import { Modal, Button, Row, Col } from "react-bootstrap"
type Props = {
isOpen: boolean,
onClose: () => void,
}
type Result = {
pizza1?: {
diameter?: number,
area?: number,
pricePerM?: number,
},
pizza2?: {
diameter?: number,
area?: number,
pricePerM?: number,
}
choice?: number,
ratio?: number,
diameterDiff?: number,
}
/** Modální dialog pro výpočet výhodnosti pizzy. */
export default function PizzaCalculatorModal({ isOpen, onClose }: Props) {
const diameter1Ref = useRef<HTMLInputElement>(null);
const price1Ref = useRef<HTMLInputElement>(null);
const diameter2Ref = useRef<HTMLInputElement>(null);
const price2Ref = useRef<HTMLInputElement>(null);
const [result, setResult] = useState<Result | null>(null);
const recalculate = () => {
const r: Result = { ...result }
// 1. pizza
if (diameter1Ref.current?.value) {
const diameter1 = parseInt(diameter1Ref.current?.value);
if (!r.pizza1) {
r.pizza1 = {};
}
if (diameter1 && diameter1 > 0) {
r.pizza1.diameter = diameter1;
r.pizza1.area = Math.PI * Math.pow(diameter1 / 2, 2);
if (price1Ref.current?.value) {
const price1 = parseInt(price1Ref.current?.value);
if (price1) {
r.pizza1.pricePerM = price1 / r.pizza1.area;
} else {
r.pizza1.pricePerM = undefined;
}
}
} else {
r.pizza1.area = undefined;
}
}
// 2. pizza
if (diameter2Ref.current?.value) {
const diameter2 = parseInt(diameter2Ref.current?.value);
if (!r.pizza2) {
r.pizza2 = {};
}
if (diameter2 && diameter2 > 0) {
r.pizza2.diameter = diameter2;
r.pizza2.area = Math.PI * Math.pow(diameter2 / 2, 2);
if (price2Ref.current?.value) {
const price2 = parseInt(price2Ref.current?.value);
if (price2) {
r.pizza2.pricePerM = price2 / r.pizza2.area;
} else {
r.pizza2.pricePerM = undefined;
}
}
} else {
r.pizza2.area = undefined;
}
}
// Srovnání
if (r.pizza1?.pricePerM && r.pizza2?.pricePerM && r.pizza1.diameter && r.pizza2.diameter) {
r.choice = r.pizza1.pricePerM < r.pizza2.pricePerM ? 1 : 2;
const bigger = r.pizza1.pricePerM > r.pizza2.pricePerM ? r.pizza1.pricePerM : r.pizza2.pricePerM;
const smaller = r.pizza1.pricePerM < r.pizza2.pricePerM ? r.pizza1.pricePerM : r.pizza2.pricePerM;
r.ratio = (bigger / smaller) - 1;
r.diameterDiff = Math.abs(r.pizza1.diameter - r.pizza2.diameter);
} else {
r.choice = undefined;
r.ratio = undefined;
r.diameterDiff = undefined;
}
setResult(r);
}
const close = () => {
setResult(null);
onClose();
}
return <Modal show={isOpen} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title>Pizza kalkulačka</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>Zadejte parametry pizzy pro jejich srovnání.</p>
<Row>
<Col size="6">
<input className="mb-3" ref={diameter1Ref} type="number" step="1" min="1" placeholder="Průměr 1. pizzy (cm)" onChange={recalculate} onKeyDown={e => e.stopPropagation()} />
</Col>
<Col size="6">
<input className="mb-3" ref={diameter2Ref} type="number" step="1" min="1" placeholder="Průměr 2. pizzy (cm)" onChange={recalculate} onKeyDown={e => e.stopPropagation()} />
</Col>
</Row>
<Row>
<Col size="6">
<input className="mb-3" ref={price1Ref} type="number" min="1" placeholder="Cena 1. pizzy (Kč)" onChange={recalculate} onKeyDown={e => e.stopPropagation()} />
</Col>
<Col size="6">
<input className="mb-3" ref={price2Ref} type="number" min="1" placeholder="Cena 2. pizzy (Kč)" onChange={recalculate} onKeyDown={e => e.stopPropagation()} />
</Col>
</Row>
<Row>
<Col size="6">
{result?.pizza1?.area && <p>Plocha: <b>{Math.round(result.pizza1.area * 10) / 10}</b> cm²</p>}
{result?.pizza1?.pricePerM && <p>Cena za m²: <b>{Math.round(result.pizza1.pricePerM * 1000000) / 100}</b> </p>}
</Col>
<Col size="6">
{result?.pizza2?.area && <p>Plocha: <b>{Math.round(result.pizza2.area * 10) / 10}</b> cm²</p>}
{result?.pizza2?.pricePerM && <p>Cena za m²: <b>{Math.round(result.pizza2.pricePerM * 1000000) / 100}</b> </p>}
</Col>
</Row>
{(result?.choice && result?.ratio && result?.ratio > 0 && result?.diameterDiff != null && <p><b>{result.choice}. pizza</b> je zhruba o <b>{Math.round(result.ratio * 1000) / 10}%</b> výhodnější než {result.choice === 1 ? "2" : "1"}. pizza.</p>) || ''}
</Modal.Body>
<Modal.Footer>
<Button variant="primary" onClick={close}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
}

View File

@@ -1,33 +1,40 @@
import { useRef } from "react";
import { Modal, Button } from "react-bootstrap"
import { useBank } from "../../context/bank";
import { useSettings } from "../../context/settings";
type Props = {
isOpen: boolean,
onClose: () => void,
onSave: (bankAccountNumber?: string, bankAccountHolderName?: string) => void,
onSave: (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean) => void,
}
/** Modální dialog pro nastavení čísla účtu a jména majitele. */
export default function BankAccountModal({ isOpen, onClose, onSave }: Props) {
const bank = useBank();
/** Modální dialog pro uživatelská nastavení. */
export default function SettingsModal({ isOpen, onClose, onSave }: Props) {
const settings = useSettings();
const bankAccountRef = useRef<HTMLInputElement>(null);
const nameRef = useRef<HTMLInputElement>(null);
const hideSoupsRef = useRef<HTMLInputElement>(null);
return <Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title>Bankovní účet</Modal.Title>
<Modal.Title><h2>Nastavení</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
<h4>Obecné</h4>
<span title="V nabídkách nebudou zobrazovány polévky. Tato funkce je experimentální, a zejména u TechTower bývá často problém polévky spolehlivě rozeznat. V případě využití této funkce průběžně nahlašujte stále se zobrazující polévky." style={{ "cursor": "help" }}>
<input ref={hideSoupsRef} type="checkbox" defaultChecked={settings?.hideSoups} /> Skrýt polévky
</span>
<hr />
<h4>Bankovní účet</h4>
<p>Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day.<br />Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.<br /><br />Číslo účtu není ukládáno na serveru, posílá se na něj pouze za účelem vygenerování QR kódů.</p>
Číslo účtu: <input className="mb-3" ref={bankAccountRef} type="text" placeholder="123456-1234567890/1234" defaultValue={bank?.bankAccount} /> <br />
Název příjemce (jméno majitele účtu): <input ref={nameRef} type="text" placeholder="Jan Novák" defaultValue={bank?.holderName} />
Číslo účtu: <input className="mb-3" ref={bankAccountRef} type="text" placeholder="123456-1234567890/1234" defaultValue={settings?.bankAccount} onKeyDown={e => e.stopPropagation()} /> <br />
Název příjemce (jméno majitele účtu): <input ref={nameRef} type="text" placeholder="Jan Novák" defaultValue={settings?.holderName} onKeyDown={e => e.stopPropagation()} />
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>
Storno
</Button>
<Button variant="primary" onClick={() => onSave(bankAccountRef.current?.value, nameRef.current?.value)}>
<Button variant="primary" onClick={() => onSave(bankAccountRef.current?.value, nameRef.current?.value, hideSoupsRef.current?.checked)}>
Uložit
</Button>
</Modal.Footer>

View File

@@ -5,6 +5,7 @@ import { deleteToken, getToken, storeToken } from "../Utils";
export type AuthContextProps = {
login?: string,
trusted?: boolean,
setToken: (name: string) => void,
logout: () => void,
}
@@ -26,7 +27,8 @@ export const useAuth = () => {
function useProvideAuth(): AuthContextProps {
const [loginName, setLoginName] = useState<string | undefined>();
const [token, setToken] = useState<string | null>(getToken());
const [trusted, setTrusted] = useState<boolean | undefined>();
const [token, setToken] = useState<string | undefined>(getToken());
const { decodedToken } = useJwt(token || '');
useEffect(() => {
@@ -40,16 +42,19 @@ function useProvideAuth(): AuthContextProps {
useEffect(() => {
if (decodedToken) {
setLoginName((decodedToken as any).login);
setTrusted((decodedToken as any).trusted);
} else {
setLoginName(undefined);
setTrusted(undefined);
}
}, [decodedToken]);
function logout() {
const trusted = (decodedToken as any).trusted;
const logoutUrl = (decodedToken as any).logoutUrl;
setToken(null);
setToken(undefined);
setLoginName(undefined);
setTrusted(undefined);
if (trusted && logoutUrl?.length) {
window.location.replace(logoutUrl);
}
@@ -57,6 +62,7 @@ function useProvideAuth(): AuthContextProps {
return {
login: loginName,
trusted,
setToken,
logout,
}

View File

@@ -0,0 +1,24 @@
import { useEffect, useState } from "react";
import { getEasterEgg } from "../api/EasterEggApi";
import { AuthContextProps } from "./auth";
import { EasterEgg } from "../../../types";
export const useEasterEgg = (auth?: AuthContextProps | null): [EasterEgg | undefined, boolean] => {
const [result, setResult] = useState<EasterEgg | undefined>();
const [loading, setLoading] = useState(false);
useEffect(() => {
async function fetchEasterEgg() {
if (auth?.login) {
setLoading(true);
const egg = await getEasterEgg();
setResult(egg);
setLoading(false);
}
}
fetchEasterEgg();
}, [auth?.login]);
return [result, loading];
}

View File

@@ -3,32 +3,36 @@ import { useEffect } from "react"
const BANK_ACCOUNT_NUMBER_KEY = 'bank_account_number';
const BANK_ACCOUNT_HOLDER_KEY = 'bank_account_holder_name';
const HIDE_SOUPS_KEY = 'hide_soups';
export type BankContextProps = {
export type SettingsContextProps = {
bankAccount?: string,
holderName?: string,
hideSoups?: boolean,
setBankAccountNumber: (accountNumber?: string) => void,
setBankAccountHolderName: (holderName?: string) => void,
setHideSoupsOption: (hideSoups?: boolean) => void,
}
type ContextProps = {
children: ReactNode
}
const bankContext = React.createContext<BankContextProps | null>(null);
const settingsContext = React.createContext<SettingsContextProps | null>(null);
export function ProvideBank(props: ContextProps) {
const bank = useProvideBank();
return <bankContext.Provider value={bank}>{props.children}</bankContext.Provider>
export function ProvideSettings(props: ContextProps) {
const settings = useProvideSettings();
return <settingsContext.Provider value={settings}>{props.children}</settingsContext.Provider>
}
export const useBank = () => {
return useContext(bankContext);
export const useSettings = () => {
return useContext(settingsContext);
}
function useProvideBank(): BankContextProps {
function useProvideSettings(): SettingsContextProps {
const [bankAccount, setBankAccount] = useState<string | undefined>();
const [holderName, setHolderName] = useState<string | undefined>();
const [hideSoups, setHideSoups] = useState<boolean | undefined>();
useEffect(() => {
const accountNumber = localStorage.getItem(BANK_ACCOUNT_NUMBER_KEY);
@@ -39,6 +43,10 @@ function useProvideBank(): BankContextProps {
if (holderName) {
setHolderName(holderName);
}
const hideSoups = localStorage.getItem(HIDE_SOUPS_KEY);
if (hideSoups !== null) {
setHideSoups(hideSoups === 'true' ? true : false);
}
}, [])
useEffect(() => {
@@ -57,6 +65,14 @@ function useProvideBank(): BankContextProps {
}
}, [holderName]);
useEffect(() => {
if (hideSoups) {
localStorage.setItem(HIDE_SOUPS_KEY, hideSoups ? 'true' : 'false');
} else {
localStorage.removeItem(HIDE_SOUPS_KEY);
}
}, [hideSoups]);
function setBankAccountNumber(bankAccount?: string) {
setBankAccount(bankAccount);
}
@@ -65,10 +81,16 @@ function useProvideBank(): BankContextProps {
setHolderName(holderName);
}
function setHideSoupsOption(hideSoups?: boolean) {
setHideSoups(hideSoups);
}
return {
bankAccount,
holderName,
hideSoups,
setBankAccountNumber,
setBankAccountHolderName,
setHideSoupsOption,
}
}

View File

@@ -1,25 +1,20 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { SocketContext, socket } from './context/socket';
import { ProvideAuth } from './context/auth';
import { ToastContainer } from 'react-toastify';
import { ProvideBank } from './context/bank';
import 'react-toastify/dist/ReactToastify.css';
import './index.css';
import AppRoutes from './AppRoutes';
import { BrowserRouter } from 'react-router';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<BrowserRouter>
<ProvideAuth>
<ProvideBank>
<SocketContext.Provider value={socket}>
<App />
<ToastContainer />
</SocketContext.Provider>
</ProvideBank>
<AppRoutes />
</ProvideAuth>
</BrowserRouter>
</React.StrictMode>
);

View File

@@ -0,0 +1,16 @@
.stats-page {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
.week-navigator {
display: flex;
align-items: center;
font-size: xx-large;
.date-range {
margin: 5px 20px;
}
}
}

View File

@@ -0,0 +1,124 @@
import { useCallback, useEffect, useState } from "react";
import Footer from "../components/Footer";
import Header from "../components/Header";
import { useAuth } from "../context/auth";
import Login from "../Login";
import { formatDate, getFirstWorkDayOfWeek, getHumanDate, getLastWorkDayOfWeek } from "../Utils";
import { getStats } from "../api/StatsApi";
import { WeeklyStats, LunchChoice } from "../../../types";
import Loader from "../components/Loader";
import { faChevronLeft, faChevronRight, faGear } from "@fortawesome/free-solid-svg-icons";
import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import './StatsPage.scss';
const CHART_WIDTH = 1400;
const CHART_HEIGHT = 700;
const STROKE_WIDTH = 2.5;
const COLORS = [
// Komentáře jsou kvůli vizualizaci barev ve VS Code
'#ff1493', // #ff1493
'#1e90ff', // #1e90ff
'#c5a700', // #c5a700
'#006400', // #006400
'#b300ff', // #b300ff
'#ff4500', // #ff4500
'#bc8f8f', // #bc8f8f
'#00ff00', // #00ff00
'#7c7c7c', // #7c7c7c
]
export default function StatsPage() {
const auth = useAuth();
const [dateRange, setDateRange] = useState<Date[]>();
const [data, setData] = useState<WeeklyStats>();
// Prvotní nastavení aktuálního týdne
useEffect(() => {
const today = new Date();
setDateRange([getFirstWorkDayOfWeek(today), getLastWorkDayOfWeek(today)]);
}, []);
// Přenačtení pro zvolený týden
useEffect(() => {
if (dateRange) {
getStats(formatDate(dateRange[0]), formatDate(dateRange[1])).then(setData);
}
}, [dateRange]);
const renderLine = (location: LunchChoice) => {
const index = Object.values(LunchChoice).indexOf(location);
const key = Object.keys(LunchChoice)[index];
return <Line key={location} name={location} type="monotone" dataKey={data => data.locations[key] ?? 0} stroke={COLORS[index]} strokeWidth={STROKE_WIDTH} />
}
const handlePreviousWeek = () => {
if (dateRange) {
const previousStartDate = new Date(dateRange[0]);
previousStartDate.setDate(previousStartDate.getDate() - 7);
const previousEndDate = new Date(previousStartDate);
previousEndDate.setDate(previousEndDate.getDate() + 4);
setDateRange([previousStartDate, previousEndDate]);
}
}
const handleNextWeek = () => {
if (dateRange) {
const nextStartDate = new Date(dateRange[0]);
nextStartDate.setDate(nextStartDate.getDate() + 7);
const nextEndDate = new Date(nextStartDate);
nextEndDate.setDate(nextEndDate.getDate() + 4);
setDateRange([nextStartDate, nextEndDate]);
}
}
const handleKeyDown = useCallback((e: any) => {
if (e.keyCode === 37) {
handlePreviousWeek();
} else if (e.keyCode === 39) {
handleNextWeek()
}
}, [dateRange]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
}
}, [handleKeyDown]);
if (!auth?.login) {
return <Login />;
}
if (!dateRange) {
return <Loader
icon={faGear}
description={'Načítám data...'}
animation={'fa-bounce'}
/>
}
return (
<>
<Header />
<div className="stats-page">
<h1>Statistiky</h1>
<div className="week-navigator">
<FontAwesomeIcon title="Předchozí týden" icon={faChevronLeft} style={{ cursor: "pointer" }} onClick={handlePreviousWeek} />
<h2 className="date-range">{getHumanDate(dateRange[0])} - {getHumanDate(dateRange[1])}</h2>
<FontAwesomeIcon title="Následující týden" icon={faChevronRight} style={{ cursor: "pointer" }} onClick={handleNextWeek} />
</div>
<LineChart width={CHART_WIDTH} height={CHART_HEIGHT} data={data}>
{Object.values(LunchChoice).map(location => renderLine(location))}
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Legend />
</LineChart>
</div>
<Footer />
</>
);
}

View File

@@ -1 +0,0 @@
/// <reference types="react-scripts" />

View File

@@ -1,11 +1,14 @@
{
"compilerOptions": {
"target": "es5",
"target": "ESNext",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"types": [
"vite/client"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,

1
client/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

16
client/vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import viteTsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
// depending on your application, base can also be "/"
base: '',
plugins: [react(), viteTsconfigPaths()],
server: {
open: true,
port: 3000,
proxy: {
'/api': 'http://localhost:3001',
}
},
})

1795
client/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,3 @@
version: '3.8'
services:
redis:
image: redis/redis-stack-server:7.2.0-RC3

View File

@@ -1,7 +0,0 @@
{
"private": true,
"workspaces": [
"client",
"server"
]
}

View File

@@ -1,5 +1,5 @@
export NODE_ENV=development
yarn install
cd server && yarn start &
cd client && yarn start &
cd types && yarn install && yarn openapi-ts
cd server && yarn install && yarn start &
cd client && yarn install && yarn start &
wait

5
server/.gitignore vendored
View File

@@ -1,5 +1,6 @@
/node_modules
/dist
data.json
/resources/easterEggs
/src/gen
.env.production
.env.development
.easter-eggs.json

View File

@@ -6,7 +6,7 @@
"private": true,
"scripts": {
"start": "ts-node src/index.ts",
"startReload": "nodemon src/index.ts",
"startReload": "nodemon --watch src src/index.ts",
"build": "tsc -p .",
"test": "jest"
},
@@ -15,12 +15,13 @@
"@babel/preset-env": "^7.22.20",
"@babel/preset-typescript": "^7.23.0",
"@types/express": "^4.17.17",
"@types/jsonwebtoken": "^9.0.2",
"@types/node": "^20.2.5",
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.11.20",
"@types/request-promise": "^4.1.48",
"babel-jest": "^29.7.0",
"jest": "^29.7.0",
"nodemon": "^2.0.22",
"nodemon": "^3.1.0",
"ts-node": "^10.9.1",
"typescript": "^5.0.2"
},
@@ -28,7 +29,7 @@
"axios": "^1.4.0",
"cheerio": "^1.0.0-rc.12",
"cors": "^2.8.5",
"dotenv": "^16.1.3",
"dotenv": "^16.4.5",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.0",
"redis": "^4.6.7",

View File

@@ -11,6 +11,8 @@ import { initWebsocket } from "./websocket";
import pizzaDayRoutes from "./routes/pizzaDayRoutes";
import foodRoutes from "./routes/foodRoutes";
import votingRoutes from "./routes/votingRoutes";
import easterEggRoutes from "./routes/easterEggRoutes";
import statsRoutes from "./routes/statsRoutes";
const ENVIRONMENT = process.env.NODE_ENV || 'production';
dotenv.config({ path: path.resolve(__dirname, `./.env.${ENVIRONMENT}`) });
@@ -32,7 +34,7 @@ app.use(cors({
}));
// Zapínatelný login přes hlavičky - pokud je zapnutý nepovolí "basicauth"
const HTTP_REMOTE_USER_ENABLED = process.env.HTTP_REMOTE_USER_ENABLED || false;
const HTTP_REMOTE_USER_ENABLED = process.env.HTTP_REMOTE_USER_ENABLED === 'true' || false;
const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME || 'remote-user';
if (HTTP_REMOTE_USER_ENABLED) {
if (!process.env.HTTP_REMOTE_TRUSTED_IPS) {
@@ -62,6 +64,8 @@ app.post("/api/login", (req, res) => {
const remoteName = req.header('remote-name');
if (remoteUser && remoteUser.length > 0 && remoteName && remoteName.length > 0) {
res.status(200).json(generateToken(Buffer.from(remoteName, 'latin1').toString(), true));
} else {
throw Error("Tohle nema nastat nekdo neco dela spatne.");
}
} else {
// Klasická autentizace loginem
@@ -97,9 +101,11 @@ app.use("/api/", (req, res, next) => {
const emailHeader = req.header('remote-email');
if (userHeader !== undefined && nameHeader !== undefined) {
const remoteName = Buffer.from(nameHeader, 'latin1').toString();
if (ENVIRONMENT !== "production") {
console.log("Tvuj username, name a email: %s, %s, %s.", userHeader, remoteName, emailHeader);
}
}
}
if (!req.headers.authorization) {
return res.status(401).json({ error: 'Nebyl předán autentizační token' });
}
@@ -126,7 +132,11 @@ app.get("/api/data", async (req, res) => {
app.use("/api/pizzaDay", pizzaDayRoutes);
app.use("/api/food", foodRoutes);
app.use("/api/voting", votingRoutes);
app.use(express.static('public'))
app.use("/api/easterEggs", easterEggRoutes);
app.use("/api/stats", statsRoutes);
app.use('/stats', express.static('public'));
app.use(express.static('public'));
// Middleware pro zpracování chyb
app.use((err: any, req: any, res: any, next: any) => {

View File

@@ -1,4 +1,4 @@
import { getDayOfWeekIndex } from "./utils";
import { WeeklyStats, LunchChoice } from "../../types";
// Mockovací data pro podporované podniky, na jeden týden
const MOCK_DATA = {
@@ -277,13 +277,25 @@ const MOCK_DATA = {
{
amount: "-",
name: "Čočka na kyselo, opečená klobása, okurka, chléb",
price: "120\xA0Kč",
price: "130\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Kuřecí medailonky se sýrovou omáčkou, hranolky",
price: "170\xA0",
name: "Smažená brokolice, brambory, tatarská omáčka",
price: "na\xA0váhu",
isSoup: false,
},
{
amount: "-",
name: "Uzený vepřový bůček, bramborové pyré",
price: "na\xA0váhu",
isSoup: false,
},
{
amount: "-",
name: "Kuřecí medailonky v sýrové omáčce, hranolky",
price: "na\xA0váhu",
isSoup: false,
}
],
@@ -297,13 +309,25 @@ const MOCK_DATA = {
{
amount: "-",
name: "Zvěřinový guláš, knedlík",
price: "120\xA0Kč",
price: "130\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Smažený hermelín, brambory, tatarská omáčka",
price: "170\xA0",
name: "Čínské nudle se zeleninou a vejcem",
price: "na\xA0váhu",
isSoup: false,
},
{
amount: "-",
name: "Jitrnice/jelito, brambory, zelný salát s křenem, hořčice",
price: "na\xA0váhu",
isSoup: false,
},
{
amount: "-",
name: "Vídeňská roštěná se smaženou cibulkou, jasmínová rýže",
price: "na\xA0váhu",
isSoup: false,
}
],
@@ -317,13 +341,25 @@ const MOCK_DATA = {
{
amount: "-",
name: "Kuřecí směs se zeleninou, rýže",
price: "120\xA0Kč",
price: "130\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Hambuger Black Angus s čedarem a slaninou, cibulové kroužky",
price: "220\xA0",
name: "Tvarohové knedlíky s meruňkami, strouhaný tvaroh, máslo, cukr",
price: "na\xA0váhu",
isSoup: false,
},
{
amount: "-",
name: "Ovar, křen, hořčice, pečivo",
price: "na\xA0váhu",
isSoup: false,
},
{
amount: "-",
name: "Telecí holandský řízek s uzeným sýrem, bramborové pyré",
price: "na\xA0váhu",
isSoup: false,
}
],
@@ -337,13 +373,25 @@ const MOCK_DATA = {
{
amount: "-",
name: "Rizoto s vepřovým masem, okurka",
price: "120\xA0Kč",
price: "130\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Steak z lososa, grilovaná zelenina",
price: "220\xA0",
name: "Tortellini s parmezánovou omáčkou",
price: "na\xA0váhu",
isSoup: false,
},
{
amount: "-",
name: "Pečený prejt, brambory, zelný salát",
price: "na\xA0váhu",
isSoup: false,
},
{
amount: "-",
name: "Chobotnice na grilu, grilovaná zelenina, bylinková bageta",
price: "na\xA0váhu",
isSoup: false,
}
],
@@ -357,17 +405,203 @@ const MOCK_DATA = {
{
amount: "-",
name: "Krůtí perkelt, těstoviny",
price: "120\xA0Kč",
price: "130\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Grilovaná vepřová panenka, parmazánové pyré",
price: "170\xA0",
name: "Grilovaný hermelín, bulgurový salát se zeleninou",
price: "na\xA0váhu",
isSoup: false,
},
{
amount: "-",
name: "Zabijačkový guláš, karlovarský knedlík",
price: "na\xA0váhu",
isSoup: false,
},
{
amount: "-",
name: "Vepřový plátek na žampionech, jasmínová rýže",
price: "na\xA0váhu",
isSoup: false,
}
]
]
],
'zastavkaUmichala': [
[
{
amount: "-",
name: "Fazolačka s klobásou & zakysačkou",
price: "39\xA0Kč",
isSoup: true,
},
{
amount: "-",
name: "Zeleninová musaka lilek, cuketa, tomatové sugo & sýrový bešamel",
price: "135\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Slovácké strapačky s uzenou slaninou, zelím, mletým pepřem & sekanou petrželkou",
price: "140\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Hovězí guláš s vejcem, zeleninovou garniturkou & žemlovými knedlíky",
price: "145\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Kuřecí roláda s kaštanovou nádivkou, demi-glace & smetanovou bramborovou kaší",
price: "150\xA0Kč",
isSoup: false,
}
],
[
{
amount: "-",
name: "Hovězí vývar se zeleninou a játrovou rýží",
price: "39\xA0Kč",
isSoup: true,
},
{
amount: "-",
name: "Pečené vepřové koleno, křen, hořčice, chléb",
price: "320\xA0Kč",
isSoup: false,
}
],
[
{
amount: "-",
name: "Zeleninová polévka s kuskusem",
price: "39\xA0Kč",
isSoup: true,
},
{
amount: "-",
name: "Poutine (trhané vepřové, hranolky, sýr, čalamáda, pikantní omáčka)",
price: "190\xA0Kč",
isSoup: false,
}
],
[
{
amount: "-",
name: "Hrachová polévka s uzeninou",
price: "39\xA0Kč",
isSoup: true,
},
{
amount: "-",
name: "Vepřový řízek z kotlety, domácí bramborový salát",
price: "170\xA0Kč",
isSoup: false,
}
],
[
{
amount: "-",
name: "Cibulačka se sýrem",
price: "39\xA0Kč",
isSoup: true,
},
{
amount: "-",
name: "Burger z Chuck rollu, hranolky, tatarská omáčka",
price: "200\xA0Kč",
isSoup: false,
}
],
],
'senkSerikova': [
[
{
amount: "-",
name: "Drůbeží vývar s masem a nudlemi",
price: "45\xA0Kč",
isSoup: true,
},
{
amount: "-",
name: "Vepřová pečeně se zelím a houskovým knedlíkem",
price: "155\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Špagety s kuřecím masem, špenátem a smetanou",
price: "145\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Medailonky z vepřové panenky s fazolkami se slaninou, šťouchané brambory",
price: "185\xA0Kč",
isSoup: false,
}
],
[
{
amount: "-",
name: "Mrkvová polévka se zázvorem a kokosovým mlékem",
price: "45\xA0Kč",
isSoup: true,
},
{
amount: "-",
name: "Hovězí po Burgundsku, bramborová kaše",
price: "155\xA0Kč",
isSoup: false,
}
],
[
{
amount: "-",
name: "Hovězí vývar s játrovými knedlíčky",
price: "45\xA0Kč",
isSoup: true,
},
{
amount: "-",
name: "Kuřecí plátky na sušených rajčatech, bylinkách a česneku, bramborová kaše",
price: "155\xA0Kč",
isSoup: false,
}
],
[
{
amount: "-",
name: "Kuřecí vývar s rýží",
price: "45\xA0Kč",
isSoup: true,
},
{
amount: "-",
name: "Rajská s plněnou paprikou, knedlík",
price: "170\xA0Kč",
isSoup: false,
}
],
[
{
amount: "-",
name: "Mexická fazolová polévka",
price: "45\xA0Kč",
isSoup: true,
},
{
amount: "-",
name: "Ragú z trhané kachny, onsen vejce, soté ze špenátu a ředkvičky, bramborové pyré, lanýžová sůl, zelený olej",
price: "189\xA0Kč",
isSoup: false,
}
],
],
}
// Mockovací data pro Pizza day
@@ -1120,8 +1354,11 @@ const MOCK_PIZZA_LIST = [
}
]
export const getTodayMock = () => {
return '2023-05-31'; // středa
/**
* Funkce vrací mock datu ve formátu YYYY-MM-DD
*/
export const getTodayMock = (): Date => {
return new Date('2025-01-10'); // pátek
}
export const getMenuSladovnickaMock = () => {
@@ -1136,6 +1373,39 @@ export const getMenuTechTowerMock = () => {
return MOCK_DATA['techTower'];
}
export const getMenuZastavkaUmichalaMock = () => {
return MOCK_DATA['zastavkaUmichala'];
}
export const getMenuSenkSerikovaMock = () => {
return MOCK_DATA['senkSerikova'];
}
export const getPizzaListMock = () => {
return MOCK_PIZZA_LIST;
}
export const getStatsMock = (): WeeklyStats => {
return [
{
date: '24.02.',
locations: { ...Object.keys(LunchChoice).reduce((prev, cur) => ({ ...prev, [cur]: Math.floor(Math.random() * 10) }), {}) }
},
{
date: '25.02.',
locations: { ...Object.keys(LunchChoice).reduce((prev, cur) => ({ ...prev, [cur]: Math.floor(Math.random() * 10) }), {}) }
},
{
date: '26.02.',
locations: { ...Object.keys(LunchChoice).reduce((prev, cur) => ({ ...prev, [cur]: Math.floor(Math.random() * 10) }), {}) }
},
{
date: '27.02.',
locations: { ...Object.keys(LunchChoice).reduce((prev, cur) => ({ ...prev, [cur]: Math.floor(Math.random() * 10) }), {}) }
},
{
date: '28.02.',
locations: { ...Object.keys(LunchChoice).reduce((prev, cur) => ({ ...prev, [cur]: Math.floor(Math.random() * 10) }), {}) }
}
];
}

View File

@@ -1,60 +1,60 @@
/** Notifikace pro gotify*/
import { ClientData, GotifyServer, NotififaceInput, NotifikaceData } from '../../types';
import axios, { AxiosError } from 'axios';
/** Notifikace */
import axios from 'axios';
import dotenv from 'dotenv';
import path from 'path';
import { getToday } from "./service";
import { formatDate, getUsersByLocation } from "./utils";
import { getClientData, getToday } from "./service";
import { getUsersByLocation, getHumanTime } from "./utils";
import getStorage from "./storage";
import { NotifikaceData, NotifikaceInput } from '../../types';
const storage = getStorage();
const ENVIRONMENT = process.env.NODE_ENV || 'production'
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
const gotifyDataRaw = process.env.GOTIFY_SERVERS_AND_KEYS || "{}";
const gotifyData: GotifyServer[] = JSON.parse(gotifyDataRaw);
export const gotifyCall = async (data: NotififaceInput, gotifyServers?: GotifyServer[]): Promise<any[]> => {
if (!Array.isArray(gotifyServers)) {
return []
}
const urls = gotifyServers.flatMap(gotifyServer =>
gotifyServer.api_keys.map(apiKey => `${gotifyServer.server}/message?token=${apiKey}`));
// const gotifyDataRaw = process.env.GOTIFY_SERVERS_AND_KEYS || "{}";
// const gotifyData: GotifyServer[] = JSON.parse(gotifyDataRaw);
// export const gotifyCall = async (data: NotififaceInput, gotifyServers?: GotifyServer[]): Promise<any[]> => {
// if (!Array.isArray(gotifyServers)) {
// return []
// }
// const urls = gotifyServers.flatMap(gotifyServer =>
// gotifyServer.api_keys.map(apiKey => `${gotifyServer.server}/message?token=${apiKey}`));
//
// const dataPayload = {
// title: "Luncher",
// message: `${data.udalost} - spustil:${data.user}`,
// priority: 7,
// };
//
// const headers = { "Content-Type": "application/json" };
//
// const promises = urls.map(url =>
// axios.post(url, dataPayload, { headers }).then(response => {
// response.data = {
// success: true,
// message: "Notifikace doručena",
// };
// return response;
// }).catch(error => {
// if (axios.isAxiosError(error)) {
// const axiosError = error as AxiosError;
// if (axiosError.response) {
// axiosError.response.data = {
// success: false,
// message: "fail",
// };
// console.log(error)
// return axiosError.response;
// }
// }
// // Handle unknown error without a response
// console.log(error, "unknown error");
// })
// );
// return promises;
// };
const dataPayload = {
title: "Luncher",
message: `${data.udalost} - spustil:${data.user}`,
priority: 7,
};
const headers = { "Content-Type": "application/json" };
const promises = urls.map(url =>
axios.post(url, dataPayload, { headers }).then(response => {
response.data = {
success: true,
message: "Notifikace doručena",
};
return response;
}).catch(error => {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError;
if (axiosError.response) {
axiosError.response.data = {
success: false,
message: "fail",
};
console.log(error)
return axiosError.response;
}
}
// Handle unknown error without a response
console.log(error, "unknown error");
})
);
return promises;
};
export const ntfyCall = async (data: NotififaceInput) => {
export const ntfyCall = async (data: NotifikaceInput) => {
const url = process.env.NTFY_HOST
const username = process.env.NTFY_USERNAME;
const password = process.env.NTFY_PASSWD;
@@ -70,8 +70,7 @@ export const ntfyCall = async (data: NotififaceInput) => {
console.log("NTFY_PASSWD není definován v env")
return
}
const today = formatDate(getToday());
let clientData: ClientData = await storage.getData(today);
let clientData = await getClientData(getToday());
const userByCLocation = getUsersByLocation(clientData.choices, data.user)
const token = Buffer.from(`${username}:${password}`, 'utf8').toString('base64');
@@ -97,27 +96,64 @@ export const ntfyCall = async (data: NotififaceInput) => {
return promises;
}
export const teamsCall = async (data: NotifikaceInput) => {
const url = process.env.TEAMS_WEBHOOK_URL;
const title = data.udalost;
let time = new Date();
time.setTime(time.getTime() + 1000 * 60);
const message = 'Odcházíme v ' + getHumanTime(time) + ', ' + data.user;
const card = {
'@type': 'MessageCard',
'@context': 'http://schema.org/extensions',
'themeColor': "0072C6", // light blue
summary: 'Summary description',
sections: [
{
activityTitle: title,
text: message,
},
],
};
if (!url) {
console.log("TEAMS_WEBHOOK_URL není definován v env")
return
}
try {
const response = await axios.post(url, card, {
headers: {
'content-type': 'application/vnd.microsoft.teams.card.o365connector'
},
});
return `${response.status} - ${response.statusText}`;
} catch (err) {
return err;
}
}
/** Zavolá notifikace na všechny konfigurované způsoby notifikace, přetížení proměných na false pro jednotlivé způsoby je vypne*/
export const callNotifikace = async ({ input, teams = true, gotify = false, ntfy = true }: NotifikaceData) => {
const notifications = [];
if (ntfy) {
const ntfyPromises = await ntfyCall(input);
if (ntfyPromises) {
notifications.push(...ntfyPromises);
}
}
/* Zatím není
if (teams) {
notifications.push(teamsCall(input));
}*/
// Add more notifications as necessary
//gotify bych řekl, že už je deprecated
if (gotify) {
const gotifyPromises = await gotifyCall(input, gotifyData);
notifications.push(...gotifyPromises);
const teamsPromises = await teamsCall(input);
if (teamsPromises) {
notifications.push(teamsPromises);
}
}
// gotify bych řekl, že už je deprecated
// if (gotify) {
// const gotifyPromises = await gotifyCall(input, gotifyData);
// notifications.push(...gotifyPromises);
// }
try {
const results = await Promise.all(notifications);

View File

@@ -1,10 +1,10 @@
import { formatDate } from "./utils";
import { callNotifikace } from "./notifikace";
import { generateQr } from "./qr";
import { ClientData, PizzaDayState, UdalostEnum, Pizza, PizzaSize, Order, PizzaOrder, DayData } from "../../types";
import getStorage from "./storage";
import { downloadPizzy } from "./chefie";
import { getToday, initIfNeeded } from "./service";
import { getClientData, getToday, initIfNeeded } from "./service";
import { Pizza, ClientData, PizzaDayState, PizzaSize, PizzaOrder, PizzaVariant, UdalostEnum } from "../../types";
const storage = getStorage();
@@ -14,8 +14,7 @@ const storage = getStorage();
*/
export async function getPizzaList(): Promise<Pizza[] | undefined> {
await initIfNeeded();
const today = formatDate(getToday());
let clientData: DayData = await storage.getData(today);
let clientData = await getClientData(getToday());
if (!clientData.pizzaList) {
const mock = process.env.MOCK_DATA === 'true';
clientData = await savePizzaList(await downloadPizzy(mock));
@@ -31,9 +30,9 @@ export async function getPizzaList(): Promise<Pizza[] | undefined> {
export async function savePizzaList(pizzaList: Pizza[]): Promise<ClientData> {
await initIfNeeded();
const today = formatDate(getToday());
const clientData: DayData = await storage.getData(today);
const clientData = await getClientData(getToday());
clientData.pizzaList = pizzaList;
clientData.pizzaListLastUpdate = new Date();
clientData.pizzaListLastUpdate = formatDate(new Date());
await storage.setData(today, clientData);
return clientData;
}
@@ -43,14 +42,14 @@ export async function savePizzaList(pizzaList: Pizza[]): Promise<ClientData> {
*/
export async function createPizzaDay(creator: string): Promise<ClientData> {
await initIfNeeded();
const today = formatDate(getToday());
const clientData: DayData = await storage.getData(today);
const clientData = await getClientData(getToday());
if (clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den již existuje");
}
// TODO berka rychlooprava, vyřešit lépe - stahovat jednou, na jediném místě!
const pizzaList = await getPizzaList();
const data: ClientData = { pizzaDay: { state: PizzaDayState.CREATED, creator, orders: [] }, pizzaList, ...clientData };
const today = formatDate(getToday());
await storage.setData(today, data);
callNotifikace({ input: { udalost: UdalostEnum.ZAHAJENA_PIZZA, user: creator } })
return data;
@@ -60,8 +59,7 @@ export async function createPizzaDay(creator: string): Promise<ClientData> {
* Smaže pizza day pro aktuální den.
*/
export async function deletePizzaDay(login: string): Promise<ClientData> {
const today = formatDate(getToday());
const clientData: DayData = await storage.getData(today);
const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
@@ -69,6 +67,7 @@ export async function deletePizzaDay(login: string): Promise<ClientData> {
throw Error("Login uživatele se neshoduje se zakladatelem Pizza Day");
}
delete clientData.pizzaDay;
const today = formatDate(getToday());
await storage.setData(today, clientData);
return clientData;
}
@@ -82,28 +81,35 @@ export async function deletePizzaDay(login: string): Promise<ClientData> {
*/
export async function addPizzaOrder(login: string, pizza: Pizza, size: PizzaSize) {
const today = formatDate(getToday());
const clientData: DayData = await storage.getData(today);
const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
}
let order: Order | undefined = clientData.pizzaDay.orders.find(o => o.customer === login);
let order: PizzaOrder | undefined = clientData.pizzaDay?.orders?.find(o => o.customer === login);
if (!order) {
order = {
customer: login,
pizzaList: [],
totalPrice: 0,
hasQr: false,
}
if (!clientData.pizzaDay.orders) {
clientData.pizzaDay.orders = [];
}
clientData.pizzaDay.orders.push(order);
}
const pizzaOrder: PizzaOrder = {
const pizzaOrder: PizzaVariant = {
varId: size.varId,
name: pizza.name,
size: size.size,
price: size.price,
}
if (!order.pizzaList) {
order.pizzaList = [];
}
order.pizzaList.push(pizzaOrder);
order.totalPrice += pizzaOrder.price;
await storage.setData(today, clientData);
@@ -116,26 +122,26 @@ export async function addPizzaOrder(login: string, pizza: Pizza, size: PizzaSize
* @param login login uživatele
* @param pizzaOrder objednávka pizzy
*/
export async function removePizzaOrder(login: string, pizzaOrder: PizzaOrder) {
export async function removePizzaOrder(login: string, pizzaOrder: PizzaVariant) {
const today = formatDate(getToday());
const clientData: DayData = await storage.getData(today);
const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
const orderIndex = clientData.pizzaDay.orders.findIndex(o => o.customer === login);
const orderIndex = clientData.pizzaDay!.orders!.findIndex(o => o.customer === login);
if (orderIndex < 0) {
throw Error("Nebyly nalezeny žádné objednávky pro uživatele " + login);
}
const order = clientData.pizzaDay.orders[orderIndex];
const index = order.pizzaList.findIndex(o => o.name === pizzaOrder.name && o.size === pizzaOrder.size);
const order = clientData.pizzaDay!.orders![orderIndex];
const index = order.pizzaList!.findIndex(o => o.name === pizzaOrder.name && o.size === pizzaOrder.size);
if (index < 0) {
throw Error("Objednávka s danými parametry nebyla nalezena");
}
const price = order.pizzaList[index].price;
order.pizzaList.splice(index, 1);
const price = order.pizzaList![index].price;
order.pizzaList!.splice(index, 1);
order.totalPrice -= price;
if (order.pizzaList.length == 0) {
clientData.pizzaDay.orders.splice(orderIndex, 1);
if (order.pizzaList!.length == 0) {
clientData.pizzaDay.orders!.splice(orderIndex, 1);
}
await storage.setData(today, clientData);
return clientData;
@@ -149,7 +155,7 @@ export async function removePizzaOrder(login: string, pizzaOrder: PizzaOrder) {
*/
export async function lockPizzaDay(login: string) {
const today = formatDate(getToday());
const clientData: DayData = await storage.getData(today);
const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
@@ -172,7 +178,7 @@ export async function lockPizzaDay(login: string) {
*/
export async function unlockPizzaDay(login: string) {
const today = formatDate(getToday());
const clientData: DayData = await storage.getData(today);
const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
@@ -195,7 +201,7 @@ export async function unlockPizzaDay(login: string) {
*/
export async function finishPizzaOrder(login: string) {
const today = formatDate(getToday());
const clientData: DayData = await storage.getData(today);
const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
@@ -220,7 +226,7 @@ export async function finishPizzaOrder(login: string) {
*/
export async function finishPizzaDelivery(login: string, bankAccount?: string, bankAccountHolder?: string) {
const today = formatDate(getToday());
const clientData: DayData = await storage.getData(today);
const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
@@ -234,9 +240,9 @@ export async function finishPizzaDelivery(login: string, bankAccount?: string, b
// Vygenerujeme QR kód, pokud k tomu máme data
if (bankAccount?.length && bankAccountHolder?.length) {
for (const order of clientData.pizzaDay.orders) {
for (const order of clientData.pizzaDay.orders!) {
if (order.customer !== login) { // zatím platí creator = objednávající, a pro toho nemá QR kód smysl
let message = order.pizzaList.map(pizza => `Pizza ${pizza.name} (${pizza.size})`).join(', ');
let message = order.pizzaList!.map(pizza => `Pizza ${pizza.name} (${pizza.size})`).join(', ');
await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice, message);
order.hasQr = true;
}
@@ -255,15 +261,15 @@ export async function finishPizzaDelivery(login: string, bankAccount?: string, b
*/
export async function updatePizzaDayNote(login: string, note?: string) {
const today = formatDate(getToday());
let clientData: DayData = await storage.getData(today);
let clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
}
const myOrder = clientData.pizzaDay.orders.find(o => o.customer === login);
if (!myOrder || !myOrder.pizzaList.length) {
const myOrder = clientData.pizzaDay.orders!.find(o => o.customer === login);
if (!myOrder?.pizzaList?.length) {
throw Error("Pizza day neobsahuje žádné objednávky uživatele " + login);
}
myOrder.note = note;
@@ -282,7 +288,7 @@ export async function updatePizzaDayNote(login: string, note?: string) {
*/
export async function updatePizzaFee(login: string, targetLogin: string, text?: string, price?: number) {
const today = formatDate(getToday());
let clientData: DayData = await storage.getData(today);
let clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
@@ -292,8 +298,8 @@ export async function updatePizzaFee(login: string, targetLogin: string, text?:
if (clientData.pizzaDay.creator !== login) {
throw Error("Příplatky může měnit pouze zakladatel Pizza day");
}
const targetOrder = clientData.pizzaDay.orders.find(o => o.customer === targetLogin);
if (!targetOrder || !targetOrder.pizzaList.length) {
const targetOrder = clientData.pizzaDay.orders!.find(o => o.customer === targetLogin);
if (!targetOrder?.pizzaList?.length) {
throw Error(`Pizza day neobsahuje žádné objednávky uživatele ${targetLogin}`);
}
if (!price) {

View File

@@ -1,16 +1,33 @@
import axios from "axios";
import { load } from 'cheerio';
import { getMenuSladovnickaMock, getMenuTechTowerMock, getMenuUMotlikuMock, getMenuZastavkaUmichalaMock, getMenuSenkSerikovaMock } from "./mock";
import { formatDate } from "./utils";
import { Food } from "../../types";
import { getMenuSladovnickaMock, getMenuTechTowerMock, getMenuUMotlikuMock } from "./mock";
// Fráze v názvech jídel, které naznačují že se jedná o polévku
const SOUP_NAMES = ['polévka', 'česnečka', 'česnekový krém', 'cibulačka', 'vývar']
const SOUP_NAMES = [
'polévka',
'česnečka',
'česnekový krém',
'cibulačka',
'vývar',
'fazolová',
'cuketový krém',
'boršč',
'slepičí s ',
'zeleninová s ',
'hovězí s ',
'kachní kaldoun',
'dršťková'
];
const DAYS_IN_WEEK = ['pondělí', 'úterý', 'středa', 'čtvrtek', 'pátek', 'sobota', 'neděle'];
// URL na týdenní menu jednotlivých restaurací
const SLADOVNICKA_URL = 'https://sladovnicka.unasplzenchutna.cz/cz/denni-nabidka';
const U_MOTLIKU_URL = 'https://www.umotliku.cz/menu';
const TECHTOWER_URL = 'https://www.equifarm.cz/restaurace-techtower';
const ZASTAVKAUMICHALA_URL = 'https://www.zastavkaumichala.cz';
const SENKSERIKOVA_URL = 'https://www.menicka.cz/6561-pivovarsky-senk-serikova.html';
/**
* Vrátí true, pokud předaný text obsahuje některé ze slov, které naznačuje, že se jedná o polévku.
@@ -137,7 +154,7 @@ export const getMenuSladovnicka = async (firstDayOfWeek: Date, mock: boolean = f
isSoup: false,
});
})
result[index] = currentDayFood;
result[dayIndex] = currentDayFood;
}
return result;
}
@@ -157,23 +174,30 @@ export const getMenuUMotliku = async (firstDayOfWeek: Date, mock: boolean = fals
const html = await getHtml(U_MOTLIKU_URL);
const $ = load(html);
// Najdeme první tabulku, nad kterou je v H3 datum začínající prvním dnem aktuálního týdne
// To může selhat mnoha způsoby, ale ty nemá cenu řešit dokud nenastanou
// Najdeme první tabulku, nad kterou je v H3 datum začínající co nejdřívějším dnem v aktuálním týdnu
const tables = $('table.table.table-hover.Xtable-striped');
let usedTable;
const firstDayOfWeekString = `${firstDayOfWeek.getDate()}.${firstDayOfWeek.getMonth() + 1}.`;
let usedDate = new Date(firstDayOfWeek.getTime());
for (let i = 0; i < 4; i++) {
const dayOfWeekString = `${usedDate.getDate()}.${usedDate.getMonth() + 1}.`;
for (const tableNode of tables) {
const table = $(tableNode);
const h3 = table.parent().prev();
const s1 = h3.text().split("-")[0].split(".");
const foundFirstDayString = `${s1[0]}.${s1[1]}.`;
if (foundFirstDayString === firstDayOfWeekString) {
if (foundFirstDayString === dayOfWeekString) {
usedTable = table;
}
}
if (usedTable != null) {
break;
}
usedDate.setDate(usedDate.getDate() + 1);
}
if (usedTable == null) {
throw Error(`Nepodařilo se najít tabulku pro datum ${firstDayOfWeekString}`);
const firstDayOfWeekString = `${firstDayOfWeek.getDate()}.${firstDayOfWeek.getMonth() + 1}.`;
throw Error(`Nepodařilo se najít tabulku pro týden začínající ${firstDayOfWeekString}`);
}
const body = usedTable.children().first();
@@ -243,46 +267,55 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
const html = await getHtml(TECHTOWER_URL);
const $ = load(html);
const fonts = $('font.wsw-41');
let secondTry = false;
// První pokus - varianta "Obědy"
let fonts = $('font.wsw-41');
let font = undefined;
fonts.each((i, f) => {
if ($(f).text().trim().startsWith('Obědy')) {
font = f;
}
})
// Druhý pokus - varianta "Jídelní lístek"
if (!font) {
fonts = $('font.wnd-font-size-90');
fonts.each((i, f) => {
if ($(f).text().trim().startsWith('Jídelní lístek')) {
font = f;
secondTry = true;
}
})
}
if (!font) {
throw Error('Chyba: nenalezen <font> pro obědy v HTML Techtower.');
}
const result: Food[][] = [];
// TODO validovat, že v textu nalezeného <font> je rozsah, do kterého spadá vstupní datum
const siblings = $(font).parent().parent().siblings();
const siblings = secondTry ? $(font).parent().siblings() : $(font).parent().parent().siblings();
let parsing = false;
let currentDayIndex = 0;
for (let i = 0; i < siblings.length; i++) {
const text = $(siblings.get(i)).text().trim().replace('\t', '').replace('\n', ' ');
if (DAYS_IN_WEEK.includes(text)) {
if (text === DAYS_IN_WEEK[currentDayIndex]) {
// Našli jsme dnešní den, odtud začínáme parsovat jídla
if (DAYS_IN_WEEK.includes(text.toLocaleLowerCase())) {
// Zjistíme aktuální index
currentDayIndex = DAYS_IN_WEEK.indexOf(text.toLocaleLowerCase());
if (!parsing) {
// Našli jsme libovolný den v týdnu a ještě neparsujeme, tak začneme
parsing = true;
continue
} else if (parsing) {
// Už parsujeme jídla, ale narazili jsme na následující den - posouváme index
currentDayIndex += 1;
continue;
}
} else if (parsing) {
if (text.length == 0) {
// Prázdná řádka - končíme (je za pátečním menu TechTower)
break;
// Prázdná řádka - bývá na zcela náhodných místech ¯\_(ツ)_/¯
continue;
}
let price = '? Kč';
let name = text;
let price = 'na\xA0váhu';
let name = text.replace('•', '');
if (text.toLowerCase().endsWith('kč')) {
const tmp = text.replace('\xA0', ' ').split(' ');
const split = [tmp.slice(0, -2).join(' ')].concat(tmp.slice(-2));
price = `${split.slice(1)[0]}\xA0Kč`
name = split[0]
name = split[0].replace('•', '');
}
if (result[currentDayIndex] == null) {
result[currentDayIndex] = [];
@@ -297,3 +330,105 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
}
return result;
}
/**
* Získá obědovou nabídku ZastavkaUmichala pro jeden týden.
*
* @param firstDayOfWeek první den v týdnu, pro který získat menu
* @param mock zda vrátit mock data
* @returns seznam jídel pro dané datum
*/
export const getMenuZastavkaUmichala = async (firstDayOfWeek: Date, mock: boolean = false): Promise<Food[][]> => {
if (mock) {
return getMenuZastavkaUmichalaMock();
}
const nowDate = new Date().getDate();
const headers = {
"Cookie": "_nss=1; PHPSESSID=9e37de17e0326b0942613d6e67a30e69",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36",
};
const result: Food[][] = [];
for (let dayIndex = 0; dayIndex < 5; dayIndex++) {
const currentDate = new Date(firstDayOfWeek);
currentDate.setDate(firstDayOfWeek.getDate() + dayIndex);
if (currentDate.getDate() < nowDate || (currentDate.getDate() === nowDate && new Date().getHours() >= 14)) {
result[dayIndex] = [{
amount: undefined,
name: "Pro tento den není uveřejněna nabídka jídel",
price: "",
isSoup: false,
}];
continue;
} else {
const url = (currentDate.getDate() === nowDate) ?
ZASTAVKAUMICHALA_URL : ZASTAVKAUMICHALA_URL + '/?do=dailyMenu-changeDate&dailyMenu-dateString=' + formatDate(currentDate, 'DD.MM.YYYY');
const html = await axios.get(url, {
headers,
}).then(res => res.data).then(content => content);
const $ = load(html);
const currentDayFood: Food[] = [];
$('.foodsList li').each((index, element) => {
currentDayFood.push({
amount: '-',
name: sanitizeText($(element).contents().not('span').text()),
price: sanitizeText($(element).find('span').text()),
isSoup: (index === 0),
});
});
result[dayIndex] = currentDayFood;
}
}
return result;
}
/**
* Získá obědovou nabídku SenkSerikova pro jeden týden.
*
* @param firstDayOfWeek první den v týdnu, pro který získat menu
* @param mock zda vrátit mock data
* @returns seznam jídel pro dané datum
*/
export const getMenuSenkSerikova = async (firstDayOfWeek: Date, mock: boolean = false): Promise<Food[][]> => {
if (mock) {
return getMenuSenkSerikovaMock();
}
const decoder = new TextDecoder('windows-1250');
const html = await axios.get(SENKSERIKOVA_URL, {
responseType: 'arraybuffer',
responseEncoding: 'binary'
}).then(res => decoder.decode(new Uint8Array(res.data))).then(content => content);
const $ = load(html);
const nowDate = new Date().getDate();
const currentDate = new Date(firstDayOfWeek);
const result: Food[][] = [];
let dayIndex = 0;
while (currentDate.getDate() < nowDate) {
result[dayIndex] = [{
amount: undefined,
name: "Pro tento den není uveřejněna nabídka jídel",
price: "",
isSoup: false,
}];
dayIndex = dayIndex + 1;
currentDate.setDate(firstDayOfWeek.getDate() + dayIndex);
}
$('.menicka').each((i, element) => {
const currentDayFood: Food[] = [];
$(element).find('.popup-gallery li').each((j, element) => {
currentDayFood.push({
amount: '-',
name: $(element).children('div.polozka').text(),
price: $(element).children('div.cena').text().replace(/ /g, '\xA0'),
isSoup: $(element).hasClass('polevka'),
});
});
result[dayIndex++] = currentDayFood;
});
return result;
}

View File

@@ -0,0 +1,156 @@
import express, { NextFunction } from "express";
import { getLogin, getTrusted } from "../auth";
import { parseToken } from "../utils";
import path from "path";
import fs from "fs";
import { EasterEgg } from "../../../types";
const EASTER_EGGS_JSON_PATH = path.join(__dirname, "../../.easter-eggs.json");
const IMAGES_PATH = '../../resources/easterEggs';
type EasterEggsJson = {
[key: string]: EasterEgg[]
}
function generateUrl() {
let result = '';
const characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
const charactersLength = characters.length;
let counter = 0;
while (counter < 32) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
counter += 1;
}
return result;
}
/**
* Vrátí náhodně jeden z definovaných easter egg obrázků pro přihlášeného uživatele.
*
* @param req request
* @param res response
* @param next next
* @returns náhodný easter egg obrázek, nebo 404 pokud žádný není definován
*/
function getEasterEggImage(req: any, res: any, next: NextFunction) {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
try {
// TODO vrátit!
// if (trusted) {
if (true) {
if (login in easterEggs) {
const imagePath = easterEggs[login][Math.floor(Math.random() * easterEggs[login].length)].path;
res.sendFile(path.join(__dirname, IMAGES_PATH, imagePath));
return;
}
}
res.sendStatus(404);
} catch (e: any) { next(e) }
}
function getRandomPosition(startOffset: number, endOffset: number) {
const choice = Math.floor(Math.random() * 4);
if (choice === 0) {
// Vlevo nahoře
return {
left: `${startOffset}px`,
startLeft: `${startOffset}px`,
"--start-left": `${startOffset}px`,
top: `${startOffset}px`,
startTop: `${startOffset}px`,
"--start-top": `${startOffset}px`,
endLeft: `${endOffset}px`,
"--end-left": `${endOffset}px`,
endTop: `${endOffset}px`,
"--end-top": `${endOffset}px`,
rotate: '135deg',
}
} else if (choice === 1) {
// Vpravo nahoře
return {
right: `${startOffset}px`,
startRight: `${startOffset}px`,
"--start-right": `${startOffset}px`,
top: `${startOffset}px`,
startTop: `${startOffset}px`,
"--start-top": `${startOffset}px`,
endRight: `${endOffset}px`,
"--end-right": `${endOffset}px`,
endTop: `${endOffset}px`,
"--end-top": `${endOffset}px`,
rotate: '-135deg',
}
} else if (choice === 2) {
// Vpravo dole
return {
right: `${startOffset}px`,
startRight: `${startOffset}px`,
"--start-right": `${startOffset}px`,
bottom: `${startOffset}px`,
startBottom: `${startOffset}px`,
"--start-bottom": `${startOffset}px`,
endRight: `${endOffset}px`,
"--end-right": `${endOffset}px`,
endBottom: `${endOffset}px`,
"--end-bottom": `${endOffset}px`,
rotate: '-45deg',
}
} else if (choice === 3) {
// Vlevo dole
return {
left: `${startOffset}px`,
startLeft: `${startOffset}px`,
"--start-left": `${startOffset}px`,
bottom: `${startOffset}px`,
startBottom: `${startOffset}px`,
"--start-bottom": `${startOffset}px`,
endLeft: `${endOffset}px`,
"--end-left": `${endOffset}px`,
endBottom: `${endOffset}px`,
"--end-bottom": `${endOffset}px`,
rotate: '45deg',
}
}
}
const router = express.Router();
let easterEggs: EasterEggsJson;
// Registrace náhodných URL pro všechny existující easter eggy
if (fs.existsSync(EASTER_EGGS_JSON_PATH)) {
const content = fs.readFileSync(EASTER_EGGS_JSON_PATH, 'utf-8');
easterEggs = JSON.parse(content);
for (const [key, eggs] of Object.entries(easterEggs)) {
for (const easterEgg of eggs) {
const url = generateUrl();
easterEgg.url = url;
router.get(`/${url}`, async (req, res, next) => {
return getEasterEggImage(req, res, next);
});
}
}
}
// Získání náhodného easter eggu pro přihlášeného uživatele
router.get("/", async (req, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
try {
// TODO vrátit!
// if (trusted) {
if (true) {
if (easterEggs && login in easterEggs) {
const randomEasterEgg = easterEggs[login][Math.floor(Math.random() * easterEggs[login].length)];
const { path, startOffset, endOffset, ...strippedEasterEgg } = randomEasterEgg; // Path klient k ničemu nepotřebuje a nemá ho znát
return res.status(200).json({ ...strippedEasterEgg, ...getRandomPosition(startOffset, endOffset) });
}
}
return res.status(200).send();
} catch (e: any) { next(e) }
});
export default router;

View File

@@ -1,9 +1,10 @@
import express from "express";
import express, { Request } from "express";
import { getLogin, getTrusted } from "../auth";
import { addChoice, addVolatileData, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime } from "../service";
import { addChoice, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime, updateNote } from "../service";
import { getDayOfWeekIndex, parseToken } from "../utils";
import { getWebsocket } from "../websocket";
import { callNotifikace } from "../notifikace";
import { AddChoiceRequest, ChangeDepartureTimeRequest, IDayIndex, RemoveChoiceRequest, RemoveChoicesRequest, UpdateNoteRequest } from "../../../types";
import { UdalostEnum } from "../../../types";
/**
@@ -13,12 +14,12 @@ import { UdalostEnum } from "../../../types";
* @param req request
* @returns index dne v týdnu
*/
const parseValidateFutureDayIndex = (req: any) => {
const parseValidateFutureDayIndex = (req: Request<{}, any, IDayIndex>) => {
if (req.body.dayIndex == null) {
throw Error(`Nebyl předán index dne v týdnu.`);
}
const todayDayIndex = getDayOfWeekIndex(getToday());
const dayIndex = parseInt(req.body.dayIndex);
const dayIndex = req.body.dayIndex;
if (isNaN(dayIndex)) {
throw Error(`Neplatný index dne v týdnu: ${req.body.dayIndex}`);
}
@@ -30,10 +31,9 @@ const parseValidateFutureDayIndex = (req: any) => {
const router = express.Router();
router.post("/addChoice", async (req, res, next) => {
router.post("/addChoice", async (req: Request<{}, any, AddChoiceRequest>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
if (req.body.locationIndex > -1) {
let date = undefined;
if (req.body.dayIndex != null) {
let dayIndex;
@@ -45,15 +45,13 @@ router.post("/addChoice", async (req, res, next) => {
date = getDateForWeekIndex(dayIndex);
}
try {
const data = await addChoice(login, trusted, req.body.locationIndex, req.body.foodIndex, date);
getWebsocket().emit("message", await addVolatileData(data));
const data = await addChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date);
getWebsocket().emit("message", data);
return res.status(200).json(data);
} catch (e: any) { next(e) }
}
return res.status(400); // TODO přidat popis chyby
});
router.post("/removeChoices", async (req, res, next) => {
router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesRequest>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
let date = undefined;
@@ -67,13 +65,13 @@ router.post("/removeChoices", async (req, res, next) => {
date = getDateForWeekIndex(dayIndex);
}
try {
const data = await removeChoices(login, trusted, req.body.locationIndex, date);
getWebsocket().emit("message", await addVolatileData(data));
const data = await removeChoices(login, trusted, req.body.locationKey, date);
getWebsocket().emit("message", data);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/removeChoice", async (req, res, next) => {
router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceRequest>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
let date = undefined;
@@ -87,13 +85,37 @@ router.post("/removeChoice", async (req, res, next) => {
date = getDateForWeekIndex(dayIndex);
}
try {
const data = await removeChoice(login, trusted, req.body.locationIndex, req.body.foodIndex, date);
getWebsocket().emit("message", await addVolatileData(data));
const data = await removeChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date);
getWebsocket().emit("message", data);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/changeDepartureTime", async (req, res, next) => {
router.post("/updateNote", async (req: Request<{}, any, UpdateNoteRequest>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
const note = req.body.note;
try {
if (note && note.length > 70) {
throw Error("Poznámka může mít maximálně 70 znaků");
}
let date = undefined;
if (req.body.dayIndex != null) {
let dayIndex;
try {
dayIndex = parseValidateFutureDayIndex(req);
} catch (e: any) {
return res.status(400).json({ error: e.message });
}
date = getDateForWeekIndex(dayIndex);
}
const data = await updateNote(login, trusted, note, date);
getWebsocket().emit("message", data);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/changeDepartureTime", async (req: Request<{}, any, ChangeDepartureTimeRequest>, res, next) => {
const login = getLogin(parseToken(req));
let date = undefined;
if (req.body.dayIndex != null) {
@@ -107,7 +129,7 @@ router.post("/changeDepartureTime", async (req, res, next) => {
}
try {
const data = await updateDepartureTime(login, req.body?.time, date);
getWebsocket().emit("message", await addVolatileData(data));
getWebsocket().emit("message", data);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
@@ -115,7 +137,7 @@ router.post("/changeDepartureTime", async (req, res, next) => {
router.post("/jdemeObed", async (req, res, next) => {
const login = getLogin(parseToken(req));
try {
await callNotifikace({ input: { user: login, udalost: UdalostEnum.JDEME_OBED }, gotify: false })
await callNotifikace({ input: { user: login, udalost: UdalostEnum.JDEME_NA_OBED }, gotify: false })
res.status(200).json({});
} catch (e: any) { next(e) }
});

View File

@@ -1,28 +1,28 @@
import express from "express";
import express, { Request } from "express";
import { getLogin } from "../auth";
import { createPizzaDay, deletePizzaDay, getPizzaList, addPizzaOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee } from "../pizza";
import { parseToken } from "../utils";
import { getWebsocket } from "../websocket";
import { addVolatileData } from "../service";
import { AddPizzaRequest, FinishDeliveryRequest, RemovePizzaRequest, UpdatePizzaDayNoteRequest, UpdatePizzaFeeRequest } from "../../../types";
const router = express.Router();
/** Založí pizza day pro aktuální den, za předpokladu že dosud neexistuje. */
router.post("/create", async (req, res) => {
router.post("/create", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req));
const data = await createPizzaDay(login);
res.status(200).json(data);
getWebsocket().emit("message", await addVolatileData(data));
getWebsocket().emit("message", data);
});
/** Smaže pizza day pro aktuální den, za předpokladu že existuje. */
router.post("/delete", async (req, res) => {
router.post("/delete", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req));
const data = await deletePizzaDay(login);
getWebsocket().emit("message", await addVolatileData(data));
getWebsocket().emit("message", data);
});
router.post("/add", async (req, res) => {
router.post("/add", async (req: Request<{}, any, AddPizzaRequest>, res) => {
const login = getLogin(parseToken(req));
if (isNaN(req.body?.pizzaIndex)) {
throw Error("Nebyl předán index pizzy");
@@ -43,66 +43,68 @@ router.post("/add", async (req, res) => {
throw Error("Neplatný index velikosti pizzy: " + pizzaSizeIndex);
}
const data = await addPizzaOrder(login, pizzy[pizzaIndex], pizzy[pizzaIndex].sizes[pizzaSizeIndex]);
getWebsocket().emit("message", await addVolatileData(data));
getWebsocket().emit("message", data);
res.status(200).json({});
});
router.post("/remove", async (req, res) => {
router.post("/remove", async (req: Request<{}, any, RemovePizzaRequest>, res) => {
const login = getLogin(parseToken(req));
if (!req.body?.pizzaOrder) {
throw Error("Nebyla předána objednávka");
}
const data = await removePizzaOrder(login, req.body?.pizzaOrder);
getWebsocket().emit("message", await addVolatileData(data));
getWebsocket().emit("message", data);
res.status(200).json({});
});
router.post("/lock", async (req, res) => {
router.post("/lock", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req));
const data = await lockPizzaDay(login);
getWebsocket().emit("message", await addVolatileData(data));
getWebsocket().emit("message", data);
res.status(200).json({});
});
router.post("/unlock", async (req, res) => {
router.post("/unlock", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req));
const data = await unlockPizzaDay(login);
getWebsocket().emit("message", await addVolatileData(data));
getWebsocket().emit("message", data);
res.status(200).json({});
});
router.post("/finishOrder", async (req, res) => {
router.post("/finishOrder", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req));
const data = await finishPizzaOrder(login);
getWebsocket().emit("message", await addVolatileData(data));
getWebsocket().emit("message", data);
res.status(200).json({});
});
router.post("/finishDelivery", async (req, res) => {
router.post("/finishDelivery", async (req: Request<{}, any, FinishDeliveryRequest>, res) => {
const login = getLogin(parseToken(req));
const data = await finishPizzaDelivery(login, req.body.bankAccount, req.body.bankAccountHolder);
getWebsocket().emit("message", await addVolatileData(data));
getWebsocket().emit("message", data);
res.status(200).json({});
});
router.post("/updatePizzaDayNote", async (req, res) => {
router.post("/updatePizzaDayNote", async (req: Request<{}, any, UpdatePizzaDayNoteRequest>, res, next) => {
const login = getLogin(parseToken(req));
if (req.body.note && req.body.note.length > 100) {
throw Error("Poznámka může mít maximálně 100 znaků");
try {
if (req.body.note && req.body.note.length > 70) {
throw Error("Poznámka může mít maximálně 70 znaků");
}
const data = await updatePizzaDayNote(login, req.body.note);
getWebsocket().emit("message", await addVolatileData(data));
getWebsocket().emit("message", data);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/updatePizzaFee", async (req, res, next) => {
router.post("/updatePizzaFee", async (req: Request<{}, any, UpdatePizzaFeeRequest>, res, next) => {
const login = getLogin(parseToken(req));
if (!req.body.login) {
return res.status(400).json({ error: "Nebyl předán login cílového uživatele" });
}
try {
const data = await updatePizzaFee(login, req.body.login, req.body.text, req.body.price);
getWebsocket().emit("message", await addVolatileData(data));
getWebsocket().emit("message", data);
res.status(200).json(data);
} catch (e: any) { next(e) }
});

View File

@@ -0,0 +1,22 @@
import express, { Request, Response } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { getStats } from "../stats";
import { WeeklyStats } from "../../../types";
const router = express.Router();
router.get("/", async (req: Request<{}, any, undefined>, res: Response<WeeklyStats>) => {
getLogin(parseToken(req));
if (typeof req.query.startDate === 'string' && typeof req.query.endDate === 'string') {
try {
const data = await getStats(req.query.startDate, req.query.endDate);
return res.status(200).json(data);
} catch (e) {
// necháme to zatím spadnout na 400
}
}
res.sendStatus(400);
});
export default router;

View File

@@ -1,17 +1,19 @@
import express from "express";
import express, { Request, Response } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { getUserVotes, updateFeatureVote } from "../voting";
import { UpdateFeatureVoteRequest } from "../../../types";
import { FeatureRequest } from "../../../types";
const router = express.Router();
router.get("/getVotes", async (req, res) => {
router.get("/getVotes", async (req: Request<{}, any, undefined>, res: Response<FeatureRequest[]>) => {
const login = getLogin(parseToken(req));
const data = await getUserVotes(login);
res.status(200).json(data);
});
router.post("/updateVote", async (req, res, next) => {
router.post("/updateVote", async (req: Request<{}, any, UpdateFeatureVoteRequest>, res, next) => {
const login = getLogin(parseToken(req));
if (req.body?.option == null || req.body?.active == null) {
res.status(400).json({ error: "Chybné parametry volání" });

View File

@@ -1,8 +1,8 @@
import { InsufficientPermissions, formatDate, getDayOfWeekIndex, getFirstWorkDayOfWeek, getHumanDate, getHumanTime, getIsWeekend, getLastWorkDayOfWeek, getWeekNumber } from "./utils";
import { ClientData, Locations, Restaurants, DayMenu, DepartureTime, DayData, WeekMenu } from "../../types";
import { InsufficientPermissions, formatDate, getDayOfWeekIndex, getFirstWorkDayOfWeek, getHumanDate, getIsWeekend, getWeekNumber } from "./utils";
import getStorage from "./storage";
import { getMenuSladovnicka, getMenuTechTower, getMenuUMotliku } from "./restaurants";
import { getMenuSladovnicka, getMenuTechTower, getMenuUMotliku, getMenuZastavkaUmichala, getMenuSenkSerikova } from "./restaurants";
import { getTodayMock } from "./mock";
import { ClientData, DepartureTime, LunchChoice, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types";
const storage = getStorage();
const MENU_PREFIX = 'menu';
@@ -10,7 +10,7 @@ const MENU_PREFIX = 'menu';
/** 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 new Date(getTodayMock());
return getTodayMock();
}
return new Date();
}
@@ -31,43 +31,31 @@ export const getDateForWeekIndex = (index: number) => {
function getEmptyData(date?: Date): ClientData {
const usedDate = date || getToday();
return {
todayDayIndex: getDayOfWeekIndex(getToday()),
date: getHumanDate(usedDate),
isWeekend: getIsWeekend(usedDate),
weekIndex: getDayOfWeekIndex(usedDate),
dayIndex: getDayOfWeekIndex(usedDate),
choices: {},
};
}
/**
* Přidá k datům "dopočítaná" data, která nejsou přímo uložena v databázi.
*
* @param data data z databáze
* @returns obohacená data
*/
export async function addVolatileData(data: ClientData): Promise<ClientData> {
data.todayWeekIndex = getDayOfWeekIndex(getToday());
return data;
}
/**
* 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 targetDate = date ?? getToday();
const dateString = formatDate(targetDate);
const data: DayData = await storage.getData(dateString) || getEmptyData(date);
let clientData: ClientData = { ...data };
const clientData = await getClientData(date);
clientData.menus = {
[Restaurants.SLADOVNICKA]: await getRestaurantMenu(Restaurants.SLADOVNICKA, date),
[Restaurants.UMOTLIKU]: await getRestaurantMenu(Restaurants.UMOTLIKU, date),
[Restaurants.TECHTOWER]: await getRestaurantMenu(Restaurants.TECHTOWER, date),
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),
}
clientData = await addVolatileData(clientData);
return clientData;
}
/**
* Vrátí klíč, pod kterým je uloženo menu pro předané datum.
* 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íč
@@ -78,13 +66,13 @@ function getMenuKey(date: Date) {
}
/**
* Vrátí menu restaurací pro předané datum, pokud již existují.
* 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 předané 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(getMenuKey(date));
return await storage.getData<WeekMenu | undefined>(getMenuKey(date));
}
// TODO přesun do restaurants.ts
@@ -96,7 +84,7 @@ async function getMenu(date: Date): Promise<WeekMenu | undefined> {
* @param date datum, ke kterému získat menu
* @param mock příznak, zda chceme pouze mock data
*/
export async function getRestaurantMenu(restaurant: Restaurants, date?: Date): Promise<DayMenu> {
export async function getRestaurantMenu(restaurant: keyof typeof Restaurant, date?: Date): Promise<RestaurantDayMenu> {
const usedDate = date ?? getToday();
const dayOfWeekIndex = getDayOfWeekIndex(usedDate);
const now = new Date().getTime();
@@ -108,70 +96,96 @@ export async function getRestaurantMenu(restaurant: Restaurants, date?: Date): P
};
}
let menus = await getMenu(usedDate);
if (menus == null) {
menus = [];
let weekMenu = await getMenu(usedDate);
if (weekMenu == null) {
weekMenu = [{}, {}, {}, {}, {}];
}
for (let i = 0; i < 5; i++) {
if (menus[i] == null) {
menus[i] = {};
if (weekMenu[i] == null) {
weekMenu[i] = {};
}
if (menus[i][restaurant] == null) {
menus[i][restaurant] = {
if (weekMenu[i][restaurant] == null) {
weekMenu[i][restaurant] = {
lastUpdate: now,
closed: false,
food: [],
};
}
}
if (!menus[dayOfWeekIndex][restaurant]?.food?.length) {
if (!weekMenu[dayOfWeekIndex][restaurant]?.food?.length) {
const firstDay = getFirstWorkDayOfWeek(usedDate);
const mock = process.env.MOCK_DATA === 'true';
switch (restaurant) {
case Restaurants.SLADOVNICKA:
case 'SLADOVNICKA':
try {
const sladovnickaFood = await getMenuSladovnicka(firstDay, mock);
for (let i = 0; i < sladovnickaFood.length; i++) {
menus[i][restaurant]!.food = sladovnickaFood[i];
weekMenu[i][restaurant]!.food = sladovnickaFood[i];
// Velice chatrný a nespolehlivý způsob detekce uzavření...
if (sladovnickaFood[i].length === 1 && sladovnickaFood[i][0].name.toLowerCase() === 'pro daný den nebyla nalezena denní nabídka') {
menus[i][restaurant]!.closed = true;
weekMenu[i][restaurant]!.closed = true;
}
}
} catch (e: any) {
console.error("Selhalo načtení jídel pro podnik Sladovnická", e);
}
break;
case Restaurants.UMOTLIKU:
try {
const uMotlikuFood = await getMenuUMotliku(firstDay, mock);
for (let i = 0; i < uMotlikuFood.length; i++) {
menus[i][restaurant]!.food = uMotlikuFood[i];
if (uMotlikuFood[i].length === 1 && uMotlikuFood[i][0].name.toLowerCase() === 'zavřeno') {
menus[i][restaurant]!.closed = true;
}
}
} catch (e: any) {
console.error("Selhalo načtení jídel pro podnik U Motlíků", e);
}
break;
case Restaurants.TECHTOWER:
// case 'UMOTLIKU':
// try {
// const uMotlikuFood = await getMenuUMotliku(firstDay, mock);
// for (let i = 0; i < uMotlikuFood.length; i++) {
// menus[i][restaurant]!.food = uMotlikuFood[i];
// if (uMotlikuFood[i].length === 1 && uMotlikuFood[i][0].name.toLowerCase() === 'zavřeno') {
// menus[i][restaurant]!.closed = true;
// }
// }
// } catch (e: any) {
// console.error("Selhalo načtení jídel pro podnik U Motlíků", e);
// }
// break;
case 'TECHTOWER':
try {
const techTowerFood = await getMenuTechTower(firstDay, mock);
for (let i = 0; i < techTowerFood.length; i++) {
menus[i][restaurant]!.food = techTowerFood[i];
if (techTowerFood[i].length === 1 && techTowerFood[i][0].name.toLowerCase() === 'svátek') {
menus[i][restaurant]!.closed = true;
weekMenu[i][restaurant]!.food = techTowerFood[i];
if (techTowerFood[i]?.length === 1 && techTowerFood[i][0].name.toLowerCase() === 'svátek') {
weekMenu[i][restaurant]!.closed = true;
}
}
break;
} catch (e: any) {
console.error("Selhalo načtení jídel pro podnik TechTower", e);
}
case 'ZASTAVKAUMICHALA':
try {
const zastavkaUmichalaFood = await getMenuZastavkaUmichala(firstDay, mock);
for (let i = 0; i < zastavkaUmichalaFood.length; i++) {
weekMenu[i][restaurant]!.food = zastavkaUmichalaFood[i];
if (zastavkaUmichalaFood[i]?.length === 1 && zastavkaUmichalaFood[i][0].name === 'Pro tento den není uveřejněna nabídka jídel.') {
weekMenu[i][restaurant]!.closed = true;
}
await storage.setData(getMenuKey(usedDate), menus);
}
return menus[dayOfWeekIndex][restaurant]!;
break;
} catch (e: any) {
console.error("Selhalo načtení jídel pro podnik Zastávka u Michala", e);
}
case 'SENKSERIKOVA':
try {
const senkSerikovaFood = await getMenuSenkSerikova(firstDay, mock);
for (let i = 0; i < senkSerikovaFood.length; i++) {
weekMenu[i][restaurant]!.food = senkSerikovaFood[i];
if (senkSerikovaFood[i]?.length === 1 && senkSerikovaFood[i][0].name === 'Pro tento den nebylo zadáno menu.') {
weekMenu[i][restaurant]!.closed = true;
}
}
break;
} catch (e: any) {
console.error("Selhalo načtení jídel pro podnik Pivovarský šenk Šeříková", e);
}
}
await storage.setData(getMenuKey(usedDate), weekMenu);
}
return weekMenu[dayOfWeekIndex][restaurant]!;
}
/**
@@ -192,19 +206,19 @@ export async function initIfNeeded(date?: Date) {
*
* @param login login uživatele
* @param trusted příznak, zda se jedná o ověřeného uživatele
* @param location vybrané "umístění"
* @param locationKey vybrané "umístění"
* @param date datum, ke kterému se volba vztahuje
* @returns
*/
export async function removeChoices(login: string, trusted: boolean, location: Locations, date?: Date) {
export async function removeChoices(login: string, trusted: boolean, locationKey: keyof typeof LunchChoice, date?: Date) {
const selectedDay = formatDate(date ?? getToday());
let data: DayData = await storage.getData(selectedDay);
let data = await getClientData(date);
validateTrusted(data, login, trusted);
if (location in data.choices) {
if (login in data.choices[location]) {
delete data.choices[location][login]
if (Object.keys(data.choices[location]).length === 0) {
delete data.choices[location]
if (locationKey in data.choices) {
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(selectedDay, data);
}
@@ -218,20 +232,20 @@ export async function removeChoices(login: string, trusted: boolean, location: L
*
* @param login login uživatele
* @param trusted příznak, zda se jedná o ověřeného uživatele
* @param location vybrané "umístění"
* @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, location: Locations, foodIndex: number, date?: Date) {
export async function removeChoice(login: string, trusted: boolean, locationKey: keyof typeof LunchChoice, foodIndex: number, date?: Date) {
const selectedDay = formatDate(date ?? getToday());
let data: DayData = await storage.getData(selectedDay);
let data = await getClientData(date);
validateTrusted(data, login, trusted);
if (location in data.choices) {
if (login in data.choices[location]) {
const index = data.choices[location][login].options.indexOf(foodIndex);
if (index > -1) {
data.choices[location][login].options.splice(index, 1)
if (locationKey in data.choices) {
if (data.choices[locationKey] && login in data.choices[locationKey]) {
const index = data.choices[locationKey][login].selectedFoods?.indexOf(foodIndex);
if (index && index > -1) {
data.choices[locationKey][login].selectedFoods?.splice(index, 1);
await storage.setData(selectedDay, data);
}
}
@@ -240,19 +254,26 @@ export async function removeChoice(login: string, trusted: boolean, location: Lo
}
/**
* Odstraní kompletně volbu uživatele.
* 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: string) {
let data: DayData = await storage.getData(date);
async function removeChoiceIfPresent(login: string, date?: Date, ignoredLocationKey?: keyof typeof LunchChoice) {
const usedDate = date ?? getToday();
let data = await getClientData(usedDate);
for (const key of Object.keys(data.choices)) {
if (login in data.choices[key]) {
delete data.choices[key][login];
if (Object.keys(data.choices[key]).length === 0) {
delete data.choices[key];
const locationKey = key as keyof typeof LunchChoice;
if (ignoredLocationKey != null && ignoredLocationKey == locationKey) {
continue;
}
await storage.setData(date, data);
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(formatDate(usedDate), data);
}
}
return data;
@@ -285,38 +306,98 @@ function validateTrusted(data: ClientData, login: string, trusted: boolean) {
*
* @param login login uživatele
* @param trusted příznak, zda se jedná o ověřeného uživatele
* @param location vybrané "umístění"
* @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, location: Locations, foodIndex?: number, date?: Date) {
export async function addChoice(login: string, trusted: boolean, locationKey: keyof typeof LunchChoice, foodIndex?: number, date?: Date) {
const usedDate = date ?? getToday();
await initIfNeeded(usedDate);
const selectedDate = formatDate(usedDate);
let data: DayData = await storage.getData(selectedDate);
let data = await getClientData(usedDate);
validateTrusted(data, login, trusted);
await validateFoodIndex(locationKey, foodIndex, date);
// Pokud měníme pouze lokaci, mažeme případné předchozí
if (foodIndex == null) {
data = await removeChoiceIfPresent(login, selectedDate);
data = await removeChoiceIfPresent(login, usedDate);
} else {
// Mažeme případné ostatní volby (měla by být maximálně jedna)
removeChoiceIfPresent(login, usedDate, locationKey);
}
if (!(location in data.choices)) {
data.choices[location] = {};
// TODO vytáhnout inicializaci "prázdné struktury" do vlastní funkce
if (!(data.choices[locationKey])) {
data.choices[locationKey] = {}
}
if (!(login in data.choices[location])) {
data.choices[location][login] = {
if (!(login in data.choices[locationKey])) {
if (!data.choices[locationKey]) {
data.choices[locationKey] = {}
}
data.choices[locationKey][login] = {
trusted,
options: []
selectedFoods: []
};
}
if (foodIndex != null && !data.choices[location][login].options.includes(foodIndex)) {
data.choices[location][login].options.push(foodIndex);
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);
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: keyof typeof LunchChoice, foodIndex?: number, date?: Date) {
if (foodIndex != null) {
if (typeof foodIndex !== 'number') {
throw Error(`Neplatný index ${foodIndex} typu ${typeof foodIndex}`);
}
if (foodIndex < 0) {
throw Error(`Neplatný index ${foodIndex}`);
}
if (!Object.keys(Restaurant).includes(locationKey)) {
throw Error(`Neplatný index ${foodIndex} pro lokalitu ${locationKey} nepodporující indexy`);
}
const usedDate = date ?? getToday();
const menu = await getRestaurantMenu(locationKey as keyof typeof 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) {
const usedDate = date ?? getToday();
await initIfNeeded(usedDate);
let data = await getClientData(usedDate);
validateTrusted(data, login, trusted);
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;
}
const selectedDate = formatDate(usedDate);
await storage.setData(selectedDate, data);
}
return data;
}
/**
* Aktualizuje preferovaný čas odchodu strávníka.
*
@@ -325,8 +406,8 @@ export async function addChoice(login: string, trusted: boolean, location: Locat
* @param date datum, ke kterému se čas vztahuje
*/
export async function updateDepartureTime(login: string, time?: string, date?: Date) {
const selectedDate = formatDate(date ?? getToday());
let clientData: DayData = await storage.getData(selectedDate);
const usedDate = date ?? getToday();
let clientData = await getClientData(usedDate);
const found = Object.values(clientData.choices).find(location => login in location);
// TODO validace, že se jedná o restauraci
if (found) {
@@ -338,7 +419,23 @@ export async function updateDepartureTime(login: string, time?: string, date?: D
}
found[login].departureTime = time;
}
await storage.setData(selectedDate, clientData);
await storage.setData(formatDate(usedDate), clientData);
}
return clientData;
}
/**
* 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): Promise<ClientData> {
const targetDate = date ?? getToday();
const dateString = formatDate(targetDate);
const clientData = await storage.getData<ClientData>(dateString) || getEmptyData(date);
return {
...clientData,
todayDayIndex: getDayOfWeekIndex(getToday()),
}
}

49
server/src/stats.ts Normal file
View File

@@ -0,0 +1,49 @@
import { DailyStats, LunchChoice, WeeklyStats } from "../../types";
import { getStatsMock } from "./mock";
import { getClientData } from "./service";
import getStorage from "./storage";
import { formatDate } from "./utils";
const storage = getStorage();
/**
* Vypočte a vrátí statistiky jednotlivých možností pro předaný rozsah dat.
*
* @param startDate počáteční datum
* @param endDate koncové datum
* @returns statistiky pro zadaný rozsah dat
*/
export async function getStats(startDate: string, endDate: string): Promise<WeeklyStats> {
if (process.env.MOCK_DATA === 'true') {
return getStatsMock();
}
const start = new Date(startDate);
const end = new Date(endDate);
// Dočasná validace, aby to někdo ručně neshodil
const daysDiff = ((end as any) - (start as any)) / (1000 * 60 * 60 * 24);
if (daysDiff > 4) {
throw Error('Neplatný rozsah');
}
const result = [];
for (const date = start; date <= end; date.setDate(date.getDate() + 1)) {
const locationsStats: DailyStats = {
// TODO vytáhnout do utils funkce
date: `${String(date.getDate()).padStart(2, "0")}.${String(date.getMonth() + 1).padStart(2, "0")}.`,
locations: {}
}
const data = await getClientData(date);
if (data?.choices) {
Object.keys(data.choices).forEach(locationKey => {
if (!locationsStats.locations) {
locationsStats.locations = {}
}
// TODO dořešit, tohle je zmatek a té hlášce Sonaru nerozumím
locationsStats.locations[locationKey as keyof typeof LunchChoice] = Object.keys(data.choices[locationKey as keyof typeof LunchChoice]!).length;
})
}
result.push(locationsStats);
}
return result as WeeklyStats;
}

View File

@@ -1,5 +1,3 @@
import { ClientData } from "../../../types";
/**
* Interface pro úložiště dat.
*
@@ -17,7 +15,7 @@ export interface StorageInterface {
* Vrátí veškerá data pro předaný klíč.
* @param key klíč, pro který vrátit data (typicky datum)
*/
getData<Type>(key: string): Promise<Type>;
getData<Type>(key: string): Promise<Type | undefined>;
/**
* Uloží data pod předaný klíč.

View File

@@ -1,8 +1,17 @@
import JSONdb from 'simple-json-db';
import { StorageInterface } from "./StorageInterface";
import * as fs from 'fs';
import * as path from 'path';
const db = new JSONdb('./data.json');
const dbPath = path.resolve(__dirname, '../../data/db.json');
const dbDir = path.dirname(dbPath);
// Zajistěte, že adresář existuje
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
const db = new JSONdb(dbPath);
/**
* Implementace úložiště používající JSON soubor.
*/

View File

@@ -1,11 +1,15 @@
import { Choices } from "../../types";
import { LunchChoice, LunchChoices } from "../../types";
const DAY_OF_WEEK_FORMAT = new Intl.DateTimeFormat(undefined, { weekday: 'long' });
/** Vrátí datum v ISO formátu. */
export function formatDate(date: Date) {
let currentDay = String(date.getDate()).padStart(2, '0');
let currentMonth = String(date.getMonth() + 1).padStart(2, "0");
let currentYear = date.getFullYear();
return `${currentYear}-${currentMonth}-${currentDay}`;
export function formatDate(date: Date, format?: string) {
let day = String(date.getDate()).padStart(2, '0');
let month = String(date.getMonth() + 1).padStart(2, "0");
let year = String(date.getFullYear());
const f = (format === undefined) ? 'YYYY-MM-DD' : format;
return f.replace('DD', day).replace('MM', month).replace('YYYY', year);
}
/** Vrátí human-readable reprezentaci předaného data pro zobrazení. */
@@ -13,7 +17,7 @@ export function getHumanDate(date: Date) {
let currentDay = String(date.getDate()).padStart(2, '0');
let currentMonth = String(date.getMonth() + 1).padStart(2, "0");
let currentYear = date.getFullYear();
let currentDayOfWeek = date.toLocaleDateString("CZ-cs", { weekday: 'long' });
let currentDayOfWeek = DAY_OF_WEEK_FORMAT.format(date);
return `${currentDay}.${currentMonth}.${currentYear} (${currentDayOfWeek})`;
}
@@ -110,21 +114,21 @@ export const checkBodyParams = (req: any, paramNames: string[]) => {
// TODO umístit do samostatného souboru
export class InsufficientPermissions extends Error { }
export const getUsersByLocation = (data: Choices, login: string): string[] => {
export const getUsersByLocation = (choices: LunchChoices, login?: string): string[] => {
const result: string[] = [];
for (const location in data) {
if (data.hasOwnProperty(location)) {
if (data[location][login]) {
for (const username in data[location]) {
if (data[location].hasOwnProperty(username)) {
for (const location of Object.entries(choices)) {
const locationKey = location[0] as keyof typeof LunchChoice;
const locationValue = location[1];
if (login && locationValue[login]) {
for (const username in choices[locationKey]) {
if (choices[locationKey].hasOwnProperty(username)) {
result.push(username);
}
}
break;
}
}
}
return result;
}

View File

@@ -15,7 +15,7 @@ const STORAGE_KEY = 'voting';
* @returns pole voleb
*/
export async function getUserVotes(login: string) {
const data: VotingData = await storage.getData(STORAGE_KEY);
const data = await storage.getData<VotingData>(STORAGE_KEY);
return data?.[login] || [];
}
@@ -28,7 +28,7 @@ export async function getUserVotes(login: string) {
* @returns aktuální data
*/
export async function updateFeatureVote(login: string, option: FeatureRequest, active: boolean): Promise<VotingData> {
let data: VotingData = await storage.getData(STORAGE_KEY);
let data = await storage.getData<VotingData>(STORAGE_KEY);
if (data == null) {
data = {};
}
@@ -46,8 +46,8 @@ export async function updateFeatureVote(login: string, option: FeatureRequest, a
}
}
} else if (active) {
if (data[login].length == 3) {
throw Error('Je možné hlasovat pro maximálně 3 možnosti');
if (data[login].length == 4) {
throw Error('Je možné hlasovat pro maximálně 4 možnosti');
}
data[login].push(option);
}

4123
server/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

56
types/RequestTypes.ts Normal file
View File

@@ -0,0 +1,56 @@
import { FeatureRequest, LunchChoice, PizzaVariant } from "../types";
export type ILocationKey = {
locationKey: keyof typeof LunchChoice,
}
export type IDayIndex = {
dayIndex?: number,
}
export type AddChoiceRequest = IDayIndex & ILocationKey & {
foodIndex?: number,
}
export type RemoveChoicesRequest = IDayIndex & ILocationKey;
export type RemoveChoiceRequest = IDayIndex & ILocationKey & {
foodIndex: number,
}
export type UpdateNoteRequest = IDayIndex & {
note?: string,
}
export type ChangeDepartureTimeRequest = IDayIndex & {
time: string,
}
export type FinishDeliveryRequest = {
bankAccount?: string,
bankAccountHolder?: string,
}
export type AddPizzaRequest = {
pizzaIndex: number,
pizzaSizeIndex: number,
}
export type RemovePizzaRequest = {
pizzaOrder: PizzaVariant,
}
export type UpdatePizzaDayNoteRequest = {
note?: string,
}
export type UpdatePizzaFeeRequest = {
login: string,
text?: string,
price?: number,
}
export type UpdateFeatureVoteRequest = {
option: FeatureRequest,
active: boolean,
}

View File

@@ -1,169 +0,0 @@
/** Výčtový typ pro restaurace, pro které umíme získat a parsovat obědové menu. */
export enum Restaurants {
SLADOVNICKA = 'sladovnicka',
UMOTLIKU = 'uMotliku',
TECHTOWER = 'techTower',
}
export interface FoodChoices {
trusted: boolean,
options: number[],
departureTime?: string,
}
export interface Choices {
[location: string]: {
[login: string]: FoodChoices
},
}
/** Velikost konkrétní pizzy */
export interface PizzaSize {
varId: number, // unikátní ID varianty pizzy
size: string, // velikost pizzy, např. "30cm"
pizzaPrice: number, // cena samotné pizzy
boxPrice: number, // cena krabice
price: number, // celková cena (pizza + krabice)
}
/** Jedna konkrétní pizza */
export interface Pizza {
name: string, // název pizzy
ingredients: string[], // seznam ingrediencí
sizes: PizzaSize[], // dostupné velikosti pizzy
}
/** Objednávka jedné konkrétní pizzy */
export interface PizzaOrder {
varId: number, // unikátní ID varianty pizzy
name: string, // název pizzy
size: string, // velikost pizzy jako string (30cm)
price: number, // cena pizzy v Kč, včetně krabice
}
/** Celková objednávka jednoho člověka */
export interface Order {
customer: string, // jméno objednatele
pizzaList: PizzaOrder[], // seznam objednaných pizz
fee?: { text?: string, price: number }, // příplatek (např. za extra ingredience)
totalPrice: number, // celková cena všech objednaných pizz, krabic a příplatků
hasQr?: boolean, // true, pokud je k objednávce vygenerován QR kód pro platbu
note?: string, // volitelná uživatelská poznámka k objednávce
}
/** Stav pizza dne */
export enum PizzaDayState {
NOT_CREATED, // Pizza day nebyl založen
CREATED, // Pizza day je založen
LOCKED, // Objednávky uzamčeny
ORDERED, // Pizzy objednány
DELIVERED // Pizzy doručeny
}
/** Informace o pizza day pro dnešní den */
interface PizzaDay {
state: PizzaDayState, // stav pizza dne
creator: string, // jméno zakladatele
orders: Order[], // seznam objednávek jednotlivých lidí
}
/** Týdenní menu jednotlivých restaurací. */
export interface WeekMenu {
[dayIndex: number]: {
[restaurant in Restaurants]?: DayMenu
}
}
/** Data vztahující se k jednomu konkrétnímu dni. */
export interface DayData {
date: string, // datum dne
isWeekend: boolean, // příznak, zda je datum víkend
weekIndex: number, // index dne v týdnu (0-6)
choices: Choices, // seznam voleb uživatelů
menus?: { [restaurant in Restaurants]?: DayMenu }, // menu jednotlivých restaurací
pizzaDay?: PizzaDay, // pizza day pro dnešní den, pokud existuje
pizzaList?: Pizza[], // seznam dostupných pizz pro dnešní den
pizzaListLastUpdate?: Date, // datum a čas poslední aktualizace pizz
}
/** Veškerá data pro zobrazení na klientovi. */
export interface ClientData extends DayData {
todayWeekIndex?: number, // index dnešního dne v týdnu (0-6)
}
/** Nabídka jídel jednoho podniku pro jeden konkrétní den. */
export interface DayMenu {
lastUpdate: number, // UNIX timestamp poslední aktualizace menu
closed: boolean, // příznak, zda je daný podnik v tento den zavřený
food: Food[], // seznam jídel v menu
}
/** Jídlo z obědového menu restaurace. */
export interface Food {
amount?: string, // množství standardní porce, např. 0,33l nebo 150g
name: string, // název/popis jídla
price: string, // cena ve formátu '135 Kč'
isSoup: boolean, // příznak, zda se jedná o polévku
}
// TODO tohle je dost špatné pojmenování, vzhledem k tomu co to obsahuje
export enum Locations {
SLADOVNICKA = 'Sladovnická',
UMOTLIKU = 'U Motlíků',
TECHTOWER = 'TechTower',
SPSE = 'SPŠE',
PIZZA = 'Pizza day',
OBJEDNAVAM = 'Budu objednávat',
NEOBEDVAM = 'Mám vlastní/neobědvám',
}
export enum UdalostEnum {
ZAHAJENA_PIZZA = "Zahájen pizza day",
OBJEDNANA_PIZZA = "Objednána pizza",
JDEME_OBED = "Jdeme oběd",
}
export interface NotififaceInput {
udalost: UdalostEnum,
user: string,
}
export interface NotifikaceData {
input: NotififaceInput,
gotify?: boolean,
teams?: boolean,
ntfy?: boolean,
}
export interface GotifyServer {
server: string;
api_keys: string[];
}
/** Čas preferovaného odchodu na oběd. */
export enum DepartureTime {
T10_00 = "10:00",
T10_15 = "10:15",
T10_30 = "10:30",
T10_45 = "10:45",
T11_00 = "11:00",
T11_15 = "11:15",
T11_30 = "11:30",
T11_45 = "11:45",
T12_00 = "12:00",
T12_15 = "12:15",
T12_30 = "12:30",
T12_45 = "12:45",
T13_00 = "13:00",
}
export enum FeatureRequest {
SINGLE_PAYMENT = "Možnost úhrady v podniku jednou osobou a generování QR pro ostatní",
NOTIFICATIONS = "Podpora push notifikací na mobil",
STATISTICS = "Statistiky (nejoblíbenější podnik, nejpopulárnější jídla, nejobjednávanější pizzy, ...)",
RESPONSIVITY = "Vylepšení responzivního designu",
SECURITY = "Zvýšení zabezpečení aplikace",
SAFETY = "Zvýšená ochrana proti chybám uživatele (potvrzovací dialogy, překliky, ...)",
UI = "Celkové vylepšení UI/UX",
DEVELOPMENT = "Zlepšení dokumentace/postupů pro ostatní vývojáře"
}

650
types/api.yml Normal file
View File

@@ -0,0 +1,650 @@
openapi: 3.0.4
info:
title: Luncher API
version: 1.0.0
servers:
- url: /api
paths:
/login:
post:
summary: Přihlášení uživatele
security: [] # Nevyžaduje autentizaci
requestBody:
content:
application/json:
schema:
type: object
properties:
login:
type: string
description: Přihlašovací jméno uživatele. Vyžadováno pouze pokud není předáno pomocí hlaviček.
responses:
"200":
description: Přihlášení bylo úspěšné
content:
application/json:
schema:
$ref: "#/components/schemas/JWTToken"
/qr:
get:
summary: Získání QR kódu pro platbu za Pizza day
security: [] # Nevyžaduje autentizaci
parameters:
- in: query
name: login
schema:
type: string
required: true
description: Přihlašovací jméno uživatele, pro kterého bude vrácen QR kód
responses:
"200":
description: Vygenerovaný QR kód pro platbu
content:
image/png:
schema:
type: string
format: binary
/data:
get:
summary: Načtení klientských dat pro aktuální nebo předaný den
parameters:
- in: query
name: dayIndex
description: Index dne v týdnu. Pokud není předán, je použit aktuální den.
schema:
type: integer
minimum: 0
maximum: 4
responses:
"200":
$ref: "#/components/responses/ClientDataResponse"
/addChoice:
post:
summary: Přidání či nahrazení volby uživatele pro zvolený den/podnik
requestBody:
required: true
content:
application/json:
schema:
required:
- locationKey
allOf:
- locationKey:
$ref: "#/components/schemas/LunchChoice"
- dayIndex:
$ref: "#/components/schemas/DayIndex"
- foodIndex:
$ref: "#/components/schemas/FoodIndex"
responses:
"200":
$ref: "#/components/responses/ClientDataResponse"
/removeChoices:
post:
summary: Odstranění volby uživatele pro zvolený den/podnik, včetně případných jídel
requestBody:
required: true
content:
application/json:
schema:
required:
- locationKey
allOf:
- locationKey:
$ref: "#/components/schemas/LunchChoice"
- dayIndex:
$ref: "#/components/schemas/DayIndex"
responses:
"200":
$ref: "#/components/responses/ClientDataResponse"
components:
schemas:
# --- OBECNÉ ---
JWTToken:
type: object
description: Klientský JWT token pro autentizaci a autorizaci
required:
- login
- trusted
- iat
properties:
login:
type: string
description: Přihlašovací jméno uživatele
trusted:
type: boolean
description: Příznak, zda se jedná o uživatele ověřeného doménovým přihlášením
iat:
type: number
description: Časové razítko vydání tokenu
ClientData:
description: Klientská data pro jeden konkrétní den. Obsahuje menu všech načtených podniků a volby jednotlivých uživatelů.
type: object
additionalProperties: false
required:
- todayDayIndex
- date
- isWeekend
- choices
properties:
todayDayIndex:
description: Index dnešního dne v týdnu
$ref: "#/components/schemas/DayIndex"
date:
description: Human-readable datum dne
type: string
isWeekend:
description: Příznak, zda je tento den víkend
type: boolean
dayIndex:
description: Index dne v týdnu, ke kterému se vztahují tato data
$ref: "#/components/schemas/DayIndex"
choices:
$ref: "#/components/schemas/LunchChoices"
menus:
$ref: "#/components/schemas/RestaurantDayMenuMap"
pizzaDay:
$ref: "#/components/schemas/PizzaDay"
pizzaList:
description: Seznam dostupných pizz pro předaný den
type: array
items:
$ref: "#/components/schemas/Pizza"
pizzaListLastUpdate:
description: Datum a čas poslední aktualizace pizz
type: string
format: date-time
# --- OBĚDY ---
UserLunchChoice:
description: Konkrétní volba stravování jednoho uživatele v konkrétní den. Může se jednat jak o stravovací podnik, tak možnosti "budu objednávat", "neobědvám" apod.
additionalProperties: false
properties:
# TODO toto je tu z dost špatného důvodu, viz použití - mělo by se místo toho z loginu zjišťovat zda je uživatel trusted
trusted:
description: Příznak, zda byla tato volba provedena uživatelem ověřeným doménovým přihlášením
type: boolean
selectedFoods:
description: Pole indexů vybraných jídel v rámci dané restaurace. Index představuje pořadí jídla v menu dané restaurace.
type: array
items:
type: integer
departureTime:
description: Čas preferovaného odchodu do dané restaurace v human-readable formátu (např. 12:00)
type: string
note:
description: Volitelná, veřejně viditelná uživatelská poznámka k vybrané volbě
type: string
LocationLunchChoicesMap:
description: Objekt, kde klíčem je možnost stravování ((#/components/schemas/LunchChoice)) a hodnotou množina uživatelů s touto volbou ((#/components/schemas/LunchChoices)).
type: object
additionalProperties:
$ref: "#/components/schemas/UserLunchChoice"
LunchChoices:
description: Objekt, představující volby všech uživatelů pro konkrétní den. Klíčem je (#/components/schemas/LunchChoice).
type: object
properties:
SLADOVNICKA:
$ref: "#/components/schemas/LocationLunchChoicesMap"
TECHTOWER:
$ref: "#/components/schemas/LocationLunchChoicesMap"
ZASTAVKAUMICHALA:
$ref: "#/components/schemas/LocationLunchChoicesMap"
SENKSERIKOVA:
$ref: "#/components/schemas/LocationLunchChoicesMap"
SPSE:
$ref: "#/components/schemas/LocationLunchChoicesMap"
PIZZA:
$ref: "#/components/schemas/LocationLunchChoicesMap"
OBJEDNAVAM:
$ref: "#/components/schemas/LocationLunchChoicesMap"
NEOBEDVAM:
$ref: "#/components/schemas/LocationLunchChoicesMap"
ROZHODUJI:
$ref: "#/components/schemas/LocationLunchChoicesMap"
Restaurant:
description: Stravovací zařízení (restaurace, jídelna, hospoda, ...)
type: string
enum:
- Sladovnická
- TechTower
- Zastávka u Michala
- Šenk Šeříková
x-enum-varnames:
- SLADOVNICKA
- TECHTOWER
- ZASTAVKAUMICHALA
- SENKSERIKOVA
LunchChoice:
description: Konkrétní možnost stravování (konkrétní restaurace, pizza day, objednání, neobědvání, rozhodování se, ...)
type: string
enum:
- Sladovnická
- TechTower
- Zastávka u Michala
- Šenk Šeříková
- SPŠE
- Pizza day
- Budu objednávat
- Neobědvám
- Rozhoduji se
x-enum-varnames:
- SLADOVNICKA
- TECHTOWER
- ZASTAVKAUMICHALA
- SENKSERIKOVA
- SPSE
- PIZZA
- OBJEDNAVAM
- NEOBEDVAM
- ROZHODUJI
DayIndex:
description: Index dne v týdnu (0 = pondělí, 4 = pátek)
type: integer
minimum: 0
maximum: 4
FoodIndex:
description: Pořadový index jídla v menu konkrétní restaurace
type: integer
minimum: 0
Food:
description: Konkrétní jídlo z menu restaurace
type: object
additionalProperties: false
required:
- name
- isSoup
properties:
amount:
description: Množství standardní porce, např. 0,33l nebo 150g
type: string
name:
description: Název/popis jídla
type: string
price:
description: Cena ve formátu '135 Kč'
type: string
isSoup:
description: Příznak, zda se jedná o polévku
type: boolean
RestaurantDayMenu:
description: Menu restaurace na konkrétní den
type: object
additionalProperties: false
properties:
lastUpdate:
description: UNIX timestamp poslední aktualizace menu
type: integer
closed:
description: Příznak, zda je daný podnik v daný den zavřený
type: boolean
food:
description: Seznam jídel pro daný den
type: array
items:
$ref: "#/components/schemas/Food"
RestaurantDayMenuMap:
description: Objekt, kde klíčem je podnik ((#/components/schemas/Restaurant)) a hodnotou denní menu daného podniku ((#/components/schemas/RestaurantDayMenu))
type: object
additionalProperties: false
properties:
SLADOVNICKA:
$ref: "#/components/schemas/RestaurantDayMenu"
TECHTOWER:
$ref: "#/components/schemas/RestaurantDayMenu"
ZASTAVKAUMICHALA:
$ref: "#/components/schemas/RestaurantDayMenu"
SENKSERIKOVA:
$ref: "#/components/schemas/RestaurantDayMenu"
WeekMenu:
description: Pole týdenních menu jednotlivých podniků. Indexem je den v týdnu (0 = pondělí, 4 = pátek), hodnotou denní menu daného podniku.
type: array
minItems: 5
maxItems: 5
items:
$ref: "#/components/schemas/RestaurantDayMenuMap"
DepartureTime:
description: Preferovaný čas odchodu na oběd
type: string
enum:
- "10:00"
- "10:15"
- "10:30"
- "10:45"
- "11:00"
- "11:15"
- "11:30"
- "11:45"
- "12:00"
- "12:15"
- "12:30"
- "12:45"
- "13:00"
x-enum-varnames:
- T10_00
- T10_15
- T10_30
- T10_45
- T11_00
- T11_15
- T11_30
- T11_45
- T12_00
- T12_15
- T12_30
- T12_45
- T13_00
# --- HLASOVÁNÍ ---
FeatureRequest:
type: string
enum:
- Ruční generování QR kódů mimo Pizza day (např. při objednávání)
- Možnost označovat si jídla jako oblíbená (taková jídla by se uživateli následně zvýrazňovala)
- Možnost úhrady v podniku za všechny jednou osobou a následné generování QR ostatním
- Zrušení \"užívejte víkend\", místo toho umožnit zpětně náhled na uplynulý týden
- Umožnění zobrazení vygenerovaného QR kódu i po následující dny (dokud ho uživatel ručně \"nezavře\", např. tlačítkem \"Zaplatil jsem\")
- Zobrazování náhledů (fotografií) pizz v rámci Pizza day
- Statistiky (nejoblíbenější podnik, nejpopulárnější jídla, nejobjednávanější pizzy, nejčastější uživatelé, ...)
- Vylepšení responzivního designu
- Zvýšení zabezpečení aplikace
- Zvýšená ochrana proti chybám uživatele (potvrzovací dialogy, překliky, ...)
- Celkové vylepšení UI/UX
- Zlepšení dokumentace/postupů pro ostatní vývojáře
x-enum-varnames:
- CUSTOM_QR
- FAVORITES
- SINGLE_PAYMENT
- NO_WEEKENDS
- QR_FOREVER
- PIZZA_PICTURES
- STATISTICS
- RESPONSIVITY
- SECURITY
- SAFETY
- UI
- DEVELOPMENT
# --- EASTER EGGS ---
EasterEgg:
description: Data pro zobrazení easter eggů
type: object
additionalProperties: false
required:
- path
- url
- startOffset
- endOffset
- duration
properties:
path:
type: string
url:
type: string
startOffset:
type: number
endOffset:
type: number
duration:
type: number
width:
type: string
zIndex:
type: integer
position:
type: string
enum:
- absolute
animationName:
type: string
animationDuration:
type: string
animationTimingFunction:
type: string
# --- STATISTIKY ---
LocationStats:
description: Objekt, kde klíčem je zvolená možnost a hodnotou počet uživatelů, kteří tuto možnosti zvolili
type: object
additionalProperties: false
properties:
# Bohužel OpenAPI neumí nadefinovat objekt, kde klíčem může být pouze hodnota existujícího enumu :(
SLADOVNICKA:
type: number
TECHTOWER:
type: number
ZASTAVKAUMICHALA:
type: number
SENKSERIKOVA:
type: number
SPSE:
type: number
PIZZA:
type: number
OBJEDNAVAM:
type: number
NEOBEDVAM:
type: number
ROZHODUJI:
type: number
DailyStats:
description: Statistika vybraných možností pro jeden konkrétní den
type: object
additionalProperties: false
required:
- date
- locations
properties:
date:
description: Datum v human-readable formátu
type: string
locations:
$ref: "#/components/schemas/LocationStats"
WeeklyStats:
description: Pole statistik vybraných možností pro jeden konkrétní týden. Index představuje den v týdnu (0 = pondělí, 4 = pátek)
type: array
minItems: 5
maxItems: 5
items:
$ref: "#/components/schemas/DailyStats"
# --- PIZZA DAY ---
PizzaDayState:
description: Stav pizza day
type: string
enum:
- Pizza day nebyl založen
- Pizza day je založen
- Objednávky uzamčeny
- Pizzy objednány
- Pizzy doručeny
x-enum-varnames:
- NOT_CREATED
- CREATED
- LOCKED
- ORDERED
- DELIVERED
# TODO toto je jen rozšířená varianta PizzaVariant - sloučit do jednoho objektu
PizzaSize:
description: Údaje o konkrétní variantě pizzy
type: object
additionalProperties: false
required:
- varId
- size
- pizzaPrice
- boxPrice
- price
properties:
varId:
description: Unikátní identifikátor varianty pizzy
type: integer
size:
description: Velikost pizzy, např. "30cm"
type: string
pizzaPrice:
description: Cena samotné pizzy v Kč
type: number
boxPrice:
description: Cena krabice pizzy v Kč
type: number
price:
description: Celková cena (pizza + krabice)
type: number
Pizza:
description: Údaje o konkrétní pizze.
type: object
additionalProperties: false
required:
- name
- ingredients
- sizes
properties:
name:
description: Název pizzy
type: string
ingredients:
description: Seznam obsažených ingrediencí
type: array
items:
type: string
sizes:
description: Dostupné velikosti pizzy
type: array
items:
$ref: "#/components/schemas/PizzaSize"
PizzaVariant:
description: Konkrétní varianta (velikost) jedné pizzy.
type: object
additionalProperties: false
required:
- varId
- name
- size
- price
properties:
varId:
description: Unikátní identifikátor varianty pizzy
type: integer
name:
description: Název pizzy
type: string
size:
description: Velikost pizzy (např. "30cm")
type: string
price:
description: Cena pizzy v Kč, včetně krabice
type: number
PizzaOrder:
description: Údaje o objednávce pizzy jednoho uživatele.
type: object
additionalProperties: false
required:
- customer
- totalPrice
- hasQr
properties:
customer:
description: Jméno objednávajícího uživatele
type: string
pizzaList:
description: Seznam variant pizz k objednání (typicky bývá jen jedna)
type: array
items:
$ref: "#/components/schemas/PizzaVariant"
fee:
description: Příplatek (např. za extra ingredience)
type: object
properties:
text:
description: Popis příplatku (např. "kuřecí maso navíc")
type: string
price:
description: Cena příplatku v Kč
type: number
totalPrice:
description: Celková cena všech objednaných pizz daného uživatele, včetně krabic a příplatků
type: number
hasQr:
description: |
Příznak, pokud je k této objednávce vygenerován QR kód pro platbu. To je typicky pravda, pokud:
- objednávající má v nastavení vyplněno číslo účtu
- pizza day je ve stavu DELIVERED (Pizzy byly doručeny)
note:
description: Volitelná uživatelská poznámka pro objednávajícího (např. "bez oliv")
type: string
PizzaDay:
description: Data o Pizza day pro konkrétní den
type: object
additionalProperties: false
properties:
state:
$ref: "#/components/schemas/PizzaDayState"
creator:
description: "Jméno zakladatele pizza day"
type: string
orders:
description: Pole objednávek jednotlivých uživatelů
type: array
items:
$ref: "#/components/schemas/PizzaOrder"
# --- NOTIFIKACE ---
UdalostEnum:
type: string
enum:
- Zahájen pizza day
- Objednána pizza
- Jdeme na oběd
x-enum-varnames:
- ZAHAJENA_PIZZA
- OBJEDNANA_PIZZA
- JDEME_NA_OBED
NotifikaceInput:
type: object
required:
- udalost
- user
properties:
udalost:
$ref: "#/components/schemas/UdalostEnum"
user:
type: string
NotifikaceData:
type: object
required:
- input
properties:
input:
$ref: "#/components/schemas/NotifikaceInput"
gotify:
type: boolean
teams:
type: boolean
ntfy:
type: boolean
GotifyServer:
type: object
required:
- server
- api_keys
properties:
server:
type: string
api_keys:
type: array
items:
type: string
responses:
ClientDataResponse:
description: Aktuální data pro klienta
content:
application/json:
schema:
$ref: "#/components/schemas/ClientData"
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
security:
- bearerAuth: []

View File

@@ -1 +1,2 @@
export * from './Types';
export * from './RequestTypes';
export * from './gen';

View File

@@ -0,0 +1,14 @@
import { defaultPlugins } from '@hey-api/openapi-ts';
export default {
input: 'api.yml',
output: 'gen',
plugins: [
...defaultPlugins,
'@hey-api/client-fetch',
{
enums: 'javascript',
name: '@hey-api/typescript',
},
],
};

11
types/package.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "@luncher/types",
"version": "1.0.0",
"license": "MIT",
"private": true,
"devDependencies": {
"@hey-api/client-fetch": "^0.8.2",
"@hey-api/openapi-ts": "^0.64.7",
"typescript": "^5.0.2"
}
}

View File

@@ -1,8 +1,6 @@
{
"compilerOptions": {
"declaration": true,
// "emitDeclarationOnly": true,
// "outDir": "./dist",
"noEmit": true
},
"include": [

309
types/yarn.lock Normal file
View File

@@ -0,0 +1,309 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@hey-api/client-fetch@^0.8.2":
version "0.8.2"
resolved "https://registry.yarnpkg.com/@hey-api/client-fetch/-/client-fetch-0.8.2.tgz#675aadfbc9478bb8eef5679f11a9334258dff4c8"
integrity sha512-61T4UGfAzY5345vMxWDX8qnSTNRJcOpWuZyvNu3vNebCTLPwMQAM85mhEuBoACdWeRtLhNoUjU0UR5liRyD1bA==
"@hey-api/json-schema-ref-parser@1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.0.2.tgz#c3824c5d9d531eeb5c2b2557857a8ad20b5c75a7"
integrity sha512-F6LSkttZcT/XiX3ydeDqTY3uRN3BLJMwyMTk4kg/ichZlKUp3+3Odv0WokSmXGSoZGTW/N66FROMYAm5NPdJlA==
dependencies:
"@jsdevtools/ono" "^7.1.3"
"@types/json-schema" "^7.0.15"
js-yaml "^4.1.0"
"@hey-api/openapi-ts@^0.64.7":
version "0.64.7"
resolved "https://registry.yarnpkg.com/@hey-api/openapi-ts/-/openapi-ts-0.64.7.tgz#f239d268b4a35b91f5ff25479d15578feb01f365"
integrity sha512-xpaBzdGAKz7cPuGah1GZWl3zTZquOXRnwmpVCQKEUpvDtSYWBLjSxtAYIqDBjwJkuNHcakZBIWLcappx7slc2g==
dependencies:
"@hey-api/json-schema-ref-parser" "1.0.2"
c12 "2.0.1"
commander "13.0.0"
handlebars "4.7.8"
"@jsdevtools/ono@^7.1.3":
version "7.1.3"
resolved "https://registry.yarnpkg.com/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796"
integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==
"@types/json-schema@^7.0.15":
version "7.0.15"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
acorn@^8.14.0:
version "8.14.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.0.tgz#063e2c70cac5fb4f6467f0b11152e04c682795b0"
integrity sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==
argparse@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
c12@2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/c12/-/c12-2.0.1.tgz#5702d280b31a08abba39833494c9b1202f0f5aec"
integrity sha512-Z4JgsKXHG37C6PYUtIxCfLJZvo6FyhHJoClwwb9ftUkLpPSkuYqn6Tr+vnaN8hymm0kIbcg6Ey3kv/Q71k5w/A==
dependencies:
chokidar "^4.0.1"
confbox "^0.1.7"
defu "^6.1.4"
dotenv "^16.4.5"
giget "^1.2.3"
jiti "^2.3.0"
mlly "^1.7.1"
ohash "^1.1.4"
pathe "^1.1.2"
perfect-debounce "^1.0.0"
pkg-types "^1.2.0"
rc9 "^2.1.2"
chokidar@^4.0.1:
version "4.0.3"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30"
integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==
dependencies:
readdirp "^4.0.1"
chownr@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece"
integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==
citty@^0.1.6:
version "0.1.6"
resolved "https://registry.yarnpkg.com/citty/-/citty-0.1.6.tgz#0f7904da1ed4625e1a9ea7e0fa780981aab7c5e4"
integrity sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==
dependencies:
consola "^3.2.3"
commander@13.0.0:
version "13.0.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-13.0.0.tgz#1b161f60ee3ceb8074583a0f95359a4f8701845c"
integrity sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==
confbox@^0.1.7, confbox@^0.1.8:
version "0.1.8"
resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.1.8.tgz#820d73d3b3c82d9bd910652c5d4d599ef8ff8b06"
integrity sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==
consola@^3.2.3, consola@^3.4.0:
version "3.4.0"
resolved "https://registry.yarnpkg.com/consola/-/consola-3.4.0.tgz#4cfc9348fd85ed16a17940b3032765e31061ab88"
integrity sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==
defu@^6.1.4:
version "6.1.4"
resolved "https://registry.yarnpkg.com/defu/-/defu-6.1.4.tgz#4e0c9cf9ff68fe5f3d7f2765cc1a012dfdcb0479"
integrity sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==
destr@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/destr/-/destr-2.0.3.tgz#7f9e97cb3d16dbdca7be52aca1644ce402cfe449"
integrity sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==
dotenv@^16.4.5:
version "16.4.7"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.7.tgz#0e20c5b82950140aa99be360a8a5f52335f53c26"
integrity sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==
fs-minipass@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb"
integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==
dependencies:
minipass "^3.0.0"
giget@^1.2.3:
version "1.2.5"
resolved "https://registry.yarnpkg.com/giget/-/giget-1.2.5.tgz#0bd4909356a0da75cc1f2b33538f93adec0d202f"
integrity sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==
dependencies:
citty "^0.1.6"
consola "^3.4.0"
defu "^6.1.4"
node-fetch-native "^1.6.6"
nypm "^0.5.4"
pathe "^2.0.3"
tar "^6.2.1"
handlebars@4.7.8:
version "4.7.8"
resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.8.tgz#41c42c18b1be2365439188c77c6afae71c0cd9e9"
integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==
dependencies:
minimist "^1.2.5"
neo-async "^2.6.2"
source-map "^0.6.1"
wordwrap "^1.0.0"
optionalDependencies:
uglify-js "^3.1.4"
jiti@^2.3.0:
version "2.4.2"
resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.4.2.tgz#d19b7732ebb6116b06e2038da74a55366faef560"
integrity sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==
js-yaml@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602"
integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==
dependencies:
argparse "^2.0.1"
minimist@^1.2.5:
version "1.2.8"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
minipass@^3.0.0:
version "3.3.6"
resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.6.tgz#7bba384db3a1520d18c9c0e5251c3444e95dd94a"
integrity sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==
dependencies:
yallist "^4.0.0"
minipass@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d"
integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==
minizlib@^2.1.1:
version "2.1.2"
resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931"
integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==
dependencies:
minipass "^3.0.0"
yallist "^4.0.0"
mkdirp@^1.0.3:
version "1.0.4"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
mlly@^1.7.1, mlly@^1.7.4:
version "1.7.4"
resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.7.4.tgz#3d7295ea2358ec7a271eaa5d000a0f84febe100f"
integrity sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==
dependencies:
acorn "^8.14.0"
pathe "^2.0.1"
pkg-types "^1.3.0"
ufo "^1.5.4"
neo-async@^2.6.2:
version "2.6.2"
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
node-fetch-native@^1.6.6:
version "1.6.6"
resolved "https://registry.yarnpkg.com/node-fetch-native/-/node-fetch-native-1.6.6.tgz#ae1d0e537af35c2c0b0de81cbff37eedd410aa37"
integrity sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ==
nypm@^0.5.4:
version "0.5.4"
resolved "https://registry.yarnpkg.com/nypm/-/nypm-0.5.4.tgz#a5ab0d8d37f96342328479f88ef58699f29b3051"
integrity sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==
dependencies:
citty "^0.1.6"
consola "^3.4.0"
pathe "^2.0.3"
pkg-types "^1.3.1"
tinyexec "^0.3.2"
ufo "^1.5.4"
ohash@^1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/ohash/-/ohash-1.1.4.tgz#ae8d83014ab81157d2c285abf7792e2995fadd72"
integrity sha512-FlDryZAahJmEF3VR3w1KogSEdWX3WhA5GPakFx4J81kEAiHyLMpdLLElS8n8dfNadMgAne/MywcvmogzscVt4g==
pathe@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.2.tgz#6c4cb47a945692e48a1ddd6e4094d170516437ec"
integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==
pathe@^2.0.1, pathe@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716"
integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==
perfect-debounce@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz#9c2e8bc30b169cc984a58b7d5b28049839591d2a"
integrity sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==
pkg-types@^1.2.0, pkg-types@^1.3.0, pkg-types@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.3.1.tgz#bd7cc70881192777eef5326c19deb46e890917df"
integrity sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==
dependencies:
confbox "^0.1.8"
mlly "^1.7.4"
pathe "^2.0.1"
rc9@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/rc9/-/rc9-2.1.2.tgz#6282ff638a50caa0a91a31d76af4a0b9cbd1080d"
integrity sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==
dependencies:
defu "^6.1.4"
destr "^2.0.3"
readdirp@^4.0.1:
version "4.1.2"
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d"
integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==
source-map@^0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
tar@^6.2.1:
version "6.2.1"
resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.1.tgz#717549c541bc3c2af15751bea94b1dd068d4b03a"
integrity sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==
dependencies:
chownr "^2.0.0"
fs-minipass "^2.0.0"
minipass "^5.0.0"
minizlib "^2.1.1"
mkdirp "^1.0.3"
yallist "^4.0.0"
tinyexec@^0.3.2:
version "0.3.2"
resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2"
integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==
typescript@^5.0.2:
version "5.8.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.2.tgz#8170b3702f74b79db2e5a96207c15e65807999e4"
integrity sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==
ufo@^1.5.4:
version "1.5.4"
resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.5.4.tgz#16d6949674ca0c9e0fbbae1fa20a71d7b1ded754"
integrity sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==
uglify-js@^3.1.4:
version "3.19.3"
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.19.3.tgz#82315e9bbc6f2b25888858acd1fff8441035b77f"
integrity sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==
wordwrap@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
yallist@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==

11200
yarn.lock

File diff suppressed because it is too large Load Diff