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
This commit is contained in:
2026-02-04 13:18:27 +01:00
parent d85c764c88
commit 6f43c74769
11 changed files with 242 additions and 12 deletions

View File

@@ -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;

View File

@@ -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] ??= {};

View File

@@ -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 = {

View File

@@ -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<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;
}