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..b7ec1e69 --- /dev/null +++ b/src/routes/(app)/shockers/stats/+page.svelte @@ -0,0 +1,229 @@ + + + + + {#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 @@