@@ -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 {
|
||||
|
||||
@@ -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)}
|
||||
</h3>
|
||||
{menu?.lastUpdate && <small>Aktualizace: {getHumanDateTime(new Date(menu.lastUpdate))}</small>}
|
||||
{menu?.warnings && menu.warnings.length > 0 && (
|
||||
<span className="restaurant-warning" title={menu.warnings.join('\n')}>
|
||||
<FontAwesomeIcon icon={faTriangleExclamation} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{content}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Date[]>();
|
||||
const [data, setData] = useState<WeeklyStats>();
|
||||
const [votingStats, setVotingStats] = useState<VotingStats>();
|
||||
|
||||
// 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 <Line key={location} name={getLunchChoiceName(location)} type="monotone" dataKey={data => 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() {
|
||||
</span>
|
||||
<h2 className="date-range">{getHumanDate(dateRange[0])} - {getHumanDate(dateRange[1])}</h2>
|
||||
<span title="Následující týden">
|
||||
<FontAwesomeIcon icon={faChevronRight} style={{ cursor: "pointer" }} onClick={handleNextWeek} />
|
||||
<FontAwesomeIcon icon={faChevronRight} style={{ cursor: "pointer", visibility: isCurrentOrFutureWeek ? "hidden" : "visible" }} onClick={handleNextWeek} />
|
||||
</span>
|
||||
</div>
|
||||
<LineChart width={CHART_WIDTH} height={CHART_HEIGHT} data={data}>
|
||||
@@ -121,6 +142,27 @@ export default function StatsPage() {
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</LineChart>
|
||||
{sortedVotingStats.length > 0 && (
|
||||
<div className="voting-stats-section">
|
||||
<h2>Hlasování o funkcích</h2>
|
||||
<table className="voting-stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Funkce</th>
|
||||
<th>Počet hlasů</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedVotingStats.map(([feature, count]) => (
|
||||
<tr key={feature}>
|
||||
<td>{feature}</td>
|
||||
<td>{count as number}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
@@ -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] ??= {};
|
||||
|
||||
@@ -25,6 +25,12 @@ export async function getStats(startDate: string, endDate: string): Promise<Week
|
||||
throw Error('Neplatný rozsah');
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(23, 59, 59, 999);
|
||||
if (end > 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 = {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -52,3 +56,21 @@ 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<VotingStatsResult> {
|
||||
const data = await storage.getData<VotingData>(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;
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
9
types/paths/voting/getVotingStats.yml
Normal file
9
types/paths/voting/getVotingStats.yml
Normal file
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user