89beb5f160
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).
152 lines
5.4 KiB
TypeScript
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>
|
|
);
|
|
}
|