diff --git a/client/src/components/classes/DashboardBlurb.tsx b/client/src/components/classes/DashboardBlurb.tsx index 562004a7..cb517403 100644 --- a/client/src/components/classes/DashboardBlurb.tsx +++ b/client/src/components/classes/DashboardBlurb.tsx @@ -92,7 +92,7 @@ export default function DashboardBlurb(props: DashboardBlurbProps) { setIncludeCompleted(!includeCompleted)}> {includeCompleted ? 'Hide completed' : 'Show completed'} - + See more in Upcoming diff --git a/client/src/components/layout/ErrorBoundary.tsx b/client/src/components/layout/ErrorBoundary.tsx index cf1eb107..527745bf 100644 --- a/client/src/components/layout/ErrorBoundary.tsx +++ b/client/src/components/layout/ErrorBoundary.tsx @@ -20,8 +20,8 @@ export default class ErrorBoundary extends Component

WATT has crashed!

-
-                        {this.state.error.name} {this.state.error.message}
+                    
+                        {this.state.error.name}: {this.state.error.message}
                     
                         {this.state.error.stack}
diff --git a/client/src/components/layout/PeriodActionButton.tsx b/client/src/components/layout/PeriodActionButton.tsx
new file mode 100644
index 00000000..d5b2d045
--- /dev/null
+++ b/client/src/components/layout/PeriodActionButton.tsx
@@ -0,0 +1,77 @@
+import { ReactNode, useContext, useState } from 'react';
+import { DateTime } from 'luxon';
+import MenuModal from '../lists/MenuModal';
+import MenuContext from '../../contexts/MenuContext';
+
+
+type ActionButtonProps = {
+    children: ReactNode,
+    now: boolean,
+    note?: string,
+    onClick?: () => void
+}
+function ActionButton(props: ActionButtonProps) {
+    const { children, now, note, onClick } = props;
+    return (
+        
+    )
+}
+
+type PeriodActionButtonProps = {
+    date: DateTime,
+    name: string,
+    now: boolean,
+    note?: string
+}
+export default function PeriodActionButton(props: PeriodActionButtonProps) {
+    const { name } = props;
+
+    if (name === 'PRIME' || name === 'Study Hall')
+        return 
+
+    if (name === 'Brunch' || name === 'Lunch')
+        return 
+
+    return <>
+}
+
+function FlexiSCHEDAction(props: PeriodActionButtonProps) {
+    return (
+        
+            
+                FlexiSCHED
+            
+        
+    )
+}
+
+function MenuAction(props: PeriodActionButtonProps) {
+    const { name, date } = props;
+    const { menu } = useContext(MenuContext);
+    const [modal, setModal] = useState(false);
+
+    const formatted = date.toFormat('MM-dd');
+    const meal = name.toLowerCase() as 'brunch' | 'lunch';
+
+    if (formatted in menu && menu[formatted][meal])
+        return (
+            <>
+                 setModal(true)}>
+                    Menu
+                
+                
+            
+        )
+
+    return <>
+}
\ No newline at end of file
diff --git a/client/src/components/lists/MenuModal.tsx b/client/src/components/lists/MenuModal.tsx
new file mode 100644
index 00000000..0b4d4fc3
--- /dev/null
+++ b/client/src/components/lists/MenuModal.tsx
@@ -0,0 +1,55 @@
+import { useState } from 'react';
+import { Dialog } from '@headlessui/react';
+
+import NutritionModal from './NutritionModal';
+import CenteredModal from '../layout/CenteredModal';
+import { DangerOutlineButton } from '../layout/OutlineButton';
+
+import type { Entry } from '../../contexts/MenuContext';
+
+
+type MenuModalProps = {
+    name: string,
+    items: { [item: string]: Entry },
+    isOpen: boolean,
+    setIsOpen: (open: boolean) => void
+}
+export default function MenuModal(props: MenuModalProps) {
+    const { name, items, isOpen, setIsOpen } = props;
+    const [nutritionModal, setNutritionModal] = useState(null);
+
+    return (
+        
+            
+                {name} Menu
+            
+
+            
+ {Object.entries(items).map(([item, nutrition]) => ( +
+
nutrition && setNutritionModal(item)} + > + {item} +
+ {nutrition && ( + setNutritionModal(null)} + /> + )} +
+ ))} +
+ +
+ setIsOpen(false)}> + Close + +
+
+ ) +} diff --git a/client/src/components/lists/NutritionModal.tsx b/client/src/components/lists/NutritionModal.tsx new file mode 100644 index 00000000..27bebe0e --- /dev/null +++ b/client/src/components/lists/NutritionModal.tsx @@ -0,0 +1,144 @@ +import { ReactNode } from 'react'; +import { Dialog } from '@headlessui/react'; + +import CenteredModal from '../layout/CenteredModal'; +import { DangerOutlineButton } from '../layout/OutlineButton'; + +import type { Entry } from '../../contexts/MenuContext'; + + +type ItemProps = { + children: ReactNode, + value?: number, + dv?: number +} +function Item(props: ItemProps) { + const { children, value, dv } = props; + + if (value === null) + return <> + + return ( + <> +
+
+ {children} + {dv && {Math.floor((value! / dv) * 100)}%} +
+ + ) +} + +type NutritionModalProps = { + item: string, + nutrition: Entry, + isOpen: boolean, + setIsOpen: (open: boolean) => void +} +export default function NutritionModal(props: NutritionModalProps) { + const { + item, + nutrition: { + serving, + nutrition, + ingredients + }, + isOpen, + setIsOpen + } = props; + + return ( + + + {item} + + +
+ {nutrition ? ( + <> + Nutrition Facts +
+ {serving && serving.serving_size_amount && serving.serving_size_unit && ( + <> +
+ Serving size + {serving.serving_size_amount} {serving.serving_size_unit} +
+
+ + )} + {nutrition.calories && ( + <> +
+ Calories + {nutrition.calories} +
+
+ + )} +
+ % Daily Value* +
+ + +

Total Fat {nutrition.g_fat}g

+
+ +

Saturated Fat {nutrition.g_saturated_fat}g

+
+ +

Trans Fat {nutrition.g_trans_fat}g

+
+ + +

Cholesterol {nutrition.mg_cholesterol}mg

+
+ + +

Sodium {nutrition.mg_sodium}mg

+
+ + +

Total Carbohydrate {nutrition.g_carbs}g

+
+ +

Dietary Fiber {nutrition.g_fiber}g

+
+ +

Total Sugars {nutrition.g_sugar}g

+
+ +

Includes {nutrition.g_added_sugar}g Added Sugars

+
+ + +

Protein {nutrition.g_protein}g

+
+ + +

+ * Percent Daily Values are based on a 2,000 calorie diet. +

+
+ + ) : ( +

No nutrition information available.

+ )} + + {ingredients && ( + <> +
+ Ingredients +

{ingredients}

+ + )} +
+ +
+ setIsOpen(false)}> + Close + +
+
+ ) +} diff --git a/client/src/components/lists/PillClubComponent.tsx b/client/src/components/lists/PillClubComponent.tsx index 91cd5169..8884309f 100644 --- a/client/src/components/lists/PillClubComponent.tsx +++ b/client/src/components/lists/PillClubComponent.tsx @@ -9,7 +9,7 @@ export default function PillClubComponent(props: Club & {id: string}) { return ( <> - setModal(true)}> + setModal(true)}> {name} diff --git a/client/src/components/schedule/Period.tsx b/client/src/components/schedule/Period.tsx index 0db0a20d..36e7a524 100644 --- a/client/src/components/schedule/Period.tsx +++ b/client/src/components/schedule/Period.tsx @@ -5,6 +5,7 @@ import {DateTime} from 'luxon'; // Components import PillClubComponent from '../lists/PillClubComponent'; +import PeriodActionButton from '../layout/PeriodActionButton'; // Contexts import UserDataContext from '../../contexts/UserDataContext'; @@ -53,7 +54,7 @@ export default function Period(props: PeriodProps) { <>

{id ? ( - + {name} ) : name} @@ -80,7 +81,7 @@ export default function Period(props: PeriodProps) { {({open}) => (<> {header} - + {note} @@ -108,6 +109,8 @@ export default function Period(props: PeriodProps) { />

)} + + ); } diff --git a/client/src/components/schedule/Periods.tsx b/client/src/components/schedule/Periods.tsx index 84e0a1c2..dcf99cfc 100644 --- a/client/src/components/schedule/Periods.tsx +++ b/client/src/components/schedule/Periods.tsx @@ -9,9 +9,11 @@ import NoSchoolImage from './NoSchoolImage'; // Contexts import CurrentTimeContext from '../../contexts/CurrentTimeContext'; import UserDataContext, {SgyPeriodData, UserData} from '../../contexts/UserDataContext'; +import {MenuProvider} from '../../contexts/MenuContext'; // Utils import {useSchedule} from '../../hooks/useSchedule'; +import {useMenu} from '../../hooks/useMenu'; import {periodNameDefault} from '@watt/shared/util/schedule'; @@ -28,6 +30,9 @@ export default function Periods(props: PeriodsProps) { const format = userData.options.time === '24' ? 'H:mm' : 'h:mm a'; const classes = userData.classes as {[key: string]: SgyPeriodData}; + // Brunch/Lunch menu + const menu = useMenu(); + // HTML for a school day, assumes periods is populated const schoolDay = () => { // End time of the last period of the day @@ -43,7 +48,7 @@ export default function Periods(props: PeriodsProps) { const displayIndicator = periods && minutes < periods[periods.length - 1].e && minutes >= periods[0].s - 20; return ( - <> +

School ends at {end.toFormat(format)} today.

@@ -62,7 +67,7 @@ export default function Periods(props: PeriodsProps) { grades={grades} /> ))} - +
) } diff --git a/client/src/contexts/MenuContext.ts b/client/src/contexts/MenuContext.ts new file mode 100644 index 00000000..894e0d75 --- /dev/null +++ b/client/src/contexts/MenuContext.ts @@ -0,0 +1,52 @@ +import { createContext } from 'react'; + + +export type Entry = { + serving?: { + serving_size_amount: string, + serving_size_unit: string + }, + nutrition?: { + calories?: number, + g_fat?: number, + g_saturated_fat?: number, + g_trans_fat?: number, + mg_cholesterol?: number, + g_carbs?: number, + g_added_sugar?: number, + g_sugar?: number, + mg_potassium?: number, + mg_sodium?: number, + g_fiber?: number, + g_protein?: number, + mg_iron?: number, + mg_calcium?: number, + mg_vitamin_c?: number, + iu_vitamin_a?: number, + re_vitamin_a?: number, + mcg_vitamin_a?: number, + mg_vitamin_d?: number, + mcg_vitamin_d?: number, + }, + ingredients?: string +} + +export type Menu = { + timestamp: string, + menu: { + [date: string]: { + brunch: { [item: string]: Entry }, + lunch: { [item: string]: Entry } + } + } +} + +export const defaultMenu: Menu = { + timestamp: new Date().toISOString(), + menu: {} +} + +const MenuContext = createContext(defaultMenu); + +export const MenuProvider = MenuContext.Provider; +export default MenuContext; \ No newline at end of file diff --git a/client/src/hooks/useMenu.ts b/client/src/hooks/useMenu.ts new file mode 100644 index 00000000..53d6150f --- /dev/null +++ b/client/src/hooks/useMenu.ts @@ -0,0 +1,42 @@ +import { useEffect, useState } from 'react'; +import { defaultMenu, Menu } from '../contexts/MenuContext'; +import { DateTime } from 'luxon'; + +import { doc } from 'firebase/firestore'; +import { httpsCallable } from 'firebase/functions'; +import { useFirestore, useFirestoreDoc, useFunctions } from 'reactfire'; + + +export function useMenu() { + const firestore = useFirestore(); + const functions = useFunctions(); + + const localStorageRaw = localStorage.getItem('menu'); + const [menu, setMenu] = useState(tryParseLocalStorageMenu()); + const { status, data: firebaseDoc } = useFirestoreDoc(doc(firestore, 'gunn/menu')); + + useEffect(() => { + const parsed = tryParseLocalStorageMenu(); + setMenu(parsed); + // Regenerate daily + if (DateTime.fromISO(parsed.timestamp).plus({ day: 1 }) < DateTime.now()) + httpsCallable(functions, 'menu')(); + localStorage.setItem('menu', JSON.stringify(parsed)); + }, [localStorageRaw]); + + useEffect(() => { + if (status !== 'success' || !firebaseDoc.exists()) return; + localStorage.setItem('menu', JSON.stringify(firebaseDoc.data())); + }, [firebaseDoc]); + + function tryParseLocalStorageMenu() { + if (!localStorageRaw) return defaultMenu; + try { + return JSON.parse(localStorageRaw) as Menu; + } catch { + return defaultMenu + } + } + + return menu; +} \ No newline at end of file diff --git a/functions/presets/firebase-export-metadata.json b/functions/presets/firebase-export-metadata.json index 7d2faf70..c8308945 100644 --- a/functions/presets/firebase-export-metadata.json +++ b/functions/presets/firebase-export-metadata.json @@ -1,7 +1,7 @@ { - "version": "10.7.1", + "version": "13.16.0", "firestore": { - "version": "1.14.3", + "version": "1.19.8", "path": "firestore_export", "metadata_file": "firestore_export/firestore_export.overall_export_metadata" }, diff --git a/functions/presets/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata b/functions/presets/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata index 3c55a193..bab799d2 100644 Binary files a/functions/presets/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata and b/functions/presets/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata differ diff --git a/functions/presets/firestore_export/all_namespaces/all_kinds/output-0 b/functions/presets/firestore_export/all_namespaces/all_kinds/output-0 index eddfa66f..8140cd21 100644 Binary files a/functions/presets/firestore_export/all_namespaces/all_kinds/output-0 and b/functions/presets/firestore_export/all_namespaces/all_kinds/output-0 differ diff --git a/functions/presets/firestore_export/firestore_export.overall_export_metadata b/functions/presets/firestore_export/firestore_export.overall_export_metadata index c4a259a5..cab64b9f 100644 Binary files a/functions/presets/firestore_export/firestore_export.overall_export_metadata and b/functions/presets/firestore_export/firestore_export.overall_export_metadata differ diff --git a/functions/src/index.ts b/functions/src/index.ts index ef187590..784d3550 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -1,4 +1,5 @@ export {api} from './api'; +export {menu} from './menu'; export {sgyauth} from './sgyauth'; export * as sgyfetch from './sgyfetch'; export * as weather from './weather'; diff --git a/functions/src/menu.ts b/functions/src/menu.ts new file mode 100644 index 00000000..d9a80586 --- /dev/null +++ b/functions/src/menu.ts @@ -0,0 +1,91 @@ +import * as functions from 'firebase-functions'; +import admin from './util/adminInit'; +import fetch from 'node-fetch'; + +import { DateTime } from 'luxon'; +import { getAlternates } from './util/apiUtil'; +import { getSchedule } from '@watt/shared/util/schedule'; +import { SCHOOL_START, SCHOOL_END } from '@watt/shared/data/schedule'; + + +const firestore = admin.firestore(); + +export const menu = functions.https.onCall(async () => { + const now = DateTime.now().setZone('America/Los_Angeles'); + + if (now < SCHOOL_START || now > SCHOOL_END) { + await firestore.collection('gunn').doc('menu').set({ + timestamp: new Date().toISOString(), + menu: {} + }); + return; + } + + const current = (await firestore.collection('gunn').doc('menu').get()).data()!; + if (DateTime.fromISO(current.timestamp).plus({ day: 1 }) > now) + return; + + const { daysInMonth, month, year } = now.plus({ week: 1 }); + const { alternates } = await getAlternates(); + const nutrislice = 'https://pausd.api.nutrislice.com/menu/api'; + const nutrition = new Map(); + + async function getNutrition(day: number) { + return await Promise.all(['breakfast', 'lunch'].map(async meal => { + const { days } = await fetch(`${nutrislice}/weeks/school/henry-m-gunn-hs/menu-type/${meal}/${year}/${month}/${day}`) + .then(res => res.json()) + .catch(() => []); + if (!days) return; + const items = days + .flatMap((day: any) => day.menu_items) + .map((items: any) => items.food) + .filter(Boolean); + for (const item of items) { + nutrition.set(item.name, { + serving: Object + .values(item.serving_size_info) + .every(x => !x) ? null : item.serving_size_info, + nutrition: Object + .values(item.rounded_nutrition_info) + .every(x => !x) ? null : item.rounded_nutrition_info, + ingredients: item.ingredients || null + }); + } + })); + } + + async function getMenu(date: DateTime) { + const { year, month, day } = date; + const [brunch, lunch] = await Promise.all(['breakfast', 'lunch'].map(async meal => { + const { menu_items } = await fetch(`${nutrislice}/digest/school/henry-m-gunn-hs/menu-type/${meal}/date/${year}/${month}/${day}`) + .then(res => res.json()) + .catch(() => []); + if (!menu_items) return; + return Object.fromEntries(menu_items.map((item: string) => [item, nutrition.get(item) ?? null])); + })); + return [date.toFormat('MM-dd'), { + brunch: brunch ?? null, + lunch: lunch ?? null + }]; + } + + const days = Array + .from({ length: daysInMonth }, (_, i) => DateTime + .fromObject({ year, month, day: i + 1 }) + .setZone('America/Los_Angeles')) + .filter(day => { + const { periods } = getSchedule(day, alternates); + return periods && periods.filter(({ n }) => n === 'B' || n === 'L').length; + }); + + await Promise.all(Array + .from({ length: Math.ceil((days[days.length - 1].day - days[0].day) / 7 + 1) }, (_, i) => 7 * i + days[0].day) + .map(getNutrition)); + + const menu = Object.fromEntries(await Promise.all(days.map(getMenu))); + + await firestore.collection('gunn').doc('menu').set({ + timestamp: new Date().toISOString(), + menu: { ...current.menu, ...menu } + }); +}) \ No newline at end of file