feat(stats): voice-time and message leaderboards per space
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.
This commit is contained in:
2026-08-31 13:29:59 -03:00
parent bbb190cbda
commit 1830051732
13 changed files with 4642 additions and 1 deletions
+25
View File
@@ -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();