diff --git a/client/src/components/PriceHistory.scss b/client/src/components/PriceHistory.scss
index fd96a1e..5d70a20 100644
--- a/client/src/components/PriceHistory.scss
+++ b/client/src/components/PriceHistory.scss
@@ -31,3 +31,27 @@
.price-history-entry + .price-history-entry {
margin-top: 6px;
}
+
+.price-history-tabs {
+ display: inline-flex;
+ gap: 2px;
+ padding: 2px;
+ margin-bottom: 6px;
+ border-radius: 999px;
+ background: var(--luncher-bg-hover);
+
+ button {
+ border: none;
+ background: none;
+ border-radius: 999px;
+ padding: 2px 10px;
+ font-size: 0.75rem;
+ font-weight: 700;
+ color: var(--luncher-text-secondary);
+
+ &.active {
+ background: var(--luncher-bg-card);
+ color: var(--luncher-primary);
+ }
+ }
+}
diff --git a/client/src/components/PriceHistory.tsx b/client/src/components/PriceHistory.tsx
index f19f12c..bac7375 100644
--- a/client/src/components/PriceHistory.tsx
+++ b/client/src/components/PriceHistory.tsx
@@ -1,6 +1,7 @@
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { getStoreMenuHistory, StoreMenuProvider, StorePriceChange, StorePriceHistoryEntry } from "../../../types";
import { getHumanDateTime } from "../Utils";
+import { computeSinceStart, plural, SinceStart } from "../utils/priceHistory";
import "./PriceHistory.scss";
type Props = {
@@ -29,23 +30,59 @@ function ChangeRow({ change }: Readonly<{ change: StorePriceChange }>) {
function EntryRow({ entry }: Readonly<{ entry: StorePriceHistoryEntry }>) {
const when = getHumanDateTime(new Date(entry.at));
if (entry.initial) {
- return
{when} — první stažení ({entry.itemCount} položek)
;
+ return {when} — první stažení ({plural(entry.itemCount ?? 0, 'položka', 'položky', 'položek')})
;
}
const ups = entry.changes.filter(c => c.before != null && c.after != null && c.after > c.before).length;
const downs = entry.changes.filter(c => c.before != null && c.after != null && c.after < c.before).length;
return (
- {when} — {entry.changes.length} změn{ups > 0 && `, ${ups}× zdražení`}{downs > 0 && `, ${downs}× zlevnění`}
+ {when} — {plural(entry.changes.length, 'změna', 'změny', 'změn')}{ups > 0 && `, ${ups}× zdražení`}{downs > 0 && `, ${downs}× zlevnění`}
{entry.changes.map((c, i) => )}
);
}
+function SinceStartView({ summary }: Readonly<{ summary: SinceStart }>) {
+ const { since, changed, added, removed } = summary;
+ if (changed.length + added.length + removed.length === 0) {
+ return Od {getHumanDateTime(new Date(since))} beze změn.;
+ }
+ const ups = changed.filter(c => c.current > c.start).length;
+ const avgPct = changed.length
+ ? changed.reduce((sum, c) => sum + (c.current - c.start) / c.start * 100, 0) / changed.length
+ : 0;
+ return (
+
+
+ Od {getHumanDateTime(new Date(since))}: {plural(changed.length, 'změna ceny', 'změny cen', 'změn cen')} ({ups}× zdražení, {changed.length - ups}× zlevnění)
+ {changed.length > 0 && `, průměrně ${avgPct > 0 ? '+' : ''}${avgPct.toFixed(1)} %`}
+ {added.length > 0 && `, ${plural(added.length, 'nová položka', 'nové položky', 'nových položek')}`}{removed.length > 0 && `, ${plural(removed.length, 'odebraná', 'odebrané', 'odebraných')}`}
+
+
+ {changed.map(c => {
+ const diff = c.current - c.start;
+ const up = diff > 0;
+ return (
+ -
+ {up ? '▲' : '▼'} {up ? '+' : ''}{diff} Kč {c.name}: {c.start} → {c.current} Kč
+ ({up ? '+' : ''}{(diff / c.start * 100).toFixed(0)} %)
+
+ );
+ })}
+ {added.map(a => - nové {a.name}{a.price != null && <> {a.price} Kč>}
)}
+ {removed.map(r => - odebráno
{r.name}{r.price != null && <> ({r.price} Kč)>} )}
+
+
+ );
+}
+
/** Historie cen nabídky obchodu u jedné služby (verze z jednotlivých stažení, nejnovější nahoře). */
export default function PriceHistory({ store, provider }: Readonly) {
const [entries, setEntries] = useState();
+ const [view, setView] = useState<'versions' | 'since'>('versions');
+ const summary = useMemo(() => entries ? computeSinceStart(entries) : undefined, [entries]);
useEffect(() => {
getStoreMenuHistory({ query: { name: store } })
@@ -55,9 +92,18 @@ export default function PriceHistory({ store, provider }: Readonly) {
if (!entries) return Načítám historii…;
if (entries.length === 0) return Historie cen zatím není — vznikne s prvním stažením nabídky.;
+ const hasChanges = entries.some(e => e.changes.length > 0);
return (
- {entries.map(e =>
)}
+ {hasChanges && (
+
+
+
+
+ )}
+ {view === 'since' && summary
+ ?
+ : entries.map(e =>
)}
{entries.length === 1 && entries[0].initial && (
Změny se objeví po dalším stažení (↻).
)}
diff --git a/client/src/utils/priceHistory.ts b/client/src/utils/priceHistory.ts
new file mode 100644
index 0000000..85dc159
--- /dev/null
+++ b/client/src/utils/priceHistory.ts
@@ -0,0 +1,50 @@
+import type { StorePriceHistoryEntry } from "../../../types";
+
+export type SinceStart = {
+ /** Od kdy se počítá (první známá verze) */
+ since: string,
+ changed: { name: string, start: number, current: number }[],
+ added: { name: string, price?: number }[],
+ removed: { name: string, price?: number }[],
+};
+
+/**
+ * Souhrn „od začátku": počáteční cena položky = „before" její nejstarší změny,
+ * aktuální = „after" nejnovější. Položka přidaná a později zase odebraná se vynechá.
+ */
+export function computeSinceStart(entries: StorePriceHistoryEntry[]): SinceStart | undefined {
+ if (entries.length === 0) return undefined;
+ const chronological = [...entries].reverse();
+ const items = new Map
();
+ for (const entry of chronological) {
+ for (const c of entry.changes) {
+ const key = c.name.trim().toLowerCase();
+ let item = items.get(key);
+ if (!item) {
+ item = { name: c.name, start: c.before, addedLater: c.before == null, removed: false };
+ items.set(key, item);
+ }
+ item.name = c.name;
+ item.current = c.after;
+ item.removed = c.after == null;
+ }
+ }
+ const result: SinceStart = { since: chronological[0].at, changed: [], added: [], removed: [] };
+ for (const item of items.values()) {
+ if (item.addedLater && item.removed) continue;
+ if (item.addedLater) result.added.push({ name: item.name, price: item.current });
+ else if (item.removed) result.removed.push({ name: item.name, price: item.start });
+ else if (item.start != null && item.current != null && item.start !== item.current) {
+ result.changed.push({ name: item.name, start: item.start, current: item.current });
+ }
+ }
+ // Největší změny nahoře
+ result.changed.sort((a, b) => Math.abs(b.current - b.start) - Math.abs(a.current - a.start));
+ return result;
+}
+
+/** Český plurál: 1 změna, 2–4 změny, 5+ změn. */
+export function plural(n: number, one: string, few: string, many: string): string {
+ const form = n === 1 ? one : n >= 2 && n <= 4 ? few : many;
+ return `${n} ${form}`;
+}