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