From 2606b0d287b1807c34cb7e022f426add86003ca8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niklas=20Schr=C3=B6tler?= <niklas@allround.digital> Date: Mon, 27 Nov 2023 02:52:23 +0100 Subject: [PATCH] MensaPanel: Implemented first draft --- public/config/default.json | 9 +- src/meta/PanelTitle.tsx | 7 +- src/panels/Mensaplan/MensaplanPanel.tsx | 214 ++++++++++++++++++++++- src/panels/Mensaplan/types/canteenAPI.ts | 19 ++ src/panels/_Panels.tsx | 2 + 5 files changed, 243 insertions(+), 8 deletions(-) create mode 100644 src/panels/Mensaplan/types/canteenAPI.ts diff --git a/public/config/default.json b/public/config/default.json index 903a287..2ab4717 100644 --- a/public/config/default.json +++ b/public/config/default.json @@ -61,12 +61,19 @@ } }, { - "type": "placeholder", + "type": "mensaplan", "position": { "x": 9, "y": 7, "w": 16, "h": 6 + }, + "config": { + "canteenId": 341, + "closingTime": { + "hours": 14, + "minutes": 15 + } } } ] diff --git a/src/meta/PanelTitle.tsx b/src/meta/PanelTitle.tsx index dd17ee6..36d94e1 100644 --- a/src/meta/PanelTitle.tsx +++ b/src/meta/PanelTitle.tsx @@ -1,9 +1,12 @@ import React from 'react'; -const PanelTitle = (props: {title: string}) => { +const PanelTitle = (props: {title: string, info?: string}) => { return ( - <div className={"px-6 py-4 text-zinc-400"}> + <div className={"px-6 py-4 text-zinc-400 flex flex-row justify-between"}> <h2>{props.title}</h2> + {props.info && ( + <p>{props.info}</p> + )} </div> ); }; diff --git a/src/panels/Mensaplan/MensaplanPanel.tsx b/src/panels/Mensaplan/MensaplanPanel.tsx index af581a5..a63392e 100644 --- a/src/panels/Mensaplan/MensaplanPanel.tsx +++ b/src/panels/Mensaplan/MensaplanPanel.tsx @@ -1,11 +1,215 @@ -import React from 'react'; +import React, {useEffect, useRef, useState} from 'react'; +import PanelWrapper from "../../meta/PanelWrapper"; +import PanelTitle from "../../meta/PanelTitle"; +import PanelContent from "../../meta/PanelContent"; +import {CanteenAPIResponse} from "./types/canteenAPI"; +import {Leaf, Plant, Bone, Warning, Record} from "@phosphor-icons/react"; + +type Dish = { + name: string, + details: string, + typeIcons: React.FC<any>[] +} + +type MensaPanelDefinition = { + canteenId: number, + closingTime: { + hours: number, + minutes: number + } +} + +const MensaplanPanel = (props: {definition: MensaPanelDefinition}) => { + const menus = useRef<Dish[]>([]); + const specials = useRef<Dish[]>([]); + const [relativeDay, setRelativeDay] = useState<string>("heute"); + const [groupName, setGroupName] = useState<string>("Menüs") + const cycle = useRef<number>(0); + const [dishes, setDishes] = useState<Dish[]>([]); + + useEffect(() => { + const update = async () => { + // Determine day to fetch for + const now = new Date(); + let fetchFor: string; + + if( + now.getHours() > props.definition.closingTime.hours || ( + now.getHours() === props.definition.closingTime.hours && + now.getMinutes() > props.definition.closingTime.minutes + ) + ) { + // After closing, fetch for next day + setRelativeDay("morgen"); + const tomorrow = new Date(now.setTime(now.getTime() + 24 * 60 * 60 * 1000)); + fetchFor = toYYYYMMDD(tomorrow); + } else { + // otherwise, fetch for today + setRelativeDay("heute"); + fetchFor = toYYYYMMDD(now); + } + + // Request the API + const request = await fetch(`https://infoscreen.oh14.de/canteen-menu/v3/canteens/${props.definition.canteenId}/${fetchFor}`); + + if(!request.ok) { + menus.current = []; + specials.current = []; + return; + } + + const data = await request.json() as CanteenAPIResponse; + + console.log(data); + + const old_menus_count = menus.current.length; + const old_specials_count = menus.current.length; + + // ToDo: This needs to be cleaned up! + menus.current = data + .filter(d => d.counter !== "Beilagen") + .filter(d => d.counter !== "Aktionsteller") + .sort((a, b) => { + return a.position - b.position + }) + .map(d => ({ + name: (d.title.de + .split(" | ") + .at(0) ?? "Name nicht bekannt") + .replace(" nach Wahl", ""), + details: d.title.de + .split(" | ") + .slice(1,-1) + .join(", ") + .replace(" nach Wahl", ""), + typeIcons: d.type.map(typeToIcon).filter(i => i !== null) as unknown as React.FC<any>[] + })) + + specials.current = data + .filter(d => d.counter === "Aktionsteller") + .sort((a, b) => { + return a.position - b.position + }) + .map(d => ({ + name: (d.title.de + .split(" | ") + .at(0) ?? "Name nicht bekannt") + .replace(" nach Wahl", ""), + details: d.title.de + .split(" | ") + .slice(1,-1) + .join(" - ") + .replace(" nach Wahl", ""), + typeIcons: d.type.map(typeToIcon).filter(i => i !== null) as unknown as React.FC<any>[] + })) + + // If the count of menus and specials changed, reset the cycler + if(menus.current.length !== old_menus_count || specials.current.length !== old_specials_count) { + setDishes(menus.current); + cycle.current = 0; + setGroupName("Menüs") + } + } + + update(); + const interval = setInterval(update, 1 * 60 * 60 * 1000); + + return () => { + clearInterval(interval); + } + }, []); + + useEffect(() => { + const update = async () => { + switch (cycle.current % 2) { + case 0: + setGroupName("Menüs"); + setDishes(menus.current); + break; + case 1: + setGroupName("Aktionsteller"); + setDishes(specials.current); + break; + } + + cycle.current = (cycle.current + 1) % 2; + } + + update(); + const interval = setInterval(update, 20 * 1000); + + return () => { + clearInterval(interval); + } + }, []); -const MensaplanPanel = () => { return ( - <div> - Mensaplan - </div> + <PanelWrapper> + <PanelTitle title={"Mensaplan für " + relativeDay} info={groupName} /> + <PanelContent> + <div className={"flex flex-col gap-4"}> + {dishes.map(dish => ( + <div className={"flex flex-row items-center gap-4"}> + {/* Fixme: This shifts the name out of line if there is more than one */} + <div className={"flex flex-row gap-2 mr-2"}> + {dish.typeIcons.map(Icon => ( + <Icon size={32} className={"text-zinc-400"}/> + ))} + </div> + + <h3 className={"text-xl leading-tight"}> + {dish.name} + </h3> + <p className={"text-sm text-zinc-300 leading-tight"}> + {dish.details} + </p> + </div> + ))} + </div> + + {dishes.length === 0 && ( + <div className={"h-full w-full flex justify-center items-center"}> + <div className={"mb-10 flex flex-col items-center"}> + <Warning size={48} className={"mb-3"}/> + <p className={"text-center text-zinc-400"}> + Aktuell sind keine Mensaplan-Daten verfügbar. + </p> + </div> + </div> + )} + </PanelContent> + </PanelWrapper> ); }; export default MensaplanPanel; + +function toYYYYMMDD(input: Date): string { + return `${input.getFullYear()}-${input.getMonth() + 1}-${input.getDate()}` +} + +function typeToIcon(type: string): React.FC | null { + switch (type) { + case "N": + // Vegan + return Plant + case "V": + // Vegetarisch + return Leaf + case "G": + // Geflügel + return Bone + case "K": + // Kosher + // ToDo: Work out proper icon + return Record + case "S": + // Schwein + return Bone + case "R": + // Rind (geraten) + return Bone + default: + return null + } +} diff --git a/src/panels/Mensaplan/types/canteenAPI.ts b/src/panels/Mensaplan/types/canteenAPI.ts new file mode 100644 index 0000000..ecaf1ac --- /dev/null +++ b/src/panels/Mensaplan/types/canteenAPI.ts @@ -0,0 +1,19 @@ +export type CanteenAPIResponse = CanteenAPIDish[]; + +export type CanteenAPIDish = { + additives: string[], + category: string, + counter: string, + dispoId: string, + position: number, + price: { + student: string, + staff: string, + guest: string + }, + title: { + de: string, + en: string + }, + type: string[] +} diff --git a/src/panels/_Panels.tsx b/src/panels/_Panels.tsx index ec12086..817b5e1 100644 --- a/src/panels/_Panels.tsx +++ b/src/panels/_Panels.tsx @@ -8,6 +8,7 @@ import FahrplanPanel from "./Fahrplan/FahrplanPanel"; import {PanelDefinition} from "../types/LayoutConfig"; import PanelWrapper from "../meta/PanelWrapper"; import BildPanel from "./Bild/BildPanel"; +import MensaplanPanel from "./Mensaplan/MensaplanPanel"; /* * First, please claim a unique id for your panel here. Convention is that it is all lowercase, in snake-case to be @@ -24,6 +25,7 @@ export type PanelTypes = "fahrplan" | "bild"; export const PanelRenderers: {[panelType: string]: React.FC<any & {definition: PanelDefinition<any>}>} = { "fahrplan": FahrplanPanel, "bild": BildPanel, + "mensaplan": MensaplanPanel, "placeholder": () => ( <PanelWrapper className={"flex flex-col items-center justify-center text-zinc-400"}> Dieses Panel wird noch entwickelt -- GitLab