feat(stats): voice-time and message leaderboards per space
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
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) Waiting to run
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
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.
This commit is contained in:
@@ -74,6 +74,22 @@ import type {
|
||||
Activity,
|
||||
} from '@backspace/shared';
|
||||
import type { AuditEvent } from '@backspace/shared/src/audit.js';
|
||||
|
||||
export interface StatsLeader {
|
||||
userId: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
avatar: string | null;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface SpaceStats {
|
||||
days: number;
|
||||
since: number;
|
||||
voice: StatsLeader[];
|
||||
messages: StatsLeader[];
|
||||
totals: { voiceMs: number; messages: number };
|
||||
}
|
||||
import { getApiForOrigin, getOwnerInstanceForDm } from '../utils/crossStoreResolvers';
|
||||
|
||||
export type { FederationPeer, FederationOrphanedAccount, FederationResetEvent, FederationResetEventsResponse, ApprovalRequest, PeeringSubscription, PeeringNotification };
|
||||
@@ -286,6 +302,10 @@ export class BackspaceApiClient {
|
||||
removeFavorite: (id: string) => Promise<void>;
|
||||
};
|
||||
|
||||
readonly stats: {
|
||||
space: (spaceId: string, days: number) => Promise<SpaceStats>;
|
||||
};
|
||||
|
||||
readonly audit: {
|
||||
log: (spaceId: string, before?: string) => Promise<{ events: AuditEvent[]; hasMore: boolean }>;
|
||||
};
|
||||
@@ -704,6 +724,11 @@ export class BackspaceApiClient {
|
||||
},
|
||||
};
|
||||
|
||||
this.stats = {
|
||||
space: (spaceId: string, days: number) =>
|
||||
request<SpaceStats>('GET', `/spaces/${spaceId}/stats?days=${days}`),
|
||||
};
|
||||
|
||||
this.audit = {
|
||||
log: (spaceId: string, before?: string) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
@@ -8,6 +8,7 @@ import { api } from '../../api/client';
|
||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||
import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel';
|
||||
import { AuditLogPanel } from './spaceSettingsPanels/AuditLogPanel';
|
||||
import { StatsPanel } from './spaceSettingsPanels/StatsPanel';
|
||||
import { useT } from '../../i18n';
|
||||
import { MembersPanel } from './spaceSettingsPanels/MembersPanel';
|
||||
import { RolesPanel } from './spaceSettingsPanels/RolesPanel';
|
||||
@@ -269,7 +270,7 @@ export function SpaceSettingsModal() {
|
||||
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
||||
|
||||
const t = useT();
|
||||
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'bans' | 'audit'>('overview');
|
||||
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'bans' | 'audit' | 'stats'>('overview');
|
||||
const [mobileView, setMobileView] = useState<'tabs' | 'content'>('tabs');
|
||||
|
||||
const isOpen = activeModal === 'spaceSettings';
|
||||
@@ -337,6 +338,7 @@ export function SpaceSettingsModal() {
|
||||
{canManageSpace && (
|
||||
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button>
|
||||
)}
|
||||
<button onClick={() => handleTabClick('stats')} className={tabClass('stats')}>{t('stats.title')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -375,6 +377,7 @@ export function SpaceSettingsModal() {
|
||||
{canManageSpace && (
|
||||
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button>
|
||||
)}
|
||||
<button onClick={() => handleTabClick('stats')} className={tabClass('stats')}>{t('stats.title')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -402,6 +405,7 @@ export function SpaceSettingsModal() {
|
||||
{tab === 'roles' && canManageRoles && <RolesPanel spaceId={currentSpaceId} />}
|
||||
{tab === 'bans' && canBanMembers && <BansPanel spaceId={currentSpaceId} />}
|
||||
{tab === 'audit' && canManageSpace && <AuditLogPanel spaceId={currentSpaceId} />}
|
||||
{tab === 'stats' && <StatsPanel spaceId={currentSpaceId} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,20 @@ export const en = {
|
||||
'settings.voice.micTest.idle': 'Test your mic without joining a call.',
|
||||
'settings.voice.micTest.failed': 'Could not open the microphone. Check the device and its permission.',
|
||||
|
||||
// Statistics
|
||||
'stats.title': 'Statistics',
|
||||
'stats.range.7': 'Last 7 days',
|
||||
'stats.range.30': 'Last 30 days',
|
||||
'stats.range.365': 'Last year',
|
||||
'stats.voice.title': 'Time in voice',
|
||||
'stats.messages.title': 'Messages sent',
|
||||
'stats.empty': 'Nothing recorded in this period yet.',
|
||||
'stats.total.voice': '{value} total',
|
||||
'stats.total.messages': '{value} messages in total',
|
||||
'stats.hours': '{hours}h {minutes}m',
|
||||
'stats.minutes': '{minutes}m',
|
||||
'stats.note': 'Counting started when this feature was installed — earlier activity is not included.',
|
||||
|
||||
// Audit log
|
||||
'audit.title': 'Audit Log',
|
||||
'audit.empty': 'Nothing recorded yet. Changes to the server show up here.',
|
||||
|
||||
@@ -32,6 +32,20 @@ export const ptBR: Partial<Dictionary> = {
|
||||
'settings.voice.micTest.idle': 'Teste seu microfone sem entrar numa call.',
|
||||
'settings.voice.micTest.failed': 'Não foi possível abrir o microfone. Verifique o dispositivo e a permissão.',
|
||||
|
||||
// Estatísticas
|
||||
'stats.title': 'Estatísticas',
|
||||
'stats.range.7': 'Últimos 7 dias',
|
||||
'stats.range.30': 'Últimos 30 dias',
|
||||
'stats.range.365': 'Último ano',
|
||||
'stats.voice.title': 'Tempo em call',
|
||||
'stats.messages.title': 'Mensagens enviadas',
|
||||
'stats.empty': 'Nada registrado neste período ainda.',
|
||||
'stats.total.voice': '{value} no total',
|
||||
'stats.total.messages': '{value} mensagens no total',
|
||||
'stats.hours': '{hours}h {minutes}min',
|
||||
'stats.minutes': '{minutes}min',
|
||||
'stats.note': 'A contagem começou quando esta funcionalidade foi instalada — atividade anterior não entra.',
|
||||
|
||||
// Registro de auditoria
|
||||
'audit.title': 'Registro de auditoria',
|
||||
'audit.empty': 'Nada registrado ainda. Mudanças no servidor aparecem aqui.',
|
||||
|
||||
Reference in New Issue
Block a user