Files
RecipesCam/docker/frontend/src/ui/Stats.tsx
T
3dtours 89beb5f160 web: put the traffic screen in the admin frame
installTracking() beacons one view per page load and one click per control that
carries a data-key, so every existing button is already counted. The new STATS
pane reads it back: a 7/30/90-day range, the three totals, an SVG timeline and
eight proportional bar lists (pages, clicked features, country, region, city,
browser, system, device).
2026-09-18 14:23:25 +07:00

152 lines
5.4 KiB
TypeScript

import { useEffect, useState } from 'react';
import { api, type StatBucket, type Stats as StatsData } from '../api';
import { useI18n } from '../i18n/I18nProvider';
// The traffic screen. One fetch per range; every figure below is drawn from it.
// No charting dependency: two polylines and a list of styled divs are what an
// admin actually reads here, and they cost a few hundred bytes instead of a
// library's worth.
const RANGES = [7, 30, 90];
export function Stats() {
const { t } = useI18n();
const [days, setDays] = useState(30);
const [data, setData] = useState<StatsData | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
let live = true;
setErr(null);
api.adminStats(days).then(
(d) => {
if (live) setData(d);
},
(e: unknown) => {
if (live) setErr(e instanceof Error ? e.message : String(e));
},
);
return () => {
live = false;
};
}, [days]);
return (
<div data-key="stats-pane">
<div className="stats-bar">
{RANGES.map((n) => (
<button
key={n}
type="button"
className={`btn${days === n ? ' primary' : ''}`}
data-key={`stats-range-${n}`}
aria-pressed={days === n}
onClick={() => setDays(n)}
>
{t('stats.days', { n })}
</button>
))}
<span className="hint">{t('stats.hint')}</span>
</div>
{err ? <p className="err adm-note">{err}</p> : null}
{data ? <Body data={data} /> : err ? null : <p className="hint adm-note">{t('auth.busy')}</p>}
</div>
);
}
function Body({ data }: { data: StatsData }) {
const { t } = useI18n();
return (
<>
<div className="stats-kpis">
<Kpi k="views" label={t('stats.views')} n={data.totals.views} />
<Kpi k="clicks" label={t('stats.clicks')} n={data.totals.clicks} />
<Kpi k="visitors" label={t('stats.visitors')} n={data.totals.visitors} />
</div>
<Chart series={data.series} />
<div className="stats-cols">
<Bars k="pages" title={t('stats.pages')} rows={data.pages} />
<Bars k="targets" title={t('stats.targets')} rows={data.targets} />
<Bars k="countries" title={t('stats.countries')} rows={data.countries} />
<Bars k="regions" title={t('stats.regions')} rows={data.regions} />
<Bars k="cities" title={t('stats.cities')} rows={data.cities} />
<Bars k="browsers" title={t('stats.browsers')} rows={data.browsers} />
<Bars k="systems" title={t('stats.systems')} rows={data.systems} />
<Bars k="devices" title={t('stats.devices')} rows={data.devices} />
</div>
</>
);
}
function Kpi({ k, label, n }: { k: string; label: string; n: number }) {
return (
<div className="stats-kpi" data-key={`stats-kpi-${k}`}>
<b data-key={`stats-kpi-${k}-n`}>{n}</b>
<span>{label}</span>
</div>
);
}
// Views and clicks per day. `viewBox` plus `preserveAspectRatio="none"` lets the
// SVG stretch to the pane's width without measuring the DOM.
function Chart({ series }: { series: StatsData['series'] }) {
const { t } = useI18n();
const W = 600;
const H = 100;
const max = Math.max(1, ...series.map((d) => Math.max(d.views, d.clicks)));
const step = series.length > 1 ? W / (series.length - 1) : 0;
const line = (pick: (d: StatsData['series'][number]) => number) =>
series.map((d, i) => `${(i * step).toFixed(1)},${(H - (pick(d) / max) * H).toFixed(1)}`).join(' ');
const first = series[0]?.date ?? '';
const last = series[series.length - 1]?.date ?? '';
return (
<section data-key="stats-chart">
<h3 className="stats-h">{t('stats.timeline')}</h3>
<svg className="stats-chart" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" role="img">
<polygon className="stats-area" points={`0,${H} ${line((d) => d.views)} ${W},${H}`} />
<polyline
className="stats-line"
vectorEffect="non-scaling-stroke"
points={line((d) => d.views)}
/>
<polyline
className="stats-line stats-line-2"
vectorEffect="non-scaling-stroke"
points={line((d) => d.clicks)}
/>
</svg>
<div className="stats-legend">
<span><i style={{ background: 'var(--accent)' }} />{t('stats.views')}</span>
<span><i style={{ background: 'var(--text-dim)' }} />{t('stats.clicks')}</span>
</div>
<p className="hint stats-axis" data-key="stats-axis">{first} → {last} · {t('stats.peak')} {max}</p>
</section>
);
}
// One grouped count, longest bar first. The bar is proportional to the block's
// own largest row, so a small dimension stays readable next to a big one.
function Bars({ k, title, rows }: { k: string; title: string; rows: StatBucket[] }) {
const { t } = useI18n();
const max = Math.max(1, ...rows.map((r) => r.n));
return (
<section data-key={`stats-block-${k}`}>
<h3 className="stats-h">{title}</h3>
{rows.length === 0 ? (
<p className="hint">{t('stats.empty')}</p>
) : (
<ul className="stats-list">
{rows.map((r) => (
<li className="stats-row" key={r.key}>
<span className="stats-key" title={r.key}>{r.key}</span>
<span className="stats-track"><i style={{ width: `${(r.n / max) * 100}%` }} /></span>
<b className="stats-n">{r.n}</b>
</li>
))}
</ul>
)}
</section>
);
}