feat: založení základní stránky příjem/výdej + import a statistiky

This commit is contained in:
Ondřej Anděl
2026-09-07 14:58:49 +02:00
commit a5711f3e1e
98 changed files with 15505 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#111827" />
<meta name="description" content="Food Tracer — evidence snědených jídel a útraty za jídlo" />
<title>Food Tracer</title>
</head>
<body>
<noscript>Pro běh aplikace je potřeba zapnutý JavaScript.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@food-tracer/client",
"version": "0.1.0",
"license": "MIT",
"private": true,
"type": "module",
"homepage": ".",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.1.0",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"bootstrap": "^5.3.8",
"react": "^19.2.0",
"react-bootstrap": "^2.10.10",
"react-dom": "^19.2.0",
"react-jwt": "^1.3.0",
"react-router": "^7.9.5",
"react-router-dom": "^7.9.5",
"react-toastify": "^11.0.5",
"recharts": "^3.4.1",
"sass": "^1.93.3",
"typescript": "^5.9.3",
"vite": "^7.2.2",
"vite-tsconfig-paths": "^5.1.4"
},
"scripts": {
"start": "vite",
"build": "tsc --noEmit && vite build"
},
"devDependencies": {
"prettier": "^3.6.2"
}
}
+723
View File
@@ -0,0 +1,723 @@
// ============================================
// DESIGN SYSTEM - FOOD TRACER
// Vychází ze systému Luncheru, aby obě aplikace působily jako jedna rodina.
// ============================================
:root, [data-bs-theme="light"] {
// Primary colors
--ft-primary: #16a34a;
--ft-primary-hover: #15803d;
--ft-primary-light: #dcfce7;
// Background colors
--ft-bg: #f9fafb;
--ft-bg-card: #ffffff;
--ft-bg-hover: #f3f4f6;
// Text colors
--ft-text: #111827;
--ft-text-secondary: #4b5563;
--ft-text-muted: #9ca3af;
// Border colors
--ft-border: #e5e7eb;
--ft-border-light: #f3f4f6;
// Navbar - Dark
--ft-navbar-bg: #111827;
--ft-navbar-text: #f9fafb;
// Action colors
--ft-success: #16a34a;
--ft-warning: #d97706;
--ft-danger: #dc2626;
--ft-info: #2563eb;
// Shadows
--ft-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--ft-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--ft-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
// Border radius
--ft-radius-sm: 6px;
--ft-radius: 10px;
--ft-radius-lg: 12px;
--ft-radius-xl: 16px;
// Transitions
--ft-transition: all 0.2s ease;
--ft-transition-slow: all 0.3s ease;
}
[data-bs-theme="dark"] {
--ft-primary: #1db954;
--ft-primary-hover: #1ed760;
--ft-primary-light: #052e16;
--ft-bg: #121212;
--ft-bg-card: #181818;
--ft-bg-hover: #282828;
--ft-text: #ffffff;
--ft-text-secondary: #b3b3b3;
--ft-text-muted: #6b7280;
--ft-border: #282828;
--ft-border-light: #1f1f1f;
--ft-navbar-bg: #000000;
--ft-navbar-text: #ffffff;
--ft-success: #1db954;
--ft-warning: #fbbf24;
--ft-danger: #f87171;
--ft-info: #60a5fa;
--ft-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.5);
--ft-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.5), 0 2px 4px -2px rgb(0 0 0 / 0.4);
--ft-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.4);
}
// ============================================
// BASE
// ============================================
html, body, #root {
height: 100%;
margin: 0;
}
html {
overflow-x: hidden;
}
body {
background-color: var(--ft-bg);
color: var(--ft-text);
transition: var(--ft-transition-slow);
}
.app-container {
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: var(--ft-bg);
}
.wrapper {
padding: 24px;
flex: 1;
max-width: 1200px;
margin: 0 auto;
width: 100%;
@media (max-width: 768px) {
padding: 16px;
}
}
// ============================================
// NAVBAR
// ============================================
.navbar {
background: var(--ft-navbar-bg) !important;
padding: 12px 24px;
box-shadow: none;
border: none;
.navbar-brand {
color: var(--ft-navbar-text) !important;
font-weight: 700;
font-size: 1.25rem;
letter-spacing: -0.02em;
&::before {
content: '🍽';
margin-right: 8px;
}
}
.nav {
margin-left: auto;
align-items: center;
gap: 4px;
}
.nav-link,
.dropdown-toggle {
color: var(--ft-navbar-text) !important;
font-weight: 500;
}
.nav-pill {
color: var(--ft-navbar-text);
background: transparent;
border: none;
border-radius: var(--ft-radius-sm);
padding: 6px 14px;
font-weight: 500;
font-size: 0.9rem;
transition: var(--ft-transition);
&:hover {
background: rgb(255 255 255 / 0.1);
}
&.active {
background: var(--ft-primary);
color: #fff;
}
}
.theme-toggle {
background: transparent;
border: none;
color: var(--ft-navbar-text);
font-size: 1rem;
padding: 6px 10px;
border-radius: var(--ft-radius-sm);
transition: var(--ft-transition);
&:hover {
background: rgb(255 255 255 / 0.1);
}
}
.dropdown-menu {
background: var(--ft-bg-card);
border: 1px solid var(--ft-border);
border-radius: var(--ft-radius);
box-shadow: var(--ft-shadow-lg);
padding: 6px;
.dropdown-item {
color: var(--ft-text);
border-radius: var(--ft-radius-sm);
padding: 8px 12px;
font-size: 0.9rem;
&:hover, &:focus {
background: var(--ft-bg-hover);
color: var(--ft-text);
}
}
.dropdown-divider {
border-color: var(--ft-border);
}
}
}
// ============================================
// CARDS & PANELS
// ============================================
.card-panel {
background: var(--ft-bg-card);
border: 1px solid var(--ft-border);
border-radius: var(--ft-radius-lg);
box-shadow: var(--ft-shadow-sm);
padding: 20px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 20px;
h1 {
font-size: 1.6rem;
font-weight: 700;
letter-spacing: -0.02em;
margin: 0;
color: var(--ft-text);
}
.page-subtitle {
margin: 4px 0 0;
color: var(--ft-text-secondary);
font-size: 0.9rem;
}
}
// ============================================
// SUMMARY TILES
// ============================================
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.summary-tile {
background: var(--ft-bg-card);
border: 1px solid var(--ft-border);
border-radius: var(--ft-radius-lg);
box-shadow: var(--ft-shadow-sm);
padding: 16px 18px;
.summary-label {
display: flex;
align-items: center;
gap: 8px;
color: var(--ft-text-secondary);
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 6px;
}
.summary-value {
font-size: 1.6rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--ft-text);
line-height: 1.2;
}
.summary-hint {
margin-top: 2px;
color: var(--ft-text-muted);
font-size: 0.8rem;
}
&.accent .summary-value {
color: var(--ft-primary);
}
}
// ============================================
// FORMS
// ============================================
.form-control,
.form-select {
background: var(--ft-bg-card);
border: 1px solid var(--ft-border);
border-radius: var(--ft-radius-sm);
color: var(--ft-text);
transition: var(--ft-transition);
&:focus {
background: var(--ft-bg-card);
color: var(--ft-text);
border-color: var(--ft-primary);
box-shadow: 0 0 0 3px var(--ft-primary-light);
}
&::placeholder {
color: var(--ft-text-muted);
}
}
.form-label {
color: var(--ft-text-secondary);
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 4px;
}
.form-text {
color: var(--ft-text-muted);
font-size: 0.8rem;
}
// ============================================
// BUTTONS
// ============================================
.btn {
border-radius: var(--ft-radius-sm);
font-weight: 600;
transition: var(--ft-transition);
}
.btn-primary {
background: var(--ft-primary);
border-color: var(--ft-primary);
&:hover, &:focus, &:active {
background: var(--ft-primary-hover) !important;
border-color: var(--ft-primary-hover) !important;
}
&:disabled {
background: var(--ft-primary);
border-color: var(--ft-primary);
opacity: 0.55;
}
}
.btn-outline-secondary {
color: var(--ft-text-secondary);
border-color: var(--ft-border);
&:hover, &:focus, &:active {
background: var(--ft-bg-hover) !important;
border-color: var(--ft-border) !important;
color: var(--ft-text) !important;
}
}
.btn-icon {
background: transparent;
border: none;
color: var(--ft-text-muted);
padding: 6px 8px;
border-radius: var(--ft-radius-sm);
transition: var(--ft-transition);
&:hover {
background: var(--ft-bg-hover);
color: var(--ft-text);
}
&.danger:hover {
color: var(--ft-danger);
}
}
// ============================================
// MODALS
// ============================================
.modal-content {
background: var(--ft-bg-card);
color: var(--ft-text);
border: 1px solid var(--ft-border);
border-radius: var(--ft-radius-lg);
box-shadow: var(--ft-shadow-lg);
}
.modal-header,
.modal-footer {
border-color: var(--ft-border);
}
.modal-title {
font-weight: 700;
font-size: 1.15rem;
}
.btn-close {
filter: var(--ft-close-filter, none);
}
[data-bs-theme="dark"] .btn-close {
filter: invert(1) grayscale(100%) brightness(200%);
}
// ============================================
// EMPTY STATE
// ============================================
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--ft-text-secondary);
.empty-icon {
font-size: 2.4rem;
color: var(--ft-text-muted);
margin-bottom: 12px;
}
.empty-title {
font-size: 1.05rem;
font-weight: 600;
color: var(--ft-text);
margin-bottom: 4px;
}
.empty-description {
font-size: 0.9rem;
margin: 0;
}
}
// ============================================
// BADGES
// ============================================
.tag {
display: inline-flex;
align-items: center;
gap: 5px;
background: var(--ft-bg-hover);
color: var(--ft-text-secondary);
border-radius: 999px;
padding: 2px 10px;
font-size: 0.78rem;
font-weight: 600;
white-space: nowrap;
}
.tag-import {
background: var(--ft-primary-light);
color: var(--ft-primary);
}
// ============================================
// FORMULÁŘ JÍDLA — gramáž a kalorie
// ============================================
.meal-fieldset {
border: 1px solid var(--ft-border);
border-radius: var(--ft-radius);
padding: 12px 16px 16px;
legend {
float: none;
width: auto;
padding: 0 6px;
margin: 0 0 0 -6px;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ft-text-muted);
}
}
// Dopočtená hodnota se needituje, tak vypadá jako text, ne jako pole
.derived-value {
display: flex;
align-items: center;
min-height: 38px;
padding: 0 2px;
font-size: 1.1rem;
font-weight: 700;
color: var(--ft-primary);
}
.meal-modal-summary {
display: flex;
gap: 14px;
margin-right: auto;
color: var(--ft-text-secondary);
font-size: 0.88rem;
font-weight: 600;
}
// ============================================
// HLEDÁNÍ KALORIÍ
// ============================================
.calorie-lookup {
width: 100%;
position: relative;
.calorie-lookup-hint {
display: block;
margin-top: 6px;
font-size: 0.78rem;
color: var(--ft-text-muted);
}
.calorie-lookup-results {
position: absolute;
z-index: 5;
top: calc(100% + 6px);
right: 0;
width: min(380px, 90vw);
background: var(--ft-bg-card);
border: 1px solid var(--ft-border);
border-radius: var(--ft-radius);
box-shadow: var(--ft-shadow-lg);
padding: 6px;
max-height: 300px;
overflow-y: auto;
}
.calorie-suggestion {
display: flex;
align-items: baseline;
gap: 8px;
width: 100%;
text-align: left;
background: transparent;
border: none;
border-radius: var(--ft-radius-sm);
padding: 7px 10px;
font-size: 0.85rem;
color: var(--ft-text);
transition: var(--ft-transition);
&:hover {
background: var(--ft-bg-hover);
}
}
.calorie-suggestion-icon {
color: var(--ft-primary);
font-size: 0.75rem;
flex: 0 0 auto;
}
.calorie-suggestion-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.calorie-suggestion-brand {
color: var(--ft-text-muted);
font-size: 0.76rem;
flex: 0 0 auto;
}
.calorie-suggestion-value {
font-weight: 700;
white-space: nowrap;
flex: 0 0 auto;
}
.calorie-lookup-external {
display: block;
padding: 8px 10px;
margin-top: 4px;
border-top: 1px solid var(--ft-border-light);
font-size: 0.8rem;
color: var(--ft-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
// ============================================
// NASTAVENÍ — sazby za 100 g
// ============================================
.settings-section-title {
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ft-text-secondary);
margin: 0 0 6px;
}
.rate-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--ft-border-light);
.rate-source {
flex: 1;
font-weight: 600;
}
.rate-value {
color: var(--ft-text-secondary);
font-size: 0.87rem;
white-space: nowrap;
}
}
.rate-form {
display: flex;
gap: 8px;
margin-top: 14px;
.rate-form-price {
width: 130px;
flex: 0 0 auto;
}
}
// ============================================
// ŠABLONY TRÉNINKŮ
// ============================================
.workout-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid var(--ft-border-light);
.workout-info {
flex: 1;
min-width: 0;
}
.workout-name {
font-weight: 600;
color: var(--ft-text);
}
.workout-items {
font-size: 0.8rem;
color: var(--ft-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workout-calories {
color: var(--ft-warning);
font-weight: 700;
font-size: 0.85rem;
white-space: nowrap;
}
}
.workout-item-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
.workout-item-quantity {
width: 90px;
flex: 0 0 auto;
}
.workout-item-unit {
width: 130px;
flex: 0 0 auto;
}
.workout-item-rate {
width: 110px;
flex: 0 0 auto;
}
@media (max-width: 640px) {
flex-wrap: wrap;
.workout-item-quantity,
.workout-item-unit,
.workout-item-rate {
width: auto;
flex: 1;
}
}
}
.workout-form-footer {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
.workout-draft-calories {
margin-left: auto;
color: var(--ft-warning);
font-weight: 600;
font-size: 0.85rem;
}
}
+30
View File
@@ -0,0 +1,30 @@
import { Routes, Route, Navigate } from "react-router-dom";
import { ToastContainer } from "react-toastify";
import { useAuth } from "./context/auth";
import Header from "./components/Header";
import Login from "./Login";
import DayPage from "./pages/DayPage";
import StatsPage from "./pages/StatsPage";
import { DEN_URL, STATISTIKY_URL } from "./routes";
export default function AppRoutes() {
const auth = useAuth();
if (!auth?.login) {
return <Login />;
}
return (
<div className="app-container">
<Header />
<div className="wrapper">
<Routes>
<Route path={DEN_URL} element={<DayPage />} />
<Route path={STATISTIKY_URL} element={<StatsPage />} />
<Route path="*" element={<Navigate to={DEN_URL} replace />} />
</Routes>
</div>
<ToastContainer position="bottom-right" />
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: var(--ft-bg);
}
.login-card {
width: 100%;
max-width: 420px;
background: var(--ft-bg-card);
border: 1px solid var(--ft-border);
border-radius: var(--ft-radius-xl);
box-shadow: var(--ft-shadow-lg);
padding: 32px;
}
.login-logo {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
letter-spacing: -0.03em;
color: var(--ft-text);
}
.login-subtitle {
margin: 6px 0 24px;
color: var(--ft-text-secondary);
font-size: 0.92rem;
}
.login-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.login-form label {
display: block;
margin-bottom: 6px;
font-size: 0.85rem;
font-weight: 600;
color: var(--ft-text-secondary);
}
.login-form input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--ft-border);
border-radius: var(--ft-radius-sm);
background: var(--ft-bg);
color: var(--ft-text);
font-size: 0.95rem;
transition: var(--ft-transition);
}
.login-form input:focus {
outline: none;
border-color: var(--ft-primary);
box-shadow: 0 0 0 3px var(--ft-primary-light);
}
.login-form .hint {
margin: 8px 0 0;
font-size: 0.8rem;
color: var(--ft-text-muted);
}
+63
View File
@@ -0,0 +1,63 @@
import { useCallback, useEffect, useRef } from 'react';
import { Button } from 'react-bootstrap';
import { useAuth } from './context/auth';
import { login } from '../../types';
import './Login.css';
/**
* Formulář pro prvotní zadání přihlašovacího jména.
*/
export default function Login() {
const auth = useAuth();
const loginRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (auth && !auth.login) {
// Vyzkoušíme přihlášení "naprázdno" — pokud projde, přihlásily nás trusted headers
login().then(response => {
if (response.data) {
auth.setToken(response.data as unknown as string);
}
}).catch(() => { /* nepřihlášeno, uživatel zadá jméno ručně */ });
}
}, [auth]);
const doLogin = useCallback(async () => {
const value = loginRef.current?.value ?? '';
if (!value.replaceAll(/\s/g, '').length) return;
const response = await login({ body: { login: value } });
if (response.data) {
auth?.setToken(response.data as unknown as string);
}
}, [auth]);
return (
<div className='login-page'>
<div className='login-card'>
<h1 className='login-logo'>🍽 Food Tracer</h1>
<p className='login-subtitle'>Přehled o tom, co jste snědli a kolik vás to stálo</p>
<div className='login-form'>
<div>
<label htmlFor="login-input">Zobrazované jméno</label>
<input
id="login-input"
ref={loginRef}
type='text'
placeholder="Např. Jan Novák"
onKeyDown={event => {
if (event.key === 'Enter') {
doLogin();
}
}}
/>
<p className='hint'>
Zadejte jméno nebo přezdívku, pod kterou se vaše záznamy uloží.
Pokud používáte Luncher, hodí se stejné jméno jako tam.
</p>
</div>
<Button onClick={doLogin}>Pokračovat</Button>
</div>
</div>
</div>
);
}
+195
View File
@@ -0,0 +1,195 @@
const TOKEN_KEY = "token";
/** Uloží token do local storage prohlížeče. */
export const storeToken = (token: string) => {
localStorage.setItem(TOKEN_KEY, token);
}
/** Vrátí token z local storage, pokud tam je. */
export const getToken = (): string | undefined => {
return localStorage.getItem(TOKEN_KEY) ?? undefined;
}
/** Odstraní token z local storage, pokud tam je. */
export const deleteToken = () => {
localStorage.removeItem(TOKEN_KEY);
}
/** Vrátí datum v ISO formátu YYYY-MM-DD v lokální časové zóně. */
export function formatDate(date: Date): string {
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = String(date.getFullYear());
return `${year}-${month}-${day}`;
}
/** Vrátí měsíc data ve formátu YYYY-MM (hodnota pro <input type="month">). */
export function formatMonth(date: Date): string {
return formatDate(date).substring(0, 7);
}
/** Převede datum ve formátu YYYY-MM-DD na DD.MM.YYYY */
export function formatDateString(date: string): string {
const [year, month, day] = date.split('-');
return `${day}.${month}.${year}`;
}
/** Převede řetězec YYYY-MM-DD na Date v lokální časové zóně. */
export function parseIsoDate(date: string): Date {
const [year, month, day] = date.split('-').map(Number);
return new Date(year, month - 1, day);
}
/** Posune datum ve formátu YYYY-MM-DD o zadaný počet dní. */
export function shiftDate(date: string, days: number): string {
const shifted = parseIsoDate(date);
shifted.setDate(shifted.getDate() + days);
return formatDate(shifted);
}
/** Vrátí první den měsíce (YYYY-MM) ve formátu YYYY-MM-DD. */
export function getMonthStart(month: string): string {
return `${month}-01`;
}
/** Vrátí poslední den měsíce (YYYY-MM) ve formátu YYYY-MM-DD. */
export function getMonthEnd(month: string): string {
const [year, monthNumber] = month.split('-').map(Number);
// Nultý den následujícího měsíce = poslední den tohoto měsíce
return formatDate(new Date(year, monthNumber, 0));
}
const DAY_OF_WEEK_FORMAT = new Intl.DateTimeFormat('cs-CZ', { weekday: 'long' });
const HUMAN_DATE_FORMAT = new Intl.DateTimeFormat('cs-CZ', { day: 'numeric', month: 'long', year: 'numeric' });
const MONTH_FORMAT = new Intl.DateTimeFormat('cs-CZ', { month: 'long', year: 'numeric' });
/** Vrátí název dne v týdnu pro datum ve formátu YYYY-MM-DD. */
export function getDayOfWeek(date: string): string {
return DAY_OF_WEEK_FORMAT.format(parseIsoDate(date));
}
/** Vrátí čitelný zápis data, např. "3. března 2025". */
export function getHumanDate(date: string): string {
return HUMAN_DATE_FORMAT.format(parseIsoDate(date));
}
/** Vrátí čitelný zápis měsíce (YYYY-MM), např. "březen 2025". */
export function getHumanMonth(month: string): string {
return MONTH_FORMAT.format(parseIsoDate(`${month}-01`));
}
const PRICE_FORMAT = new Intl.NumberFormat('cs-CZ', {
style: 'currency',
currency: 'CZK',
maximumFractionDigits: 2,
});
/**
* Naformátuje cenu z haléřů na koruny, např. 15900 → "159,00 Kč".
* Nevyplněnou cenu zobrazí jako pomlčku.
*/
export function formatPrice(halere?: number): string {
if (halere == null) return '—';
return PRICE_FORMAT.format(halere / 100);
}
/** Naformátuje cenu z haléřů bez desetinných míst — pro souhrny a osy grafů. */
export function formatPriceShort(halere: number): string {
return `${Math.round(halere / 100).toLocaleString('cs-CZ')}`;
}
/**
* Převede uživatelem zadanou cenu v korunách na haléře.
* Přijímá desetinnou čárku i tečku. Prázdný vstup vrací undefined.
*/
export function parsePriceInput(value: string): number | undefined {
const trimmed = value.trim().replace(',', '.');
if (!trimmed.length) return undefined;
const parsed = Number(trimmed);
return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed * 100) : undefined;
}
/** Převede haléře na text pro editační pole (koruny s desetinnou čárkou). */
export function formatPriceInput(halere?: number): string {
if (halere == null) return '';
return (halere / 100).toFixed(2).replace('.', ',');
}
/** Naformátuje počet kalorií, např. 850 → "850 kcal". */
export function formatCalories(kcal?: number): string {
if (!kcal) return '—';
return `${kcal.toLocaleString('cs-CZ')} kcal`;
}
/** Načte soubor jako Base64 řetězec (bez data: prefixu). */
export function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = String(reader.result);
// FileReader vrací "data:<mime>;base64,<data>", server chce jen data
resolve(result.substring(result.indexOf(',') + 1));
};
reader.onerror = () => reject(new Error('Soubor se nepodařilo načíst'));
reader.readAsDataURL(file);
});
}
/**
* Dopočte gramáž a kalorie z jednotkových hodnot.
*
* Musí dávat stejné výsledky jako `deriveAmounts` na serveru — klient jen ukazuje
* živý náhled toho, co server uloží.
*
* @param price cena v haléřích
* @param pricePer100g cena za 100 g v haléřích
* @param weight ručně zadaná gramáž v gramech, má přednost před dopočtem
* @param caloriesPer100g energie na 100 g v kcal
*/
export function deriveAmounts(
price?: number,
pricePer100g?: number,
weight?: number,
caloriesPer100g?: number,
): { weight?: number, calories?: number } {
const resolvedWeight = weight ?? (
price != null && pricePer100g ? Math.round(price / pricePer100g * 100) : undefined
);
const calories = resolvedWeight != null && caloriesPer100g
? Math.round(resolvedWeight / 100 * caloriesPer100g)
: undefined;
return { weight: resolvedWeight, calories };
}
/** Naformátuje gramáž, např. 400 → "400 g". */
export function formatWeight(grams?: number): string {
if (grams == null) return '—';
return `${grams.toLocaleString('cs-CZ')} g`;
}
/** Převede celé číslo z pole na number, prázdné pole na undefined. */
export function parseIntInput(value: string): number | undefined {
const trimmed = value.trim();
if (!trimmed.length) return undefined;
const parsed = Number(trimmed);
return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : undefined;
}
/** Naformátuje množství aktivity i s jednotkou, např. "10 000 kroků". */
export function formatQuantity(quantity: number, unitShort: string): string {
return `${quantity.toLocaleString('cs-CZ')} ${unitShort}`;
}
/** Dopočte spálené kalorie z množství a sazby na 100 jednotek. */
export function deriveActivityCalories(quantity?: number, caloriesPer100Units?: number): number | undefined {
if (quantity == null || !caloriesPer100Units) return undefined;
return Math.round(quantity / 100 * caloriesPer100Units);
}
/** Naformátuje bilanci se znaménkem, např. "320 kcal" nebo "+150 kcal". */
export function formatBalance(kcal: number): string {
const sign = kcal < 0 ? '' : '+';
return `${sign}${Math.abs(kcal).toLocaleString('cs-CZ')} kcal`;
}
+111
View File
@@ -0,0 +1,111 @@
import { useState } from "react";
import { Button, Spinner } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faMagnifyingGlass, faArrowUpRightFromSquare, faBookmark } from "@fortawesome/free-solid-svg-icons";
import { CalorieSearchResult, searchCalories } from "../../../types";
type Props = {
/** Název jídla, ke kterému se energie hledá. */
name: string,
/** Zavolá se po výběru návrhu s energií na 100 g v kcal. */
onPick: (caloriesPer100g: number) => void,
};
/** Návrhy pocházející z vlastní knihovny se odlišují ikonou a popiskem. */
const LIBRARY_ORIGIN = 'library';
/**
* Vyhledání energetické hodnoty k názvu jídla.
*
* Návrhy chodí z vlastní knihovny dřívějších zadání a od externího poskytovatele.
* Když je poskytovatel nedostupný (což se stává), zůstane aspoň knihovna a odkaz
* na KalorickéTabulky.cz, odkud si hodnotu uživatel opíše ručně — automaticky se
* odtud nic nestahuje, veřejné API nemají.
*/
export default function CalorieLookup({ name, onPick }: Readonly<Props>) {
const [result, setResult] = useState<CalorieSearchResult | undefined>();
const [searching, setSearching] = useState(false);
const [copied, setCopied] = useState(false);
const canSearch = name.trim().length > 0 && !searching;
/**
* Zkopíruje název jídla do schránky při otevření Kalorických tabulek.
* Jejich vyhledávání nejde předvyplnit z URL, takže aspoň stačí vložit.
*/
const copyName = () => {
navigator.clipboard?.writeText(name.trim())
.then(() => setCopied(true))
.catch(() => { /* schránka není dostupná, odkaz se stejně otevře */ });
};
const search = async () => {
if (!canSearch) return;
setSearching(true);
const response = await searchCalories({ query: { q: name.trim() } });
setResult(response.data);
setCopied(false);
setSearching(false);
};
return (
<div className="calorie-lookup">
<Button variant="outline-secondary" size="sm" onClick={search} disabled={!canSearch}>
{searching
? <Spinner animation="border" size="sm" />
: <FontAwesomeIcon icon={faMagnifyingGlass} />}
{' '}Najít kalorie
</Button>
{!canSearch && !name.trim().length && (
<span className="calorie-lookup-hint">nejdřív vyplňte název jídla</span>
)}
{result && !searching && (
<div className="calorie-lookup-results">
{result.suggestions.map(suggestion => (
<button
type="button"
className="calorie-suggestion"
key={`${suggestion.origin}-${suggestion.name}-${suggestion.caloriesPer100g}`}
onClick={() => onPick(suggestion.caloriesPer100g)}
title="Použít tuto hodnotu"
>
{suggestion.origin === LIBRARY_ORIGIN && (
<FontAwesomeIcon icon={faBookmark} className="calorie-suggestion-icon" />
)}
<span className="calorie-suggestion-name">{suggestion.name}</span>
{suggestion.brand && (
<span className="calorie-suggestion-brand">{suggestion.brand}</span>
)}
<span className="calorie-suggestion-value">{suggestion.caloriesPer100g} kcal/100 g</span>
</button>
))}
{!result.suggestions.length && (
<div className="calorie-lookup-hint">
Nic se nenašlo{!result.providerAvailable && ' a externí databáze je nedostupná'}.
</div>
)}
{result.providerAvailable === false && !!result.suggestions.length && (
<div className="calorie-lookup-hint">
Externí databáze je nedostupná, návrhy jsou jen z vašich dřívějších zadání.
</div>
)}
<a
className="calorie-lookup-external"
href={result.externalSearchUrl}
target="_blank"
rel="noreferrer noopener"
onClick={copyName}
>
{copied ? 'Název zkopírován, vložte ho do jejich hledání' : 'Otevřít KalorickéTabulky.cz'}
{' '}<FontAwesomeIcon icon={faArrowUpRightFromSquare} />
</a>
</div>
)}
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { useState } from "react";
import { Navbar, Nav, NavDropdown } from "react-bootstrap";
import { useLocation, useNavigate } from "react-router";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSun, faMoon, faGear } from "@fortawesome/free-solid-svg-icons";
import { useAuth } from "../context/auth";
import { useSettings, ThemePreference } from "../context/settings";
import { DEN_URL, STATISTIKY_URL } from "../routes";
import SettingsModal from "./modals/SettingsModal";
/** Položky hlavní navigace. */
const NAV_ITEMS = [
{ url: DEN_URL, label: 'Můj den' },
{ url: STATISTIKY_URL, label: 'Statistiky' },
];
export default function Header() {
const [settingsOpen, setSettingsOpen] = useState(false);
const auth = useAuth();
const settings = useSettings();
const navigate = useNavigate();
const location = useLocation();
const effectiveDark = settings?.effectiveDark ?? false;
const toggleTheme = () => {
const next: ThemePreference = effectiveDark ? 'light' : 'dark';
settings?.setThemePreference(next);
};
return (
<Navbar variant="dark" expand="lg">
<Navbar.Brand href={DEN_URL}>Food Tracer</Navbar.Brand>
<Navbar.Toggle aria-controls="main-navbar-nav" />
<Navbar.Collapse id="main-navbar-nav">
<Nav className="nav">
{NAV_ITEMS.map(item => (
<button
key={item.url}
className={`nav-pill ${location.pathname === item.url ? 'active' : ''}`}
onClick={() => navigate(item.url)}
>
{item.label}
</button>
))}
<button
className="theme-toggle"
onClick={toggleTheme}
title={effectiveDark ? 'Přepnout na světlý režim' : 'Přepnout na tmavý režim'}
aria-label="Přepnout světlý/tmavý režim"
>
<FontAwesomeIcon icon={effectiveDark ? faSun : faMoon} />
</button>
<NavDropdown align="end" title={auth?.login} id="user-nav-dropdown">
<NavDropdown.Item onClick={() => navigate(DEN_URL)}>Můj den</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(STATISTIKY_URL)}>Statistiky</NavDropdown.Item>
<NavDropdown.Item onClick={() => setSettingsOpen(true)}>
<FontAwesomeIcon icon={faGear} /> Nastavení
</NavDropdown.Item>
<NavDropdown.Divider />
<NavDropdown.Item onClick={auth?.logout}>Odhlásit se</NavDropdown.Item>
</NavDropdown>
</Nav>
</Navbar.Collapse>
<SettingsModal isOpen={settingsOpen} onClose={() => setSettingsOpen(false)} />
</Navbar>
);
}
@@ -0,0 +1,208 @@
import { useEffect, useState } from "react";
import { Button, Form, Modal } from "react-bootstrap";
import { ActivityEntry, ActivityUnit, DayOverview, addActivity, updateActivity } from "../../../../types";
import { ACTIVITY_PRESETS, ACTIVITY_UNITS_IN_ORDER, ACTIVITY_UNIT_NAMES } from "../../enums";
import { deriveActivityCalories, formatCalories, parseIntInput } from "../../Utils";
type Props = {
isOpen: boolean,
/** Den, do kterého se zakládá nová aktivita. */
date: string,
/** Upravovaný záznam. Pokud chybí, jde o založení nového. */
entry?: ActivityEntry,
onClose: () => void,
onSaved: (day: DayOverview) => void,
};
/** Prázdný formulář pro novou aktivitu v daném dni. */
function emptyForm(date: string) {
return {
date,
name: '',
unit: ActivityUnit.KROKY as ActivityUnit,
quantity: '',
caloriesPer100Units: '',
calories: '',
note: '',
};
}
/**
* Dialog pro přidání nebo úpravu pohybové aktivity.
*
* Kalorie se dopočítají z množství a spotřeby na 100 jednotek — sto proto, že
* u kroků by sazba na jeden krok byla zlomek. Výběr známé aktivity předvyplní
* jednotku i orientační sazbu.
*/
export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }: Readonly<Props>) {
const [form, setForm] = useState(() => emptyForm(date));
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!isOpen) return;
setForm(entry
? {
date: entry.date,
name: entry.name,
unit: entry.unit,
quantity: String(entry.quantity),
caloriesPer100Units: entry.caloriesPer100Units != null ? String(entry.caloriesPer100Units) : '',
calories: entry.calories != null ? String(entry.calories) : '',
note: entry.note ?? '',
}
: emptyForm(date));
}, [isOpen, entry, date]);
const update = (changes: Partial<typeof form>) => setForm(current => ({ ...current, ...changes }));
/** Výběr z nabídky předvyplní jednotku i orientační sazbu. */
const applyPreset = (name: string) => {
const preset = ACTIVITY_PRESETS.find(item => item.name === name);
if (!preset) {
update({ name });
return;
}
update({
name: preset.name,
unit: preset.unit,
caloriesPer100Units: String(preset.caloriesPer100Units),
});
};
const quantity = parseIntInput(form.quantity);
const caloriesPer100Units = parseIntInput(form.caloriesPer100Units);
const derivedCalories = deriveActivityCalories(quantity, caloriesPer100Units);
const manualCalories = parseIntInput(form.calories);
const effectiveCalories = derivedCalories ?? manualCalories;
const canSave = form.name.trim().length > 0 && quantity != null && !saving;
const save = async () => {
if (!canSave) return;
setSaving(true);
const body = {
date: form.date,
name: form.name.trim(),
unit: form.unit,
quantity,
caloriesPer100Units,
// Server si kalorie dopočte sám, ručně zadané použije jen jako zálohu
calories: derivedCalories != null ? undefined : manualCalories,
note: form.note.trim() || undefined,
};
const response = entry
? await updateActivity({ body: { id: entry.id, ...body } })
: await addActivity({ body });
setSaving(false);
if (response.data) {
onSaved(response.data);
}
};
return (
<Modal show={isOpen} onHide={onClose} centered size="lg">
<Modal.Header closeButton>
<Modal.Title>{entry ? 'Úprava aktivity' : 'Nová aktivita'}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form onSubmit={event => { event.preventDefault(); save(); }}>
<div className="row g-3 mb-3">
<div className="col-sm-8">
<Form.Label>Aktivita</Form.Label>
<Form.Control
autoFocus
list="activity-presets"
value={form.name}
placeholder="Např. Chůze"
onChange={event => applyPreset(event.target.value)}
/>
<datalist id="activity-presets">
{ACTIVITY_PRESETS.map(preset => (
<option key={preset.name} value={preset.name} />
))}
</datalist>
</div>
<div className="col-sm-4">
<Form.Label>Datum</Form.Label>
<Form.Control
type="date"
value={form.date}
onChange={event => event.target.value && update({ date: event.target.value })}
/>
</div>
</div>
<fieldset className="meal-fieldset mb-3">
<legend>Množství a spotřeba</legend>
<div className="row g-3">
<div className="col-sm-3">
<Form.Label>Množství</Form.Label>
<Form.Control
inputMode="numeric"
value={form.quantity}
placeholder="10000"
onChange={event => update({ quantity: event.target.value.replace(/\D/g, '') })}
/>
</div>
<div className="col-sm-3">
<Form.Label>Jednotka</Form.Label>
<Form.Select
value={form.unit}
onChange={event => update({ unit: event.target.value as ActivityUnit })}
>
{ACTIVITY_UNITS_IN_ORDER.map(unit => (
<option key={unit} value={unit}>{ACTIVITY_UNIT_NAMES[unit]}</option>
))}
</Form.Select>
</div>
<div className="col-sm-3">
<Form.Label>Kcal na 100 jedn.</Form.Label>
<Form.Control
inputMode="numeric"
value={form.caloriesPer100Units}
placeholder="např. 4"
onChange={event => update({ caloriesPer100Units: event.target.value.replace(/\D/g, '') })}
/>
</div>
<div className="col-sm-3">
<Form.Label>Spáleno</Form.Label>
{derivedCalories != null ? (
<div className="derived-value">{formatCalories(derivedCalories)}</div>
) : (
<div className="input-group">
<Form.Control
inputMode="numeric"
value={form.calories}
placeholder="volitelné"
onChange={event => update({ calories: event.target.value.replace(/\D/g, '') })}
/>
<span className="input-group-text">kcal</span>
</div>
)}
</div>
</div>
</fieldset>
<Form.Group>
<Form.Label>Poznámka</Form.Label>
<Form.Control
as="textarea"
rows={2}
value={form.note}
onChange={event => update({ note: event.target.value })}
/>
</Form.Group>
</Form>
</Modal.Body>
<Modal.Footer>
<div className="meal-modal-summary">
{effectiveCalories != null && <span>{formatCalories(effectiveCalories)}</span>}
</div>
<Button variant="outline-secondary" onClick={onClose}>Zrušit</Button>
<Button onClick={save} disabled={!canSave}>
{saving ? 'Ukládám…' : 'Uložit'}
</Button>
</Modal.Footer>
</Modal>
);
}
@@ -0,0 +1,183 @@
import { useEffect, useState } from "react";
import { Alert, Button, Form, Modal, Spinner } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFileImport, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
import { ImportResult, MealType, importLuncher } from "../../../../types";
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
import { formatDateString, formatPrice, readFileAsBase64 } from "../../Utils";
type Props = {
isOpen: boolean,
onClose: () => void,
/** Zavolá se po dokončení importu, aby se přepočítaly statistiky. */
onImported: () => void,
};
/** Kolik záznamů náhledu se vypíše, než se zbytek shrne do jednoho řádku. */
const PREVIEW_LIMIT = 8;
/**
* Dialog pro import měsíčního přehledu ze stránky statistik Luncheru.
*
* Import běží dvoufázově — nejdřív náhled (dryRun), aby uživatel viděl, co se
* založí a kolik řádků už má, a teprve po potvrzení se data uloží.
*/
export default function ImportModal({ isOpen, onClose, onImported }: Readonly<Props>) {
const [file, setFile] = useState<File | undefined>();
const [mealType, setMealType] = useState<MealType>(MealType.OBED);
const [preview, setPreview] = useState<ImportResult | undefined>();
const [result, setResult] = useState<ImportResult | undefined>();
const [busy, setBusy] = useState(false);
// Každé otevření začíná načisto, aby nezůstal viset náhled z minula
useEffect(() => {
if (!isOpen) return;
setFile(undefined);
setPreview(undefined);
setResult(undefined);
setBusy(false);
}, [isOpen]);
/** Odešle soubor na server — buď jako náhled, nebo jako ostrý import. */
const send = async (dryRun: boolean) => {
if (!file) return;
setBusy(true);
try {
const content = await readFileAsBase64(file);
const response = await importLuncher({
body: { fileName: file.name, content, defaultMealType: mealType, dryRun },
});
if (!response.data) return;
if (dryRun) {
setPreview(response.data);
} else {
setResult(response.data);
setPreview(undefined);
onImported();
}
} finally {
setBusy(false);
}
};
const chooseFile = (chosen?: File) => {
setFile(chosen);
setPreview(undefined);
setResult(undefined);
};
return (
<Modal show={isOpen} onHide={onClose} centered size="lg">
<Modal.Header closeButton>
<Modal.Title>
<FontAwesomeIcon icon={faFileImport} /> Import z Luncheru
</Modal.Title>
</Modal.Header>
<Modal.Body>
<p className="text-body-secondary small">
Na stránce statistik v Luncheru si stáhněte přehled za měsíc (XLSX, CSV nebo JSON)
a nahrajte ho sem. Záznamy se založí do dnů, ke kterým patří. Opakovaný import
stejného měsíce data nezduplikuje.
</p>
<Form.Group className="mb-3">
<Form.Label>Soubor s přehledem</Form.Label>
<Form.Control
type="file"
accept=".xlsx,.csv,.json"
onChange={event => chooseFile((event.target as HTMLInputElement).files?.[0])}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Založit jako</Form.Label>
<Form.Select
value={mealType}
onChange={event => { setMealType(event.target.value as MealType); setPreview(undefined); }}
>
{MEAL_TYPES_IN_ORDER.map(type => (
<option key={type} value={type}>
{MEAL_TYPE_ICONS[type]} {MEAL_TYPE_NAMES[type]}
</option>
))}
</Form.Select>
<Form.Text>
Luncher řeší výběr obědů, proto je předvolený oběd. Jednotlivá jídla si pak
můžete v přehledu dne upravit nebo doplnit o další.
</Form.Text>
</Form.Group>
{busy && (
<div className="text-center py-3">
<Spinner animation="border" variant="success" />
</div>
)}
{preview && !busy && (
<Alert variant={preview.imported ? 'success' : 'secondary'}>
<strong>Náhled importu</strong>
<div className="small mt-1">
V souboru je {preview.rowCount} řádků. Založí se {preview.imported} záznamů
{preview.skipped > 0 && <> a {preview.skipped} se přeskočí, protože je máte</>}.
{preview.days.length > 0 && (
<> Dotčené dny: {formatDateString(preview.days[0])}
{preview.days.length > 1 && <> {formatDateString(preview.days[preview.days.length - 1])}</>}
{' '}({preview.days.length}).</>
)}
{' '}Celkem za {formatPrice(preview.totalPrice)}.
</div>
{preview.entries.length > 0 && (
<ul className="import-preview-list">
{preview.entries.slice(0, PREVIEW_LIMIT).map(entry => (
<li key={entry.id}>
<span className="import-preview-date">{formatDateString(entry.date)}</span>
{entry.name}
{entry.source && <span className="text-body-secondary"> · {entry.source}</span>}
<span className="import-preview-price">{formatPrice(entry.price)}</span>
</li>
))}
{preview.entries.length > PREVIEW_LIMIT && (
<li className="text-body-secondary">
a dalších {preview.entries.length - PREVIEW_LIMIT}
</li>
)}
</ul>
)}
</Alert>
)}
{result && !busy && (
<Alert variant="success">
<strong>Import dokončen</strong>
<div className="small mt-1">
Založeno {result.imported} záznamů ve {result.days.length} dnech
{result.skipped > 0 && <>, {result.skipped} přeskočeno jako již existující</>}.
Celkem za {formatPrice(result.totalPrice)}.
</div>
</Alert>
)}
{(preview ?? result)?.warnings.map(warning => (
<div className="import-warning" key={warning}>
<FontAwesomeIcon icon={faTriangleExclamation} /> {warning}
</div>
))}
</Modal.Body>
<Modal.Footer>
<Button variant="outline-secondary" onClick={onClose}>
{result ? 'Zavřít' : 'Zrušit'}
</Button>
{!result && !preview && (
<Button onClick={() => send(true)} disabled={!file || busy}>
Zobrazit náhled
</Button>
)}
{!result && preview && (
<Button onClick={() => send(false)} disabled={busy || preview.imported === 0}>
Naimportovat {preview.imported} záznamů
</Button>
)}
</Modal.Footer>
</Modal>
);
}
+309
View File
@@ -0,0 +1,309 @@
import { useEffect, useMemo, useState } from "react";
import { Button, Form, Modal } from "react-bootstrap";
import { DayRecord, MealEntry, MealType, UserSettings, addMeal, getSettings, updateMeal } from "../../../../types";
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
import {
deriveAmounts, formatCalories, formatPriceInput, formatWeight, parseIntInput, parsePriceInput,
} from "../../Utils";
import CalorieLookup from "../CalorieLookup";
type Props = {
isOpen: boolean,
/** Den, do kterého se zakládá nové jídlo. */
date: string,
/** Upravovaný záznam. Pokud chybí, jde o založení nového. */
entry?: MealEntry,
onClose: () => void,
onSaved: (day: DayRecord) => void,
};
/** Prázdný formulář pro nové jídlo v daném dni. */
function emptyForm(date: string) {
return {
date,
mealType: MealType.OBED as MealType,
name: '',
source: '',
price: '',
pricePer100g: '',
weight: '',
caloriesPer100g: '',
calories: '',
note: '',
};
}
/** Znormalizuje název zdroje pro porovnání se sazbami z nastavení. */
function normalizeSource(source: string): string {
return source.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim();
}
/**
* Dialog pro přidání nebo úpravu jídla.
*
* Kromě ceny umí i gramáž — u podniků účtujících podle váhy (TechTower, 44 Kč/100 g)
* se dopočte z ceny a sazby, ale dá se přepsat. Z gramáže a energie na 100 g se
* pak dopočtou kalorie. Každá položka má vlastní sazbu, takže hlavní jídlo a salát
* s jiným cenováním můžou být ve stejném dni vedle sebe.
*/
export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Readonly<Props>) {
const [form, setForm] = useState(() => emptyForm(date));
const [settings, setSettings] = useState<UserSettings | undefined>();
const [saving, setSaving] = useState(false);
// Při každém otevření se formulář naplní dle toho, co se upravuje
useEffect(() => {
if (!isOpen) return;
setForm(entry
? {
date: entry.date,
mealType: entry.mealType,
name: entry.name,
source: entry.source ?? '',
price: formatPriceInput(entry.price),
pricePer100g: formatPriceInput(entry.pricePer100g),
weight: entry.weight != null ? String(entry.weight) : '',
caloriesPer100g: entry.caloriesPer100g != null ? String(entry.caloriesPer100g) : '',
calories: entry.calories != null ? String(entry.calories) : '',
note: entry.note ?? '',
}
: emptyForm(date));
}, [isOpen, entry, date]);
// Sazby podniků se načtou jednou při otevření a slouží k předvyplnění Kč/100 g
useEffect(() => {
if (!isOpen || settings) return;
getSettings().then(response => setSettings(response.data));
}, [isOpen, settings]);
const update = (changes: Partial<typeof form>) => setForm(current => ({ ...current, ...changes }));
/** Sazba nastavená pro právě zadaný zdroj, pokud nějaká je. */
const rateForSource = useMemo(() => {
const key = normalizeSource(form.source);
if (!key.length) return undefined;
return settings?.sourceRates.find(rate => normalizeSource(rate.source) === key)?.pricePer100g;
}, [settings, form.source]);
/**
* Doplní sazbu podle zdroje, jakmile ji uživatel vybere a pole je prázdné.
* Ručně zadanou sazbu nepřepisujeme — u salátu bývá jiná než u hlavního jídla.
*/
useEffect(() => {
if (rateForSource && !form.pricePer100g.trim().length) {
setForm(current => ({ ...current, pricePer100g: formatPriceInput(rateForSource) }));
}
}, [rateForSource, form.pricePer100g]);
const price = parsePriceInput(form.price);
const pricePer100g = parsePriceInput(form.pricePer100g);
const manualWeight = parseIntInput(form.weight);
const caloriesPer100g = parseIntInput(form.caloriesPer100g);
const derived = deriveAmounts(price, pricePer100g, manualWeight, caloriesPer100g);
/** Kalorie zadané ručně se použijí jen tehdy, když nejdou dopočítat. */
const manualCalories = parseIntInput(form.calories);
const effectiveCalories = derived.calories ?? manualCalories;
const canSave = form.name.trim().length > 0 && !saving;
const save = async () => {
if (!canSave) return;
setSaving(true);
const body = {
date: form.date,
mealType: form.mealType,
name: form.name.trim(),
source: form.source.trim() || undefined,
price,
pricePer100g,
weight: manualWeight,
caloriesPer100g,
// Server si kalorie dopočte sám, ručně poslané použije jen jako zálohu
calories: derived.calories != null ? undefined : manualCalories,
note: form.note.trim() || undefined,
};
const response = entry
? await updateMeal({ body: { id: entry.id, ...body } })
: await addMeal({ body });
setSaving(false);
if (response.data) {
onSaved(response.data);
}
};
return (
<Modal show={isOpen} onHide={onClose} centered size="lg">
<Modal.Header closeButton>
<Modal.Title>{entry ? 'Úprava jídla' : 'Nové jídlo'}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form onSubmit={event => { event.preventDefault(); save(); }}>
<Form.Group className="mb-3">
<Form.Label>Jídlo</Form.Label>
<Form.Control
autoFocus
value={form.name}
placeholder="Např. Kuřecí gyros s hranolkami"
onChange={event => update({ name: event.target.value })}
/>
</Form.Group>
<div className="row g-3 mb-3">
<div className="col-sm-4">
<Form.Label>Chod</Form.Label>
<Form.Select
value={form.mealType}
onChange={event => update({ mealType: event.target.value as MealType })}
>
{MEAL_TYPES_IN_ORDER.map(mealType => (
<option key={mealType} value={mealType}>
{MEAL_TYPE_ICONS[mealType]} {MEAL_TYPE_NAMES[mealType]}
</option>
))}
</Form.Select>
</div>
<div className="col-sm-4">
<Form.Label>Datum</Form.Label>
<Form.Control
type="date"
value={form.date}
onChange={event => event.target.value && update({ date: event.target.value })}
/>
</div>
<div className="col-sm-4">
<Form.Label>Zdroj</Form.Label>
<Form.Control
list="known-sources"
value={form.source}
placeholder="TechTower, doma…"
onChange={event => update({ source: event.target.value })}
/>
<datalist id="known-sources">
{settings?.sourceRates.map(rate => (
<option key={rate.source} value={rate.source} />
))}
</datalist>
</div>
</div>
<fieldset className="meal-fieldset mb-3">
<legend>Cena a gramáž</legend>
<div className="row g-3">
<div className="col-sm-4">
<Form.Label>Cena</Form.Label>
<div className="input-group">
<Form.Control
inputMode="decimal"
value={form.price}
placeholder="0,00"
onChange={event => update({ price: event.target.value })}
/>
<span className="input-group-text"></span>
</div>
</div>
<div className="col-sm-4">
<Form.Label>Cena za 100 g</Form.Label>
<div className="input-group">
<Form.Control
inputMode="decimal"
value={form.pricePer100g}
placeholder="např. 44,00"
onChange={event => update({ pricePer100g: event.target.value })}
/>
<span className="input-group-text"></span>
</div>
{rateForSource != null && (
<Form.Text>sazba zdroje {form.source.trim()}</Form.Text>
)}
</div>
<div className="col-sm-4">
<Form.Label>Gramáž</Form.Label>
<div className="input-group">
<Form.Control
inputMode="numeric"
value={form.weight}
placeholder={derived.weight != null ? String(derived.weight) : 'volitelné'}
onChange={event => update({ weight: event.target.value.replace(/\D/g, '') })}
/>
<span className="input-group-text">g</span>
</div>
{manualWeight == null && derived.weight != null && (
<Form.Text>dopočteno z ceny</Form.Text>
)}
{manualWeight != null && pricePer100g != null && (
<Form.Text>zadáno ručně, cena se nemění</Form.Text>
)}
</div>
</div>
</fieldset>
<fieldset className="meal-fieldset mb-3">
<legend>Kalorie</legend>
<div className="row g-3">
<div className="col-sm-4">
<Form.Label>Energie na 100 g</Form.Label>
<div className="input-group">
<Form.Control
inputMode="numeric"
value={form.caloriesPer100g}
placeholder="např. 172"
onChange={event => update({ caloriesPer100g: event.target.value.replace(/\D/g, '') })}
/>
<span className="input-group-text">kcal</span>
</div>
</div>
<div className="col-sm-4">
<Form.Label>Kalorie celkem</Form.Label>
{derived.calories != null ? (
<div className="derived-value">{formatCalories(derived.calories)}</div>
) : (
<div className="input-group">
<Form.Control
inputMode="numeric"
value={form.calories}
placeholder="volitelné"
onChange={event => update({ calories: event.target.value.replace(/\D/g, '') })}
/>
<span className="input-group-text">kcal</span>
</div>
)}
{derived.calories != null && (
<Form.Text>
{formatWeight(derived.weight)} × {caloriesPer100g} kcal/100 g
</Form.Text>
)}
</div>
<div className="col-sm-4 d-flex align-items-end">
<CalorieLookup
name={form.name}
onPick={value => update({ caloriesPer100g: String(value) })}
/>
</div>
</div>
</fieldset>
<Form.Group>
<Form.Label>Poznámka</Form.Label>
<Form.Control
as="textarea"
rows={2}
value={form.note}
onChange={event => update({ note: event.target.value })}
/>
</Form.Group>
</Form>
</Modal.Body>
<Modal.Footer>
<div className="meal-modal-summary">
{derived.weight != null && <span>{formatWeight(derived.weight)}</span>}
{effectiveCalories != null && <span>{formatCalories(effectiveCalories)}</span>}
</div>
<Button variant="outline-secondary" onClick={onClose}>Zrušit</Button>
<Button onClick={save} disabled={!canSave}>
{saving ? 'Ukládám…' : 'Uložit'}
</Button>
</Modal.Footer>
</Modal>
);
}
@@ -0,0 +1,144 @@
import { useEffect, useState } from "react";
import { Button, Form, Modal } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrash, faPlus } from "@fortawesome/free-solid-svg-icons";
import { UserSettings, getSettings, saveBasalCalories, saveSourceRate } from "../../../../types";
import { formatPriceInput, parseIntInput, parsePriceInput } from "../../Utils";
type Props = {
isOpen: boolean,
onClose: () => void,
};
/**
* Nastavení sazeb za 100 g u podniků, které účtují podle váhy.
*
* Sazba slouží jako výchozí hodnota při zadávání jídla — u konkrétní položky
* ji jde přepsat, protože salát bývá účtovaný jinak než hlavní jídlo.
*/
export default function SettingsModal({ isOpen, onClose }: Readonly<Props>) {
const [settings, setSettings] = useState<UserSettings | undefined>();
const [newSource, setNewSource] = useState('');
const [newRate, setNewRate] = useState('');
const [basal, setBasal] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!isOpen) return;
setNewSource('');
setNewRate('');
getSettings().then(response => {
setSettings(response.data);
setBasal(response.data?.basalCalories != null ? String(response.data.basalCalories) : '');
});
}, [isOpen]);
const save = async (source: string, pricePer100g: number | null) => {
setSaving(true);
const response = await saveSourceRate({ body: { source, pricePer100g } });
setSaving(false);
if (response.data) {
setSettings(response.data);
}
};
/** Uloží klidový výdej. Prázdné pole ho odstraní. */
const saveBasal = async () => {
setSaving(true);
const response = await saveBasalCalories({ body: { basalCalories: parseIntInput(basal) ?? null } });
setSaving(false);
if (response.data) {
setSettings(response.data);
}
};
const addRate = async () => {
const rate = parsePriceInput(newRate);
if (!newSource.trim().length || !rate) return;
await save(newSource.trim(), rate);
setNewSource('');
setNewRate('');
};
return (
<Modal show={isOpen} onHide={onClose} centered>
<Modal.Header closeButton>
<Modal.Title>Nastavení</Modal.Title>
</Modal.Header>
<Modal.Body>
<h3 className="settings-section-title">Klidový výdej</h3>
<p className="text-body-secondary small">
Kolik kcal spálíte za den bez pohybu (bazální metabolismus). Bez něj
porovnává bilance dne jen jídlo proti pohybu a nejde o skutečný deficit.
</p>
<div className="rate-form mb-4">
<div className="input-group">
<Form.Control
inputMode="numeric"
value={basal}
placeholder="např. 1600"
onChange={event => setBasal(event.target.value.replace(/\D/g, ''))}
/>
<span className="input-group-text">kcal / den</span>
</div>
<Button onClick={saveBasal} disabled={saving}>Uložit</Button>
</div>
<h3 className="settings-section-title">Cena za 100 g</h3>
<p className="text-body-secondary small">
U podniků, které účtují podle váhy, se z ceny jídla a této sazby dopočítá
gramáž a z kalorie. U konkrétního jídla jde sazbu vždy přepsat.
</p>
{settings?.sourceRates.length === 0 && (
<p className="text-body-secondary small fst-italic">Zatím nemáte žádnou sazbu.</p>
)}
{settings?.sourceRates.map(rate => (
<div className="rate-row" key={rate.source}>
<span className="rate-source">{rate.source}</span>
<span className="rate-value">{formatPriceInput(rate.pricePer100g)} / 100 g</span>
<button
className="btn-icon danger"
onClick={() => save(rate.source, null)}
disabled={saving}
title="Odebrat sazbu"
aria-label={`Odebrat sazbu ${rate.source}`}
>
<FontAwesomeIcon icon={faTrash} />
</button>
</div>
))}
<Form
className="rate-form"
onSubmit={event => { event.preventDefault(); addRate(); }}
>
<Form.Control
value={newSource}
placeholder="Podnik, např. TechTower"
onChange={event => setNewSource(event.target.value)}
/>
<div className="input-group rate-form-price">
<Form.Control
inputMode="decimal"
value={newRate}
placeholder="44,00"
onChange={event => setNewRate(event.target.value)}
/>
<span className="input-group-text"></span>
</div>
<Button
onClick={addRate}
disabled={saving || !newSource.trim().length || !parsePriceInput(newRate)}
>
<FontAwesomeIcon icon={faPlus} />
</Button>
</Form>
</Modal.Body>
<Modal.Footer>
<Button variant="outline-secondary" onClick={onClose}>Zavřít</Button>
</Modal.Footer>
</Modal>
);
}
@@ -0,0 +1,257 @@
import { useEffect, useState } from "react";
import { Button, Form, Modal } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrash, faPlus, faPlay, faPenToSquare } from "@fortawesome/free-solid-svg-icons";
import {
ActivityUnit, DayOverview, WorkoutTemplate, WorkoutTemplateItem,
applyWorkoutTemplate, deleteWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate,
} from "../../../../types";
import { ACTIVITY_PRESETS, ACTIVITY_UNITS_IN_ORDER, ACTIVITY_UNIT_NAMES, ACTIVITY_UNIT_SHORT } from "../../enums";
import { deriveActivityCalories, formatCalories, formatQuantity, parseIntInput } from "../../Utils";
type Props = {
isOpen: boolean,
/** Den, do kterého se šablona použije. */
date: string,
onClose: () => void,
/** Zavolá se po použití šablony s aktualizovaným přehledem dne. */
onApplied: (day: DayOverview) => void,
};
/** Rozpracovaná položka šablony — hodnoty jsou texty, protože se editují v polích. */
type DraftItem = {
name: string,
unit: ActivityUnit,
quantity: string,
caloriesPer100Units: string,
};
function emptyItem(): DraftItem {
return { name: '', unit: ActivityUnit.OPAKOVANI, quantity: '', caloriesPer100Units: '' };
}
/**
* Správa šablon tréninků a jejich použití na den.
*
* Šablona je seznam cviků, který se jedním kliknutím založí do dne — opakovaný
* trénink tak není potřeba zadávat cvik po cviku znovu. Založené položky jsou
* pak samostatné aktivity, takže úprava jedné z nich šablonu nemění.
*/
export default function WorkoutModal({ isOpen, date, onClose, onApplied }: Readonly<Props>) {
const [templates, setTemplates] = useState<WorkoutTemplate[]>([]);
const [editedId, setEditedId] = useState<string | undefined>();
const [name, setName] = useState('');
const [items, setItems] = useState<DraftItem[]>([emptyItem()]);
const [busy, setBusy] = useState(false);
const resetForm = () => {
setEditedId(undefined);
setName('');
setItems([emptyItem()]);
};
useEffect(() => {
if (!isOpen) return;
resetForm();
getWorkoutTemplates().then(response => setTemplates(response.data ?? []));
}, [isOpen]);
const updateItem = (index: number, changes: Partial<DraftItem>) =>
setItems(current => current.map((item, i) => (i === index ? { ...item, ...changes } : item)));
/** Výběr známého cviku předvyplní jednotku i orientační sazbu. */
const applyPreset = (index: number, value: string) => {
const preset = ACTIVITY_PRESETS.find(item => item.name === value);
updateItem(index, preset
? { name: preset.name, unit: preset.unit, caloriesPer100Units: String(preset.caloriesPer100Units) }
: { name: value });
};
/** Převede rozpracované položky na ty, které jdou uložit. */
const toItems = (): WorkoutTemplateItem[] => items
.filter(item => item.name.trim().length && parseIntInput(item.quantity) != null)
.map(item => ({
name: item.name.trim(),
unit: item.unit,
quantity: parseIntInput(item.quantity)!,
...(parseIntInput(item.caloriesPer100Units) != null
? { caloriesPer100Units: parseIntInput(item.caloriesPer100Units) }
: {}),
}));
const canSave = name.trim().length > 0 && toItems().length > 0 && !busy;
const save = async () => {
if (!canSave) return;
setBusy(true);
const response = await saveWorkoutTemplate({ body: { id: editedId, name: name.trim(), items: toItems() } });
setBusy(false);
if (response.data) {
setTemplates(response.data);
resetForm();
}
};
const remove = async (id: string) => {
setBusy(true);
const response = await deleteWorkoutTemplate({ body: { id } });
setBusy(false);
if (response.data) {
setTemplates(response.data);
if (editedId === id) resetForm();
}
};
const apply = async (id: string) => {
setBusy(true);
const response = await applyWorkoutTemplate({ body: { id, date } });
setBusy(false);
if (response.data) {
onApplied(response.data);
}
};
/** Načte šablonu do formuláře k úpravě. */
const edit = (template: WorkoutTemplate) => {
setEditedId(template.id);
setName(template.name);
setItems(template.items.map(item => ({
name: item.name,
unit: item.unit,
quantity: String(item.quantity),
caloriesPer100Units: item.caloriesPer100Units != null ? String(item.caloriesPer100Units) : '',
})));
};
/** Průběžný odhad kalorií rozpracované šablony. */
const draftCalories = items.reduce((sum, item) =>
sum + (deriveActivityCalories(parseIntInput(item.quantity), parseIntInput(item.caloriesPer100Units)) ?? 0), 0);
return (
<Modal show={isOpen} onHide={onClose} centered size="lg">
<Modal.Header closeButton>
<Modal.Title>Šablony tréninků</Modal.Title>
</Modal.Header>
<Modal.Body>
{templates.length === 0 && (
<p className="text-body-secondary small fst-italic">
Zatím nemáte žádnou šablonu. Sestavte si ji níže příště pak stačí jedno kliknutí.
</p>
)}
{templates.map(template => (
<div className="workout-row" key={template.id}>
<div className="workout-info">
<div className="workout-name">{template.name}</div>
<div className="workout-items">
{template.items.map(item =>
`${item.name} ${formatQuantity(item.quantity, ACTIVITY_UNIT_SHORT[item.unit])}`
).join(' · ')}
</div>
</div>
<span className="workout-calories">{formatCalories(template.estimatedCalories)}</span>
<Button size="sm" onClick={() => apply(template.id)} disabled={busy} title="Použít na tento den">
<FontAwesomeIcon icon={faPlay} /> Použít
</Button>
<button className="btn-icon" onClick={() => edit(template)} title="Upravit" aria-label="Upravit šablonu">
<FontAwesomeIcon icon={faPenToSquare} />
</button>
<button
className="btn-icon danger"
onClick={() => remove(template.id)}
disabled={busy}
title="Smazat"
aria-label="Smazat šablonu"
>
<FontAwesomeIcon icon={faTrash} />
</button>
</div>
))}
<fieldset className="meal-fieldset mt-3">
<legend>{editedId ? 'Úprava šablony' : 'Nová šablona'}</legend>
<Form.Group className="mb-3">
<Form.Label>Název</Form.Label>
<Form.Control
value={name}
placeholder="Např. Workout day 1"
onChange={event => setName(event.target.value)}
/>
</Form.Group>
{items.map((item, index) => (
// Položky nemají stabilní id, dokud se šablona neuloží — index je tu jediný klíč
<div className="workout-item-row" key={index}>
<Form.Control
list="activity-presets-workout"
value={item.name}
placeholder="Cvik"
onChange={event => applyPreset(index, event.target.value)}
/>
<Form.Control
className="workout-item-quantity"
inputMode="numeric"
value={item.quantity}
placeholder="60"
onChange={event => updateItem(index, { quantity: event.target.value.replace(/\D/g, '') })}
/>
<Form.Select
className="workout-item-unit"
value={item.unit}
onChange={event => updateItem(index, { unit: event.target.value as ActivityUnit })}
>
{ACTIVITY_UNITS_IN_ORDER.map(unit => (
<option key={unit} value={unit}>{ACTIVITY_UNIT_NAMES[unit]}</option>
))}
</Form.Select>
<Form.Control
className="workout-item-rate"
inputMode="numeric"
value={item.caloriesPer100Units}
placeholder="kcal/100"
onChange={event => updateItem(index, { caloriesPer100Units: event.target.value.replace(/\D/g, '') })}
/>
<button
className="btn-icon danger"
onClick={() => setItems(current => current.filter((_, i) => i !== index))}
disabled={items.length === 1}
title="Odebrat položku"
aria-label="Odebrat položku"
>
<FontAwesomeIcon icon={faTrash} />
</button>
</div>
))}
<datalist id="activity-presets-workout">
{ACTIVITY_PRESETS.map(preset => (
<option key={preset.name} value={preset.name} />
))}
</datalist>
<div className="workout-form-footer">
<Button
variant="outline-secondary"
size="sm"
onClick={() => setItems(current => [...current, emptyItem()])}
>
<FontAwesomeIcon icon={faPlus} /> Další cvik
</Button>
{draftCalories > 0 && (
<span className="workout-draft-calories">celkem {formatCalories(draftCalories)}</span>
)}
{editedId && (
<Button variant="outline-secondary" size="sm" onClick={resetForm}>Zrušit úpravu</Button>
)}
<Button size="sm" onClick={save} disabled={!canSave}>
{editedId ? 'Uložit změny' : 'Vytvořit šablonu'}
</Button>
</div>
</fieldset>
</Modal.Body>
<Modal.Footer>
<Button variant="outline-secondary" onClick={onClose}>Zavřít</Button>
</Modal.Footer>
</Modal>
);
}
+68
View File
@@ -0,0 +1,68 @@
import React, { ReactNode, useContext, useEffect, useState } from "react";
import { useJwt } from "react-jwt";
import { deleteToken, getToken, storeToken } from "../Utils";
export type AuthContextProps = {
login?: string,
trusted?: boolean,
setToken: (token: string) => void,
logout: () => void,
}
type ContextProps = {
children: ReactNode
}
const authContext = React.createContext<AuthContextProps | null>(null);
export function ProvideAuth(props: Readonly<ContextProps>) {
const auth = useProvideAuth();
return <authContext.Provider value={auth}>{props.children}</authContext.Provider>
}
export const useAuth = () => {
return useContext(authContext);
}
function useProvideAuth(): AuthContextProps {
const [loginName, setLoginName] = useState<string | undefined>();
const [trusted, setTrusted] = useState<boolean | undefined>();
const [token, setToken] = useState<string | undefined>(getToken());
const { decodedToken } = useJwt(token ?? '');
useEffect(() => {
if (token && token.length > 0) {
storeToken(token);
} else {
deleteToken();
}
}, [token]);
useEffect(() => {
if (decodedToken) {
setLoginName((decodedToken as any).login);
setTrusted((decodedToken as any).trusted);
} else {
setLoginName(undefined);
setTrusted(undefined);
}
}, [decodedToken]);
function logout() {
const isTrusted = (decodedToken as any)?.trusted;
const logoutUrl = (decodedToken as any)?.logoutUrl;
setToken(undefined);
setLoginName(undefined);
setTrusted(undefined);
if (isTrusted && logoutUrl?.length) {
globalThis.location.replace(logoutUrl);
}
}
return {
login: loginName,
trusted,
setToken,
logout,
}
}
+60
View File
@@ -0,0 +1,60 @@
import React, { ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
/** Volba motivu — buď pevná, nebo řízená nastavením operačního systému. */
export type ThemePreference = 'light' | 'dark' | 'system';
const THEME_KEY = 'themePreference';
export type SettingsContextProps = {
themePreference: ThemePreference,
/** Skutečně použitý motiv po vyhodnocení volby 'system'. */
effectiveDark: boolean,
setThemePreference: (preference: ThemePreference) => void,
};
const settingsContext = React.createContext<SettingsContextProps | null>(null);
export const useSettings = () => useContext(settingsContext);
/** Vrátí uloženou volbu motivu, nebo 'system', pokud uživatel nic nezvolil. */
function getStoredPreference(): ThemePreference {
const stored = localStorage.getItem(THEME_KEY);
return stored === 'light' || stored === 'dark' ? stored : 'system';
}
export function ProvideSettings(props: Readonly<{ children: ReactNode }>) {
const [themePreference, setStoredPreference] = useState<ThemePreference>(getStoredPreference);
const [systemDark, setSystemDark] = useState(() =>
globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false);
// Při volbě 'system' musí přepnutí motivu v OS překreslit i běžící aplikaci
useEffect(() => {
const query = globalThis.matchMedia?.('(prefers-color-scheme: dark)');
if (!query) return;
const listener = (event: MediaQueryListEvent) => setSystemDark(event.matches);
query.addEventListener('change', listener);
return () => query.removeEventListener('change', listener);
}, []);
const effectiveDark = themePreference === 'system' ? systemDark : themePreference === 'dark';
// Bootstrap i vlastní proměnné se řídí atributem na <html>
useEffect(() => {
document.documentElement.setAttribute('data-bs-theme', effectiveDark ? 'dark' : 'light');
}, [effectiveDark]);
const setThemePreference = useCallback((preference: ThemePreference) => {
setStoredPreference(preference);
if (preference === 'system') {
localStorage.removeItem(THEME_KEY);
} else {
localStorage.setItem(THEME_KEY, preference);
}
}, []);
const value = useMemo(
() => ({ themePreference, effectiveDark, setThemePreference }),
[themePreference, effectiveDark, setThemePreference]);
return <settingsContext.Provider value={value}>{props.children}</settingsContext.Provider>;
}
+73
View File
@@ -0,0 +1,73 @@
import { ActivityUnit, MealType } from "../../types";
/** Lidsky čitelné názvy typů jídel. */
export const MEAL_TYPE_NAMES: Record<MealType, string> = {
SNIDANE: 'Snídaně',
DOPOLEDNI_SVACINA: 'Dopolední svačina',
OBED: 'Oběd',
ODPOLEDNI_SVACINA: 'Odpolední svačina',
VECERE: 'Večeře',
JINE: 'Jiné',
};
/** Emoji doprovázející typ jídla — odlišuje chody v seznamu na první pohled. */
export const MEAL_TYPE_ICONS: Record<MealType, string> = {
SNIDANE: '🥐',
DOPOLEDNI_SVACINA: '🍎',
OBED: '🍲',
ODPOLEDNI_SVACINA: '🥨',
VECERE: '🍽',
JINE: '🍫',
};
/** Typy jídel v pořadí, v jakém probíhá den. Určuje i pořadí v seznamech a výběrech. */
export const MEAL_TYPES_IN_ORDER: MealType[] = [
MealType.SNIDANE,
MealType.DOPOLEDNI_SVACINA,
MealType.OBED,
MealType.ODPOLEDNI_SVACINA,
MealType.VECERE,
MealType.JINE,
];
/** Lidsky čitelné názvy jednotek aktivit. */
export const ACTIVITY_UNIT_NAMES: Record<ActivityUnit, string> = {
KROKY: 'kroky',
MINUTY: 'minuty',
KM: 'km',
OPAKOVANI: 'opakování',
};
/** Zkratka jednotky za číslo, např. "10 000 kroků". */
export const ACTIVITY_UNIT_SHORT: Record<ActivityUnit, string> = {
KROKY: 'kroků',
MINUTY: 'min',
KM: 'km',
OPAKOVANI: '×',
};
/** Jednotky v pořadí, v jakém se nabízejí. */
export const ACTIVITY_UNITS_IN_ORDER: ActivityUnit[] = [
ActivityUnit.KROKY,
ActivityUnit.MINUTY,
ActivityUnit.KM,
ActivityUnit.OPAKOVANI,
];
/**
* Orientační spotřeba běžných aktivit v kcal na 100 jednotek.
*
* Hodnoty jsou hrubý odhad pro člověka kolem 75 kg a slouží jen jako
* předvyplnění — uživatel si je může kdykoli přepsat.
*/
export const ACTIVITY_PRESETS: { name: string, unit: ActivityUnit, caloriesPer100Units: number }[] = [
{ name: 'Chůze', unit: ActivityUnit.KROKY, caloriesPer100Units: 4 },
{ name: 'Svižná chůze', unit: ActivityUnit.MINUTY, caloriesPer100Units: 500 },
{ name: 'Běh', unit: ActivityUnit.MINUTY, caloriesPer100Units: 1000 },
{ name: 'Kolo', unit: ActivityUnit.MINUTY, caloriesPer100Units: 700 },
{ name: 'Plavání', unit: ActivityUnit.MINUTY, caloriesPer100Units: 800 },
{ name: 'Posilovna', unit: ActivityUnit.MINUTY, caloriesPer100Units: 600 },
{ name: 'Kliky', unit: ActivityUnit.OPAKOVANI, caloriesPer100Units: 50 },
{ name: 'Dřepy', unit: ActivityUnit.OPAKOVANI, caloriesPer100Units: 40 },
{ name: 'Shyby', unit: ActivityUnit.OPAKOVANI, caloriesPer100Units: 100 },
];
+35
View File
@@ -0,0 +1,35 @@
html,
body,
#root {
width: 100%;
height: 100%;
}
body {
margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.5;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
html {
scroll-behavior: smooth;
}
:focus-visible {
outline: 2px solid var(--ft-primary);
outline-offset: 2px;
}
::selection {
background: var(--ft-primary-light);
color: var(--ft-primary);
}
+40
View File
@@ -0,0 +1,40 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import { toast } from 'react-toastify';
import { ProvideAuth } from './context/auth';
import { ProvideSettings } from './context/settings';
import AppRoutes from './AppRoutes';
import { client } from '../../types/gen/client.gen';
import { getToken } from './Utils';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'react-toastify/dist/ReactToastify.css';
import './index.css';
import './App.scss';
client.setConfig({
auth: () => getToken(),
baseUrl: '/api', // openapi-ts si to neumí převzít z api.yml
});
// Interceptor na vyhození toasteru při chybě
client.interceptors.response.use(async response => {
// Login je výjimka — voláme ho "naprázdno", abychom zjistili, zda nás nepřihlásily trusted headers
if (!response.ok && !response.url.includes('/login')) {
const json = await response.clone().json().catch(() => ({}));
toast.error(json.error ?? 'Něco se nepovedlo', { theme: 'colored' });
}
return response;
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ProvideAuth>
<ProvideSettings>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</ProvideSettings>
</ProvideAuth>
</React.StrictMode>
);
+200
View File
@@ -0,0 +1,200 @@
.day-picker {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding: 12px 16px;
.day-picker-current {
flex: 1;
min-width: 0;
}
.day-picker-date {
font-weight: 700;
font-size: 1.05rem;
color: var(--ft-text);
letter-spacing: -0.01em;
}
.day-picker-weekday {
font-size: 0.82rem;
color: var(--ft-text-secondary);
text-transform: lowercase;
}
.day-picker-input {
width: auto;
flex: 0 0 auto;
}
@media (max-width: 576px) {
flex-wrap: wrap;
.day-picker-input {
order: 10;
width: 100%;
}
}
}
.meal-group {
margin-bottom: 20px;
.meal-group-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ft-text-secondary);
margin: 0 4px 8px;
}
.meal-group-icon {
font-size: 1rem;
}
.meal-group-total {
margin-left: auto;
color: var(--ft-text-muted);
font-weight: 600;
letter-spacing: 0;
text-transform: none;
}
}
.meal-list {
padding: 0;
overflow: hidden;
}
.meal-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--ft-border-light);
transition: var(--ft-transition);
&:last-child {
border-bottom: none;
}
&:hover {
background: var(--ft-bg-hover);
}
.meal-main {
flex: 1;
min-width: 0;
}
.meal-name {
font-weight: 600;
color: var(--ft-text);
word-break: break-word;
}
.meal-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 5px;
}
.meal-note {
margin-top: 5px;
font-size: 0.83rem;
color: var(--ft-text-muted);
word-break: break-word;
}
.meal-price {
font-weight: 700;
color: var(--ft-text);
white-space: nowrap;
padding-top: 1px;
}
.meal-actions {
display: flex;
gap: 2px;
flex: 0 0 auto;
}
}
.header-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
// ============================================
// ZÁLOŽKY PŘÍJEM / VÝDEJ
// ============================================
.day-tabs {
display: flex;
gap: 4px;
margin-bottom: 16px;
border-bottom: 1px solid var(--ft-border);
}
.day-tab {
display: flex;
align-items: center;
gap: 8px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
padding: 10px 18px;
margin-bottom: -1px;
font-weight: 600;
font-size: 0.92rem;
color: var(--ft-text-secondary);
transition: var(--ft-transition);
&:hover {
color: var(--ft-text);
}
&.active {
color: var(--ft-primary);
border-bottom-color: var(--ft-primary);
}
.day-tab-count {
background: var(--ft-bg-hover);
border-radius: 999px;
padding: 1px 8px;
font-size: 0.76rem;
font-weight: 700;
}
&.active .day-tab-count {
background: var(--ft-primary-light);
color: var(--ft-primary);
}
}
// ============================================
// BILANCE
// ============================================
.balance-tile {
&.deficit .summary-value {
color: var(--ft-success);
}
&.surplus .summary-value {
color: var(--ft-warning);
}
}
// Spálené kalorie se odlišují od ceny, aby v seznamu nesplývaly
.meal-price.burn {
color: var(--ft-warning);
}
+351
View File
@@ -0,0 +1,351 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Spinner } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faChevronLeft, faChevronRight, faPlus, faPenToSquare, faTrash,
faUtensils, faPersonRunning, faCoins, faFire, faScaleBalanced, faDumbbell,
} from "@fortawesome/free-solid-svg-icons";
import { ActivityEntry, DayOverview, MealEntry, deleteActivity, deleteMeal, getDayOverview } from "../../../types";
import {
ACTIVITY_UNIT_SHORT, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES,
} from "../enums";
import {
formatBalance, formatCalories, formatDate, formatPrice, formatQuantity, formatWeight,
getDayOfWeek, getHumanDate, shiftDate,
} from "../Utils";
import MealModal from "../components/modals/MealModal";
import ActivityModal from "../components/modals/ActivityModal";
import WorkoutModal from "../components/modals/WorkoutModal";
import "./DayPage.scss";
/** Záložky dne — co do těla přišlo a co se vydalo. */
type Tab = 'prijem' | 'vydej';
/** Prázdný přehled dne — použije se, než dorazí data ze serveru. */
function emptyOverview(date: string): DayOverview {
return {
date,
meals: { date, entries: [], totalPrice: 0, totalCalories: 0 },
activities: { date, entries: [], totalCalories: 0 },
energy: { intake: 0, activityBurn: 0, basal: 0, totalBurn: 0, balance: 0, hasBasal: false },
};
}
export default function DayPage() {
const [date, setDate] = useState<string>(() => formatDate(new Date()));
const [day, setDay] = useState<DayOverview>(() => emptyOverview(date));
const [tab, setTab] = useState<Tab>('prijem');
const [loading, setLoading] = useState(true);
const [editedMeal, setEditedMeal] = useState<MealEntry | undefined>();
const [mealModalOpen, setMealModalOpen] = useState(false);
const [editedActivity, setEditedActivity] = useState<ActivityEntry | undefined>();
const [activityModalOpen, setActivityModalOpen] = useState(false);
const [workoutModalOpen, setWorkoutModalOpen] = useState(false);
const loadDay = useCallback(async (forDate: string) => {
setLoading(true);
const response = await getDayOverview({ query: { date: forDate } });
setDay(response.data ?? emptyOverview(forDate));
setLoading(false);
}, []);
useEffect(() => { loadDay(date); }, [date, loadDay]);
const isToday = date === formatDate(new Date());
/** Jídla rozdělená do chodů — zobrazují se jen chody, které něco obsahují. */
const mealGroups = useMemo(() => MEAL_TYPES_IN_ORDER
.map(mealType => ({
mealType,
entries: day.meals.entries.filter(entry => entry.mealType === mealType),
}))
.filter(group => group.entries.length > 0), [day.meals.entries]);
const { energy } = day;
const isDeficit = energy.balance < 0;
const removeMeal = async (entry: MealEntry) => {
await deleteMeal({ body: { id: entry.id, date: entry.date } });
loadDay(date);
};
const removeActivity = async (entry: ActivityEntry) => {
const response = await deleteActivity({ body: { id: entry.id, date: entry.date } });
if (response.data) setDay(response.data);
};
return (
<>
<div className="page-header">
<h1>Můj den</h1>
{tab === 'prijem' ? (
<Button onClick={() => { setEditedMeal(undefined); setMealModalOpen(true); }}>
<FontAwesomeIcon icon={faPlus} /> Přidat jídlo
</Button>
) : (
<div className="header-actions">
<Button variant="outline-secondary" onClick={() => setWorkoutModalOpen(true)}>
<FontAwesomeIcon icon={faDumbbell} /> Šablony
</Button>
<Button onClick={() => { setEditedActivity(undefined); setActivityModalOpen(true); }}>
<FontAwesomeIcon icon={faPlus} /> Přidat aktivitu
</Button>
</div>
)}
</div>
<div className="day-picker card-panel">
<button
className="btn-icon"
onClick={() => setDate(shiftDate(date, -1))}
aria-label="Předchozí den"
title="Předchozí den"
>
<FontAwesomeIcon icon={faChevronLeft} />
</button>
<div className="day-picker-current">
<div className="day-picker-date">{getHumanDate(date)}</div>
<div className="day-picker-weekday">{isToday ? 'dnes' : getDayOfWeek(date)}</div>
</div>
<button
className="btn-icon"
onClick={() => setDate(shiftDate(date, 1))}
aria-label="Následující den"
title="Následující den"
>
<FontAwesomeIcon icon={faChevronRight} />
</button>
<input
type="date"
className="form-control day-picker-input"
value={date}
onChange={event => event.target.value && setDate(event.target.value)}
/>
{!isToday && (
<Button variant="outline-secondary" size="sm" onClick={() => setDate(formatDate(new Date()))}>
Dnes
</Button>
)}
</div>
<div className="summary-grid">
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faUtensils} /> Příjem</div>
<div className="summary-value">{formatCalories(energy.intake)}</div>
<div className="summary-hint">{formatPrice(day.meals.totalPrice)}</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faFire} /> Výdej</div>
<div className="summary-value">{formatCalories(energy.totalBurn)}</div>
<div className="summary-hint">
{energy.hasBasal
? `klid ${energy.basal} + pohyb ${energy.activityBurn} kcal`
: `pohyb ${energy.activityBurn} kcal`}
</div>
</div>
<div className={`summary-tile balance-tile ${isDeficit ? 'deficit' : 'surplus'}`}>
<div className="summary-label"><FontAwesomeIcon icon={faScaleBalanced} /> Bilance</div>
<div className="summary-value">{formatBalance(energy.balance)}</div>
<div className="summary-hint">
{energy.hasBasal
? (isDeficit ? 'jste v deficitu' : 'jste v přebytku')
: 'nastavte klidový výdej pro skutečný deficit'}
</div>
</div>
<div className="summary-tile accent">
<div className="summary-label"><FontAwesomeIcon icon={faCoins} /> Útrata</div>
<div className="summary-value">{formatPrice(day.meals.totalPrice)}</div>
</div>
</div>
<div className="day-tabs">
<button
className={`day-tab ${tab === 'prijem' ? 'active' : ''}`}
onClick={() => setTab('prijem')}
>
<FontAwesomeIcon icon={faUtensils} /> Příjem
<span className="day-tab-count">{day.meals.entries.length}</span>
</button>
<button
className={`day-tab ${tab === 'vydej' ? 'active' : ''}`}
onClick={() => setTab('vydej')}
>
<FontAwesomeIcon icon={faPersonRunning} /> Výdej
<span className="day-tab-count">{day.activities.entries.length}</span>
</button>
</div>
{loading && (
<div className="empty-state">
<Spinner animation="border" variant="success" />
</div>
)}
{!loading && tab === 'prijem' && (
<>
{!day.meals.entries.length && (
<div className="card-panel empty-state">
<div className="empty-icon"><FontAwesomeIcon icon={faUtensils} /></div>
<div className="empty-title">Za tento den zatím nic nemáte</div>
<p className="empty-description">
Přidejte jídlo ručně, nebo si ve statistikách naimportujte měsíční přehled z Luncheru.
</p>
</div>
)}
{mealGroups.map(group => (
<div className="meal-group" key={group.mealType}>
<h2 className="meal-group-title">
<span className="meal-group-icon">{MEAL_TYPE_ICONS[group.mealType]}</span>
{MEAL_TYPE_NAMES[group.mealType]}
<span className="meal-group-total">
{formatPrice(group.entries.reduce((sum, entry) => sum + (entry.price ?? 0), 0))}
</span>
</h2>
<div className="meal-list card-panel">
{group.entries.map(entry => (
<MealRow
key={entry.id}
entry={entry}
onEdit={() => { setEditedMeal(entry); setMealModalOpen(true); }}
onDelete={() => removeMeal(entry)}
/>
))}
</div>
</div>
))}
</>
)}
{!loading && tab === 'vydej' && (
<>
{!day.activities.entries.length && (
<div className="card-panel empty-state">
<div className="empty-icon"><FontAwesomeIcon icon={faPersonRunning} /></div>
<div className="empty-title">Za tento den nemáte žádný pohyb</div>
<p className="empty-description">
Přidejte aktivitu, nebo použijte šablonu tréninku a založí se celá naráz.
</p>
</div>
)}
{!!day.activities.entries.length && (
<div className="meal-list card-panel">
{day.activities.entries.map(entry => (
<ActivityRow
key={entry.id}
entry={entry}
onEdit={() => { setEditedActivity(entry); setActivityModalOpen(true); }}
onDelete={() => removeActivity(entry)}
/>
))}
</div>
)}
</>
)}
<MealModal
isOpen={mealModalOpen}
date={date}
entry={editedMeal}
onClose={() => setMealModalOpen(false)}
onSaved={saved => {
setMealModalOpen(false);
// Úprava mohla jídlo přesunout jinam — pak přepneme na jeho nový den
if (saved.date !== date) {
setDate(saved.date);
} else {
loadDay(date);
}
}}
/>
<ActivityModal
isOpen={activityModalOpen}
date={date}
entry={editedActivity}
onClose={() => setActivityModalOpen(false)}
onSaved={saved => {
setActivityModalOpen(false);
if (saved.date !== date) {
setDate(saved.date);
} else {
setDay(saved);
}
}}
/>
<WorkoutModal
isOpen={workoutModalOpen}
date={date}
onClose={() => setWorkoutModalOpen(false)}
onApplied={saved => {
setDay(saved);
setWorkoutModalOpen(false);
}}
/>
</>
);
}
type MealRowProps = {
entry: MealEntry,
onEdit: () => void,
onDelete: () => void,
};
/** Jeden řádek seznamu jídel. */
function MealRow({ entry, onEdit, onDelete }: Readonly<MealRowProps>) {
return (
<div className="meal-row">
<div className="meal-main">
<div className="meal-name">{entry.name}</div>
<div className="meal-meta">
{entry.source && <span className="tag">{entry.source}</span>}
{entry.importSource && <span className="tag tag-import">z Luncheru</span>}
{entry.weight != null && <span className="tag">{formatWeight(entry.weight)}</span>}
{entry.calories != null && <span className="tag">{formatCalories(entry.calories)}</span>}
</div>
{entry.note && <div className="meal-note">{entry.note}</div>}
</div>
<div className="meal-price">{formatPrice(entry.price)}</div>
<RowActions onEdit={onEdit} onDelete={onDelete} label="jídlo" />
</div>
);
}
type ActivityRowProps = {
entry: ActivityEntry,
onEdit: () => void,
onDelete: () => void,
};
/** Jeden řádek seznamu aktivit. */
function ActivityRow({ entry, onEdit, onDelete }: Readonly<ActivityRowProps>) {
return (
<div className="meal-row">
<div className="meal-main">
<div className="meal-name">{entry.name}</div>
<div className="meal-meta">
<span className="tag">{formatQuantity(entry.quantity, ACTIVITY_UNIT_SHORT[entry.unit])}</span>
{entry.templateId && <span className="tag tag-import">ze šablony</span>}
</div>
{entry.note && <div className="meal-note">{entry.note}</div>}
</div>
<div className="meal-price burn">{formatCalories(entry.calories)}</div>
<RowActions onEdit={onEdit} onDelete={onDelete} label="aktivitu" />
</div>
);
}
/** Tlačítka pro úpravu a smazání řádku. */
function RowActions({ onEdit, onDelete, label }: Readonly<{ onEdit: () => void, onDelete: () => void, label: string }>) {
return (
<div className="meal-actions">
<button className="btn-icon" onClick={onEdit} title="Upravit" aria-label={`Upravit ${label}`}>
<FontAwesomeIcon icon={faPenToSquare} />
</button>
<button className="btn-icon danger" onClick={onDelete} title="Smazat" aria-label={`Smazat ${label}`}>
<FontAwesomeIcon icon={faTrash} />
</button>
</div>
);
}
+142
View File
@@ -0,0 +1,142 @@
.range-picker {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 16px;
padding: 12px 16px;
.range-modes {
display: inline-flex;
background: var(--ft-bg-hover);
border-radius: var(--ft-radius-sm);
padding: 3px;
gap: 2px;
}
.range-mode {
background: transparent;
border: none;
border-radius: calc(var(--ft-radius-sm) - 2px);
padding: 5px 16px;
font-size: 0.87rem;
font-weight: 600;
color: var(--ft-text-secondary);
transition: var(--ft-transition);
&:hover {
color: var(--ft-text);
}
&.active {
background: var(--ft-bg-card);
color: var(--ft-primary);
box-shadow: var(--ft-shadow-sm);
}
}
.range-input {
width: auto;
min-width: 140px;
}
}
.chart-panel {
margin-bottom: 16px;
.chart-title {
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ft-text-secondary);
margin: 0 0 14px;
}
// Recharts kreslí popisky vlastními <text>, barvu jim musíme vnutit
.recharts-legend-item-text,
.recharts-cartesian-axis-tick-value {
fill: var(--ft-text-secondary) !important;
color: var(--ft-text-secondary) !important;
font-size: 0.8rem;
}
}
.chart-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 16px;
}
.source-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
th {
text-align: left;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--ft-text-muted);
padding-bottom: 8px;
border-bottom: 1px solid var(--ft-border);
}
td {
padding: 9px 0;
border-bottom: 1px solid var(--ft-border-light);
color: var(--ft-text);
}
tr:last-child td {
border-bottom: none;
}
.numeric {
text-align: right;
white-space: nowrap;
}
td.numeric {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
}
.import-preview-list {
list-style: none;
margin: 12px 0 0;
padding: 0;
font-size: 0.85rem;
li {
display: flex;
align-items: baseline;
gap: 8px;
padding: 4px 0;
border-top: 1px solid var(--ft-border-light);
}
.import-preview-date {
font-variant-numeric: tabular-nums;
color: var(--ft-text-secondary);
flex: 0 0 auto;
}
.import-preview-price {
margin-left: auto;
font-weight: 600;
white-space: nowrap;
}
}
.import-warning {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 0.82rem;
color: var(--ft-warning);
padding: 3px 0;
}
+255
View File
@@ -0,0 +1,255 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Spinner } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCoins, faFire, faListCheck, faCalendarDay, faFileImport, faChartColumn } from "@fortawesome/free-solid-svg-icons";
import {
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Legend,
} from "recharts";
import { FoodStats, MealType, getFoodStats } from "../../../types";
import { MEAL_TYPE_NAMES } from "../enums";
import {
formatCalories, formatDateString, formatMonth, formatPrice, formatPriceShort,
getHumanMonth, getMonthEnd, getMonthStart,
} from "../Utils";
import ImportModal from "../components/modals/ImportModal";
import "./StatsPage.scss";
/** Rozsahy, za které lze statistiky zobrazit. */
type RangeMode = 'month' | 'year' | 'all';
const RANGE_LABELS: Record<RangeMode, string> = {
month: 'Měsíc',
year: 'Rok',
all: 'Vše',
};
/** Nejstarší rok, do kterého sahá volba "Vše". Starší data nikdo evidovat nebude. */
const EARLIEST_YEAR = 2015;
/** Barvy výsečí grafu dle typu jídla — laděné do zelené palety aplikace. */
const PIE_COLORS = ['#16a34a', '#22c55e', '#4ade80', '#86efac', '#0ea5e9', '#a3a3a3'];
/** Vzhled bubliny s hodnotou — recharts ji kreslí inline stylem, ne přes CSS třídu. */
const TOOLTIP_STYLE = {
background: 'var(--ft-bg-card)',
border: '1px solid var(--ft-border)',
borderRadius: 'var(--ft-radius-sm)',
color: 'var(--ft-text)',
};
/** Naformátuje hodnotu v bublině grafu na koruny. Hodnoty chodí z rechartsu jako `unknown`. */
function formatChartPrice(value: unknown): string {
return `${Number(value).toLocaleString('cs-CZ')}`;
}
export default function StatsPage() {
const [mode, setMode] = useState<RangeMode>('month');
const [month, setMonth] = useState<string>(() => formatMonth(new Date()));
const [year, setYear] = useState<number>(() => new Date().getFullYear());
const [stats, setStats] = useState<FoodStats | undefined>();
const [loading, setLoading] = useState(true);
const [importOpen, setImportOpen] = useState(false);
/** Přepočte hranice období z aktuálně zvoleného rozsahu. */
const range = useMemo(() => {
if (mode === 'month') {
return { from: getMonthStart(month), to: getMonthEnd(month) };
}
if (mode === 'year') {
return { from: `${year}-01-01`, to: `${year}-12-31` };
}
return { from: `${EARLIEST_YEAR}-01-01`, to: `${new Date().getFullYear()}-12-31` };
}, [mode, month, year]);
const load = useCallback(async () => {
setLoading(true);
const response = await getFoodStats({ query: range });
setStats(response.data);
setLoading(false);
}, [range]);
useEffect(() => { load(); }, [load]);
/** Řada pro sloupcový graf — dny u měsíce, měsíce u roku, roky u celého přehledu. */
const series = useMemo(() => {
if (!stats) return [];
if (mode === 'month') {
return stats.byDay.map(total => ({
label: formatDateString(total.period).slice(0, 5),
price: total.price / 100,
calories: total.calories,
}));
}
if (mode === 'year') {
return stats.byMonth.map(total => ({
label: getHumanMonth(total.period).split(' ')[0],
price: total.price / 100,
calories: total.calories,
}));
}
return stats.byYear.map(total => ({
label: total.period,
price: total.price / 100,
calories: total.calories,
}));
}, [stats, mode]);
const pieData = useMemo(() => (stats?.byMealType ?? [])
.filter(total => total.price > 0)
.map(total => ({
name: MEAL_TYPE_NAMES[total.key as MealType] ?? total.key,
value: total.price / 100,
})), [stats]);
const averagePerDay = stats?.dayCount ? Math.round(stats.totalPrice / stats.dayCount) : 0;
return (
<>
<div className="page-header">
<div>
<h1>Statistiky</h1>
<p className="page-subtitle">Kolik jste utratili za jídlo a za co</p>
</div>
<Button onClick={() => setImportOpen(true)}>
<FontAwesomeIcon icon={faFileImport} /> Import z Luncheru
</Button>
</div>
<div className="range-picker card-panel">
<div className="range-modes">
{(Object.keys(RANGE_LABELS) as RangeMode[]).map(value => (
<button
key={value}
className={`range-mode ${mode === value ? 'active' : ''}`}
onClick={() => setMode(value)}
>
{RANGE_LABELS[value]}
</button>
))}
</div>
{mode === 'month' && (
<input
type="month"
className="form-control range-input"
value={month}
onChange={event => event.target.value && setMonth(event.target.value)}
/>
)}
{mode === 'year' && (
<input
type="number"
className="form-control range-input"
value={year}
min={EARLIEST_YEAR}
max={new Date().getFullYear()}
onChange={event => setYear(Number(event.target.value))}
/>
)}
</div>
<div className="summary-grid">
<div className="summary-tile accent">
<div className="summary-label"><FontAwesomeIcon icon={faCoins} /> Celkem utraceno</div>
<div className="summary-value">{formatPrice(stats?.totalPrice ?? 0)}</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faCalendarDay} /> Průměr na den</div>
<div className="summary-value">{formatPrice(averagePerDay)}</div>
<div className="summary-hint">z {stats?.dayCount ?? 0} vykázaných dní</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faListCheck} /> Jídel</div>
<div className="summary-value">{stats?.entryCount ?? 0}</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faFire} /> Kalorie</div>
<div className="summary-value">{formatCalories(stats?.totalCalories)}</div>
</div>
</div>
{loading && (
<div className="empty-state">
<Spinner animation="border" variant="success" />
</div>
)}
{!loading && !stats?.entryCount && (
<div className="card-panel empty-state">
<div className="empty-icon"><FontAwesomeIcon icon={faChartColumn} /></div>
<div className="empty-title">Za zvolené období nemáte žádná data</div>
<p className="empty-description">
Přidejte jídla v přehledu dne, nebo si sem naimportujte měsíční přehled z Luncheru.
</p>
</div>
)}
{!loading && !!stats?.entryCount && (
<>
<div className="card-panel chart-panel">
<h2 className="chart-title">Útrata v čase</h2>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={series} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--ft-border)" vertical={false} />
<XAxis dataKey="label" tick={{ fontSize: 12 }} stroke="var(--ft-text-muted)" />
<YAxis tick={{ fontSize: 12 }} stroke="var(--ft-text-muted)" width={56} />
<Tooltip
formatter={value => [formatChartPrice(value), 'Útrata']}
contentStyle={TOOLTIP_STYLE}
/>
<Bar dataKey="price" fill="var(--ft-primary)" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="chart-row">
<div className="card-panel chart-panel">
<h2 className="chart-title">Podle chodu</h2>
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie data={pieData} dataKey="value" nameKey="name" innerRadius={50} outerRadius={85}>
{pieData.map((entry, index) => (
<Cell key={entry.name} fill={PIE_COLORS[index % PIE_COLORS.length]} />
))}
</Pie>
<Legend />
<Tooltip
formatter={value => formatChartPrice(value)}
contentStyle={TOOLTIP_STYLE}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="card-panel chart-panel">
<h2 className="chart-title">Kde nejvíc utrácíte</h2>
<table className="source-table">
<thead>
<tr>
<th>Zdroj</th>
<th className="numeric">Jídel</th>
<th className="numeric">Útrata</th>
</tr>
</thead>
<tbody>
{stats.bySource.slice(0, 8).map(total => (
<tr key={total.key}>
<td>{total.key}</td>
<td className="numeric">{total.count}</td>
<td className="numeric">{formatPriceShort(total.price)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
<ImportModal
isOpen={importOpen}
onClose={() => setImportOpen(false)}
onImported={load}
/>
</>
);
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Adresy stránek aplikace.
*
* Žijí ve vlastním modulu, a ne v AppRoutes — hlavička je potřebuje, AppRoutes
* potřebuje hlavičku, a kruhový import by konstanty nechal při načtení nedefinované.
*/
export const DEN_URL = '/';
export const STATISTIKY_URL = '/statistiky';
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"types": ["vite/client"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "bundler",
"module": "ESNext",
"target": "ESNext",
"resolveJsonModule": true,
"isolatedModules": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src", "../types"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import viteTsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
base: '',
plugins: [react(), viteTsconfigPaths()],
build: {
sourcemap: true,
},
server: {
open: true,
host: '0.0.0.0',
port: 3000,
proxy: {
'/api': 'http://localhost:3001',
}
},
})
+1527
View File
File diff suppressed because it is too large Load Diff