feat: souhrn historie cen od začátku
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 26s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m49s
CI / Build and push Docker image (push) Successful in 48s
CI / Notify (push) Successful in 1s
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 26s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m49s
CI / Build and push Docker image (push) Successful in 48s
CI / Notify (push) Successful in 1s
This commit is contained in:
@@ -31,3 +31,27 @@
|
|||||||
.price-history-entry + .price-history-entry {
|
.price-history-entry + .price-history-entry {
|
||||||
margin-top: 6px;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { getStoreMenuHistory, StoreMenuProvider, StorePriceChange, StorePriceHistoryEntry } from "../../../types";
|
import { getStoreMenuHistory, StoreMenuProvider, StorePriceChange, StorePriceHistoryEntry } from "../../../types";
|
||||||
import { getHumanDateTime } from "../Utils";
|
import { getHumanDateTime } from "../Utils";
|
||||||
|
import { computeSinceStart, plural, SinceStart } from "../utils/priceHistory";
|
||||||
import "./PriceHistory.scss";
|
import "./PriceHistory.scss";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -29,23 +30,59 @@ function ChangeRow({ change }: Readonly<{ change: StorePriceChange }>) {
|
|||||||
function EntryRow({ entry }: Readonly<{ entry: StorePriceHistoryEntry }>) {
|
function EntryRow({ entry }: Readonly<{ entry: StorePriceHistoryEntry }>) {
|
||||||
const when = getHumanDateTime(new Date(entry.at));
|
const when = getHumanDateTime(new Date(entry.at));
|
||||||
if (entry.initial) {
|
if (entry.initial) {
|
||||||
return <div className="price-history-entry"><small className="text-muted">{when} — první stažení ({entry.itemCount} položek)</small></div>;
|
return <div className="price-history-entry"><small className="text-muted">{when} — první stažení ({plural(entry.itemCount ?? 0, 'položka', 'položky', 'položek')})</small></div>;
|
||||||
}
|
}
|
||||||
const ups = entry.changes.filter(c => c.before != null && c.after != null && c.after > c.before).length;
|
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;
|
const downs = entry.changes.filter(c => c.before != null && c.after != null && c.after < c.before).length;
|
||||||
return (
|
return (
|
||||||
<div className="price-history-entry">
|
<div className="price-history-entry">
|
||||||
<small className="text-muted">
|
<small className="text-muted">
|
||||||
{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í`}
|
||||||
</small>
|
</small>
|
||||||
<ul>{entry.changes.map((c, i) => <ChangeRow key={i} change={c} />)}</ul>
|
<ul>{entry.changes.map((c, i) => <ChangeRow key={i} change={c} />)}</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SinceStartView({ summary }: Readonly<{ summary: SinceStart }>) {
|
||||||
|
const { since, changed, added, removed } = summary;
|
||||||
|
if (changed.length + added.length + removed.length === 0) {
|
||||||
|
return <small className="text-muted">Od {getHumanDateTime(new Date(since))} beze změn.</small>;
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<div className="price-history-entry">
|
||||||
|
<small className="text-muted">
|
||||||
|
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')}`}
|
||||||
|
</small>
|
||||||
|
<ul>
|
||||||
|
{changed.map(c => {
|
||||||
|
const diff = c.current - c.start;
|
||||||
|
const up = diff > 0;
|
||||||
|
return (
|
||||||
|
<li key={c.name} className={up ? 'price-up' : 'price-down'}>
|
||||||
|
<span className="price-tag">{up ? '▲' : '▼'} {up ? '+' : ''}{diff} Kč</span> {c.name}: {c.start} → <strong>{c.current} Kč</strong>
|
||||||
|
<small className="text-muted"> ({up ? '+' : ''}{(diff / c.start * 100).toFixed(0)} %)</small>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{added.map(a => <li key={`+${a.name}`} className="price-new"><span className="price-tag">nové</span> {a.name}{a.price != null && <> <strong>{a.price} Kč</strong></>}</li>)}
|
||||||
|
{removed.map(r => <li key={`-${r.name}`} className="price-removed"><span className="price-tag">odebráno</span> <s>{r.name}</s>{r.price != null && <> ({r.price} Kč)</>}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Historie cen nabídky obchodu u jedné služby (verze z jednotlivých stažení, nejnovější nahoře). */
|
/** 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<Props>) {
|
export default function PriceHistory({ store, provider }: Readonly<Props>) {
|
||||||
const [entries, setEntries] = useState<StorePriceHistoryEntry[]>();
|
const [entries, setEntries] = useState<StorePriceHistoryEntry[]>();
|
||||||
|
const [view, setView] = useState<'versions' | 'since'>('versions');
|
||||||
|
const summary = useMemo(() => entries ? computeSinceStart(entries) : undefined, [entries]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getStoreMenuHistory({ query: { name: store } })
|
getStoreMenuHistory({ query: { name: store } })
|
||||||
@@ -55,9 +92,18 @@ export default function PriceHistory({ store, provider }: Readonly<Props>) {
|
|||||||
|
|
||||||
if (!entries) return <small className="text-muted">Načítám historii…</small>;
|
if (!entries) return <small className="text-muted">Načítám historii…</small>;
|
||||||
if (entries.length === 0) return <small className="text-muted">Historie cen zatím není — vznikne s prvním stažením nabídky.</small>;
|
if (entries.length === 0) return <small className="text-muted">Historie cen zatím není — vznikne s prvním stažením nabídky.</small>;
|
||||||
|
const hasChanges = entries.some(e => e.changes.length > 0);
|
||||||
return (
|
return (
|
||||||
<div className="price-history">
|
<div className="price-history">
|
||||||
{entries.map(e => <EntryRow key={e.at} entry={e} />)}
|
{hasChanges && (
|
||||||
|
<div className="price-history-tabs">
|
||||||
|
<button type="button" className={view === 'versions' ? 'active' : ''} onClick={() => setView('versions')}>Po verzích</button>
|
||||||
|
<button type="button" className={view === 'since' ? 'active' : ''} onClick={() => setView('since')}>Od začátku</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{view === 'since' && summary
|
||||||
|
? <SinceStartView summary={summary} />
|
||||||
|
: entries.map(e => <EntryRow key={e.at} entry={e} />)}
|
||||||
{entries.length === 1 && entries[0].initial && (
|
{entries.length === 1 && entries[0].initial && (
|
||||||
<small className="text-muted d-block mt-1">Změny se objeví po dalším stažení (↻).</small>
|
<small className="text-muted d-block mt-1">Změny se objeví po dalším stažení (↻).</small>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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<string, { name: string, start?: number, current?: number, addedLater: boolean, removed: boolean }>();
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user