This commit is contained in:
@@ -23,6 +23,8 @@ import { getHumanDateTime, isInTheFuture } from './Utils';
|
||||
import NoteModal from './components/modals/NoteModal';
|
||||
import { useEasterEgg } from './context/eggs';
|
||||
import { getImage } from './api/EasterEggApi';
|
||||
import { Link } from 'react-router';
|
||||
import { STATS_URL } from './AppRoutes';
|
||||
|
||||
const EVENT_CONNECT = "connect"
|
||||
|
||||
@@ -409,8 +411,8 @@ function App() {
|
||||
<img src='snowman.png' style={{ position: "absolute", height: "110px", right: 10, top: 5 }} />
|
||||
Poslední změny:
|
||||
<ul>
|
||||
<li>Přidání restaurací Zastávka u Michala a Pivovarský šenk Šeříková</li>
|
||||
<li>Možnost výběru restaurace a jídel kliknutím v tabulce</li>
|
||||
<li><Link to={STATS_URL}>Statistiky</Link></li>
|
||||
</ul>
|
||||
</Alert>
|
||||
{dayIndex != null &&
|
||||
|
||||
33
client/src/AppRoutes.tsx
Normal file
33
client/src/AppRoutes.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { ProvideSettings } from "./context/settings";
|
||||
import Snowfall from "react-snowfall";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import { SocketContext, socket } from "./context/socket";
|
||||
import StatsPage from "./pages/StatsPage";
|
||||
import App from "./App";
|
||||
|
||||
export const STATS_URL = '/stats';
|
||||
|
||||
export default function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path={STATS_URL} element={<StatsPage />} />
|
||||
<Route path="/" element={
|
||||
<ProvideSettings>
|
||||
<SocketContext.Provider value={socket}>
|
||||
<>
|
||||
<Snowfall style={{
|
||||
zIndex: 2,
|
||||
position: 'fixed',
|
||||
width: '100vw',
|
||||
height: '100vh'
|
||||
}} />
|
||||
<App />
|
||||
</>
|
||||
<ToastContainer />
|
||||
</SocketContext.Provider>
|
||||
</ProvideSettings>
|
||||
} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -62,3 +62,45 @@ export function isInTheFuture(time: DepartureTime) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrátí index dne v týdnu, kde pondělí=0, neděle=6
|
||||
*
|
||||
* @param date datum
|
||||
* @returns index dne v týdnu
|
||||
*/
|
||||
export const getDayOfWeekIndex = (date: Date) => {
|
||||
// https://stackoverflow.com/a/4467559
|
||||
return (((date.getDay() - 1) % 7) + 7) % 7;
|
||||
}
|
||||
|
||||
/** Vrátí první pracovní den v týdnu předaného data. */
|
||||
export function getFirstWorkDayOfWeek(date: Date) {
|
||||
const firstDay = new Date(date.getTime());
|
||||
firstDay.setDate(date.getDate() - getDayOfWeekIndex(date));
|
||||
return firstDay;
|
||||
}
|
||||
|
||||
/** Vrátí poslední pracovní den v týdnu předaného data. */
|
||||
export function getLastWorkDayOfWeek(date: Date) {
|
||||
const lastDay = new Date(date.getTime());
|
||||
lastDay.setDate(date.getDate() + (4 - getDayOfWeekIndex(date)));
|
||||
return lastDay;
|
||||
}
|
||||
|
||||
/** Vrátí datum v ISO formátu. */
|
||||
export function formatDate(date: Date, format?: string) {
|
||||
let day = String(date.getDate()).padStart(2, '0');
|
||||
let month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
let year = String(date.getFullYear());
|
||||
|
||||
const f = (format === undefined) ? 'YYYY-MM-DD' : format;
|
||||
return f.replace('DD', day).replace('MM', month).replace('YYYY', year);
|
||||
}
|
||||
|
||||
/** Vrátí human-readable reprezentaci předaného data pro zobrazení. */
|
||||
export function getHumanDate(date: Date) {
|
||||
let currentDay = String(date.getDate()).padStart(2, '0');
|
||||
let currentMonth = String(date.getMonth() + 1).padStart(2, "0");
|
||||
let currentYear = date.getFullYear();
|
||||
return `${currentDay}.${currentMonth}.${currentYear}`;
|
||||
}
|
||||
8
client/src/api/StatsApi.ts
Normal file
8
client/src/api/StatsApi.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { WeeklyStats } from "../../../types";
|
||||
import { api } from "./Api";
|
||||
|
||||
const STATS_API_PREFIX = '/api/stats';
|
||||
|
||||
export const getStats = async (startDate: string, endDate: string) => {
|
||||
return await api.get<WeeklyStats>(`${STATS_API_PREFIX}?startDate=${startDate}&endDate=${endDate}`);
|
||||
}
|
||||
@@ -8,11 +8,14 @@ import { FeatureRequest } from "../../../types";
|
||||
import { errorHandler } from "../api/Api";
|
||||
import { getFeatureVotes, updateFeatureVote } from "../api/VotingApi";
|
||||
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
|
||||
import { useNavigate } from "react-router";
|
||||
import { STATS_URL } from "../AppRoutes";
|
||||
|
||||
|
||||
export default function Header() {
|
||||
const auth = useAuth();
|
||||
const settings = useSettings();
|
||||
const navigate = useNavigate();
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false);
|
||||
const [votingModalOpen, setVotingModalOpen] = useState<boolean>(false);
|
||||
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
|
||||
@@ -108,7 +111,7 @@ export default function Header() {
|
||||
}
|
||||
|
||||
return <Navbar variant='dark' expand="lg">
|
||||
<Navbar.Brand>Luncher</Navbar.Brand>
|
||||
<Navbar.Brand href="/">Luncher</Navbar.Brand>
|
||||
<Navbar.Toggle aria-controls="basic-navbar-nav" />
|
||||
<Navbar.Collapse id="basic-navbar-nav">
|
||||
<Nav className="nav">
|
||||
@@ -116,6 +119,7 @@ export default function Header() {
|
||||
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => setVotingModalOpen(true)}>Hlasovat o nových funkcích</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
|
||||
<NavDropdown.Divider />
|
||||
<NavDropdown.Item onClick={auth?.logout}>Odhlásit se</NavDropdown.Item>
|
||||
</NavDropdown>
|
||||
|
||||
@@ -1,33 +1,20 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { SocketContext, socket } from './context/socket';
|
||||
import { ProvideAuth } from './context/auth';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import { ProvideSettings } from './context/settings';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import './index.css';
|
||||
import Snowfall from 'react-snowfall';
|
||||
import AppRoutes from './AppRoutes';
|
||||
import { BrowserRouter } from 'react-router';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ProvideAuth>
|
||||
<ProvideSettings>
|
||||
<SocketContext.Provider value={socket}>
|
||||
<>
|
||||
<Snowfall style={{
|
||||
zIndex: 2,
|
||||
position: 'fixed',
|
||||
width: '100vw',
|
||||
height: '100vh'}} />
|
||||
<App />
|
||||
</>
|
||||
<ToastContainer />
|
||||
</SocketContext.Provider>
|
||||
</ProvideSettings>
|
||||
</ProvideAuth>
|
||||
<BrowserRouter>
|
||||
<ProvideAuth>
|
||||
<AppRoutes />
|
||||
</ProvideAuth>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
16
client/src/pages/StatsPage.scss
Normal file
16
client/src/pages/StatsPage.scss
Normal file
@@ -0,0 +1,16 @@
|
||||
.stats-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
|
||||
.week-navigator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: xx-large;
|
||||
|
||||
.date-range {
|
||||
margin: 5px 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
124
client/src/pages/StatsPage.tsx
Normal file
124
client/src/pages/StatsPage.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useCallback, useEffect, 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 { getStats } from "../api/StatsApi";
|
||||
import { WeeklyStats, LocationKey, Locations } 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";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import './StatsPage.scss';
|
||||
|
||||
const CHART_WIDTH = 1400;
|
||||
const CHART_HEIGHT = 700;
|
||||
const STROKE_WIDTH = 2.5;
|
||||
|
||||
const COLORS = [
|
||||
// Komentáře jsou kvůli vizualizaci barev ve VS Code
|
||||
'#ff1493', // #ff1493
|
||||
'#1e90ff', // #1e90ff
|
||||
'#c5a700', // #c5a700
|
||||
'#006400', // #006400
|
||||
'#b300ff', // #b300ff
|
||||
'#ff4500', // #ff4500
|
||||
'#bc8f8f', // #bc8f8f
|
||||
'#00ff00', // #00ff00
|
||||
'#7c7c7c', // #7c7c7c
|
||||
]
|
||||
|
||||
export default function StatsPage() {
|
||||
const auth = useAuth();
|
||||
const [dateRange, setDateRange] = useState<Date[]>();
|
||||
const [data, setData] = useState<WeeklyStats>();
|
||||
|
||||
// Prvotní nastavení aktuálního týdne
|
||||
useEffect(() => {
|
||||
const today = new Date();
|
||||
setDateRange([getFirstWorkDayOfWeek(today), getLastWorkDayOfWeek(today)]);
|
||||
}, []);
|
||||
|
||||
// Přenačtení pro zvolený týden
|
||||
useEffect(() => {
|
||||
if (dateRange) {
|
||||
getStats(formatDate(dateRange[0]), formatDate(dateRange[1])).then(setData);
|
||||
}
|
||||
}, [dateRange]);
|
||||
|
||||
const renderLine = (location: Locations) => {
|
||||
const index = Object.values(Locations).indexOf(location);
|
||||
const key = Object.keys(Locations)[index];
|
||||
return <Line key={location} name={location} type="monotone" dataKey={data => data.locations[key] ?? 0} stroke={COLORS[index]} strokeWidth={STROKE_WIDTH} />
|
||||
}
|
||||
|
||||
const handlePreviousWeek = () => {
|
||||
if (dateRange) {
|
||||
const previousStartDate = new Date(dateRange[0]);
|
||||
previousStartDate.setDate(previousStartDate.getDate() - 7);
|
||||
const previousEndDate = new Date(previousStartDate);
|
||||
previousEndDate.setDate(previousEndDate.getDate() + 4);
|
||||
setDateRange([previousStartDate, previousEndDate]);
|
||||
}
|
||||
}
|
||||
|
||||
const handleNextWeek = () => {
|
||||
if (dateRange) {
|
||||
const nextStartDate = new Date(dateRange[0]);
|
||||
nextStartDate.setDate(nextStartDate.getDate() + 7);
|
||||
const nextEndDate = new Date(nextStartDate);
|
||||
nextEndDate.setDate(nextEndDate.getDate() + 4);
|
||||
setDateRange([nextStartDate, nextEndDate]);
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = useCallback((e: any) => {
|
||||
if (e.keyCode === 37) {
|
||||
handlePreviousWeek();
|
||||
} else if (e.keyCode === 39) {
|
||||
handleNextWeek()
|
||||
}
|
||||
}, [dateRange]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
}, [handleKeyDown]);
|
||||
|
||||
if (!auth?.login) {
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
if (!dateRange) {
|
||||
return <Loader
|
||||
icon={faGear}
|
||||
description={'Načítám data...'}
|
||||
animation={'fa-bounce'}
|
||||
/>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<div className="stats-page">
|
||||
<h1>Statistiky</h1>
|
||||
<div className="week-navigator">
|
||||
<FontAwesomeIcon title="Předchozí týden" icon={faChevronLeft} style={{ cursor: "pointer" }} onClick={handlePreviousWeek} />
|
||||
<h2 className="date-range">{getHumanDate(dateRange[0])} - {getHumanDate(dateRange[1])}</h2>
|
||||
<FontAwesomeIcon title="Následující týden" icon={faChevronRight} style={{ cursor: "pointer" }} onClick={handleNextWeek} />
|
||||
</div>
|
||||
<LineChart width={CHART_WIDTH} height={CHART_HEIGHT} data={data}>
|
||||
{Object.values(Locations).map(location => renderLine(location))}
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</LineChart>
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user