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).
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user