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í`}
); } +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')}`} + + +
+ ); +} + /** 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}`; +}