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..0f88eac 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, FeatureRequest, 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 + + + + Funkce + Počet hlasů + + + + {sortedVotingStats.map(([feature, count]) => ( + + {FeatureRequest[feature as keyof typeof FeatureRequest] ?? feature} + {count as number} + + ))} + + + + )} > diff --git a/server/src/routes/votingRoutes.ts b/server/src/routes/votingRoutes.ts index 86890d8..0b3a7d4 100644 --- a/server/src/routes/votingRoutes.ts +++ b/server/src/routes/votingRoutes.ts @@ -1,7 +1,7 @@ import express, { Request } from "express"; import { getLogin } from "../auth"; import { parseToken } from "../utils"; -import { getUserVotes, updateFeatureVote } from "../voting"; +import { getUserVotes, updateFeatureVote, getVotingStats } from "../voting"; import { GetVotesData, UpdateVoteData } from "../../../types"; const router = express.Router(); @@ -23,4 +23,11 @@ router.post("/updateVote", async (req: Request<{}, any, UpdateVoteData["body"]>, } catch (e: any) { next(e) } }); +router.get("/stats", async (req, res, next) => { + try { + const data = await getVotingStats(); + res.status(200).json(data); + } catch (e: any) { next(e) } +}); + export default router; \ No newline at end of file diff --git a/server/src/service.ts b/server/src/service.ts index 00d24d9..c73477a 100644 --- a/server/src/service.ts +++ b/server/src/service.ts @@ -198,7 +198,14 @@ export async function getRestaurantMenu(restaurant: Restaurant, date?: Date, for food: [], }; } - if (forceRefresh || (!weekMenu[dayOfWeekIndex][restaurant]?.food?.length && !weekMenu[dayOfWeekIndex][restaurant]?.closed)) { + const MENU_REFETCH_TTL_MS = 60 * 60 * 1000; // 1 hour + const existingMenu = weekMenu[dayOfWeekIndex][restaurant]; + const lastFetchExpired = !existingMenu?.lastUpdate || + existingMenu.lastUpdate === now || // freshly initialized, never fetched + (now - existingMenu.lastUpdate) > MENU_REFETCH_TTL_MS; + const shouldFetch = forceRefresh || + (!existingMenu?.food?.length && !existingMenu?.closed && lastFetchExpired); + if (shouldFetch) { const firstDay = getFirstWorkDayOfWeek(usedDate); try { @@ -240,7 +247,32 @@ export async function getRestaurantMenu(restaurant: Restaurant, date?: Date, for console.error(`Selhalo načtení jídel pro podnik ${restaurant}`, e); } } - return weekMenu[dayOfWeekIndex][restaurant]!; + const result = weekMenu[dayOfWeekIndex][restaurant]!; + result.warnings = generateMenuWarnings(result, now); + return result; +} + +/** + * Generuje varování o kvalitě/úplnosti dat menu restaurace. + */ +function generateMenuWarnings(menu: RestaurantDayMenu, now: number): string[] { + const warnings: string[] = []; + if (!menu.food?.length || menu.closed) { + return warnings; + } + const hasSoup = menu.food.some(f => f.isSoup); + if (!hasSoup) { + warnings.push('Chybí polévka'); + } + const missingPrice = menu.food.some(f => !f.isSoup && (!f.price || f.price.trim() === '')); + if (missingPrice) { + warnings.push('U některých jídel chybí cena'); + } + const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; + if (menu.lastUpdate && (now - menu.lastUpdate) > STALE_THRESHOLD_MS) { + warnings.push('Data jsou starší než 24 hodin'); + } + return warnings; } /** @@ -378,7 +410,7 @@ export async function addChoice(login: string, trusted: boolean, locationKey: Lu data = await removeChoiceIfPresent(login, usedDate); } else { // Mažeme případné ostatní volby (měla by být maximálně jedna) - removeChoiceIfPresent(login, usedDate, locationKey); + data = await removeChoiceIfPresent(login, usedDate, locationKey); } // TODO vytáhnout inicializaci "prázdné struktury" do vlastní funkce data.choices[locationKey] ??= {}; diff --git a/server/src/stats.ts b/server/src/stats.ts index 0943baa..66f5495 100644 --- a/server/src/stats.ts +++ b/server/src/stats.ts @@ -25,6 +25,12 @@ export async function getStats(startDate: string, endDate: string): Promise today) { + throw Error('Nelze načíst statistiky pro budoucí datum'); + } + const result = []; for (const date = start; date <= end; date.setDate(date.getDate() + 1)) { const locationsStats: DailyStats = { diff --git a/server/src/voting.ts b/server/src/voting.ts index db0d147..e2c86f6 100644 --- a/server/src/voting.ts +++ b/server/src/voting.ts @@ -1,10 +1,14 @@ -import { FeatureRequest } from "../../types/gen/types.gen"; +import { FeatureRequest, VotingStats } from "../../types/gen/types.gen"; import getStorage from "./storage"; interface VotingData { [login: string]: FeatureRequest[], } +export interface VotingStatsResult { + [feature: string]: number; +} + const storage = getStorage(); const STORAGE_KEY = 'voting'; @@ -51,4 +55,22 @@ export async function updateFeatureVote(login: string, option: FeatureRequest, a } await storage.setData(STORAGE_KEY, data); return data; +} + +/** + * Vrátí agregované statistiky hlasování - počet hlasů pro každou funkci. + * + * @returns objekt, kde klíčem je název funkce a hodnotou počet hlasů + */ +export async function getVotingStats(): Promise { + const data = await storage.getData(STORAGE_KEY); + const stats: VotingStatsResult = {}; + if (data) { + for (const votes of Object.values(data)) { + for (const feature of votes) { + stats[feature] = (stats[feature] || 0) + 1; + } + } + } + return stats; } \ No newline at end of file diff --git a/types/api.yml b/types/api.yml index 114d26e..3c546d6 100644 --- a/types/api.yml +++ b/types/api.yml @@ -66,6 +66,8 @@ paths: $ref: "./paths/voting/getVotes.yml" /voting/updateVote: $ref: "./paths/voting/updateVote.yml" + /voting/stats: + $ref: "./paths/voting/getVotingStats.yml" components: schemas: diff --git a/types/paths/voting/getVotingStats.yml b/types/paths/voting/getVotingStats.yml new file mode 100644 index 0000000..c1babf1 --- /dev/null +++ b/types/paths/voting/getVotingStats.yml @@ -0,0 +1,9 @@ +get: + operationId: getVotingStats + summary: Vrátí agregované statistiky hlasování o nových funkcích. + responses: + "200": + content: + application/json: + schema: + $ref: "../../schemas/_index.yml#/VotingStats" diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml index 8bef9d5..742a219 100644 --- a/types/schemas/_index.yml +++ b/types/schemas/_index.yml @@ -176,6 +176,11 @@ RestaurantDayMenu: type: array items: $ref: "#/Food" + warnings: + description: Seznam varování o kvalitě/úplnosti dat menu + type: array + items: + type: string RestaurantDayMenuMap: description: Objekt, kde klíčem je podnik ((#Restaurant)) a hodnotou denní menu daného podniku ((#RestaurantDayMenu)) type: object @@ -258,6 +263,12 @@ FeatureRequest: - UI - DEVELOPMENT +VotingStats: + description: Statistiky hlasování - klíčem je název funkce, hodnotou počet hlasů + type: object + additionalProperties: + type: integer + # --- EASTER EGGS --- EasterEgg: description: Data pro zobrazení easter eggů ssss