From 40eec8b39888225126e4943d4d89edccc826f5fb Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 16 Oct 2026 20:58:17 +0200 Subject: [PATCH 1/2] feat: add shocker control stats page Regenerate the v1 client from the dev API, which adds the self control stats endpoint, the integrations control stat changes endpoint and the ControlStatsRead automation token type. Add /shockers/stats with received and given totals, a daily chart and per shocker, controller and owner breakdowns. --- src/lib/api/index.ts | 7 + src/lib/api/internal/v1/index.ts | 22 ++ src/lib/api/internal/v1/sdk.gen.ts | 66 +++++ src/lib/api/internal/v1/transformers.gen.ts | 76 ++++++ src/lib/api/internal/v1/types.gen.ts | 181 +++++++++++++- src/lib/utils/controlStats.test.ts | 113 +++++++++ src/lib/utils/controlStats.ts | 129 ++++++++++ .../(app)/admin/automation-tokens/columns.ts | 1 + src/routes/(app)/shockers/own/+page.svelte | 5 +- src/routes/(app)/shockers/stats/+page.svelte | 227 ++++++++++++++++++ .../shockers/stats/breakdown-table.svelte | 91 +++++++ src/routes/(app)/shockers/stats/colors.ts | 13 + .../(app)/shockers/stats/daily-chart.svelte | 97 ++++++++ .../(app)/shockers/stats/totals-cards.svelte | 60 +++++ src/routes/Sidebar.svelte | 2 + 15 files changed, 1086 insertions(+), 4 deletions(-) create mode 100644 src/lib/utils/controlStats.test.ts create mode 100644 src/lib/utils/controlStats.ts create mode 100644 src/routes/(app)/shockers/stats/+page.svelte create mode 100644 src/routes/(app)/shockers/stats/breakdown-table.svelte create mode 100644 src/routes/(app)/shockers/stats/colors.ts create mode 100644 src/routes/(app)/shockers/stats/daily-chart.svelte create mode 100644 src/routes/(app)/shockers/stats/totals-cards.svelte diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index afd6051f..aeb2ee08 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -100,6 +100,7 @@ export { tokenDeleteDeleteToken, usersGetByName, usersGetSelf, + usersGetSelfControlStats, versionGetBackendInfo, } from './internal/v1'; @@ -107,6 +108,7 @@ export { export { AutomationTokenType, ConfigurationValueType, + ControlType, EmailStatus, EmailType, MatchTypeEnum, @@ -135,7 +137,10 @@ export type { BackendInfoResponse, BasicUserInfo, ConfigurationItemDto, + ControlStatTotals, + ControlStatsResponse, CreatedAutomationTokenDto, + DailyControlStats, DeviceWithShockersResponse, EmailOutboxBulkResultDto, EmailOutboxMessageDto, @@ -157,8 +162,10 @@ export type { PublicShareResponse, PublicShareShocker, SharedShocker, + ShockerControlStats, ShockerResponse, ShockerWithDevice, + UserControlStats, UserNameBlacklistDto, WebhookDto, } from './internal/v1'; diff --git a/src/lib/api/internal/v1/index.ts b/src/lib/api/internal/v1/index.ts index d905df7d..bc4336f3 100644 --- a/src/lib/api/internal/v1/index.ts +++ b/src/lib/api/internal/v1/index.ts @@ -68,6 +68,7 @@ export { devicesOtaGetOtaUpdateHistory, devicesRegenerateDeviceToken, devicesRemoveDevice, + integrationsGetControlStatChanges, publicGetOnlineDevicesStatistics, publicGetPublicShare, sessionsDeleteSession, @@ -107,6 +108,7 @@ export { tokensSelfGetSelfToken, usersGetByName, usersGetSelf, + usersGetSelfControlStats, versionGetBackendInfo, type Options, } from './sdk.gen'; @@ -375,9 +377,14 @@ export { type ConfigurationUpdateItemRequest, type Control, type ControlLogSenderLight, + type ControlStatChangesRequest, + type ControlStatChangesResponse, + type ControlStatTotals, + type ControlStatsResponse, type CreateAutomationTokenDto, type CreateTokenRequest, type CreatedAutomationTokenDto, + type DailyControlStats, type DeviceGetLiveControlGatewayData, type DeviceGetLiveControlGatewayError, type DeviceGetLiveControlGatewayErrors, @@ -445,7 +452,13 @@ export { type EmailOutboxMessageDto, type EmailOutboxStatsDto, type EmailProviderBlacklistDto, + type GivenControlStats, type HubEditRequest, + type IntegrationsGetControlStatChangesData, + type IntegrationsGetControlStatChangesError, + type IntegrationsGetControlStatChangesErrors, + type IntegrationsGetControlStatChangesResponse, + type IntegrationsGetControlStatChangesResponses, type LcgNodeResponse, type LcgResponse, type LegacyDataResponseOfArrayOfAdminOnlineDeviceResponse, @@ -503,6 +516,7 @@ export { type PublicShareEditShocker, type PublicShareResponse, type PublicShareShocker, + type ReceivedControlStats, type ReportTokensRequest, type SendTestEmailDto, type SessionsDeleteSessionData, @@ -563,6 +577,7 @@ export { type SharesLinkShareCodeErrors, type SharesLinkShareCodeResponse, type SharesLinkShareCodeResponses, + type ShockerControlStats, type ShockerEditShockerData, type ShockerEditShockerError, type ShockerEditShockerErrors, @@ -667,6 +682,8 @@ export { type TokensSelfGetSelfTokenData, type TokensSelfGetSelfTokenResponse, type TokensSelfGetSelfTokenResponses, + type UserControlStatTotals, + type UserControlStats, type UserNameBlacklistDto, type UserPatchDto, type UserSelfResponse, @@ -677,6 +694,11 @@ export { type UsersGetByNameErrors, type UsersGetByNameResponse, type UsersGetByNameResponses, + type UsersGetSelfControlStatsData, + type UsersGetSelfControlStatsError, + type UsersGetSelfControlStatsErrors, + type UsersGetSelfControlStatsResponse, + type UsersGetSelfControlStatsResponses, type UsersGetSelfData, type UsersGetSelfResponse, type UsersGetSelfResponses, diff --git a/src/lib/api/internal/v1/sdk.gen.ts b/src/lib/api/internal/v1/sdk.gen.ts index 297a45fc..90e587a4 100644 --- a/src/lib/api/internal/v1/sdk.gen.ts +++ b/src/lib/api/internal/v1/sdk.gen.ts @@ -24,6 +24,7 @@ import { devicesGetShockersResponseTransformer, devicesListDevicesResponseTransformer, devicesOtaGetOtaUpdateHistoryResponseTransformer, + integrationsGetControlStatChangesResponseTransformer, publicGetOnlineDevicesStatisticsResponseTransformer, publicGetPublicShareResponseTransformer, sessionsGetSelfSessionResponseTransformer, @@ -39,6 +40,7 @@ import { tokensGetTokenByIdResponseTransformer, tokensListTokensResponseTransformer, tokensSelfGetSelfTokenResponseTransformer, + usersGetSelfControlStatsResponseTransformer, versionGetBackendInfoResponseTransformer, } from './transformers.gen'; import type { @@ -229,6 +231,9 @@ import type { DevicesRemoveDeviceData, DevicesRemoveDeviceErrors, DevicesRemoveDeviceResponses, + IntegrationsGetControlStatChangesData, + IntegrationsGetControlStatChangesErrors, + IntegrationsGetControlStatChangesResponses, PublicGetOnlineDevicesStatisticsData, PublicGetOnlineDevicesStatisticsResponses, PublicGetPublicShareData, @@ -331,6 +336,9 @@ import type { UsersGetByNameData, UsersGetByNameErrors, UsersGetByNameResponses, + UsersGetSelfControlStatsData, + UsersGetSelfControlStatsErrors, + UsersGetSelfControlStatsResponses, UsersGetSelfData, UsersGetSelfResponses, VersionGetBackendInfoData, @@ -380,6 +388,29 @@ export const usersGetSelf = ( ...options, }); +/** + * Get control statistics for the current user, both for their own shockers and for controls they sent. + */ +export const usersGetSelfControlStats = ( + options?: Options +): RequestResult< + UsersGetSelfControlStatsResponses, + UsersGetSelfControlStatsErrors, + ThrowOnError, + 'data' +> => + (options?.client ?? client).get< + UsersGetSelfControlStatsResponses, + UsersGetSelfControlStatsErrors, + ThrowOnError, + 'data' + >({ + responseTransformer: usersGetSelfControlStatsResponseTransformer, + responseStyle: 'data', + url: '/1/users/self/control-stats', + ...options, + }); + export const usersGetByName = ( options: Options ): RequestResult => @@ -1121,6 +1152,41 @@ export const publicGetPublicShare = ( ...options, }); +/** + * Lets a service holding many users keep their lifetime control stats in sync with a single call per tick. + * Returns the lifetime totals of every requested user whose stats changed since the given cursor. + * + * Requires an automation token granting `ControlStatsRead`. "Changed since" is answered from when each + * stats row was last written rather than the time it covers, so live control stats written late (e.g. after a + * database outage) are still picked up. Rows deleted along with their owner lower the totals of everyone who + * controlled them without leaving a row behind, those are picked up from ControlStatRemoval. The + * lookback is widened by ChangeOverlap to cover writes still committing while the previous call + * read, so a user can be reported again although nothing changed. + */ +export const integrationsGetControlStatChanges = ( + options: Options +): RequestResult< + IntegrationsGetControlStatChangesResponses, + IntegrationsGetControlStatChangesErrors, + ThrowOnError, + 'data' +> => + (options.client ?? client).post< + IntegrationsGetControlStatChangesResponses, + IntegrationsGetControlStatChangesErrors, + ThrowOnError, + 'data' + >({ + responseTransformer: integrationsGetControlStatChangesResponseTransformer, + responseStyle: 'data', + url: '/1/integrations/control-stats/changes', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + /** * Gets the OTA update history for a device */ diff --git a/src/lib/api/internal/v1/transformers.gen.ts b/src/lib/api/internal/v1/transformers.gen.ts index a826efc5..cc3f326a 100644 --- a/src/lib/api/internal/v1/transformers.gen.ts +++ b/src/lib/api/internal/v1/transformers.gen.ts @@ -24,6 +24,7 @@ import type { DevicesGetShockersResponse, DevicesListDevicesResponse, DevicesOtaGetOtaUpdateHistoryResponse, + IntegrationsGetControlStatChangesResponse, PublicGetOnlineDevicesStatisticsResponse, PublicGetPublicShareResponse, SessionsGetSelfSessionResponse, @@ -39,6 +40,7 @@ import type { TokensGetTokenByIdResponse, TokensListTokensResponse, TokensSelfGetSelfTokenResponse, + UsersGetSelfControlStatsResponse, VersionGetBackendInfoResponse, } from './types.gen'; @@ -59,6 +61,59 @@ export const versionGetBackendInfoResponseTransformer = async ( return data; }; +const controlStatTotalsSchemaResponseTransformer = (data: any) => { + data.count = BigInt(data.count.toString()); + data.durationMs = BigInt(data.durationMs.toString()); + return data; +}; + +const shockerControlStatsSchemaResponseTransformer = (data: any) => { + data.stats = data.stats.map((item: any) => controlStatTotalsSchemaResponseTransformer(item)); + return data; +}; + +const userControlStatsSchemaResponseTransformer = (data: any) => { + data.stats = data.stats.map((item: any) => controlStatTotalsSchemaResponseTransformer(item)); + return data; +}; + +const dailyControlStatsSchemaResponseTransformer = (data: any) => { + data.date = Temporal.PlainDate.from(data.date); + data.stats = data.stats.map((item: any) => controlStatTotalsSchemaResponseTransformer(item)); + return data; +}; + +const receivedControlStatsSchemaResponseTransformer = (data: any) => { + data.totals = data.totals.map((item: any) => controlStatTotalsSchemaResponseTransformer(item)); + data.shockers = data.shockers.map((item: any) => + shockerControlStatsSchemaResponseTransformer(item) + ); + data.controllers = data.controllers.map((item: any) => + userControlStatsSchemaResponseTransformer(item) + ); + data.days = data.days.map((item: any) => dailyControlStatsSchemaResponseTransformer(item)); + return data; +}; + +const givenControlStatsSchemaResponseTransformer = (data: any) => { + data.totals = data.totals.map((item: any) => controlStatTotalsSchemaResponseTransformer(item)); + data.owners = data.owners.map((item: any) => userControlStatsSchemaResponseTransformer(item)); + return data; +}; + +const controlStatsResponseSchemaResponseTransformer = (data: any) => { + data.received = receivedControlStatsSchemaResponseTransformer(data.received); + data.given = givenControlStatsSchemaResponseTransformer(data.given); + return data; +}; + +export const usersGetSelfControlStatsResponseTransformer = async ( + data: any +): Promise => { + data = controlStatsResponseSchemaResponseTransformer(data); + return data; +}; + const tokenResponseSchemaResponseTransformer = (data: any) => { data.createdOn = Temporal.Instant.from(data.createdOn); if (data.validUntil) { @@ -297,6 +352,27 @@ export const publicGetPublicShareResponseTransformer = async ( return data; }; +const userControlStatTotalsSchemaResponseTransformer = (data: any) => { + data.received = data.received.map((item: any) => + controlStatTotalsSchemaResponseTransformer(item) + ); + data.given = data.given.map((item: any) => controlStatTotalsSchemaResponseTransformer(item)); + return data; +}; + +const controlStatChangesResponseSchemaResponseTransformer = (data: any) => { + data.cursor = Temporal.Instant.from(data.cursor); + data.users = data.users.map((item: any) => userControlStatTotalsSchemaResponseTransformer(item)); + return data; +}; + +export const integrationsGetControlStatChangesResponseTransformer = async ( + data: any +): Promise => { + data = controlStatChangesResponseSchemaResponseTransformer(data); + return data; +}; + const otaItemSchemaResponseTransformer = (data: any) => { data.startedAt = Temporal.Instant.from(data.startedAt); return data; diff --git a/src/lib/api/internal/v1/types.gen.ts b/src/lib/api/internal/v1/types.gen.ts index 06d09bde..5acfa4c1 100644 --- a/src/lib/api/internal/v1/types.gen.ts +++ b/src/lib/api/internal/v1/types.gen.ts @@ -362,12 +362,16 @@ export type AutomationTokenDto = { }; /** - * A protection that an automation token is allowed to switch off. + * A protection that an automation token is allowed to switch off, or a service capability it grants. */ -export const AutomationTokenType = { Turnstile: 'Turnstile', RateLimit: 'RateLimit' } as const; +export const AutomationTokenType = { + Turnstile: 'Turnstile', + RateLimit: 'RateLimit', + ControlStatsRead: 'ControlStatsRead', +} as const; /** - * A protection that an automation token is allowed to switch off. + * A protection that an automation token is allowed to switch off, or a service capability it grants. */ export type AutomationTokenType = (typeof AutomationTokenType)[keyof typeof AutomationTokenType]; @@ -513,6 +517,54 @@ export type ControlLogSenderLight = { customName: null | string; }; +export type ControlStatChangesRequest = { + /** + * The cursor returned by the previous call, omit for an initial sync of every requested user. + */ + since?: null | Temporal.Instant; + /** + * The users the caller is interested in, only these are ever reported. + */ + userIds: Array; +}; + +export type ControlStatChangesResponse = { + /** + * Pass as `since` on the next call. + */ + cursor: Temporal.Instant; + users: Array; +}; + +/** + * Aggregated activity of one control type, split by whether it was live control. + */ +export type ControlStatTotals = { + type: ControlType; + live: boolean; + /** + * Number of controls, for live control the number of continuous bursts. + */ + count: bigint; + durationMs: bigint; + /** + * Average intensity weighted by duration. + */ + averageIntensity: number; + maxIntensity: number; +}; + +export type ControlStatsResponse = { + /** + * Activity on shockers you own. + */ + received: ReceivedControlStats; + /** + * Activity you sent to shockers, your own included. + */ + given: GivenControlStats; +}; + export const ControlType = { Stop: 'Stop', Shock: 'Shock', @@ -550,6 +602,14 @@ export type CreatedAutomationTokenDto = { autoCleanupAfter?: null | string; }; +export type DailyControlStats = { + /** + * Day in the requested time zone. + */ + date: Temporal.PlainDate; + stats: Array; +}; + export type DeviceResponse = { id: string; name: string; @@ -674,6 +734,11 @@ export const EmailType = { */ export type EmailType = (typeof EmailType)[keyof typeof EmailType]; +export type GivenControlStats = { + totals: Array; + owners: Array; +}; + export type HubEditRequest = { name: string; }; @@ -1096,6 +1161,13 @@ export type PublicShareShocker = { paused: number; }; +export type ReceivedControlStats = { + totals: Array; + shockers: Array; + controllers: Array; + days: Array; +}; + export type ReportTokensRequest = { turnstileResponse: string; secrets: Array; @@ -1168,6 +1240,15 @@ export type SharedShocker = { limits: ShockerLimits; }; +export type ShockerControlStats = { + /** + * Null for shockers that have since been deleted, these are combined into one entry. + */ + shockerId: null | string; + shockerName: null | string; + stats: Array; +}; + export type ShockerLimits = { intensity: null | number; duration: null | number; @@ -1246,6 +1327,33 @@ export type TokenResponse = { permissions: Array; }; +/** + * Lifetime totals of one user, the same views as ControlStatsResponse without breakdowns. + */ +export type UserControlStatTotals = { + userId: string; + /** + * Activity on shockers the user owns. + */ + received: Array; + /** + * Activity the user sent to shockers, their own included. + */ + given: Array; +}; + +export type UserControlStats = { + /** + * Null for guests (public shares). + */ + userId: null | string; + /** + * Null for guests and deleted users. + */ + user: null | BasicUserInfo; + stats: Array; +}; + export type UserNameBlacklistDto = { id: string; value: string; @@ -1337,6 +1445,46 @@ export type UsersGetSelfResponses = { export type UsersGetSelfResponse = UsersGetSelfResponses[keyof UsersGetSelfResponses]; +export type UsersGetSelfControlStatsData = { + body?: never; + path?: never; + query?: { + /** + * IANA time zone the daily breakdown and the date range are in, e.g. `Europe/Berlin`, defaults to UTC. + */ + tz?: string; + /** + * First day to include (inclusive), defaults to all time. + */ + from?: Temporal.PlainDate; + /** + * Last day to include (inclusive), defaults to today. + */ + to?: Temporal.PlainDate; + }; + url: '/1/users/self/control-stats'; +}; + +export type UsersGetSelfControlStatsErrors = { + /** + * Unknown time zone or invalid range. + */ + 400: OpenShockProblem; +}; + +export type UsersGetSelfControlStatsError = + UsersGetSelfControlStatsErrors[keyof UsersGetSelfControlStatsErrors]; + +export type UsersGetSelfControlStatsResponses = { + /** + * The statistics. + */ + 200: ControlStatsResponse; +}; + +export type UsersGetSelfControlStatsResponse = + UsersGetSelfControlStatsResponses[keyof UsersGetSelfControlStatsResponses]; + export type UsersGetByNameData = { body?: never; path: { @@ -2432,6 +2580,33 @@ export type PublicGetPublicShareResponses = { export type PublicGetPublicShareResponse = PublicGetPublicShareResponses[keyof PublicGetPublicShareResponses]; +export type IntegrationsGetControlStatChangesData = { + body: ControlStatChangesRequest; + path?: never; + query?: never; + url: '/1/integrations/control-stats/changes'; +}; + +export type IntegrationsGetControlStatChangesErrors = { + /** + * No automation token granting ControlStatsRead was presented. + */ + 403: OpenShockProblem; +}; + +export type IntegrationsGetControlStatChangesError = + IntegrationsGetControlStatChangesErrors[keyof IntegrationsGetControlStatChangesErrors]; + +export type IntegrationsGetControlStatChangesResponses = { + /** + * The changed users' totals and the cursor to pass next time. + */ + 200: ControlStatChangesResponse; +}; + +export type IntegrationsGetControlStatChangesResponse = + IntegrationsGetControlStatChangesResponses[keyof IntegrationsGetControlStatChangesResponses]; + export type DevicesOtaGetOtaUpdateHistoryData = { body?: never; path: { diff --git a/src/lib/utils/controlStats.test.ts b/src/lib/utils/controlStats.test.ts new file mode 100644 index 00000000..1e039cdb --- /dev/null +++ b/src/lib/utils/controlStats.test.ts @@ -0,0 +1,113 @@ +import type { ControlStatTotals } from '#lib/api/index.js'; +import { describe, expect, it } from 'vitest'; +import { + dailySeries, + formatStatDuration, + summarizeByType, + totalCount, + totalDurationMs, +} from './controlStats'; + +function row( + type: ControlStatTotals['type'], + live: boolean, + count: number, + durationMs: number, + averageIntensity: number, + maxIntensity: number +): ControlStatTotals { + return { + type, + live, + count: BigInt(count), + durationMs: BigInt(durationMs), + averageIntensity, + maxIntensity, + }; +} + +describe('summarizeByType', () => { + it('returns an empty summary for every charted type', () => { + const summaries = summarizeByType([]); + expect(summaries.map((s) => s.type)).toEqual(['Shock', 'Vibrate', 'Sound']); + expect(summaries.every((s) => s.count === 0 && s.averageIntensity === 0)).toBe(true); + }); + + it('folds live and non-live rows and weights the average by duration', () => { + const [shock] = summarizeByType([ + row('Shock', false, 3, 1000, 20, 40), + row('Shock', true, 1, 3000, 60, 80), + ]); + expect(shock).toEqual({ + type: 'Shock', + count: 4, + durationMs: 4000, + averageIntensity: 50, + maxIntensity: 80, + liveCount: 1, + liveDurationMs: 3000, + }); + }); + + it('ignores Stop rows', () => { + const summaries = summarizeByType([row('Stop', false, 5, 0, 0, 0)]); + expect(summaries.every((s) => s.count === 0)).toBe(true); + }); +}); + +describe('totalCount and totalDurationMs', () => { + it('sum every type except Stop', () => { + const rows = [ + row('Shock', false, 2, 500, 10, 10), + row('Vibrate', true, 3, 1500, 10, 10), + row('Stop', false, 7, 0, 0, 0), + ]; + expect(totalCount(rows)).toBe(5); + expect(totalDurationMs(rows)).toBe(2000); + }); +}); + +describe('dailySeries', () => { + it('fills days without activity with zeroes', () => { + const from = Temporal.PlainDate.from('2026-10-01'); + const to = Temporal.PlainDate.from('2026-10-03'); + const series = dailySeries( + [ + { + date: Temporal.PlainDate.from('2026-10-02'), + stats: [row('Shock', false, 2, 500, 10, 10), row('Shock', true, 1, 100, 10, 10)], + }, + ], + 'count', + from, + to + ); + expect(series).toEqual([ + { date: '2026-10-01', Shock: 0, Vibrate: 0, Sound: 0 }, + { date: '2026-10-02', Shock: 3, Vibrate: 0, Sound: 0 }, + { date: '2026-10-03', Shock: 0, Vibrate: 0, Sound: 0 }, + ]); + }); + + it('can chart duration instead of count', () => { + const day = Temporal.PlainDate.from('2026-10-02'); + const series = dailySeries( + [{ date: day, stats: [row('Sound', false, 2, 750, 10, 10)] }], + 'durationMs', + day, + day + ); + expect(series[0].Sound).toBe(750); + }); +}); + +describe('formatStatDuration', () => { + it.each([ + [4200, '4.2s'], + [65_000, '1m 5s'], + [8_040_000, '2h 14m'], + [97_200_000, '1d 3h'], + ])('formats %d ms as %s', (ms, expected) => { + expect(formatStatDuration(ms)).toBe(expected); + }); +}); diff --git a/src/lib/utils/controlStats.ts b/src/lib/utils/controlStats.ts new file mode 100644 index 00000000..1801c0f1 --- /dev/null +++ b/src/lib/utils/controlStats.ts @@ -0,0 +1,129 @@ +import { ControlType, type ControlStatTotals, type DailyControlStats } from '#lib/api/index.js'; + +/** Control types that carry activity worth charting, Stop is only ever a command. */ +export const StatControlTypes = [ + ControlType.Shock, + ControlType.Vibrate, + ControlType.Sound, +] as const; +export type StatControlType = (typeof StatControlTypes)[number]; + +export interface ControlTypeSummary { + type: StatControlType; + count: number; + durationMs: number; + averageIntensity: number; + maxIntensity: number; + liveCount: number; + liveDurationMs: number; +} + +function emptySummary(type: StatControlType): ControlTypeSummary { + return { + type, + count: 0, + durationMs: 0, + averageIntensity: 0, + maxIntensity: 0, + liveCount: 0, + liveDurationMs: 0, + }; +} + +function isStatControlType(type: ControlType): type is StatControlType { + return (StatControlTypes as readonly ControlType[]).includes(type); +} + +/** + * Folds the live and non-live rows of each control type into one summary per type. + * The average intensity stays weighted by duration, matching how the API computes it per row. + */ +export function summarizeByType(totals: ControlStatTotals[]): ControlTypeSummary[] { + const summaries = new Map(StatControlTypes.map((type) => [type, emptySummary(type)])); + const weightedIntensity = new Map(); + + for (const row of totals) { + if (!isStatControlType(row.type)) continue; + const summary = summaries.get(row.type)!; + const count = Number(row.count); + const durationMs = Number(row.durationMs); + + summary.count += count; + summary.durationMs += durationMs; + summary.maxIntensity = Math.max(summary.maxIntensity, row.maxIntensity); + if (row.live) { + summary.liveCount += count; + summary.liveDurationMs += durationMs; + } + weightedIntensity.set( + row.type, + (weightedIntensity.get(row.type) ?? 0) + row.averageIntensity * durationMs + ); + } + + for (const summary of summaries.values()) { + if (summary.durationMs > 0) { + summary.averageIntensity = weightedIntensity.get(summary.type)! / summary.durationMs; + } + } + + return [...summaries.values()]; +} + +/** Sum of counts across the given control types, Stop excluded. */ +export function totalCount(totals: ControlStatTotals[]): number { + return totals.reduce( + (sum, row) => (isStatControlType(row.type) ? sum + Number(row.count) : sum), + 0 + ); +} + +/** Sum of durations across the given control types, Stop excluded. */ +export function totalDurationMs(totals: ControlStatTotals[]): number { + return totals.reduce( + (sum, row) => (isStatControlType(row.type) ? sum + Number(row.durationMs) : sum), + 0 + ); +} + +export type DailyPoint = { date: string } & Record; + +/** + * Turns the sparse daily breakdown into one point per day between `from` and `to`, both inclusive. + * Days without activity are filled with zeroes so the chart keeps an even time axis. + */ +export function dailySeries( + days: DailyControlStats[], + metric: 'count' | 'durationMs', + from: Temporal.PlainDate, + to: Temporal.PlainDate +): DailyPoint[] { + const byDate = new Map(days.map((day) => [day.date.toString(), day.stats])); + const points: DailyPoint[] = []; + + for (let date = from; Temporal.PlainDate.compare(date, to) <= 0; date = date.add({ days: 1 })) { + const point = { date: date.toString(), Shock: 0, Vibrate: 0, Sound: 0 }; + for (const row of byDate.get(date.toString()) ?? []) { + if (!isStatControlType(row.type)) continue; + point[row.type] += Number(metric === 'count' ? row.count : row.durationMs); + } + points.push(point); + } + + return points; +} + +/** Formats a duration in milliseconds as the two most significant units, e.g. `2h 14m` or `4.2s`. */ +export function formatStatDuration(durationMs: number): string { + const totalSeconds = durationMs / 1000; + if (totalSeconds < 60) return `${Math.round(totalSeconds * 10) / 10}s`; + + const seconds = Math.floor(totalSeconds) % 60; + const minutes = Math.floor(totalSeconds / 60) % 60; + const hours = Math.floor(totalSeconds / 3600) % 24; + const days = Math.floor(totalSeconds / 86400); + + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m ${seconds}s`; +} diff --git a/src/routes/(app)/admin/automation-tokens/columns.ts b/src/routes/(app)/admin/automation-tokens/columns.ts index 28d94e13..707092da 100644 --- a/src/routes/(app)/admin/automation-tokens/columns.ts +++ b/src/routes/(app)/admin/automation-tokens/columns.ts @@ -41,6 +41,7 @@ export const automationTokenTypes = Object.values(AutomationTokenType); const automationTokenTypeLabels: Record = { [AutomationTokenType.Turnstile]: 'Turnstile', [AutomationTokenType.RateLimit]: 'Rate limit', + [AutomationTokenType.ControlStatsRead]: 'Control stats read', }; export function formatTokenType(type: AutomationTokenType): string { diff --git a/src/routes/(app)/shockers/own/+page.svelte b/src/routes/(app)/shockers/own/+page.svelte index 400936fe..3c98d81d 100644 --- a/src/routes/(app)/shockers/own/+page.svelte +++ b/src/routes/(app)/shockers/own/+page.svelte @@ -2,7 +2,7 @@ import { PUBLIC_DISABLE_SHOCKER_MAP } from '$app/env/public'; import { shockerPauseShocker, shockerRegisterShocker } from '#lib/api/index.js'; import type { NewShocker } from '#lib/api/index.js'; - import { Layers, LogsIcon, Plus, RotateCcw, Settings, Zap } from '@lucide/svelte'; + import { ChartColumn, Layers, LogsIcon, Plus, RotateCcw, Settings, Zap } from '@lucide/svelte'; import { resolve } from '$app/paths'; import { Container, EmptyState, PageHeader } from '@openshock/svelte-core/components'; import { Spinner } from '@openshock/svelte-core/components/ui/spinner'; @@ -287,6 +287,9 @@ + diff --git a/src/routes/(app)/shockers/stats/+page.svelte b/src/routes/(app)/shockers/stats/+page.svelte new file mode 100644 index 00000000..f4ccc575 --- /dev/null +++ b/src/routes/(app)/shockers/stats/+page.svelte @@ -0,0 +1,227 @@ + + + + + {#if isFetching} + + {/if} + + +
+
+ + {ranges[range].label} + + {#each Object.entries(ranges) as [key, option] (key)} + {option.label} + {/each} + + + {#if range === 'custom'} + filters.from ?? '', (v) => (filters.from = v || undefined)} + /> + to + filters.to ?? '', (v) => (filters.to = v || undefined)} + /> + {/if} +
+ + {#if stats} + + + Received + Given + + + + + +
+ + +
+
+ + + + + +
+ {:else if isFetching} +
+ {/if} +
+
diff --git a/src/routes/(app)/shockers/stats/breakdown-table.svelte b/src/routes/(app)/shockers/stats/breakdown-table.svelte new file mode 100644 index 00000000..359c86fd --- /dev/null +++ b/src/routes/(app)/shockers/stats/breakdown-table.svelte @@ -0,0 +1,91 @@ + + + + + + + {title} + + + {#if summarized.length === 0} +

{emptyText}

+ {:else} + + + + {nameHeader} + {#each StatControlTypes as type (type)} + {controlTypeLabels[type]} + {/each} + Duration + Max intensity + + + + {#each summarized as row (row.key)} + + + {row.name} + + {#each row.byType as summary (summary.type)} + + {numberFormat.format(summary.count)} + + {/each} + + {formatStatDuration(row.durationMs)} + + {row.maxIntensity}% + + {/each} + + + {/if} +
+
diff --git a/src/routes/(app)/shockers/stats/colors.ts b/src/routes/(app)/shockers/stats/colors.ts new file mode 100644 index 00000000..cd2ee3ba --- /dev/null +++ b/src/routes/(app)/shockers/stats/colors.ts @@ -0,0 +1,13 @@ +import type { StatControlType } from '#lib/utils/controlStats.js'; + +export const controlTypeColors: Record = { + Shock: { light: '#eb6834', dark: '#d95926' }, + Vibrate: { light: '#2a78d6', dark: '#3987e5' }, + Sound: { light: '#a9489f', dark: '#bf5ab8' }, +}; + +export const controlTypeLabels: Record = { + Shock: 'Shocks', + Vibrate: 'Vibrations', + Sound: 'Sounds', +}; diff --git a/src/routes/(app)/shockers/stats/daily-chart.svelte b/src/routes/(app)/shockers/stats/daily-chart.svelte new file mode 100644 index 00000000..8abb0064 --- /dev/null +++ b/src/routes/(app)/shockers/stats/daily-chart.svelte @@ -0,0 +1,97 @@ + + + + + Daily activity +
+ + +
+
+ + + + {#snippet tooltip()} + + {#snippet formatter({ value, item })} +
+
+ {item.label} + + {formatValue(Number(value))} + +
+ {/snippet} +
+ {/snippet} +
+
+
+
diff --git a/src/routes/(app)/shockers/stats/totals-cards.svelte b/src/routes/(app)/shockers/stats/totals-cards.svelte new file mode 100644 index 00000000..9b3f92de --- /dev/null +++ b/src/routes/(app)/shockers/stats/totals-cards.svelte @@ -0,0 +1,60 @@ + + +
+ {#each summaries as summary (summary.type)} + + + + {controlTypeLabels[summary.type]} + + +
+ + {numberFormat.format(summary.count)} + + + {formatStatDuration(summary.durationMs)} + +
+
+
+
Avg intensity
+
+ {Math.round(summary.averageIntensity)}% +
+
+
+
Max intensity
+
{summary.maxIntensity}%
+
+
+
Live
+
+ {numberFormat.format(summary.liveCount)} +
+
+
+
+
+ {/each} +
diff --git a/src/routes/Sidebar.svelte b/src/routes/Sidebar.svelte index 68324956..89f8957d 100644 --- a/src/routes/Sidebar.svelte +++ b/src/routes/Sidebar.svelte @@ -1,5 +1,6 @@