Files
Rocketmc/apps/frontend/src/providers/analytics/analytics-data-utils.ts
T
Truman Gao 11b2b6e6c0 feat: improve analytics dashboard (#5897)
* feat: implement cancel/apply for custom timeframe range picker

* feat: implement dot for showing todays date

* feat: add max date to be today and show todays date

* feat: if ratio mode, dont show total

* feat: implement show more batching excess lines into "Other" bucket

* refactor: pnpm prepr

* feat: add pick and plop for date range start/end dates

* feat: implement reset query button

* feat: clear button to clear breakdown

* feat: more aggressively trim allowed minimum group by option

* fix: dont show project status filter when from project settings/analytics

* fix: clear selected X above number when appropriate

* feat: graph style updates and dont show year in x axis unless more than 2 year timeframe

* fix: loading state to include legend in blur

* feat: add project icon to project select

* feat: filter out draft projects from analytics

* feat: implement multiselect sections headers, project select org sections, and project options icons

* feat: implement click and drag to select date range

* feat: implement windows history for query builder

* revert: no longer switch breakdown/filter option if same category

* feat: implement showing project for project version breakdown/filter when there are multiple projects

* feat: implement modrinth sided events

* fix: border radius

* feat: implement analytics range highlight

* fix: loading state showing empty state text

* refactor: pnpm prepr

* feat: improve dropdown filter bar and multiselect performance

* fix: multiselect keyboard use

* fix: graph overflow issues

* fix: loading state text on table

* feat: implement tooltip scroll

* fix: adjust charts event tooltip

* feat: shorten time to not repeat am/pm

* feat: implement query params for graph component settings

* fix: qa

* feat: add reset timeframe button

* fix: legend colors moving between metric by determining color based on only downloads metric index

* feat: implement auto switching temporarily to group by day for renvenue metric and disable revenue metric for time range < 2 days

* fix: change to > 1 day

* fix: custom timeframe picker

* feat: implement big performance improvement for table

* feat: implement hover on legend to highlight graph

* fix: defer commit in query builder/filter and style fixes

* feat: more performance optimization to analytics dashboard state, chart, and table

* feat: add tooltip for other item

* feat: improve custom time frame range select

* feat: implement analytics events admin page

* fix: switch column order

* pnpm prepr

* feat: implement mock analytics events

* feat: improve analytics events admin page

* feat: focus title input on analytics create event modal

* fix: remove labels annoying

* feat: hook up analytics events backend

* fix: type error

* feat: reduce combobox padding

* feat: reduce padding on multiselect

* feat: add overlay scrollbar for combobox

* feat: a bunch of style fixes to combobox, multiselect and dropdown filter bar

* feat: MORE PADDING fixes

* feat: use user_agent for download source

* Revert "feat: use user_agent for download source"

This reverts commit d6dc8a99f11f94660872427796cdcf6fc93bb21d.

* fix: query filter project version lag and borked virtualization

* feat: rename breakdown "none" to "project"

* feat: implement right side checkmark for multiselect

* feat: keep crossed out legend items still shown in tooltip but also crossed out

* fix: focus styles

* fix: focus styles pt2

* feat: implement filter by top 8

* fix: preview is incorrect when selecting same date in range date picker

* feat (playtest): cross out legend items in tooltip and allow hide/show in tooltip

* feat (playtest): table component controls what graph shows

* feat: change download source to use user_agent

* feat: fix click to cross out in legend

* feat: add hover legend item to highlight line in tooltip

* fix: export csv to always be dropdown

* feat: implement breakdown = none

* performance: frontend memory reduction

* performance: reduce memory usage from project versions query by keeping only whats necessary

* fix: table checked items not in graph if 0

* feat: add shift click to select a range in table

* performance: add caching for metric types so switching between them is snappy

* performance: batch analytics requests by 15 project ids, with 150 ms delay between, so backend is happy

* feat: add analytics table search

* refactor: pnpm prepr

* fix: query filter options not coming in from analytics fetch

* feat: remove breakdown = none when there are multiple projects

* feat: improve table sorting

* feat: sort projects in project dropdown

* fix: getting project name for project versions

* fix: add loading state for filter and parallel fetch

* performance: use precomputed map for project version options to remove first hover lag

* feat: dropdown filter always open on one side and improve styles

* fix: custom time range picker being weird

* refactor: pnpm prepr

* fix: add back in batch with 300ms interval for projects to prevent backend rate limiting

* performance: only do queries to populate graph first before other analytics queries

* fix: QA polish issues around style and copy

* feat: dont show select all when its just one item in section

* fix: bugs with ratio mode and hiding chart lines

* fix: adjust padding in combobox and multiselect and fix not unfocusing when deselect

* fix: small styles

* fix: polish admin analytic events

* fix: keep scroll position with selection action row appearing when selecting one

* feat: add subheading in graph for showing N items from table

* feat: add unmonetized explaination tooltip

* performance: implement limit on how many lines can be shown in graph

* feat: mobile pass

* refactor: pnpm prepr

* add clear button

* feat: add time in analytics event and normalize date/time so its correct to timezones

* fix: padding

* feat: implement show prev period toggle

* feat: extract TimeFramePicker to packages/ui

* fix: adjust style

* feat: keep table selected persisted in query parameter

* fix: style on prev item value in legend

* fix: when breakdown switches, reset selected series

* fix: tooltip styles

* feat: change project selection to reset to show top 8 only if reconciled down to 0 items

* feat: implement show top 8 button in graph subheading

* fix: rename download type to download reason

* fix: formatting label for table

* feat: persist table sort by and sort direction

* fix: show top 8 button in graph not defaulting to top 8 for other metrics

* feat: implement prev period analytics fetch into the same current period fetch by shifting start date

* refactor: pnpm prepr

* fix: remove number if its just top 1

* fix: brief select items empty state when switch breakdown

* feat: implement format table playtime column

* feat: update export csv filename

* feat: change playtime column to display in hours

* refactor: pnpm prepr

* fix: still download type in filter

* feat: update analytics tooltip

* fix: wrong all projects icon

* feat: force legend order and graph colour for monetization

* refactor: pnpm prepr

* fix: multiselect and combobox sizes

* fix: chart icon add hover delay

* feat: (to playtest) implement multiple breakdowns

* fix: couple UX things for multiple breakdown

* fix: cannot unpin on page click

* fix: multiple breakdown legend and tooltip labels

* feat: add right side checkmark for dropdown filtr bar

* feat: enabling prev period will cross out prev for current ones already crossed out

* feat-mobile: remove drag to select time frame in graph

* feat-mobile: dropdown filter to replace dropdown for submenus on small screen

* feat-mobile: time frame picker to use different start and end date pickers for mobile

* fix-mobile: fix multiselect scroll on mobile

* feat: consolidate is mobile ref into context

* fix-mobile: combobox and multiselect scroll bug when mobile search bar open, fix timeframe picker mobile pick date, and dropdown filter bar click outside to close

* fix-mobile: smaller metric card font

* fix: dropdown filter bar scroll while search

* feat: implement project side events

* feat: implement better mobile view design for query builder

* feat: handle events overflow

* small: add select none

* feat: remove clear project and breakdown

* fix: event icon hover color

* feat: default hide project events if there are multiple projects, and default show if only 1 project

* feat: implement analytics performance updates, including facets, and v3 user projects

* feat: grey out dimmed lined on legend item hover

* feat-mobile: style fixes

* add close on select prop

* feat: add close on select for time frame picker mobile

* feat: date picker default read only

* refactor: pnpm prepr

* feat: default to projects breakdown instead of no breakdown with multiple projects

* fix-mobile: improve graph touch interactions

* small: 2 sig figs on playtime

* feat: deduplicate version uploads that have same version number and are uploaded on same day

* fix: analytics events grouping causing overflow

* feat: improve performance on analytics events grouping

* fix: tooltip expanding page width briefly

* fix: prevent double tap to zoom on inputs

* feat: add click to show chart event for mobile

* fix: toggle not having touch manipulation

* fix: chart tooltip scroll in mobile

* fix: remove project breakdownoption as it is default breakdown when none are selected

* fix: dropdown filter bar briefly empty when switching pages in mobile

* feat: keep tooltip open after drag in mobile

* fix: using plural instead of single for project breakdown

* fix: date picker scrolling page after picking date in mobile by suppressing focus

* fix: callback to Organization instead of org id

* feat: improve chart tooltip date range label formatter to be much more consistent

* feat: tap to toggle event tooltip

* fix: add user select none on graph and fix zoom into download threshold input

* fix: frontend still filtering after backend already filters

* feat: fix emptys state height content shift

* fix: qa issues

* fix: a number of qa issues

- Hide project events based on visible project legend/table selection
- Filter project status events by end status and add explicit copy for approved, private, and unlisted
- Style Modrinth analytics events with blue icon, marker, guide, and range borders
- Add scroll fade shadows to analytics chart and event tooltips
- Show previous-period date range in the chart tooltip
- Make project breakdown conditional on multiple selected projects and allow no breakdown when none are selected
- Add breakdown selection actions and fix “Group by day” copy

* feat: implement graph controls dropdown

* fix date picker typing into time input

* fix: styles in events table

* small: style

* feat: implement using new backend facets route

* feat: implement user get all projects

* performance: deter non-critical fetches to after analytics is in

* fix: refreshing causes multiple projects to do breakdown=none

* performance: cache project version options to fix lag on open sub menu

* refactor: remove chart event height being controlled by parent

* feat: update controls dropdown to have fainter border

* fix: loading bar not fading away

* fix: cannot click in graph

* feat: dont conditionally show multiselect selection actions

* fix: z-index and padding issues

* fix: project events incorrectly toggling on for first page load

* feat: remove show more and show less in legend, always show all

* fix: playtime y axis labels

* feat: improve y axis formatting for playtime and others

* feat: use tabs for game version select, and remove prev period when change breakdown or project selection

* refactor: pnpm prepr

* feat: change hidden legend items to not contribute to ratio percentages

* feat: event icon consume scroll for tooltip panel

* feat: remove gap inside chart tooltip

* feat: add gap for date picker 2 calendar view

* feat: improve analytics events grouping logic for modrinth events to be close to target

* pnpm prepr

* fix: cant click in gap in toggle

* fix: bugs around selected series from table not persisting with timeframe or filter changes

* refactor: kabab case

* refactor: split up large analytics chart and table component files into smaller components and ts modules

* fix: legend is stale after resetting query

* refactor: split up giant analytics provider with utils

* i18n pass

* revert: format number composable change

* fix: playtime was choosing y axis ticks in seconds instead of hours

* refactor: rename folder that with components to match main component name

* refactor: same rename for analytics table for consistency

* refactor: name main components to index.vue and keep folder name as component name

* refactor: pnpm prepr

* refactor: rename types

* refactor: move query builder types into types file and move components into components/analytics-dashboard

* refactor: colocate query builder url with analytics-dashboard component

* refactor: pnpm prepr:frontend

* fix: download threshold not width fit

* fix: no option to see release/all game versions in selected filter dropdown

* fix: game version dropdown width

---------

Signed-off-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
2026-05-29 19:39:55 +00:00

532 lines
15 KiB
TypeScript

import type { Labrinth } from '@modrinth/api-client'
import type { ProjectStatusFilterValue } from '~/components/analytics-dashboard/query-builder/query-filter'
import { getProjectIdsMatchingStatusFilter } from './analytics-project-utils'
import type {
AnalyticsDashboardTotals,
AnalyticsFetchData,
AnalyticsGroupByPreset,
AnalyticsLastTimeframeUnit,
AnalyticsProjectFetchRequest,
AnalyticsSelectedFilters,
AnalyticsTimeframeMode,
AnalyticsTimeframePreset,
AnalyticsTimeSliceSplit,
} from './analytics-types'
const ANALYTICS_START_TIMESTAMP = '2023-01-01T00:00:00.000Z'
export const ANALYTICS_START_DATE_INPUT_VALUE = ANALYTICS_START_TIMESTAMP.slice(0, 10)
const ANALYTICS_START_TIME = new Date(ANALYTICS_START_TIMESTAMP).getTime()
export const REVENUE_MIN_TIMEFRAME_MS = 1 * 24 * 60 * 60 * 1000 // need at least 1 day in timeframe range to show revenue
const ANALYTICS_DAY_MS = 24 * 60 * 60 * 1000
const ANALYTICS_MAX_TIME_SLICES = 256 // controls granularity allowed in "group by" for timeframe ranges
const ANALYTICS_PROJECT_IDS_FETCH_BATCH_SIZE = 2000
const ANALYTICS_PROJECT_IDS_FETCH_BATCH_DELAY_MS = 300
function isProjectAnalyticsPoint(
dataPoint: Labrinth.Analytics.v3.AnalyticsData,
): dataPoint is Labrinth.Analytics.v3.ProjectAnalytics {
return 'source_project' in dataPoint
}
export function buildComparisonFetchRequest(
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
): AnalyticsProjectFetchRequest | null {
if (!isAnalyticsFetchRequestReady(fetchRequest)) {
return null
}
const startTimestamp = new Date(fetchRequest.time_range.start).getTime()
const endTimestamp = new Date(fetchRequest.time_range.end).getTime()
const duration = endTimestamp - startTimestamp
if (!Number.isFinite(duration) || duration <= 0) {
return null
}
const previousStart = new Date(startTimestamp - duration)
if (previousStart.getTime() < ANALYTICS_START_TIME) {
return null
}
return {
...fetchRequest,
time_range: {
start: previousStart.toISOString(),
end: fetchRequest.time_range.end,
resolution:
'slices' in fetchRequest.time_range.resolution
? {
slices: fetchRequest.time_range.resolution.slices * 2,
}
: fetchRequest.time_range.resolution,
},
}
}
export function isAnalyticsFetchRequestReady(
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
): fetchRequest is AnalyticsProjectFetchRequest {
return Array.isArray(fetchRequest?.project_ids) && fetchRequest.project_ids.length > 0
}
function getAnalyticsTimeSliceCount(
timeRange: Labrinth.Analytics.v3.TimeRange,
fallback: number,
): number {
if ('slices' in timeRange.resolution) {
return Math.max(1, timeRange.resolution.slices)
}
const startTime = new Date(timeRange.start).getTime()
const endTime = new Date(timeRange.end).getTime()
const bucketMs = timeRange.resolution.minutes * 60 * 1000
if (bucketMs > 0 && endTime > startTime) {
return Math.max(1, Math.floor((endTime - startTime) / bucketMs))
}
return Math.max(1, fallback)
}
export function splitAnalyticsTimeSlices(
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
): AnalyticsTimeSliceSplit {
if (!isAnalyticsFetchRequestReady(fetchRequest) || !buildComparisonFetchRequest(fetchRequest)) {
return {
currentTimeSlices: timeSlices,
previousTimeSlices: [],
}
}
const currentSliceCount = getAnalyticsTimeSliceCount(fetchRequest.time_range, timeSlices.length)
const currentStartIndex = Math.max(0, timeSlices.length - currentSliceCount)
const previousStartIndex = Math.max(0, currentStartIndex - currentSliceCount)
return {
currentTimeSlices: timeSlices.slice(currentStartIndex),
previousTimeSlices: timeSlices.slice(previousStartIndex, currentStartIndex),
}
}
export function getAnalyticsProjectEventsInTimeRange(
projectEvents: Labrinth.Analytics.v3.ProjectAnalyticsEvent[],
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
): Labrinth.Analytics.v3.ProjectAnalyticsEvent[] {
if (!isAnalyticsFetchRequestReady(fetchRequest)) {
return projectEvents
}
const startTime = new Date(fetchRequest.time_range.start).getTime()
const endTime = new Date(fetchRequest.time_range.end).getTime()
if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime < startTime) {
return []
}
return projectEvents.filter((event) => {
const eventTime = new Date(event.timestamp).getTime()
return Number.isFinite(eventTime) && eventTime >= startTime && eventTime <= endTime
})
}
function buildAnalyticsFetchRequestBatches(
fetchRequest: AnalyticsProjectFetchRequest,
): AnalyticsProjectFetchRequest[] {
if (fetchRequest.project_ids.length <= ANALYTICS_PROJECT_IDS_FETCH_BATCH_SIZE) {
return [fetchRequest]
}
const requests: AnalyticsProjectFetchRequest[] = []
for (
let index = 0;
index < fetchRequest.project_ids.length;
index += ANALYTICS_PROJECT_IDS_FETCH_BATCH_SIZE
) {
requests.push({
...fetchRequest,
project_ids: fetchRequest.project_ids.slice(
index,
index + ANALYTICS_PROJECT_IDS_FETCH_BATCH_SIZE,
),
})
}
return requests
}
function mergeAnalyticsTimeSlices(
timeSliceGroups: Labrinth.Analytics.v3.TimeSlice[][],
): Labrinth.Analytics.v3.TimeSlice[] {
const mergedTimeSlices: Labrinth.Analytics.v3.TimeSlice[] = []
for (const timeSlices of timeSliceGroups) {
timeSlices.forEach((timeSlice, index) => {
if (!mergedTimeSlices[index]) {
mergedTimeSlices[index] = []
}
for (const dataPoint of timeSlice) {
mergedTimeSlices[index].push(dataPoint)
}
})
}
return mergedTimeSlices
}
function mergeAnalyticsProjectEvents(
projectEventGroups: Labrinth.Analytics.v3.ProjectAnalyticsEvent[][],
): Labrinth.Analytics.v3.ProjectAnalyticsEvent[] {
const mergedProjectEvents: Labrinth.Analytics.v3.ProjectAnalyticsEvent[] = []
for (const projectEvents of projectEventGroups) {
for (const projectEvent of projectEvents) {
mergedProjectEvents.push(projectEvent)
}
}
return mergedProjectEvents.sort((left, right) => {
const timestampDifference =
new Date(left.timestamp).getTime() - new Date(right.timestamp).getTime()
return (
timestampDifference ||
left.project_id.localeCompare(right.project_id) ||
left.kind.localeCompare(right.kind)
)
})
}
function waitForAnalyticsFetchBatchDelay(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ANALYTICS_PROJECT_IDS_FETCH_BATCH_DELAY_MS))
}
export async function fetchAnalyticsData(
fetchRequest: AnalyticsProjectFetchRequest,
fetchAnalytics: (
request: Labrinth.Analytics.v3.FetchRequest,
) => Promise<Labrinth.Analytics.v3.FetchResponse>,
): Promise<AnalyticsFetchData> {
const fetchRequests = buildAnalyticsFetchRequestBatches(fetchRequest)
const timeSliceGroups: Labrinth.Analytics.v3.TimeSlice[][] = []
const projectEventGroups: Labrinth.Analytics.v3.ProjectAnalyticsEvent[][] = []
for (let index = 0; index < fetchRequests.length; index++) {
if (index > 0) {
await waitForAnalyticsFetchBatchDelay()
}
const response = await fetchAnalytics(fetchRequests[index])
timeSliceGroups.push(response.metrics)
projectEventGroups.push(response.project_events ?? [])
}
return {
metrics: mergeAnalyticsTimeSlices(timeSliceGroups),
project_events: mergeAnalyticsProjectEvents(projectEventGroups),
}
}
export async function fetchAnalyticsTimeSlices(
fetchRequest: AnalyticsProjectFetchRequest,
fetchAnalytics: (
request: Labrinth.Analytics.v3.FetchRequest,
) => Promise<Labrinth.Analytics.v3.FetchResponse>,
): Promise<Labrinth.Analytics.v3.TimeSlice[]> {
const response = await fetchAnalyticsData(fetchRequest, fetchAnalytics)
return response.metrics
}
export function areAnalyticsFetchRequestsEqual(
left: Labrinth.Analytics.v3.FetchRequest | null,
right: Labrinth.Analytics.v3.FetchRequest,
): boolean {
return JSON.stringify(left) === JSON.stringify(right)
}
export function buildAnalyticsCurrentTimeSlicesQueryKey(
userId: string | undefined,
nextFetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
refreshTimestamp: number,
) {
return ['analytics', 'dashboard', userId, 'current', nextFetchRequest, refreshTimestamp]
}
export function isRevenueHourlyGroupBy(groupBy: AnalyticsGroupByPreset): boolean {
return groupBy === '1h' || groupBy === '6h'
}
export function buildDailyAnalyticsFetchRequest(
nextFetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
): Labrinth.Analytics.v3.FetchRequest | null {
if (!isAnalyticsFetchRequestReady(nextFetchRequest)) {
return null
}
const startTime = new Date(nextFetchRequest.time_range.start).getTime()
const endTime = new Date(nextFetchRequest.time_range.end).getTime()
const durationMs = endTime - startTime
if (!Number.isFinite(durationMs) || durationMs <= 0) {
return null
}
const desiredSlices = Math.max(1, Math.floor(durationMs / ANALYTICS_DAY_MS))
return {
...nextFetchRequest,
time_range: {
...nextFetchRequest.time_range,
resolution: {
slices: Math.min(ANALYTICS_MAX_TIME_SLICES, desiredSlices),
},
},
}
}
export function buildAnalyticsFacetsRequest(
projectIds: string[],
timeRange: Labrinth.Analytics.v3.TimeRange,
): Labrinth.Analytics.v3.FetchRequest {
return {
time_range: {
start: timeRange.start,
end: timeRange.end,
resolution: {
slices: 1,
},
},
project_ids: projectIds,
return_metrics: {
project_downloads: {
bucket_by: [
'project_id',
'domain',
'user_agent',
'version_id',
'monetized',
'country',
'reason',
'game_version',
'loader',
],
},
},
}
}
function addAnalyticsDays(date: Date, days: number): Date {
const nextDate = new Date(date)
nextDate.setDate(nextDate.getDate() + days)
return nextDate
}
function parseAnalyticsDateInputValue(value: string): Date | null {
const parsedDate = new Date(`${value}T00:00:00`)
return Number.isNaN(parsedDate.getTime()) ? null : parsedDate
}
function parseAnalyticsDateTimeInputValue(value: string): Date | null {
const parsedDate = new Date(value)
return Number.isNaN(parsedDate.getTime()) ? null : parsedDate
}
export function getAnalyticsTimeframeDurationMs({
mode,
preset,
lastAmount,
lastUnit,
customStartDate,
customEndDate,
nowTimestamp,
}: {
mode: AnalyticsTimeframeMode
preset: AnalyticsTimeframePreset
lastAmount: number
lastUnit: AnalyticsLastTimeframeUnit
customStartDate: string
customEndDate: string
nowTimestamp: number
}): number {
if (mode === 'preset') {
switch (preset) {
case 'today':
case 'yesterday':
return 24 * 60 * 60 * 1000
case 'last_7_days':
return 7 * 24 * 60 * 60 * 1000
case 'last_14_days':
return 14 * 24 * 60 * 60 * 1000
case 'last_30_days':
return 30 * 24 * 60 * 60 * 1000
case 'last_90_days':
return 90 * 24 * 60 * 60 * 1000
case 'last_180_days':
return 180 * 24 * 60 * 60 * 1000
case 'year_to_date': {
const now = new Date(nowTimestamp)
const yearStart = new Date(now.getFullYear(), 0, 1)
yearStart.setHours(0, 0, 0, 0)
return now.getTime() - yearStart.getTime()
}
case 'all_time':
return REVENUE_MIN_TIMEFRAME_MS
}
}
if (mode === 'last') {
const amount = Math.max(1, Math.floor(lastAmount))
switch (lastUnit) {
case 'hours':
return amount * 60 * 60 * 1000
case 'days':
return amount * 24 * 60 * 60 * 1000
case 'weeks':
return amount * 7 * 24 * 60 * 60 * 1000
case 'months':
return REVENUE_MIN_TIMEFRAME_MS
}
}
if (mode === 'custom_range') {
const start = parseAnalyticsDateInputValue(customStartDate)
const inclusiveEnd = parseAnalyticsDateInputValue(customEndDate)
if (!start || !inclusiveEnd) {
return 0
}
return addAnalyticsDays(inclusiveEnd, 1).getTime() - start.getTime()
}
const start = parseAnalyticsDateTimeInputValue(customStartDate)
const end = parseAnalyticsDateTimeInputValue(customEndDate)
if (!start || !end) {
return 0
}
return end.getTime() - start.getTime()
}
export function getPercentChange(currentValue: number, previousValue: number): number {
if (previousValue === 0) {
if (currentValue === 0) {
return 0
}
return 100
}
return ((currentValue - previousValue) / previousValue) * 100
}
export function computeTotals(
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
selectedProjectIds: Set<string>,
availableProjectIds: Set<string>,
projectStatusById: Map<string, ProjectStatusFilterValue>,
filters: AnalyticsSelectedFilters,
): AnalyticsDashboardTotals {
const totals: AnalyticsDashboardTotals = {
views: 0,
downloads: 0,
revenue: 0,
playtime: 0,
}
if (availableProjectIds.size === 0) {
return totals
}
const effectiveProjectIds = selectedProjectIds.size > 0 ? selectedProjectIds : availableProjectIds
const filteredProjectIds = new Set(
getProjectIdsMatchingStatusFilter([...effectiveProjectIds], projectStatusById, filters),
)
if (filteredProjectIds.size === 0) {
return totals
}
for (const timeSlice of timeSlices) {
for (const dataPoint of timeSlice) {
if (!isProjectAnalyticsPoint(dataPoint)) {
continue
}
if (!filteredProjectIds.has(dataPoint.source_project)) {
continue
}
switch (dataPoint.metric_kind) {
case 'views':
totals.views += dataPoint.views
break
case 'downloads':
totals.downloads += dataPoint.downloads
break
case 'playtime':
totals.playtime += dataPoint.seconds
break
case 'revenue': {
const value = Number.parseFloat(dataPoint.revenue)
totals.revenue += Number.isFinite(value) ? value : 0
break
}
}
}
}
return totals
}
export function cloneAnalyticsFetchRequest(
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
): Labrinth.Analytics.v3.FetchRequest | null {
return fetchRequest ? JSON.parse(JSON.stringify(fetchRequest)) : null
}
export function addVersionIdsFromTimeSlices(
versionIds: Set<string>,
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
) {
for (const timeSlice of timeSlices) {
for (const dataPoint of timeSlice) {
if (!isProjectAnalyticsPoint(dataPoint)) {
continue
}
if (
(dataPoint.metric_kind === 'downloads' || dataPoint.metric_kind === 'playtime') &&
dataPoint.version_id
) {
const versionId = dataPoint.version_id.trim()
if (versionId.length > 0) {
versionIds.add(versionId)
}
}
}
}
}
export function addVersionProjectNamesFromTimeSlices(
versionProjectNames: Map<string, string>,
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
projectNamesById: Map<string, string>,
) {
for (const timeSlice of timeSlices) {
for (const dataPoint of timeSlice) {
if (!isProjectAnalyticsPoint(dataPoint)) {
continue
}
if (
(dataPoint.metric_kind === 'downloads' || dataPoint.metric_kind === 'playtime') &&
dataPoint.version_id
) {
const versionId = dataPoint.version_id.trim()
const projectName = projectNamesById.get(dataPoint.source_project)
if (versionId.length > 0 && projectName && !versionProjectNames.has(versionId)) {
versionProjectNames.set(versionId, projectName)
}
}
}
}
}