From ca9a7c5c23af8d0727fc272939ba32596fd2e6e8 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Sun, 15 Oct 2023 19:05:19 +0200 Subject: [PATCH] =?UTF-8?q?Parsov=C3=A1n=C3=AD=20j=C3=ADdel=20na=20cel?= =?UTF-8?q?=C3=BD=20t=C3=BDden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/App.tsx | 14 +- server/src/mock.ts | 12 +- server/src/pizza.ts | 26 +-- server/src/restaurants.ts | 344 +++++++++++++++++++------------------- server/src/service.ts | 145 ++++++++++------ server/src/utils.ts | 23 +++ types/Types.ts | 34 ++-- 7 files changed, 338 insertions(+), 260 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 51dd614..787bd4d 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -14,7 +14,7 @@ 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, Menu } from './types'; +import { ClientData, Restaurants, Food, Order, Locations, PizzaOrder, PizzaDayState, FoodChoices, DayMenu } from './types'; import Footer from './components/Footer'; import { faChainBroken, faChevronLeft, faChevronRight, faGear, faSatelliteDish, faSearch } from '@fortawesome/free-solid-svg-icons'; import Loader from './components/Loader'; @@ -28,7 +28,7 @@ function App() { const bank = useBank(); const [isConnected, setIsConnected] = useState(false); const [data, setData] = useState(); - const [food, setFood] = useState<{ [key in Restaurants]?: Menu }>(); + const [food, setFood] = useState<{ [key in Restaurants]?: DayMenu }>(); const [myOrder, setMyOrder] = useState(); const [foodChoiceList, setFoodChoiceList] = useState(); const [closed, setClosed] = useState(false); @@ -288,7 +288,7 @@ function App() { } } - const renderFoodTable = (name: string, menu: Menu) => { + const renderFoodTable = (name: string, menu: DayMenu) => { let content; if (menu?.closed) { content =

Zavřeno

@@ -352,13 +352,7 @@ function App() { Poslední změny:
    -
  • Oprava generování QR kódů pro Pizza day
  • -
  • Serverová validace času odchodu
  • -
  • Loader při zakládání Pizza day
  • -
  • Možnost ručního zadání příplatku k Pizza day objednávkám
  • -
  • Vylepšená detekce uzavření pro podniky Sladovnická a TechTower
  • -
  • Úprava zvýraznění aktuálního dne
  • -
  • Možnost hlasování o nových funkcích
  • +
  • Parsování jídelních lístků na celý týden
{dayIndex != null && diff --git a/server/src/mock.ts b/server/src/mock.ts index d32d980..981f343 100644 --- a/server/src/mock.ts +++ b/server/src/mock.ts @@ -1124,16 +1124,16 @@ export const getTodayMock = () => { return '2023-05-31'; // středa } -export const getMenuSladovnickaMock = (date: Date) => { - return MOCK_DATA['sladovnicka'][getDayOfWeekIndex(date)]; +export const getMenuSladovnickaMock = () => { + return MOCK_DATA['sladovnicka']; } -export const getMenuUMotlikuMock = (date: Date) => { - return MOCK_DATA['uMotliku'][getDayOfWeekIndex(date)]; +export const getMenuUMotlikuMock = () => { + return MOCK_DATA['uMotliku']; } -export const getMenuTechTowerMock = (date: Date) => { - return MOCK_DATA['techTower'][getDayOfWeekIndex(date)]; +export const getMenuTechTowerMock = () => { + return MOCK_DATA['techTower']; } export const getPizzaListMock = () => { diff --git a/server/src/pizza.ts b/server/src/pizza.ts index aeb8486..6dc0129 100644 --- a/server/src/pizza.ts +++ b/server/src/pizza.ts @@ -1,7 +1,7 @@ import { formatDate } from "./utils"; import { callNotifikace } from "./notifikace"; import { generateQr } from "./qr"; -import { ClientData, PizzaDayState, UdalostEnum, Pizza, PizzaSize, Order, PizzaOrder } from "../../types"; +import { ClientData, PizzaDayState, UdalostEnum, Pizza, PizzaSize, Order, PizzaOrder, DayData } from "../../types"; import getStorage from "./storage"; import { downloadPizzy } from "./chefie"; import { getToday, initIfNeeded } from "./service"; @@ -15,7 +15,7 @@ const storage = getStorage(); export async function getPizzaList(): Promise { await initIfNeeded(); const today = formatDate(getToday()); - let clientData: ClientData = await storage.getData(today); + let clientData: DayData = await storage.getData(today); if (!clientData.pizzaList) { const mock = process.env.MOCK_DATA === 'true'; clientData = await savePizzaList(await downloadPizzy(mock)); @@ -31,7 +31,7 @@ export async function getPizzaList(): Promise { export async function savePizzaList(pizzaList: Pizza[]): Promise { await initIfNeeded(); const today = formatDate(getToday()); - const clientData: ClientData = await storage.getData(today); + const clientData: DayData = await storage.getData(today); clientData.pizzaList = pizzaList; clientData.pizzaListLastUpdate = new Date(); await storage.setData(today, clientData); @@ -44,7 +44,7 @@ export async function savePizzaList(pizzaList: Pizza[]): Promise { export async function createPizzaDay(creator: string): Promise { await initIfNeeded(); const today = formatDate(getToday()); - const clientData: ClientData = await storage.getData(today); + const clientData: DayData = await storage.getData(today); if (clientData.pizzaDay) { throw Error("Pizza day pro dnešní den již existuje"); } @@ -61,7 +61,7 @@ export async function createPizzaDay(creator: string): Promise { */ export async function deletePizzaDay(login: string): Promise { const today = formatDate(getToday()); - const clientData: ClientData = await storage.getData(today); + const clientData: DayData = await storage.getData(today); if (!clientData.pizzaDay) { throw Error("Pizza day pro dnešní den neexistuje"); } @@ -82,7 +82,7 @@ export async function deletePizzaDay(login: string): Promise { */ export async function addPizzaOrder(login: string, pizza: Pizza, size: PizzaSize) { const today = formatDate(getToday()); - const clientData: ClientData = await storage.getData(today); + const clientData: DayData = await storage.getData(today); if (!clientData.pizzaDay) { throw Error("Pizza day pro dnešní den neexistuje"); } @@ -118,7 +118,7 @@ export async function addPizzaOrder(login: string, pizza: Pizza, size: PizzaSize */ export async function removePizzaOrder(login: string, pizzaOrder: PizzaOrder) { const today = formatDate(getToday()); - const clientData: ClientData = await storage.getData(today); + const clientData: DayData = await storage.getData(today); if (!clientData.pizzaDay) { throw Error("Pizza day pro dnešní den neexistuje"); } @@ -149,7 +149,7 @@ export async function removePizzaOrder(login: string, pizzaOrder: PizzaOrder) { */ export async function lockPizzaDay(login: string) { const today = formatDate(getToday()); - const clientData: ClientData = await storage.getData(today); + const clientData: DayData = await storage.getData(today); if (!clientData.pizzaDay) { throw Error("Pizza day pro dnešní den neexistuje"); } @@ -172,7 +172,7 @@ export async function lockPizzaDay(login: string) { */ export async function unlockPizzaDay(login: string) { const today = formatDate(getToday()); - const clientData: ClientData = await storage.getData(today); + const clientData: DayData = await storage.getData(today); if (!clientData.pizzaDay) { throw Error("Pizza day pro dnešní den neexistuje"); } @@ -195,7 +195,7 @@ export async function unlockPizzaDay(login: string) { */ export async function finishPizzaOrder(login: string) { const today = formatDate(getToday()); - const clientData: ClientData = await storage.getData(today); + const clientData: DayData = await storage.getData(today); if (!clientData.pizzaDay) { throw Error("Pizza day pro dnešní den neexistuje"); } @@ -220,7 +220,7 @@ export async function finishPizzaOrder(login: string) { */ export async function finishPizzaDelivery(login: string, bankAccount?: string, bankAccountHolder?: string) { const today = formatDate(getToday()); - const clientData: ClientData = await storage.getData(today); + const clientData: DayData = await storage.getData(today); if (!clientData.pizzaDay) { throw Error("Pizza day pro dnešní den neexistuje"); } @@ -255,7 +255,7 @@ export async function finishPizzaDelivery(login: string, bankAccount?: string, b */ export async function updatePizzaDayNote(login: string, note?: string) { const today = formatDate(getToday()); - let clientData: ClientData = await storage.getData(today); + let clientData: DayData = await storage.getData(today); if (!clientData.pizzaDay) { throw Error("Pizza day pro dnešní den neexistuje"); } @@ -282,7 +282,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: ClientData = await storage.getData(today); + let clientData: DayData = await storage.getData(today); if (!clientData.pizzaDay) { throw Error("Pizza day pro dnešní den neexistuje"); } diff --git a/server/src/restaurants.ts b/server/src/restaurants.ts index a234f0a..0cef7a3 100644 --- a/server/src/restaurants.ts +++ b/server/src/restaurants.ts @@ -2,7 +2,6 @@ import axios from "axios"; import { load } from 'cheerio'; import { Food } from "../../types"; import { getMenuSladovnickaMock, getMenuTechTowerMock, getMenuUMotlikuMock } from "./mock"; -import { getDayOfWeekIndex } from "./utils"; // 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'] @@ -48,181 +47,184 @@ const getHtml = async (url: string): Promise => { } /** - * Získá obědovou nabídku Sladovnické pro předané datum. + * Získá obědovou nabídku Sladovnické pro jeden týden. * - * @param date datum, pro které získat menu + * @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 + * @returns seznam jídel pro daný týden */ -export const getMenuSladovnicka = async (date: Date = new Date(), mock: boolean = false): Promise => { +export const getMenuSladovnicka = async (firstDayOfWeek: Date, mock: boolean = false): Promise => { if (mock) { - return getMenuSladovnickaMock(date); - } - const todayDayIndex = getDayOfWeekIndex(date); - if (todayDayIndex == 5 || todayDayIndex == 6) { // Víkend - return []; + return getMenuSladovnickaMock(); } + const html = await getHtml(SLADOVNICKA_URL); const $ = load(html); - // Najdeme index pro vstupní datum (např. při svátcích bude posunutý) - // TODO validovat, že vstupní datum je v aktuálním týdnu - // TODO tenhle způsob je zbytečně komplikovaný - stačilo by hledat rovnou v div.tab-content, protože každý den tam má datum taky (akorát je print-only) + const list = $('ul.tab-links').children(); - const searchedDayText = `${date.getDate()}.${date.getMonth() + 1}.${capitalize(DAYS_IN_WEEK[todayDayIndex])}`; - let index = undefined; - list.each((i, dayRow) => { - const rowText = $(dayRow).first().text().trim(); - if (rowText === searchedDayText) { - index = i; - return; + const result: Food[][] = []; + for (let dayIndex = 0; dayIndex < 5; dayIndex++) { + const currentDate = new Date(firstDayOfWeek); + currentDate.setDate(firstDayOfWeek.getDate() + dayIndex); + const searchedDayText = `${currentDate.getDate()}.${currentDate.getMonth() + 1}.${capitalize(DAYS_IN_WEEK[dayIndex])}`; + // Najdeme index pro vstupní datum (např. při svátcích bude posunutý) + // TODO validovat, že vstupní datum je v aktuálním týdnu + // TODO tenhle způsob je zbytečně komplikovaný - stačilo by hledat rovnou v div.tab-content, protože každý den tam má datum taky (akorát je print-only) + let index = undefined; + list.each((i, dayRow) => { + const rowText = $(dayRow).first().text().trim(); + if (rowText === searchedDayText) { + index = i; + return; + } + }) + if (index === undefined) { + // Pravděpodobně svátek, nebo je zavřeno + result[dayIndex] = [{ + amount: undefined, + name: "Pro daný den nebyla nalezena denní nabídka", + price: "", + isSoup: false, + }]; + continue; } - }) - if (index === undefined) { - // Pravděpodobně svátek, nebo je zavřeno - return [{ - amount: undefined, - name: "Pro daný den nebyla nalezena denní nabídka", - price: "", - isSoup: false, - }]; - } - // Dle dohledaného indexu najdeme správný tabpanel - const rows = $('div.tab-content').children(); - if (index >= rows.length) { - throw Error("V HTML nebyl nalezen řádek menu pro index " + index); - } - const tabPanel = $(rows.get(index)); - - // Opětovná validace, že daný tabpanel je pro vstupní datum - const headers = tabPanel.find('h2'); - if (headers.length !== 3) { - throw Error("Neočekávaný počet elementů h2 v menu pro datum " + searchedDayText + ", očekávány 3, ale nalezeno bylo " + headers.length); - } - const dayText = $(headers.get(0)).text().trim(); - if (dayText !== searchedDayText) { - throw Error("Neočekávaný datum na řádce nalezeného dne: '" + dayText + "', ale očekáváno bylo '" + searchedDayText + "'"); - } - - // V tabpanelu očekáváme dvě tabulky - pro polévku a pro hlavní jídlo - const tables = tabPanel.find('table'); - if (tables.length !== 2) { - throw Error("Neočekávaný počet tabulek na řádce nalezeného dne: " + tables.length + ", ale očekávány byly 2"); - } - const results: Food[] = []; - // Polévka - div -> table -> tbody -> tr -> 3x td - const soupCells = $(tables.get(0)).children().first().children().first().children(); - if (soupCells.length !== 3) { - throw Error("Neočekávaný počet buněk v tabulce polévky: " + soupCells.length + ", ale očekávány byly 3"); - } - results.push({ - amount: sanitizeText($(soupCells.get(0)).text()), - name: sanitizeText($(soupCells.get(1)).text()), - price: sanitizeText($(soupCells.get(2)).text().replace(' ', '\xA0')), - isSoup: true, - }); - // Hlavní jídla - div -> table -> tbody -> 3x tr - const mainCourseRows = $(tables.get(1)).children().first().children(); - // Záměrně zakomentováno - občas je ve Sladovnické jídel méně - // if (mainCourseRows.length !== 3) { - // throw Error("Neočekávaný počet řádek jídel: " + mainCourseRows.length + ", ale očekávány byly 3"); - // } - mainCourseRows.each((i, foodRow) => { - const foodCells = $(foodRow).children(); - if (foodCells.length !== 3) { - throw Error("Neočekávaný počet buněk v řádku jídla: " + foodCells.length + ", ale očekávány byly 3"); + // Dle dohledaného indexu najdeme správný tabpanel + const rows = $('div.tab-content').children(); + if (index >= rows.length) { + throw Error("V HTML nebyl nalezen řádek menu pro index " + index); } - results.push({ - amount: sanitizeText($(foodCells.get(0)).text()), - name: sanitizeText($(foodCells.get(1)).text()), - price: sanitizeText($(foodCells.get(2)).text().replace(' ', '\xA0')), - isSoup: false, + const tabPanel = $(rows.get(index)); + + // Opětovná validace, že daný tabpanel je pro vstupní datum + const headers = tabPanel.find('h2'); + if (headers.length !== 3) { + throw Error("Neočekávaný počet elementů h2 v menu pro datum " + searchedDayText + ", očekávány 3, ale nalezeno bylo " + headers.length); + } + const dayText = $(headers.get(0)).text().trim(); + if (dayText !== searchedDayText) { + throw Error("Neočekávaný datum na řádce nalezeného dne: '" + dayText + "', ale očekáváno bylo '" + searchedDayText + "'"); + } + + // V tabpanelu očekáváme dvě tabulky - pro polévku a pro hlavní jídlo + const tables = tabPanel.find('table'); + if (tables.length !== 2) { + throw Error("Neočekávaný počet tabulek na řádce nalezeného dne: " + tables.length + ", ale očekávány byly 2"); + } + const currentDayFood: Food[] = []; + // Polévka - div -> table -> tbody -> tr -> 3x td + const soupCells = $(tables.get(0)).children().first().children().first().children(); + if (soupCells.length !== 3) { + throw Error("Neočekávaný počet buněk v tabulce polévky: " + soupCells.length + ", ale očekávány byly 3"); + } + currentDayFood.push({ + amount: sanitizeText($(soupCells.get(0)).text()), + name: sanitizeText($(soupCells.get(1)).text()), + price: sanitizeText($(soupCells.get(2)).text().replace(' ', '\xA0')), + isSoup: true, }); - }) - return results; + // Hlavní jídla - div -> table -> tbody -> 3x tr + const mainCourseRows = $(tables.get(1)).children().first().children(); + mainCourseRows.each((i, foodRow) => { + const foodCells = $(foodRow).children(); + if (foodCells.length !== 3) { + throw Error("Neočekávaný počet buněk v řádku jídla: " + foodCells.length + ", ale očekávány byly 3"); + } + currentDayFood.push({ + amount: sanitizeText($(foodCells.get(0)).text()), + name: sanitizeText($(foodCells.get(1)).text()), + price: sanitizeText($(foodCells.get(2)).text().replace(' ', '\xA0')), + isSoup: false, + }); + }) + result[index] = currentDayFood; + } + return result; } /** - * Získá obědovou nabídku restaurace U Motlíků pro předané datum. + * Získá obědovou nabídku restaurace U Motlíků pro jeden týden. * - * @param date datum, pro které získat menu + * @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 getMenuUMotliku = async (date: Date = new Date(), mock: boolean = false): Promise => { +export const getMenuUMotliku = async (firstDayOfWeek: Date, mock: boolean = false): Promise => { if (mock) { - return getMenuUMotlikuMock(date); - } - const todayDayIndex = getDayOfWeekIndex(date); - if (todayDayIndex == 5 || todayDayIndex == 6) { // Víkend - return []; + return getMenuUMotlikuMock(); } + const html = await getHtml(U_MOTLIKU_URL); const $ = load(html); + const table = $('table.table.table-hover.Xtable-striped').first(); const body = table.children().first(); const rows = body.children(); - const results: Food[] = []; - let parsing = false; - let isSoup = false; - rows.each((i, row) => { - const firstChild = $(row).children().get(0); - if (firstChild?.name == 'th') { - const childText = $(firstChild).text(); - if (capitalize(DAYS_IN_WEEK[todayDayIndex]) === childText) { // Našli jsme dnešek - parsing = true; - } else if (parsing) { - // Narazili jsme na další den - konec parsování - parsing = false; - return; - } - } else if (parsing) { // Jsme aktuálně na dnešním dni - const children = $(row).children(); - if (children.length === 1) { // Nadpis "Polévka" nebo "Hlavní jídlo" - const foodType = children.first().text(); - if (foodType === 'Polévka') { - isSoup = true; - } else if (foodType === 'Hlavní jídlo') { - isSoup = false; - } else { - throw Error("Neočekáváný typ jídla: " + foodType); - } - } else { - if (children.length !== 3) { - throw Error("Neočekávaný počet child elementů pro jídlo: " + children.length + ", očekávány 3"); - } - const amount = sanitizeText($(children.get(0)).text()); - const name = sanitizeText($(children.get(1)).text()); - const price = sanitizeText($(children.get(2)).text()).replace(',-', '').replace(' ', '\xA0'); - results.push({ - amount, - name, - price, - isSoup, - }) - } + + const result: Food[][] = []; + for (let dayIndex = 0; dayIndex < 5; dayIndex++) { + if (!(dayIndex in result)) { + result[dayIndex] = []; } - }) - return results; + let parsing = false; + let isSoup = false; + rows.each((i, row) => { + const firstChild = $(row).children().get(0); + if (firstChild?.name == 'th') { + const childText = $(firstChild).text(); + if (capitalize(DAYS_IN_WEEK[dayIndex]) === childText) { + parsing = true; + } else if (parsing) { + // Narazili jsme na další den - konec parsování + parsing = false; + return; + } + } else if (parsing) { + const children = $(row).children(); + if (children.length === 1) { // Nadpis "Polévka" nebo "Hlavní jídlo" + const foodType = children.first().text(); + if (foodType === 'Polévka') { + isSoup = true; + } else if (foodType === 'Hlavní jídlo') { + isSoup = false; + } else { + throw Error("Neočekáváný typ jídla: " + foodType); + } + } else { + if (children.length !== 3) { + throw Error("Neočekávaný počet child elementů pro jídlo: " + children.length + ", očekávány 3"); + } + const amount = sanitizeText($(children.get(0)).text()); + const name = sanitizeText($(children.get(1)).text()); + const price = sanitizeText($(children.get(2)).text()).replace(',-', '').replace(' ', '\xA0'); + result[dayIndex].push({ + amount, + name, + price, + isSoup, + }) + } + } + }) + } + return result; } /** - * Získá obědovou nabídku TechTower pro předané datum. + * Získá obědovou nabídku TechTower pro jeden týden. * - * @param date datum, pro které získat menu + * @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 getMenuTechTower = async (date: Date = new Date(), mock: boolean = false) => { +export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = false): Promise => { if (mock) { - return getMenuTechTowerMock(date); - } - const todayDayIndex = getDayOfWeekIndex(date); - if (todayDayIndex == 5 || todayDayIndex == 6) { // Víkend - return []; + return getMenuTechTowerMock(); } + const html = await getHtml(TECHTOWER_URL); const $ = load(html); + const fonts = $('font.wsw-41'); let font = undefined; fonts.each((i, f) => { @@ -236,38 +238,44 @@ export const getMenuTechTower = async (date: Date = new Date(), mock: boolean = // TODO validovat, že v textu nalezeného je rozsah, do kterého spadá vstupní datum const siblings = $(font).parent().parent().siblings(); let parsing = false; - const results: Food[] = []; - 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[todayDayIndex]) { - // Našli jsme dnešní den, odtud začínáme parsovat jídla - parsing = true; - continue + const result: Food[][] = []; + // TODO toto je kvůli poslednímu "línému" refaktoru neoptimální, stačilo by to projít jedním cyklem + for (let dayIndex = 0; dayIndex < 5; dayIndex++) { + if (!(dayIndex in result)) { + result[dayIndex] = []; + } + 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[dayIndex]) { + // Našli jsme dnešní den, odtud začínáme parsovat jídla + parsing = true; + continue + } else if (parsing) { + // Už parsujeme jídla, ale narazili jsme na následující den - končíme + break; + } } else if (parsing) { - // Už parsujeme jídla, ale narazili jsme na následující den - končíme - break; + if (text.length == 0) { + // Prázdná řádka - končíme (je za pátečním menu TechTower) + break; + } + let price = '? Kč'; + let name = text; + 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] + } + result[dayIndex].push({ + amount: '-', + name, + price, + isSoup: isTextSoupName(name), + }) } - } else if (parsing) { - if (text.length == 0) { - // Prázdná řádka - končíme (je za pátečním menu TechTower) - break; - } - let price = '? Kč'; - let name = text; - 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] - } - results.push({ - amount: '-', - name, - price, - isSoup: isTextSoupName(name), - }) } } - return results; + return result; } \ No newline at end of file diff --git a/server/src/service.ts b/server/src/service.ts index ef045e6..d04eb1f 100644 --- a/server/src/service.ts +++ b/server/src/service.ts @@ -1,10 +1,11 @@ -import { InsufficientPermissions, formatDate, getDayOfWeekIndex, getHumanDate, getHumanTime, getIsWeekend } from "./utils"; -import { ClientData, Locations, Restaurants, Menu, DepartureTime } from "../../types"; +import { InsufficientPermissions, formatDate, getDayOfWeekIndex, getFirstWorkDayOfWeek, getHumanDate, getHumanTime, getIsWeekend, getLastWorkDayOfWeek, getWeekNumber } from "./utils"; +import { ClientData, Locations, Restaurants, DayMenu, DepartureTime, DayData, WeekMenu } from "../../types"; import getStorage from "./storage"; import { getMenuSladovnicka, getMenuTechTower, getMenuUMotliku } from "./restaurants"; import { getTodayMock } from "./mock"; const storage = getStorage(); +const MENU_PREFIX = 'menu'; /** Vrátí dnešní datum, případně fiktivní datum pro účely vývoje a testování. */ export function getToday(): Date { @@ -34,7 +35,7 @@ function getEmptyData(date?: Date): ClientData { isWeekend: getIsWeekend(usedDate), weekIndex: getDayOfWeekIndex(usedDate), choices: {}, - departureTimes: Object.values(DepartureTime), + departureTimes: Object.values(DepartureTime), // TODO tohle zmizí, bude se přidávat do dat dynamicky }; } @@ -42,73 +43,112 @@ function getEmptyData(date?: Date): ClientData { * Vrátí veškerá klientská data pro předaný den, nebo aktuální den, pokud není předán. */ export async function getData(date?: Date): Promise { - const dateString = formatDate(date ?? getToday()); - const data: ClientData = await storage.getData(dateString) || getEmptyData(date); - data.todayWeekIndex = getDayOfWeekIndex(getToday()); - // Dotažení jídel, pokud je ještě nemáme - if (!data.menus) { - data.menus = { - [Restaurants.SLADOVNICKA]: await getRestaurantMenu(Restaurants.SLADOVNICKA, date ?? getToday()), - [Restaurants.UMOTLIKU]: await getRestaurantMenu(Restaurants.UMOTLIKU, date ?? getToday()), - [Restaurants.TECHTOWER]: await getRestaurantMenu(Restaurants.TECHTOWER, date ?? getToday()), - } - await storage.setData(dateString, data); + const targetDate = date ?? getToday(); + const dateString = formatDate(targetDate); + const data: DayData = await storage.getData(dateString) || getEmptyData(date); + const clientData: ClientData = { ...data }; + clientData.todayWeekIndex = getDayOfWeekIndex(getToday()); + clientData.menus = { + [Restaurants.SLADOVNICKA]: await getRestaurantMenu(Restaurants.SLADOVNICKA, targetDate), + [Restaurants.UMOTLIKU]: await getRestaurantMenu(Restaurants.UMOTLIKU, targetDate), + [Restaurants.TECHTOWER]: await getRestaurantMenu(Restaurants.TECHTOWER, targetDate), } - return data; + return clientData; +} + +/** + * Vrátí klíč, pod kterým je uloženo menu pro předané datum. + * + * @param date datum + * @returns databázový klíč + */ +function getMenuKey(date: Date) { + const weekNumber = getWeekNumber(date); + return `${MENU_PREFIX}_${date.getFullYear()}_${weekNumber}`; +} + +/** + * Vrátí menu restaurací pro předané datum, pokud již existují. + * + * @param date datum + * @returns menu restaurací pro předané datum + */ +async function getMenu(date: Date): Promise { + return await storage.getData(getMenuKey(date)); } // TODO přesun do restaurants.ts /** - * Vrátí menu dané restaurace pro předaný den. Pokud neexistuje, provede jeho stažení a uložení do DB. + * Vrátí menu dané restaurace pro předaný den. + * Pokud neexistuje, provede stažení menu pro příslušný týden a uložení do DB. * * @param restaurant restaurace - * @param date datum + * @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 { - await initIfNeeded(date); - const selectedDay = formatDate(date ?? getToday()); - const clientData: ClientData = await storage.getData(selectedDay); - if (!clientData.menus) { - clientData.menus = {}; - storage.setData(selectedDay, clientData); +export async function getRestaurantMenu(restaurant: Restaurants, date?: Date): Promise { + const usedDate = date ?? getToday(); + const dayOfWeekIndex = getDayOfWeekIndex(usedDate); + + let menus = await getMenu(usedDate); + if (menus == null) { + menus = []; } - if (!clientData.menus[restaurant]) { - clientData.menus[restaurant] = { - lastUpdate: getHumanTime(new Date()), - closed: false, - food: [], - }; + for (let i = 0; i < 5; i++) { + if (menus[i] == null) { + menus[i] = {}; + } + if (menus[i][restaurant] == null) { + menus[i][restaurant] = { + lastUpdate: getHumanTime(new Date()), + closed: false, + food: [], + }; + } + } + if (!menus[dayOfWeekIndex][restaurant]?.food?.length) { + const firstDay = getFirstWorkDayOfWeek(usedDate); const mock = process.env.MOCK_DATA === 'true'; switch (restaurant) { case Restaurants.SLADOVNICKA: - const sladovnickaFood = await getMenuSladovnicka(date, mock); - clientData.menus[restaurant]!.food = sladovnickaFood; - // Velice chatrný a nespolehlivý způsob detekce uzavření... - if (sladovnickaFood.length === 1 && sladovnickaFood[0].name.toLowerCase() === 'pro daný den nebyla nalezena denní nabídka') { - clientData.menus[restaurant]!.closed = true; + const sladovnickaFood = await getMenuSladovnicka(firstDay, mock); + for (let i = 0; i < sladovnickaFood.length; i++) { + menus[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; + } } break; case Restaurants.UMOTLIKU: - const uMotlikuFood = await getMenuUMotliku(date, mock); - clientData.menus[restaurant]!.food = uMotlikuFood; - if (uMotlikuFood.length === 1 && uMotlikuFood[0].name.toLowerCase() === 'zavřeno') { - clientData.menus[restaurant]!.closed = true; + 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; + } } break; case Restaurants.TECHTOWER: - const techTowerFood = await getMenuTechTower(date, mock); - clientData.menus[restaurant]!.food = techTowerFood; - if (techTowerFood.length === 1 && techTowerFood[0].name.toLowerCase() === 'svátek') { - clientData.menus[restaurant]!.closed = true; + 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; + } } break; } - storage.setData(selectedDay, clientData); + await storage.setData(getMenuKey(usedDate), menus); } - return clientData.menus[restaurant]!; + return menus[dayOfWeekIndex][restaurant]!; } +/** + * Inicializuje výchozí data pro předané datum, nebo dnešek, pokud není datum předáno. + * + * @param date datum + */ export async function initIfNeeded(date?: Date) { const usedDate = formatDate(date ?? getToday()); const hasData = await storage.hasData(usedDate); @@ -128,7 +168,7 @@ export async function initIfNeeded(date?: Date) { */ export async function removeChoices(login: string, trusted: boolean, location: Locations, date?: Date) { const selectedDay = formatDate(date ?? getToday()); - let data: ClientData = await storage.getData(selectedDay); + let data: DayData = await storage.getData(selectedDay); validateTrusted(data, login, trusted); if (location in data.choices) { if (login in data.choices[location]) { @@ -155,7 +195,7 @@ export async function removeChoices(login: string, trusted: boolean, location: L */ export async function removeChoice(login: string, trusted: boolean, location: Locations, foodIndex: number, date?: Date) { const selectedDay = formatDate(date ?? getToday()); - let data: ClientData = await storage.getData(selectedDay); + let data: DayData = await storage.getData(selectedDay); validateTrusted(data, login, trusted); if (location in data.choices) { if (login in data.choices[location]) { @@ -175,7 +215,7 @@ export async function removeChoice(login: string, trusted: boolean, location: Lo * @param login login uživatele */ async function removeChoiceIfPresent(login: string, date: string) { - let data: ClientData = await storage.getData(date); + let data: DayData = await storage.getData(date); for (const key of Object.keys(data.choices)) { if (login in data.choices[key]) { delete data.choices[key][login]; @@ -222,9 +262,10 @@ function validateTrusted(data: ClientData, login: string, trusted: boolean) { * @returns aktuální data */ export async function addChoice(login: string, trusted: boolean, location: Locations, foodIndex?: number, date?: Date) { - await initIfNeeded(); - const selectedDate = formatDate(date ?? getToday()); - let data: ClientData = await storage.getData(selectedDate); + const usedDate = date ?? getToday(); + await initIfNeeded(usedDate); + const selectedDate = formatDate(usedDate); + let data: DayData = await storage.getData(selectedDate); validateTrusted(data, login, trusted); // Pokud měníme pouze lokaci, mažeme případné předchozí if (foodIndex == null) { @@ -255,7 +296,7 @@ export async function addChoice(login: string, trusted: boolean, location: Locat */ export async function updateDepartureTime(login: string, time?: string, date?: Date) { const selectedDate = formatDate(date ?? getToday()); - let clientData: ClientData = await storage.getData(selectedDate); + let clientData: DayData = await storage.getData(selectedDate); const found = Object.values(clientData.choices).find(location => login in location); // TODO validace, že se jedná o restauraci if (found) { diff --git a/server/src/utils.ts b/server/src/utils.ts index 1dbd885..50c82e1 100644 --- a/server/src/utils.ts +++ b/server/src/utils.ts @@ -39,6 +39,29 @@ export function getIsWeekend(date: Date) { return index == 5 || index == 6; } +/** 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í pořadové číslo týdne předaného data v roce dle ISO 8601. */ +export function getWeekNumber(inputDate: Date) { + var date = new Date(inputDate.getTime()); + date.setHours(0, 0, 0, 0); + date.setDate(date.getDate() + 3 - (date.getDay() + 6) % 7); + var week1 = new Date(date.getFullYear(), 0, 4); + return 1 + Math.round(((date.getTime() - week1.getTime()) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7); +} + /** * Vrátí JWT token z hlaviček, pokud ho obsahují. * diff --git a/types/Types.ts b/types/Types.ts index ccc2c44..46ec676 100644 --- a/types/Types.ts +++ b/types/Types.ts @@ -67,24 +67,36 @@ interface PizzaDay { orders: Order[], // seznam objednávek jednotlivých lidí } -/** Veškerá data pro zobrazení na klientovi */ -export interface ClientData { - date: string, // datum vybraného dne pro zobrazení - isWeekend: boolean, // příznak, zda je zvolené datum víkend - weekIndex: number, // index zvoleného dne v týdnu (0-6) - todayWeekIndex?: number, // index dnešního dne v týdnu (0-6) - choices: Choices, // seznam voleb +/** 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ů + // TODO smazat departureTimes: DepartureTime[], // seznam možných časů odchodu - menus?: { [restaurant in Restaurants]?: Menu }, // menu jednotlivých restaurací + 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 } -/** Nabídka jídel jednoho podniku. */ -export interface Menu { +/** 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: string, // human-readable čas poslední aktualizace menu - closed: boolean, // příznak, zda je daný podnik aktuálně zavřený + closed: boolean, // příznak, zda je daný podnik v tento den zavřený food: Food[], // seznam jídel v menu }