feat: nová stránka pro návrhy na vylepšení
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 21s
CI / Build server (push) Successful in 24s
CI / Build client (push) Successful in 38s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Build and push Docker image (push) Successful in 40s
CI / Notify (push) Successful in 2s

This commit is contained in:
2026-06-05 19:15:46 +02:00
parent f28f127a92
commit 17132d4124
27 changed files with 857 additions and 515 deletions
+7
View File
@@ -6,15 +6,22 @@ import { ToastContainer } from "react-toastify";
import { SocketContext, socket } from "./context/socket"; import { SocketContext, socket } from "./context/socket";
import StatsPage from "./pages/StatsPage"; import StatsPage from "./pages/StatsPage";
import OrderGroupsPage from "./pages/OrderGroupsPage"; import OrderGroupsPage from "./pages/OrderGroupsPage";
import SuggestionsPage from "./pages/SuggestionsPage";
import App from "./App"; import App from "./App";
export const STATS_URL = '/stats'; export const STATS_URL = '/stats';
export const OBJEDNANI_URL = '/objednani'; export const OBJEDNANI_URL = '/objednani';
export const NAVRHY_URL = '/navrhy';
export default function AppRoutes() { export default function AppRoutes() {
return ( return (
<Routes> <Routes>
<Route path={STATS_URL} element={<StatsPage />} /> <Route path={STATS_URL} element={<StatsPage />} />
<Route path={NAVRHY_URL} element={
<ProvideSettings>
<SuggestionsPage />
</ProvideSettings>
} />
<Route path={OBJEDNANI_URL} element={ <Route path={OBJEDNANI_URL} element={
<ProvideSettings> <ProvideSettings>
<SocketContext.Provider value={socket}> <SocketContext.Provider value={socket}>
+3 -30
View File
@@ -4,15 +4,14 @@ import { useAuth } from "../context/auth";
import SettingsModal from "./modals/SettingsModal"; import SettingsModal from "./modals/SettingsModal";
import { useSettings, ThemePreference } from "../context/settings"; import { useSettings, ThemePreference } from "../context/settings";
import HuePicker from "./HuePicker"; import HuePicker from "./HuePicker";
import FeaturesVotingModal from "./modals/FeaturesVotingModal";
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal"; import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
import RefreshMenuModal from "./modals/RefreshMenuModal"; import RefreshMenuModal from "./modals/RefreshMenuModal";
import GenerateQrModal from "./modals/GenerateQrModal"; import GenerateQrModal from "./modals/GenerateQrModal";
import GenerateMockDataModal from "./modals/GenerateMockDataModal"; import GenerateMockDataModal from "./modals/GenerateMockDataModal";
import ClearMockDataModal from "./modals/ClearMockDataModal"; import ClearMockDataModal from "./modals/ClearMockDataModal";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { STATS_URL, OBJEDNANI_URL } from "../AppRoutes"; import { STATS_URL, OBJEDNANI_URL, NAVRHY_URL } from "../AppRoutes";
import { FeatureRequest, getVotes, updateVote, LunchChoices, getChangelogs } from "../../../types"; import { LunchChoices, getChangelogs } from "../../../types";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons"; import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
import { formatDateString } from "../Utils"; import { formatDateString } from "../Utils";
@@ -31,7 +30,6 @@ export default function Header({ choices, dayIndex }: Props) {
const settings = useSettings(); const settings = useSettings();
const navigate = useNavigate(); const navigate = useNavigate();
const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false); const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false);
const [votingModalOpen, setVotingModalOpen] = useState<boolean>(false);
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false); const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false); const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false);
const [changelogModalOpen, setChangelogModalOpen] = useState<boolean>(false); const [changelogModalOpen, setChangelogModalOpen] = useState<boolean>(false);
@@ -39,18 +37,9 @@ export default function Header({ choices, dayIndex }: Props) {
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false); const [qrModalOpen, setQrModalOpen] = useState<boolean>(false);
const [generateMockModalOpen, setGenerateMockModalOpen] = useState<boolean>(false); const [generateMockModalOpen, setGenerateMockModalOpen] = useState<boolean>(false);
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false); const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[] | undefined>([]);
const effectiveDark = settings?.effectiveDark ?? false; const effectiveDark = settings?.effectiveDark ?? false;
useEffect(() => {
if (auth?.login) {
getVotes().then(response => {
setFeatureVotes(response.data);
})
}
}, [auth?.login]);
useEffect(() => { useEffect(() => {
if (!auth?.login) return; if (!auth?.login) return;
const lastSeen = localStorage.getItem(LAST_SEEN_CHANGELOG_KEY) ?? undefined; const lastSeen = localStorage.getItem(LAST_SEEN_CHANGELOG_KEY) ?? undefined;
@@ -68,10 +57,6 @@ export default function Header({ choices, dayIndex }: Props) {
setSettingsModalOpen(false); setSettingsModalOpen(false);
} }
const closeVotingModal = () => {
setVotingModalOpen(false);
}
const closePizzaModal = () => { const closePizzaModal = () => {
setPizzaModalOpen(false); setPizzaModalOpen(false);
} }
@@ -158,17 +143,6 @@ export default function Header({ choices, dayIndex }: Props) {
closeSettingsModal(); closeSettingsModal();
} }
const saveFeatureVote = async (option: FeatureRequest, active: boolean) => {
await updateVote({ body: { option, active } });
const votes = [...featureVotes || []];
if (active) {
votes.push(option);
} else {
votes.splice(votes.indexOf(option), 1);
}
setFeatureVotes(votes);
}
return <Navbar variant='dark' expand="lg"> return <Navbar variant='dark' expand="lg">
<Navbar.Brand href="/">Luncher</Navbar.Brand> <Navbar.Brand href="/">Luncher</Navbar.Brand>
<Navbar.Toggle aria-controls="basic-navbar-nav" /> <Navbar.Toggle aria-controls="basic-navbar-nav" />
@@ -190,7 +164,7 @@ export default function Header({ choices, dayIndex }: Props) {
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown"> <NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item> <NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item> <NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
<NavDropdown.Item onClick={() => setVotingModalOpen(true)}>Hlasovat o nových funkcích</NavDropdown.Item> <NavDropdown.Item onClick={() => navigate(NAVRHY_URL)}>Návrhy na vylepšení</NavDropdown.Item>
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item> <NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
<NavDropdown.Item onClick={handleQrMenuClick}>Generování QR kódů</NavDropdown.Item> <NavDropdown.Item onClick={handleQrMenuClick}>Generování QR kódů</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item> <NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
@@ -220,7 +194,6 @@ export default function Header({ choices, dayIndex }: Props) {
</Navbar.Collapse> </Navbar.Collapse>
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} /> <SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} /> <RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
<FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} />
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} /> <PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
{choices && settings?.bankAccount && settings?.holderName && ( {choices && settings?.bankAccount && settings?.holderName && (
<GenerateQrModal <GenerateQrModal
@@ -0,0 +1,79 @@
import { useState } from "react";
import { Modal, Button, Form } from "react-bootstrap";
type Props = {
isOpen: boolean;
onClose: () => void;
onSubmit: (title: string, description: string) => Promise<void>;
};
/** Modální dialog pro přidání nového návrhu na vylepšení. */
export default function AddSuggestionModal({ isOpen, onClose, onSubmit }: Readonly<Props>) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [submitting, setSubmitting] = useState(false);
const reset = () => {
setTitle("");
setDescription("");
};
const handleClose = () => {
reset();
onClose();
};
const handleSubmit = async () => {
if (!title.trim() || !description.trim()) return;
setSubmitting(true);
try {
await onSubmit(title.trim(), description.trim());
reset();
onClose();
} finally {
setSubmitting(false);
}
};
return (
<Modal show={isOpen} onHide={handleClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Nový návrh na vylepšení</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
<Form.Group className="mb-3">
<Form.Label>Název</Form.Label>
<Form.Control
type="text"
placeholder="Stručný název návrhu"
value={title}
maxLength={120}
onChange={e => setTitle(e.target.value)}
onKeyDown={e => e.stopPropagation()}
autoFocus
/>
<Form.Text className="text-muted">Krátký, výstižný název navrhované úpravy.</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Popis</Form.Label>
<Form.Control
as="textarea"
rows={5}
placeholder="Detailní popis navrhované úpravy, řešení apod."
value={description}
onChange={e => setDescription(e.target.value)}
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={handleClose}>
Storno
</Button>
<Button onClick={handleSubmit} disabled={submitting || !title.trim() || !description.trim()}>
Přidat
</Button>
</Modal.Footer>
</Modal>
);
}
@@ -1,45 +0,0 @@
import { Modal, Button, Form } from "react-bootstrap"
import { FeatureRequest } from "../../../../types";
type Props = {
isOpen: boolean,
onClose: () => void,
onChange: (option: FeatureRequest, active: boolean) => void,
initialValues?: FeatureRequest[],
}
/** Modální dialog pro hlasování o nových funkcích. */
export default function FeaturesVotingModal({ isOpen, onClose, onChange, initialValues }: Readonly<Props>) {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.currentTarget.value as FeatureRequest, e.currentTarget.checked);
}
return <Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title>
Hlasujte pro nové funkce
<p style={{ fontSize: '12px' }}>Je možno vybrat maximálně 4 možnosti</p>
</Modal.Title>
</Modal.Header>
<Modal.Body>
{(Object.keys(FeatureRequest) as Array<keyof typeof FeatureRequest>).map(key => {
return <Form.Check
key={key}
type='checkbox'
id={key}
label={FeatureRequest[key]}
onChange={handleChange}
value={key}
defaultChecked={initialValues?.includes(key as FeatureRequest)}
/>
})}
<p className="mt-3" style={{ fontSize: '12px' }}>Něco jiného? Dejte vědět.</p>
</Modal.Body>
<Modal.Footer>
<Button variant="primary" onClick={onClose}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
}
@@ -0,0 +1,29 @@
import { Modal, Button } from "react-bootstrap";
import { Suggestion } from "../../../../types";
type Props = {
suggestion?: Suggestion;
onClose: () => void;
};
/** Modální dialog zobrazující celý detail návrhu na vylepšení. */
export default function SuggestionDetailModal({ suggestion, onClose }: Readonly<Props>) {
return (
<Modal show={!!suggestion} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>{suggestion?.title}</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
<p className="text-muted mb-3">
Navrhovatel: <strong>{suggestion?.author}</strong> · Hlasy: <strong>{suggestion?.voteScore}</strong>
</p>
<p style={{ whiteSpace: "pre-wrap" }}>{suggestion?.description}</p>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
);
}
-63
View File
@@ -89,67 +89,4 @@
.recharts-cartesian-grid-vertical line { .recharts-cartesian-grid-vertical line {
stroke: var(--luncher-border); stroke: var(--luncher-border);
} }
.voting-stats-section {
margin-top: 48px;
width: 100%;
max-width: 800px;
h2 {
font-size: 1.5rem;
font-weight: 700;
color: var(--luncher-text);
margin-bottom: 16px;
text-align: center;
}
}
.voting-stats-table {
width: 100%;
background: var(--luncher-bg-card);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
border: 1px solid var(--luncher-border-light);
overflow: hidden;
border-collapse: collapse;
th {
background: var(--luncher-primary);
color: #ffffff;
padding: 12px 20px;
text-align: left;
font-weight: 600;
font-size: 0.9rem;
&:last-child {
text-align: center;
width: 120px;
}
}
td {
padding: 12px 20px;
border-bottom: 1px solid var(--luncher-border-light);
color: var(--luncher-text);
font-size: 0.9rem;
&:last-child {
text-align: center;
font-weight: 600;
color: var(--luncher-primary);
}
}
tbody tr {
transition: var(--luncher-transition);
&:hover {
background: var(--luncher-bg-hover);
}
&:last-child td {
border-bottom: none;
}
}
}
} }
+1 -36
View File
@@ -4,7 +4,7 @@ import Header from "../components/Header";
import { useAuth } from "../context/auth"; import { useAuth } from "../context/auth";
import Login from "../Login"; import Login from "../Login";
import { formatDate, getFirstWorkDayOfWeek, getHumanDate, getLastWorkDayOfWeek } from "../Utils"; import { formatDate, getFirstWorkDayOfWeek, getHumanDate, getLastWorkDayOfWeek } from "../Utils";
import { WeeklyStats, LunchChoice, VotingStats, FeatureRequest, getStats, getVotingStats } from "../../../types"; import { WeeklyStats, LunchChoice, getStats } from "../../../types";
import Loader from "../components/Loader"; import Loader from "../components/Loader";
import { faChevronLeft, faChevronRight, faGear } from "@fortawesome/free-solid-svg-icons"; import { faChevronLeft, faChevronRight, faGear } from "@fortawesome/free-solid-svg-icons";
import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts"; import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
@@ -32,7 +32,6 @@ export default function StatsPage() {
const auth = useAuth(); const auth = useAuth();
const [dateRange, setDateRange] = useState<Date[]>(); const [dateRange, setDateRange] = useState<Date[]>();
const [data, setData] = useState<WeeklyStats>(); const [data, setData] = useState<WeeklyStats>();
const [votingStats, setVotingStats] = useState<VotingStats>();
// Prvotní nastavení aktuálního týdne // Prvotní nastavení aktuálního týdne
useEffect(() => { useEffect(() => {
@@ -49,19 +48,6 @@ export default function StatsPage() {
} }
}, [dateRange]); }, [dateRange]);
// Načtení statistik hlasování
useEffect(() => {
getVotingStats().then(response => {
setVotingStats(response.data);
});
}, []);
const sortedVotingStats = useMemo(() => {
if (!votingStats) return [];
return Object.entries(votingStats)
.sort((a, b) => (b[1] as number) - (a[1] as number));
}, [votingStats]);
const renderLine = (location: LunchChoice) => { const renderLine = (location: LunchChoice) => {
const index = Object.values(LunchChoice).indexOf(location); const index = Object.values(LunchChoice).indexOf(location);
return <Line key={location} name={getLunchChoiceName(location)} type="monotone" dataKey={data => data.locations[location] ?? 0} stroke={COLORS[index]} strokeWidth={STROKE_WIDTH} /> return <Line key={location} name={getLunchChoiceName(location)} type="monotone" dataKey={data => data.locations[location] ?? 0} stroke={COLORS[index]} strokeWidth={STROKE_WIDTH} />
@@ -142,27 +128,6 @@ export default function StatsPage() {
<Tooltip /> <Tooltip />
<Legend /> <Legend />
</LineChart> </LineChart>
{sortedVotingStats.length > 0 && (
<div className="voting-stats-section">
<h2>Hlasování o funkcích</h2>
<table className="voting-stats-table">
<thead>
<tr>
<th>Funkce</th>
<th>Počet hlasů</th>
</tr>
</thead>
<tbody>
{sortedVotingStats.map(([feature, count]) => (
<tr key={feature}>
<td>{FeatureRequest[feature as keyof typeof FeatureRequest] ?? feature}</td>
<td>{count as number}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div> </div>
<Footer /> <Footer />
</> </>
+122
View File
@@ -0,0 +1,122 @@
.suggestions-page {
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 24px;
min-height: calc(100vh - 140px);
background: var(--luncher-bg);
.suggestions-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
width: 100%;
max-width: 900px;
flex-wrap: wrap;
h1 {
font-size: 2rem;
font-weight: 700;
color: var(--luncher-text);
margin: 0;
}
}
.suggestions-info {
width: 100%;
max-width: 900px;
margin: 12px 0 24px;
color: var(--luncher-text-secondary);
font-size: 0.95rem;
}
.suggestions-empty {
color: var(--luncher-text-secondary);
margin-top: 32px;
}
.suggestions-table {
width: 100%;
max-width: 900px;
background: var(--luncher-bg-card);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
border: 1px solid var(--luncher-border-light);
overflow: hidden;
border-collapse: collapse;
th {
background: var(--luncher-primary);
color: #ffffff;
padding: 12px 20px;
text-align: left;
font-weight: 600;
font-size: 0.9rem;
}
td {
padding: 12px 20px;
border-bottom: 1px solid var(--luncher-border-light);
color: var(--luncher-text);
font-size: 0.9rem;
vertical-align: middle;
}
.col-score {
text-align: center;
width: 80px;
font-weight: 600;
}
td.col-score {
color: var(--luncher-primary);
}
.col-actions {
text-align: center;
width: 150px;
white-space: nowrap;
}
tbody tr {
cursor: pointer;
transition: var(--luncher-transition);
&:hover {
background: var(--luncher-bg-hover);
}
&:last-child td {
border-bottom: none;
}
}
.vote-btn {
background: transparent;
border: none;
cursor: pointer;
padding: 6px 8px;
border-radius: var(--luncher-radius-sm, 6px);
color: var(--luncher-text-secondary);
transition: var(--luncher-transition);
&:hover {
background: var(--luncher-bg-hover);
color: var(--luncher-text);
}
&.vote-up.active {
color: #2e7d32;
}
&.vote-down.active {
color: #c62828;
}
&.delete-btn:hover {
color: #c62828;
}
}
}
}
+150
View File
@@ -0,0 +1,150 @@
import { useCallback, useEffect, useState } from "react";
import { Button, OverlayTrigger, Tooltip } from "react-bootstrap";
import { ToastContainer } from "react-toastify";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faThumbsUp, faThumbsDown, faTrash, faPlus, faGear } from "@fortawesome/free-solid-svg-icons";
import Header from "../components/Header";
import Footer from "../components/Footer";
import Loader from "../components/Loader";
import { useAuth } from "../context/auth";
import Login from "../Login";
import AddSuggestionModal from "../components/modals/AddSuggestionModal";
import SuggestionDetailModal from "../components/modals/SuggestionDetailModal";
import {
Suggestion,
VoteDirection,
listSuggestions,
addSuggestion,
voteSuggestion,
deleteSuggestion,
} from "../../../types";
import "./SuggestionsPage.scss";
export default function SuggestionsPage() {
const auth = useAuth();
const [suggestions, setSuggestions] = useState<Suggestion[]>();
const [addModalOpen, setAddModalOpen] = useState(false);
const [detail, setDetail] = useState<Suggestion>();
const reload = useCallback(async () => {
if (!auth?.login) return;
const response = await listSuggestions();
setSuggestions(response.data ?? []);
}, [auth?.login]);
useEffect(() => {
reload();
}, [reload]);
const handleAdd = async (title: string, description: string) => {
const response = await addSuggestion({ body: { title, description } });
if (response.data) {
setSuggestions(response.data);
}
};
const handleVote = async (id: string, direction: VoteDirection) => {
const response = await voteSuggestion({ body: { id, direction } });
if (response.data) {
setSuggestions(response.data);
}
};
const handleDelete = async (suggestion: Suggestion) => {
if (!window.confirm(`Opravdu chcete smazat návrh „${suggestion.title}“? Smažou se i všechny jeho hlasy.`)) {
return;
}
const response = await deleteSuggestion({ body: { id: suggestion.id } });
if (response.data) {
setSuggestions(response.data);
}
};
if (!auth?.login) {
return <Login />;
}
if (!suggestions) {
return <Loader icon={faGear} description={"Načítám návrhy..."} animation={"fa-bounce"} />;
}
return (
<>
<Header />
<div className="suggestions-page">
<div className="suggestions-header">
<h1>Návrhy na vylepšení</h1>
<Button onClick={() => setAddModalOpen(true)}>
<FontAwesomeIcon icon={faPlus} /> Přidat návrh
</Button>
</div>
<p className="suggestions-info">
Zde můžete navrhovat vylepšení aplikace a hlasovat o návrzích ostatních. U každého návrhu je
zobrazeno jméno navrhovatele. Jména hlasujících jsou dostupná pouze administrátorům.
</p>
{suggestions.length === 0 ? (
<p className="suggestions-empty">Zatím nebyly přidány žádné návrhy. Buďte první!</p>
) : (
<table className="suggestions-table">
<thead>
<tr>
<th>Navrhovatel</th>
<th>Název</th>
<th className="col-score">Hlasy</th>
<th className="col-actions">Akce</th>
</tr>
</thead>
<tbody>
{suggestions.map(suggestion => (
<OverlayTrigger
key={suggestion.id}
placement="top"
overlay={<Tooltip id={`tooltip-${suggestion.id}`}>{suggestion.description}</Tooltip>}
>
<tr onClick={() => setDetail(suggestion)}>
<td>{suggestion.author}</td>
<td>{suggestion.title}</td>
<td className="col-score">{suggestion.voteScore}</td>
<td className="col-actions" onClick={e => e.stopPropagation()}>
<button
type="button"
className={`vote-btn vote-up ${suggestion.myVote === VoteDirection.UP ? "active" : ""}`}
title="Hlasovat pro"
onClick={() => handleVote(suggestion.id, VoteDirection.UP)}
>
<FontAwesomeIcon icon={faThumbsUp} />
</button>
<button
type="button"
className={`vote-btn vote-down ${suggestion.myVote === VoteDirection.DOWN ? "active" : ""}`}
title="Hlasovat proti"
onClick={() => handleVote(suggestion.id, VoteDirection.DOWN)}
>
<FontAwesomeIcon icon={faThumbsDown} />
</button>
{suggestion.isMine && (
<button
type="button"
className="vote-btn delete-btn"
title="Smazat návrh"
onClick={() => handleDelete(suggestion)}
>
<FontAwesomeIcon icon={faTrash} />
</button>
)}
</td>
</tr>
</OverlayTrigger>
))}
</tbody>
</table>
)}
</div>
<Footer />
<AddSuggestionModal isOpen={addModalOpen} onClose={() => setAddModalOpen(false)} onSubmit={handleAdd} />
<SuggestionDetailModal suggestion={detail} onClose={() => setDetail(undefined)} />
<ToastContainer />
</>
);
}
+3
View File
@@ -0,0 +1,3 @@
[
"Nová stránka pro návrhy na vylepšení (dostupná z uživatelského menu)"
]
+2 -2
View File
@@ -14,7 +14,7 @@ import { startReminderScheduler, verifyQuickChoiceToken } from "./pushReminder";
import { storageReady } from "./storage"; import { storageReady } from "./storage";
import pizzaDayRoutes from "./routes/pizzaDayRoutes"; import pizzaDayRoutes from "./routes/pizzaDayRoutes";
import foodRoutes, { refreshMetoda } from "./routes/foodRoutes"; import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
import votingRoutes from "./routes/votingRoutes"; import suggestionRoutes from "./routes/suggestionRoutes";
import easterEggRoutes from "./routes/easterEggRoutes"; import easterEggRoutes from "./routes/easterEggRoutes";
import statsRoutes from "./routes/statsRoutes"; import statsRoutes from "./routes/statsRoutes";
import notificationRoutes from "./routes/notificationRoutes"; import notificationRoutes from "./routes/notificationRoutes";
@@ -199,7 +199,7 @@ app.get("/api/data", async (req, res) => {
// Ostatní routes // Ostatní routes
app.use("/api/pizzaDay", pizzaDayRoutes); app.use("/api/pizzaDay", pizzaDayRoutes);
app.use("/api/food", foodRoutes); app.use("/api/food", foodRoutes);
app.use("/api/voting", votingRoutes); app.use("/api/suggestions", suggestionRoutes);
app.use("/api/easterEggs", easterEggRoutes); app.use("/api/easterEggs", easterEggRoutes);
app.use("/api/stats", statsRoutes); app.use("/api/stats", statsRoutes);
app.use("/api/notifications", notificationRoutes); app.use("/api/notifications", notificationRoutes);
+50
View File
@@ -0,0 +1,50 @@
import express, { Request } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { addSuggestion, deleteSuggestion, listSuggestions, voteSuggestion } from "../suggestions";
import { AddSuggestionData, VoteSuggestionData, DeleteSuggestionData } from "../../../types";
const router = express.Router();
router.get("/list", async (req: Request, res, next) => {
try {
const login = getLogin(parseToken(req));
const data = await listSuggestions(login);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/add", async (req: Request<{}, any, AddSuggestionData["body"]>, res, next) => {
try {
const login = getLogin(parseToken(req));
if (!req.body?.title || !req.body?.description) {
return res.status(400).json({ error: "Chybné parametry volání" });
}
const data = await addSuggestion(login, req.body.title, req.body.description);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/vote", async (req: Request<{}, any, VoteSuggestionData["body"]>, res, next) => {
try {
const login = getLogin(parseToken(req));
if (!req.body?.id || !req.body?.direction) {
return res.status(400).json({ error: "Chybné parametry volání" });
}
const data = await voteSuggestion(login, req.body.id, req.body.direction);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/delete", async (req: Request<{}, any, DeleteSuggestionData["body"]>, res, next) => {
try {
const login = getLogin(parseToken(req));
if (!req.body?.id) {
return res.status(400).json({ error: "Chybné parametry volání" });
}
const data = await deleteSuggestion(login, req.body.id);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
export default router;
-33
View File
@@ -1,33 +0,0 @@
import express, { Request } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { getUserVotes, updateFeatureVote, getVotingStats } from "../voting";
import { GetVotesData, UpdateVoteData } from "../../../types";
const router = express.Router();
router.get("/getVotes", async (req: Request<{}, any, GetVotesData["body"]>, res) => {
const login = getLogin(parseToken(req));
const data = await getUserVotes(login);
res.status(200).json(data);
});
router.post("/updateVote", async (req: Request<{}, any, UpdateVoteData["body"]>, res, next) => {
const login = getLogin(parseToken(req));
if (req.body?.option == null || req.body?.active == null) {
res.status(400).json({ error: "Chybné parametry volání" });
}
try {
const data = await updateFeatureVote(login, req.body.option, req.body.active);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.get("/stats", async (req, res, next) => {
try {
const data = await getVotingStats();
res.status(200).json(data);
} catch (e: any) { next(e) }
});
export default router;
+142
View File
@@ -0,0 +1,142 @@
import crypto from "crypto";
import { Suggestion, VoteDirection } from "../../types/gen/types.gen";
import getStorage from "./storage";
/** Interní reprezentace návrhu uložená ve storage (včetně seznamů hlasujících). */
interface StoredSuggestion {
id: string;
author: string;
title: string;
description: string;
createdAt: string;
/** Loginy uživatelů hlasujících PRO návrh */
upvoters: string[];
/** Loginy uživatelů hlasujících PROTI návrhu */
downvoters: string[];
}
const storage = getStorage();
const STORAGE_KEY = 'suggestions';
/** Načte interní seznam návrhů ze storage. */
async function loadSuggestions(): Promise<StoredSuggestion[]> {
return (await storage.getData<StoredSuggestion[]>(STORAGE_KEY)) ?? [];
}
/**
* Převede interní návrh na DTO pro daného uživatele - skryje seznamy hlasujících
* a doplní hlas přihlášeného uživatele a příznak vlastnictví.
*/
function toDto(suggestion: StoredSuggestion, login: string): Suggestion {
let myVote: VoteDirection | undefined;
if (suggestion.upvoters.includes(login)) {
myVote = 'up';
} else if (suggestion.downvoters.includes(login)) {
myVote = 'down';
}
return {
id: suggestion.id,
author: suggestion.author,
title: suggestion.title,
description: suggestion.description,
voteScore: suggestion.upvoters.length - suggestion.downvoters.length,
myVote,
isMine: suggestion.author === login,
};
}
/**
* Vrátí seznam návrhů jako DTO pro daného uživatele, seřazený sestupně dle skóre
* (při shodě skóre stabilně dle data vytvoření vzestupně).
*
* @param login login přihlášeného uživatele
*/
export async function listSuggestions(login: string): Promise<Suggestion[]> {
const suggestions = await loadSuggestions();
return suggestions
.map(s => toDto(s, login))
.sort((a, b) => b.voteScore - a.voteScore);
}
/**
* Přidá nový návrh. Autorovi se automaticky nastaví hlas pro.
*
* @param login login autora
* @param title název návrhu
* @param description detailní popis návrhu
* @returns aktualizovaný seznam návrhů jako DTO
*/
export async function addSuggestion(login: string, title: string, description: string): Promise<Suggestion[]> {
const trimmedTitle = title?.trim();
const trimmedDescription = description?.trim();
if (!trimmedTitle) {
throw new Error('Název návrhu nesmí být prázdný');
}
if (!trimmedDescription) {
throw new Error('Popis návrhu nesmí být prázdný');
}
const suggestions = await loadSuggestions();
suggestions.push({
id: crypto.randomUUID(),
author: login,
title: trimmedTitle,
description: trimmedDescription,
createdAt: new Date().toISOString(),
// Autor automaticky hlasuje pro svůj návrh
upvoters: [login],
downvoters: [],
});
await storage.setData(STORAGE_KEY, suggestions);
return listSuggestions(login);
}
/**
* Přepne hlas uživatele u návrhu. Klik na již aktivní směr hlas zruší,
* opačný směr stávající hlas přepíše.
*
* @param login login hlasujícího uživatele
* @param id identifikátor návrhu
* @param direction směr hlasu, na který uživatel klikl
* @returns aktualizovaný seznam návrhů jako DTO
*/
export async function voteSuggestion(login: string, id: string, direction: VoteDirection): Promise<Suggestion[]> {
const suggestions = await loadSuggestions();
const suggestion = suggestions.find(s => s.id === id);
if (!suggestion) {
throw new Error('Návrh nebyl nalezen');
}
const hadUp = suggestion.upvoters.includes(login);
const hadDown = suggestion.downvoters.includes(login);
// Nejprve odebereme případný stávající hlas uživatele
suggestion.upvoters = suggestion.upvoters.filter(l => l !== login);
suggestion.downvoters = suggestion.downvoters.filter(l => l !== login);
// Hlas přidáme pouze pokud uživatel neklikl na již aktivní směr (jinak ho jen zrušíme)
if (direction === 'up' && !hadUp) {
suggestion.upvoters.push(login);
} else if (direction === 'down' && !hadDown) {
suggestion.downvoters.push(login);
}
await storage.setData(STORAGE_KEY, suggestions);
return listSuggestions(login);
}
/**
* Smaže návrh včetně všech jeho hlasů. Smazat lze pouze vlastní návrh.
*
* @param login login uživatele požadujícího smazání
* @param id identifikátor návrhu ke smazání
* @returns aktualizovaný seznam návrhů jako DTO
*/
export async function deleteSuggestion(login: string, id: string): Promise<Suggestion[]> {
const suggestions = await loadSuggestions();
const suggestion = suggestions.find(s => s.id === id);
if (!suggestion) {
throw new Error('Návrh nebyl nalezen');
}
if (suggestion.author !== login) {
throw new Error('Smazat lze pouze vlastní návrh');
}
const filtered = suggestions.filter(s => s.id !== id);
await storage.setData(STORAGE_KEY, filtered);
return listSuggestions(login);
}
+126
View File
@@ -0,0 +1,126 @@
import { resetMemoryStorage } from '../storage/memory';
import { addSuggestion, deleteSuggestion, listSuggestions, voteSuggestion } from '../suggestions';
const AUTHOR = 'tomas';
const VOTER = 'petr';
const OTHER = 'jana';
beforeEach(() => {
resetMemoryStorage();
});
/** Pomocná funkce: vytvoří návrh a vrátí jeho id (z pohledu autora). */
async function createSuggestion(author = AUTHOR, title = 'Tmavý režim', description = 'Přidat tmavý režim aplikace') {
const list = await addSuggestion(author, title, description);
return list.find(s => s.title === title)!.id;
}
describe('addSuggestion', () => {
test('přidá návrh a autorovi nastaví hlas pro', async () => {
const list = await addSuggestion(AUTHOR, 'Tmavý režim', 'Popis');
expect(list).toHaveLength(1);
const s = list[0];
expect(s.author).toBe(AUTHOR);
expect(s.title).toBe('Tmavý režim');
expect(s.description).toBe('Popis');
expect(s.voteScore).toBe(1);
expect(s.myVote).toBe('up');
expect(s.isMine).toBe(true);
});
test('ořízne mezery a odmítne prázdný název i popis', async () => {
await expect(addSuggestion(AUTHOR, ' ', 'popis')).rejects.toThrow();
await expect(addSuggestion(AUTHOR, 'název', ' ')).rejects.toThrow();
const list = await addSuggestion(AUTHOR, ' Název ', ' Popis ');
expect(list[0].title).toBe('Název');
expect(list[0].description).toBe('Popis');
});
test('jeden uživatel může přidat více návrhů', async () => {
await addSuggestion(AUTHOR, 'První', 'popis');
const list = await addSuggestion(AUTHOR, 'Druhý', 'popis');
expect(list).toHaveLength(2);
});
});
describe('listSuggestions', () => {
test('řadí sestupně dle skóre', async () => {
const lowId = await createSuggestion(AUTHOR, 'Nízké', 'popis');
const highId = await createSuggestion(OTHER, 'Vysoké', 'popis');
// "Vysoké" dostane další hlas pro
await voteSuggestion(VOTER, highId, 'up');
const list = await listSuggestions(VOTER);
expect(list[0].id).toBe(highId);
expect(list[0].voteScore).toBe(2);
expect(list[1].id).toBe(lowId);
});
test('myVote a isMine jsou relativní k uživateli', async () => {
const id = await createSuggestion(AUTHOR);
const asAuthor = (await listSuggestions(AUTHOR))[0];
expect(asAuthor.isMine).toBe(true);
expect(asAuthor.myVote).toBe('up');
const asOther = (await listSuggestions(VOTER))[0];
expect(asOther.isMine).toBe(false);
expect(asOther.myVote).toBeUndefined();
// Seznamy hlasujících se klientovi neposílají
expect((asOther as any).upvoters).toBeUndefined();
expect(id).toBeDefined();
});
});
describe('voteSuggestion', () => {
test('hlas pro a proti od jiného uživatele', async () => {
const id = await createSuggestion();
let list = await voteSuggestion(VOTER, id, 'up');
expect(list[0].voteScore).toBe(2);
expect(list.find(s => s.id === id)!.voteScore).toBe(2);
// přehlasování z pro na proti
list = await voteSuggestion(VOTER, id, 'down');
// autor +1, voter -1 => 0
expect(list[0].voteScore).toBe(0);
expect((await listSuggestions(VOTER))[0].myVote).toBe('down');
});
test('klik na aktivní směr hlas zruší', async () => {
const id = await createSuggestion();
await voteSuggestion(VOTER, id, 'up');
const list = await voteSuggestion(VOTER, id, 'up');
// zůstává jen autorův hlas
expect(list[0].voteScore).toBe(1);
expect((await listSuggestions(VOTER))[0].myVote).toBeUndefined();
});
test('autor může svůj automatický hlas odebrat', async () => {
const id = await createSuggestion();
const list = await voteSuggestion(AUTHOR, id, 'up');
expect(list[0].voteScore).toBe(0);
expect((await listSuggestions(AUTHOR))[0].myVote).toBeUndefined();
});
test('hlasování pro neexistující návrh vyhodí chybu', async () => {
await expect(voteSuggestion(VOTER, 'neexistuje', 'up')).rejects.toThrow();
});
});
describe('deleteSuggestion', () => {
test('autor smaže svůj návrh včetně hlasů', async () => {
const id = await createSuggestion();
await voteSuggestion(VOTER, id, 'up');
const list = await deleteSuggestion(AUTHOR, id);
expect(list).toHaveLength(0);
});
test('cizí uživatel nemůže smazat návrh', async () => {
const id = await createSuggestion();
await expect(deleteSuggestion(VOTER, id)).rejects.toThrow();
expect(await listSuggestions(AUTHOR)).toHaveLength(1);
});
test('smazání neexistujícího návrhu vyhodí chybu', async () => {
await expect(deleteSuggestion(AUTHOR, 'neexistuje')).rejects.toThrow();
});
});
-77
View File
@@ -1,77 +0,0 @@
import { getUserVotes, updateFeatureVote, getVotingStats } from '../voting';
import { resetMemoryStorage } from '../storage/memory';
import { FeatureRequest } from '../../../types/gen/types.gen';
const OPT_A = FeatureRequest.STATISTICS;
const OPT_B = FeatureRequest.UI;
beforeEach(() => {
resetMemoryStorage();
});
describe('updateFeatureVote', () => {
test('přidá hlas pro nového uživatele', async () => {
const result = await updateFeatureVote('alice', OPT_A, true);
expect(result['alice']).toContain(OPT_A);
});
test('vyhodí chybu při duplicitním hlasování', async () => {
await updateFeatureVote('alice', OPT_A, true);
await expect(updateFeatureVote('alice', OPT_A, true)).rejects.toThrow('hlasovali');
});
test('odebere hlas', async () => {
await updateFeatureVote('alice', OPT_A, true);
await updateFeatureVote('alice', OPT_A, false);
const stats = await getVotingStats();
expect(stats[OPT_A] ?? 0).toBe(0);
});
test('odebrání neexistujícího hlasu je no-op', async () => {
await expect(updateFeatureVote('alice', OPT_A, false)).resolves.not.toThrow();
});
test('odebrání posledního hlasu odstraní login ze storage', async () => {
await updateFeatureVote('alice', OPT_A, true);
const data = await updateFeatureVote('alice', OPT_A, false);
expect('alice' in data).toBe(false);
});
test('vyhodí chybu po 4 hlasech', async () => {
const options = Object.values(FeatureRequest);
for (let i = 0; i < 4; i++) {
await updateFeatureVote('alice', options[i], true);
}
await expect(updateFeatureVote('alice', options[4], true)).rejects.toThrow('4');
});
});
describe('getUserVotes', () => {
test('vrátí hlasy uživatele', async () => {
await updateFeatureVote('alice', OPT_A, true);
const votes = await getUserVotes('alice');
expect(votes).toContain(OPT_A);
});
test('vrátí prázdné pole pro uživatele bez hlasů', async () => {
const votes = await getUserVotes('neexistujici');
expect(votes).toEqual([]);
});
});
describe('getVotingStats', () => {
test('vrátí agregované počty hlasů', async () => {
await updateFeatureVote('alice', OPT_A, true);
await updateFeatureVote('bob', OPT_A, true);
await updateFeatureVote('bob', OPT_B, true);
const stats = await getVotingStats();
expect(stats[OPT_A]).toBe(2);
expect(stats[OPT_B]).toBe(1);
});
test('vrátí prázdný objekt bez hlasů', async () => {
const stats = await getVotingStats();
expect(stats).toEqual({});
});
});
-76
View File
@@ -1,76 +0,0 @@
import express from 'express';
import request from 'supertest';
import bodyParser from 'body-parser';
import { generateToken } from '../auth';
import { resetMemoryStorage } from '../storage/memory';
import { FeatureRequest } from '../../../types/gen/types.gen';
import votingRouter from '../routes/votingRoutes';
const VALID_OPTION = FeatureRequest.STATISTICS;
function buildApp() {
const app = express();
app.use(bodyParser.json());
app.use('/api/voting', votingRouter);
app.use((err: any, _req: any, res: any, _next: any) => {
res.status(400).json({ error: err.message });
});
return app;
}
const TOKEN = `Bearer ${generateToken('testuser')}`;
beforeEach(() => {
resetMemoryStorage();
});
test('GET /getVotes vrátí 200 s prázdným polem pro nového uživatele', async () => {
const res = await request(buildApp())
.get('/api/voting/getVotes')
.set('Authorization', TOKEN);
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
test('GET /getVotes vrátí 401 bez tokenu', async () => {
const res = await request(buildApp()).get('/api/voting/getVotes');
expect(res.status).toBeGreaterThanOrEqual(400);
});
test('POST /updateVote přidá hlas a vrátí 200', async () => {
const res = await request(buildApp())
.post('/api/voting/updateVote')
.set('Authorization', TOKEN)
.send({ option: VALID_OPTION, active: true });
expect(res.status).toBe(200);
});
test('POST /updateVote vrátí 400 pro chybějící parametry', async () => {
const res = await request(buildApp())
.post('/api/voting/updateVote')
.set('Authorization', TOKEN)
.send({});
expect(res.status).toBe(400);
});
test('POST /updateVote vrátí 400 při duplicitním hlasu', async () => {
const app = buildApp();
await request(app)
.post('/api/voting/updateVote')
.set('Authorization', TOKEN)
.send({ option: VALID_OPTION, active: true });
const res = await request(app)
.post('/api/voting/updateVote')
.set('Authorization', TOKEN)
.send({ option: VALID_OPTION, active: true });
expect(res.status).toBe(400);
expect(res.body.error).toContain('hlasovali');
});
test('GET /stats vrátí 200 s objektem', async () => {
const res = await request(buildApp())
.get('/api/voting/stats')
.set('Authorization', TOKEN);
expect(res.status).toBe(200);
expect(typeof res.body).toBe('object');
});
-76
View File
@@ -1,76 +0,0 @@
import { FeatureRequest, VotingStats } from "../../types/gen/types.gen";
import getStorage from "./storage";
interface VotingData {
[login: string]: FeatureRequest[],
}
export interface VotingStatsResult {
[feature: string]: number;
}
const storage = getStorage();
const STORAGE_KEY = 'voting';
/**
* Vrátí pole voleb, pro které uživatel aktuálně hlasuje.
*
* @param login login uživatele
* @returns pole voleb
*/
export async function getUserVotes(login: string) {
const data = await storage.getData<VotingData>(STORAGE_KEY);
return data?.[login] || [];
}
/**
* Aktualizuje hlas uživatele pro konkrétní volbu.
*
* @param login login uživatele
* @param option volba
* @param active příznak, zda volbu přidat nebo odebrat
* @returns aktuální data
*/
export async function updateFeatureVote(login: string, option: FeatureRequest, active: boolean): Promise<VotingData> {
let data = await storage.getData<VotingData>(STORAGE_KEY);
data ??= {};
if (!(login in data)) {
data[login] = [];
}
const index = data[login].indexOf(option);
if (index > -1) {
if (active) {
throw new Error('Pro tuto možnost jste již hlasovali');
} else {
data[login].splice(index, 1);
if (data[login].length === 0) {
delete data[login];
}
}
} else if (active) {
if (data[login].length == 4) {
throw new Error('Je možné hlasovat pro maximálně 4 možnosti');
}
data[login].push(option);
}
await storage.setData(STORAGE_KEY, data);
return data;
}
/**
* Vrátí agregované statistiky hlasování - počet hlasů pro každou funkci.
*
* @returns objekt, kde klíčem je název funkce a hodnotou počet hlasů
*/
export async function getVotingStats(): Promise<VotingStatsResult> {
const data = await storage.getData<VotingData>(STORAGE_KEY);
const stats: VotingStatsResult = {};
if (data) {
for (const votes of Object.values(data)) {
for (const feature of votes) {
stats[feature] = (stats[feature] || 0) + 1;
}
}
}
return stats;
}
+9 -7
View File
@@ -69,13 +69,15 @@ paths:
/stats: /stats:
$ref: "./paths/stats/stats.yml" $ref: "./paths/stats/stats.yml"
# Hlasování (/api/voting) # Návrhy na vylepšení (/api/suggestions)
/voting/getVotes: /suggestions/list:
$ref: "./paths/voting/getVotes.yml" $ref: "./paths/suggestions/list.yml"
/voting/updateVote: /suggestions/add:
$ref: "./paths/voting/updateVote.yml" $ref: "./paths/suggestions/add.yml"
/voting/stats: /suggestions/vote:
$ref: "./paths/voting/getVotingStats.yml" $ref: "./paths/suggestions/vote.yml"
/suggestions/delete:
$ref: "./paths/suggestions/delete.yml"
# Changelog (/api/changelogs) # Changelog (/api/changelogs)
/changelogs: /changelogs:
+28
View File
@@ -0,0 +1,28 @@
post:
operationId: addSuggestion
summary: Přidá nový návrh na vylepšení. Autorovi se automaticky nastaví hlas pro.
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- title
- description
properties:
title:
type: string
description: Stručný jednořádkový název návrhu
description:
type: string
description: Detailní popis navrhované úpravy
responses:
"200":
description: Aktualizovaný seznam návrhů.
content:
application/json:
schema:
type: array
items:
$ref: "../../schemas/_index.yml#/Suggestion"
+26
View File
@@ -0,0 +1,26 @@
post:
operationId: deleteSuggestion
summary: >-
Smaže návrh včetně všech jeho hlasů. Smazat lze pouze vlastní návrh
(validováno na serveru).
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- id
properties:
id:
type: string
description: Identifikátor návrhu ke smazání
responses:
"200":
description: Aktualizovaný seznam návrhů.
content:
application/json:
schema:
type: array
items:
$ref: "../../schemas/_index.yml#/Suggestion"
+11
View File
@@ -0,0 +1,11 @@
get:
operationId: listSuggestions
summary: Vrátí seznam návrhů na vylepšení seřazený sestupně dle počtu hlasů.
responses:
"200":
content:
application/json:
schema:
type: array
items:
$ref: "../../schemas/_index.yml#/Suggestion"
+30
View File
@@ -0,0 +1,30 @@
post:
operationId: voteSuggestion
summary: >-
Přepne hlas přihlášeného uživatele u návrhu. Klik na již aktivní směr hlas
zruší, opačný směr stávající hlas přepíše.
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- id
- direction
properties:
id:
type: string
description: Identifikátor návrhu
direction:
description: Směr hlasu, na který uživatel klikl
$ref: "../../schemas/_index.yml#/VoteDirection"
responses:
"200":
description: Aktualizovaný seznam návrhů.
content:
application/json:
schema:
type: array
items:
$ref: "../../schemas/_index.yml#/Suggestion"
-11
View File
@@ -1,11 +0,0 @@
get:
operationId: getVotes
summary: Vrátí statistiky hlasování o nových funkcích.
responses:
"200":
content:
application/json:
schema:
type: array
items:
$ref: "../../schemas/_index.yml#/FeatureRequest"
-9
View File
@@ -1,9 +0,0 @@
get:
operationId: getVotingStats
summary: Vrátí agregované statistiky hlasování o nových funkcích.
responses:
"200":
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/VotingStats"
-22
View File
@@ -1,22 +0,0 @@
post:
operationId: updateVote
summary: Aktualizuje hlasování uživatele o dané funkcionalitě.
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- option
- active
properties:
option:
description: Hlasovací možnost, kterou uživatel zvolil.
$ref: "../../schemas/_index.yml#/FeatureRequest"
active:
type: boolean
description: True, pokud uživatel hlasoval pro, jinak false.
responses:
"200":
description: Hlasování bylo úspěšně aktualizováno.
+38 -27
View File
@@ -272,39 +272,50 @@ DepartureTime:
- T12_45 - T12_45
- T13_00 - T13_00
# --- HLASOVÁNÍ --- # --- NÁVRHY NA VYLEPŠENÍ ---
FeatureRequest: VoteDirection:
description: Směr hlasu uživatele u návrhu
type: string type: string
enum: enum:
- Možnost označovat si jídla jako oblíbená (taková jídla by se uživateli následně zvýrazňovala) - up
- Možnost úhrady v podniku za všechny jednou osobou a následné generování QR ostatním - down
- Zrušení \"užívejte víkend\", místo toho umožnit zpětně náhled na uplynulý týden
- Umožnění zobrazení vygenerovaného QR kódu i po následující dny (dokud ho uživatel ručně \"nezavře\", např. tlačítkem \"Zaplatil jsem\")
- Zobrazování náhledů (fotografií) pizz v rámci Pizza day
- Statistiky (nejoblíbenější podnik, nejpopulárnější jídla, nejobjednávanější pizzy, nejčastější uživatelé, ...)
- Vylepšení responzivního designu
- Zvýšení zabezpečení aplikace
- Zvýšená ochrana proti chybám uživatele (potvrzovací dialogy, překliky, ...)
- Celkové vylepšení UI/UX
- Zlepšení dokumentace/postupů pro ostatní vývojáře
x-enum-varnames: x-enum-varnames:
- FAVORITES - UP
- SINGLE_PAYMENT - DOWN
- NO_WEEKENDS
- QR_FOREVER
- PIZZA_PICTURES
- STATISTICS
- RESPONSIVITY
- SECURITY
- SAFETY
- UI
- DEVELOPMENT
VotingStats: Suggestion:
description: Statistiky hlasování - klíčem je název funkce, hodnotou počet hlasů description: Návrh na vylepšení aplikace tak, jak je posílán klientovi.
type: object type: object
additionalProperties: additionalProperties: false
required:
- id
- author
- title
- description
- voteScore
- isMine
properties:
id:
type: string
description: Jednoznačný identifikátor návrhu
author:
type: string
description: Login uživatele, který návrh vytvořil
title:
type: string
description: Stručný jednořádkový název návrhu
description:
type: string
description: Detailní popis navrhované úpravy
voteScore:
type: integer type: integer
description: Skóre návrhu = počet hlasů pro mínus počet hlasů proti
myVote:
description: Hlas přihlášeného uživatele (chybí, pokud nehlasoval)
$ref: "#/VoteDirection"
isMine:
type: boolean
description: True, pokud návrh vytvořil přihlášený uživatel
# --- EASTER EGGS --- # --- EASTER EGGS ---
EasterEgg: EasterEgg: