From 6f43c74769aa702580b385a20bb8296941e61182 Mon Sep 17 00:00:00 2001 From: batmanisko Date: Wed, 4 Feb 2026 13:18:27 +0100 Subject: [PATCH] fix: resolve 6 Gitea issues (#9, #10, #12, #14, #15, #21) - #21: Add missing await in removeChoiceIfPresent() to prevent user appearing in two restaurants - #15: Add 1-hour TTL for menu refetching to avoid scraping on every page load - #9: Block stats API and UI navigation for future dates - #14: Add restaurant warnings (missing soup/prices, stale data) with warning icon - #12: Pre-fill restaurant/departure dropdowns from existing choices on page refresh - #10: Add voting statistics endpoint and table on stats page --- client/src/App.scss | 7 +++ client/src/App.tsx | 33 +++++++++++++- client/src/pages/StatsPage.scss | 63 +++++++++++++++++++++++++++ client/src/pages/StatsPage.tsx | 52 +++++++++++++++++++--- server/src/routes/votingRoutes.ts | 9 +++- server/src/service.ts | 38 ++++++++++++++-- server/src/stats.ts | 6 +++ server/src/voting.ts | 24 +++++++++- types/api.yml | 2 + types/paths/voting/getVotingStats.yml | 9 ++++ types/schemas/_index.yml | 11 +++++ 11 files changed, 242 insertions(+), 12 deletions(-) create mode 100644 types/paths/voting/getVotingStats.yml diff --git a/client/src/App.scss b/client/src/App.scss index c0049ff..8a154ad 100644 --- a/client/src/App.scss +++ b/client/src/App.scss @@ -334,6 +334,13 @@ body { color: rgba(255, 255, 255, 0.7); font-size: 0.75rem; } + + .restaurant-warning { + color: #f59e0b; + cursor: help; + margin-left: 8px; + font-size: 1rem; + } } .restaurant-closed { diff --git a/client/src/App.tsx b/client/src/App.tsx index ae32097..c0c289c 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -13,7 +13,7 @@ import './App.scss'; import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons'; import { useSettings } from './context/settings'; import Footer from './components/Footer'; -import { faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faSatelliteDish, faSearch } from '@fortawesome/free-solid-svg-icons'; +import { faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'; import Loader from './components/Loader'; import { getHumanDateTime, isInTheFuture } from './Utils'; import NoteModal from './components/modals/NoteModal'; @@ -138,9 +138,33 @@ function App() { }, [socket]); useEffect(() => { - if (!auth?.login) { + if (!auth?.login || !data?.choices) { return } + // Pre-fill form refs from existing choices + let foundKey: LunchChoice | undefined; + let foundChoice: UserLunchChoice | undefined; + for (const key of Object.keys(data.choices)) { + const locationKey = key as LunchChoice; + const locationChoices = data.choices[locationKey]; + if (locationChoices && auth.login in locationChoices) { + foundKey = locationKey; + foundChoice = locationChoices[auth.login]; + break; + } + } + if (foundKey && choiceRef.current) { + choiceRef.current.value = foundKey; + const restaurantKey = Object.keys(Restaurant).indexOf(foundKey); + if (restaurantKey > -1 && food) { + const restaurant = Object.keys(Restaurant)[restaurantKey] as Restaurant; + setFoodChoiceList(food[restaurant]?.food); + setClosed(food[restaurant]?.closed ?? false); + } + } + if (foundChoice?.departureTime && departureChoiceRef.current) { + departureChoiceRef.current.value = foundChoice.departureTime; + } }, [auth, auth?.login, data?.choices]) // Reference na mojí objednávku @@ -388,6 +412,11 @@ function App() { {getLunchChoiceName(location)} {menu?.lastUpdate && Aktualizace: {getHumanDateTime(new Date(menu.lastUpdate))}} + {menu?.warnings && menu.warnings.length > 0 && ( + + + + )} {content} diff --git a/client/src/pages/StatsPage.scss b/client/src/pages/StatsPage.scss index 7b6c82b..d6f4a62 100644 --- a/client/src/pages/StatsPage.scss +++ b/client/src/pages/StatsPage.scss @@ -89,4 +89,67 @@ .recharts-cartesian-grid-vertical line { stroke: var(--luncher-border); } + + .voting-stats-section { + margin-top: 48px; + width: 100%; + max-width: 800px; + + h2 { + font-size: 1.5rem; + font-weight: 700; + color: var(--luncher-text); + margin-bottom: 16px; + text-align: center; + } + } + + .voting-stats-table { + width: 100%; + background: var(--luncher-bg-card); + border-radius: var(--luncher-radius-lg); + box-shadow: var(--luncher-shadow); + border: 1px solid var(--luncher-border-light); + overflow: hidden; + border-collapse: collapse; + + th { + background: var(--luncher-primary); + color: #ffffff; + padding: 12px 20px; + text-align: left; + font-weight: 600; + font-size: 0.9rem; + + &:last-child { + text-align: center; + width: 120px; + } + } + + td { + padding: 12px 20px; + border-bottom: 1px solid var(--luncher-border-light); + color: var(--luncher-text); + font-size: 0.9rem; + + &:last-child { + text-align: center; + font-weight: 600; + color: var(--luncher-primary); + } + } + + tbody tr { + transition: var(--luncher-transition); + + &:hover { + background: var(--luncher-bg-hover); + } + + &:last-child td { + border-bottom: none; + } + } + } } diff --git a/client/src/pages/StatsPage.tsx b/client/src/pages/StatsPage.tsx index cc79bb1..fdb2074 100644 --- a/client/src/pages/StatsPage.tsx +++ b/client/src/pages/StatsPage.tsx @@ -1,10 +1,10 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, 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 { WeeklyStats, LunchChoice, getStats } from "../../../types"; +import { WeeklyStats, LunchChoice, VotingStats, getStats, getVotingStats } 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"; @@ -32,6 +32,7 @@ export default function StatsPage() { const auth = useAuth(); const [dateRange, setDateRange] = useState(); const [data, setData] = useState(); + const [votingStats, setVotingStats] = useState(); // Prvotní nastavení aktuálního týdne useEffect(() => { @@ -48,6 +49,19 @@ export default function StatsPage() { } }, [dateRange]); + // Načtení statistik hlasování + useEffect(() => { + getVotingStats().then(response => { + setVotingStats(response.data); + }); + }, []); + + const sortedVotingStats = useMemo(() => { + if (!votingStats) return []; + return Object.entries(votingStats) + .sort((a, b) => (b[1] as number) - (a[1] as number)); + }, [votingStats]); + const renderLine = (location: LunchChoice) => { const index = Object.values(LunchChoice).indexOf(location); return data.locations[location] ?? 0} stroke={COLORS[index]} strokeWidth={STROKE_WIDTH} /> @@ -73,13 +87,20 @@ export default function StatsPage() { } } + const isCurrentOrFutureWeek = useMemo(() => { + if (!dateRange) return true; + const currentWeekEnd = getLastWorkDayOfWeek(new Date()); + currentWeekEnd.setHours(23, 59, 59, 999); + return dateRange[1] >= currentWeekEnd; + }, [dateRange]); + const handleKeyDown = useCallback((e: any) => { if (e.keyCode === 37) { handlePreviousWeek(); - } else if (e.keyCode === 39) { + } else if (e.keyCode === 39 && !isCurrentOrFutureWeek) { handleNextWeek() } - }, [dateRange]); + }, [dateRange, isCurrentOrFutureWeek]); useEffect(() => { document.addEventListener('keydown', handleKeyDown); @@ -111,7 +132,7 @@ export default function StatsPage() {

{getHumanDate(dateRange[0])} - {getHumanDate(dateRange[1])}

- + @@ -121,6 +142,27 @@ export default function StatsPage() { + {sortedVotingStats.length > 0 && ( +
+

Hlasování o funkcích

+ + + + + + + + + {sortedVotingStats.map(([feature, count]) => ( + + + + + ))} + +
FunkcePočet hlasů
{feature}{count as number}
+
+ )}