CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Voice stays get their own table rather than joining the audit log: that table records points in time, a call is an interval, and pairing join/leave point events would leave every query guessing at joins whose leave never arrived. Sessions are opened and closed inside joinRoom/leaveCurrentRoom rather than at the seven call sites that reach them, so no path can be missed, and destroyRoom closes them too — it bypasses leaveCurrentRoom and would otherwise leak open rows. A restart leaves sessions open with an unknowable end time. They are closed at startedAt, discarding that time rather than inventing it: crediting the gap would hand someone hours they never spent, and the numbers are the point. Mirrors the existing users.status sweep on boot. Only closed sessions count, so a figure does not move on every refresh. Bars scale to the leader, not the total — with five people every share of a total looks identical. Statistics are readable by any member, since they are the group's own numbers; the audit log, which names who did what, stays on MANAGE_SPACE.
143 lines
4.8 KiB
TypeScript
143 lines
4.8 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { api, type SpaceStats, type StatsLeader } from '../../../api/client';
|
|
import { Avatar } from '../../ui/Avatar';
|
|
import { useT, type TranslationKey } from '../../../i18n';
|
|
|
|
interface StatsPanelProps {
|
|
spaceId: string;
|
|
}
|
|
|
|
const RANGES: { days: number; key: TranslationKey }[] = [
|
|
{ days: 7, key: 'stats.range.7' },
|
|
{ days: 30, key: 'stats.range.30' },
|
|
{ days: 365, key: 'stats.range.365' },
|
|
];
|
|
|
|
function Leaderboard({
|
|
title,
|
|
rows,
|
|
format,
|
|
}: {
|
|
title: string;
|
|
rows: StatsLeader[];
|
|
format: (value: number) => string;
|
|
}) {
|
|
// The bar is relative to the leader, not to the total: with five people the
|
|
// share of a total is tiny and every bar looks the same.
|
|
const max = rows.length > 0 ? Math.max(...rows.map((r) => r.value)) : 0;
|
|
|
|
return (
|
|
<div>
|
|
<h3 className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary mb-2">{title}</h3>
|
|
<ul className="space-y-1.5">
|
|
{rows.map((row) => (
|
|
<li key={row.userId} className="flex items-center gap-3">
|
|
<Avatar src={row.avatar} name={row.displayName ?? row.username} size={26} userId={row.userId} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-baseline justify-between gap-2">
|
|
<span className="text-[13px] text-txt-secondary truncate">
|
|
{row.displayName ?? row.username}
|
|
</span>
|
|
<span className="text-[12px] text-txt-tertiary tabular-nums flex-shrink-0">
|
|
{format(row.value)}
|
|
</span>
|
|
</div>
|
|
<div className="h-[3px] rounded-full bg-interactive-muted mt-1 overflow-hidden">
|
|
<div
|
|
className="h-full bg-accent-primary rounded-full"
|
|
style={{ width: max > 0 ? `${(row.value / max) * 100}%` : '0%' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function StatsPanel({ spaceId }: StatsPanelProps) {
|
|
const t = useT();
|
|
const [days, setDays] = useState(30);
|
|
const [stats, setStats] = useState<SpaceStats | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
api.stats.space(spaceId, days)
|
|
.then((data) => { if (!cancelled) setStats(data); })
|
|
.catch(() => { if (!cancelled) setStats(null); })
|
|
.finally(() => { if (!cancelled) setLoading(false); });
|
|
return () => { cancelled = true; };
|
|
}, [spaceId, days]);
|
|
|
|
const formatDuration = (ms: number) => {
|
|
const minutes = Math.round(ms / 60000);
|
|
const hours = Math.floor(minutes / 60);
|
|
return hours > 0
|
|
? t('stats.hours', { hours, minutes: minutes % 60 })
|
|
: t('stats.minutes', { minutes });
|
|
};
|
|
|
|
const isEmpty = !stats || (stats.voice.length === 0 && stats.messages.length === 0);
|
|
|
|
return (
|
|
<div className="max-w-2xl">
|
|
<h2 className="text-lg font-semibold text-txt-primary mb-4">{t('stats.title')}</h2>
|
|
|
|
<div className="flex gap-1.5 mb-5">
|
|
{RANGES.map((range) => (
|
|
<button
|
|
key={range.days}
|
|
type="button"
|
|
onClick={() => setDays(range.days)}
|
|
className={`px-2.5 py-1 rounded-full text-[12px] font-medium transition-colors ${
|
|
days === range.days
|
|
? 'bg-accent-primary text-white'
|
|
: 'bg-surface-elevated text-txt-secondary hover:text-txt-primary'
|
|
}`}
|
|
>
|
|
{t(range.key)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="space-y-2">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<div key={i} className="h-10 rounded-lg bg-surface-elevated animate-pulse" />
|
|
))}
|
|
</div>
|
|
) : isEmpty ? (
|
|
<p className="text-[13px] text-txt-tertiary">{t('stats.empty')}</p>
|
|
) : (
|
|
<div className="space-y-6">
|
|
{stats.voice.length > 0 && (
|
|
<div>
|
|
<Leaderboard title={t('stats.voice.title')} rows={stats.voice} format={formatDuration} />
|
|
<p className="text-[11px] text-txt-tertiary mt-2">
|
|
{t('stats.total.voice', { value: formatDuration(stats.totals.voiceMs) })}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{stats.messages.length > 0 && (
|
|
<div>
|
|
<Leaderboard
|
|
title={t('stats.messages.title')}
|
|
rows={stats.messages}
|
|
format={(value) => String(value)}
|
|
/>
|
|
<p className="text-[11px] text-txt-tertiary mt-2">
|
|
{t('stats.total.messages', { value: stats.totals.messages })}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<p className="text-[11px] text-txt-tertiary mt-6">{t('stats.note')}</p>
|
|
</div>
|
|
);
|
|
}
|