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
+
+
+
+ | Funkce |
+ Počet hlasů |
+
+
+
+ {sortedVotingStats.map(([feature, count]) => (
+
+ | {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