mirror of
https://github.com/modrinth/code.git
synced 2026-08-27 18:14:49 +00:00
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 d6dc8a99f1.
* 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>
This commit is contained in:
@@ -0,0 +1,531 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import type {
|
||||
AnalyticsDashboardFilterOptions,
|
||||
AnalyticsFacetsFilterOptionSummary,
|
||||
AnalyticsProjectVersionSource,
|
||||
AnalyticsSelectedFilters,
|
||||
AnalyticsVersionMetadata,
|
||||
NormalizedAnalyticsSelectedFilters,
|
||||
ProjectVersionFilterOptionSummary,
|
||||
} from './analytics-types'
|
||||
|
||||
export function sortStringValues(values: string[]): string[] {
|
||||
return [...values].sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
function toAnalyticsVersionMetadata(
|
||||
version: Labrinth.Versions.v3.Version,
|
||||
): AnalyticsVersionMetadata {
|
||||
return {
|
||||
id: version.id,
|
||||
versionNumber: version.version_number,
|
||||
datePublished: version.date_published,
|
||||
projectId: version.project_id,
|
||||
downloads: version.downloads,
|
||||
gameVersions: [...version.game_versions],
|
||||
loaders:
|
||||
version.mrpack_loaders && version.mrpack_loaders.length > 0
|
||||
? [...version.mrpack_loaders]
|
||||
: [...version.loaders],
|
||||
}
|
||||
}
|
||||
|
||||
export function getProjectVersionFilterOptionSummary(
|
||||
versions: AnalyticsVersionMetadata[],
|
||||
): ProjectVersionFilterOptionSummary {
|
||||
const gameVersions = new Set<string>()
|
||||
const loaders = new Set<string>()
|
||||
const versionIds = new Set<string>()
|
||||
|
||||
for (const version of versions) {
|
||||
versionIds.add(version.id)
|
||||
|
||||
for (const gameVersion of version.gameVersions) {
|
||||
const normalizedGameVersion = gameVersion.trim()
|
||||
if (normalizedGameVersion.length > 0) {
|
||||
gameVersions.add(normalizedGameVersion)
|
||||
}
|
||||
}
|
||||
|
||||
for (const loader of version.loaders) {
|
||||
const normalizedLoader = loader.trim().toLowerCase()
|
||||
if (normalizedLoader.length > 0 && normalizedLoader !== 'mrpack') {
|
||||
loaders.add(normalizedLoader)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
gameVersions: sortStringValues([...gameVersions]),
|
||||
loaderTypes: sortStringValues([...loaders]),
|
||||
versionIds: sortStringValues([...versionIds]),
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAnalyticsVersionMetadataByIds(
|
||||
versionIds: string[],
|
||||
getVersions: (ids: string[]) => Promise<Labrinth.Versions.v3.Version[]>,
|
||||
): Promise<AnalyticsVersionMetadata[]> {
|
||||
const metadata: AnalyticsVersionMetadata[] = []
|
||||
const segmentSize = 800
|
||||
|
||||
for (let index = 0; index < versionIds.length; index += segmentSize) {
|
||||
const versions = await getVersions(versionIds.slice(index, index + segmentSize))
|
||||
metadata.push(...versions.map(toAnalyticsVersionMetadata))
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
export function getAnalyticsVersionIdsFromProjects(
|
||||
projects: readonly AnalyticsProjectVersionSource[],
|
||||
projectIds: readonly string[],
|
||||
): string[] {
|
||||
const selectedProjectIds = new Set(projectIds)
|
||||
const versionIds = new Set<string>()
|
||||
|
||||
for (const project of projects) {
|
||||
if (!selectedProjectIds.has(project.id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const versionId of project.versions ?? []) {
|
||||
const normalizedVersionId = versionId.trim()
|
||||
if (normalizedVersionId.length > 0) {
|
||||
versionIds.add(normalizedVersionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sortStringValues([...versionIds])
|
||||
}
|
||||
|
||||
function retainAvailableSelectedFilterValues(
|
||||
values: string[],
|
||||
availableValues: string[],
|
||||
): string[] {
|
||||
const availableValueSet = new Set(availableValues)
|
||||
return values.filter((value) => availableValueSet.has(value))
|
||||
}
|
||||
|
||||
export function sanitizeAnalyticsSelectedFiltersForAvailableOptions(
|
||||
filters: AnalyticsSelectedFilters,
|
||||
filterOptions: AnalyticsDashboardFilterOptions,
|
||||
): AnalyticsSelectedFilters {
|
||||
return {
|
||||
...filters,
|
||||
download_reason: retainAvailableSelectedFilterValues(
|
||||
filters.download_reason,
|
||||
filterOptions.downloadReasons,
|
||||
),
|
||||
game_version: retainAvailableSelectedFilterValues(
|
||||
filters.game_version,
|
||||
filterOptions.gameVersions,
|
||||
),
|
||||
loader_type: retainAvailableSelectedFilterValues(
|
||||
filters.loader_type,
|
||||
filterOptions.loaderTypes,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function cloneAnalyticsSelectedFilters(
|
||||
filters: AnalyticsSelectedFilters,
|
||||
): AnalyticsSelectedFilters {
|
||||
return {
|
||||
project: [...filters.project],
|
||||
project_status: [...filters.project_status],
|
||||
country: [...filters.country],
|
||||
monetization: [...filters.monetization],
|
||||
user_agent: [...filters.user_agent],
|
||||
download_reason: [...filters.download_reason],
|
||||
version_id: [...filters.version_id],
|
||||
game_version: [...filters.game_version],
|
||||
loader_type: [...filters.loader_type],
|
||||
}
|
||||
}
|
||||
|
||||
export function cloneAnalyticsFilterOptions(
|
||||
filterOptions: AnalyticsDashboardFilterOptions,
|
||||
): AnalyticsDashboardFilterOptions {
|
||||
return {
|
||||
countries: [...filterOptions.countries],
|
||||
downloadSources: [...filterOptions.downloadSources],
|
||||
downloadReasons: [...filterOptions.downloadReasons],
|
||||
gameVersions: [...filterOptions.gameVersions],
|
||||
loaderTypes: [...filterOptions.loaderTypes],
|
||||
versionIds: [...filterOptions.versionIds],
|
||||
}
|
||||
}
|
||||
|
||||
function getEmptyAnalyticsFacetsFilterOptionSummary(): AnalyticsFacetsFilterOptionSummary {
|
||||
return {
|
||||
countries: [],
|
||||
downloadSources: [],
|
||||
downloadReasons: [],
|
||||
gameVersions: [],
|
||||
loaderTypes: [],
|
||||
versionIds: [],
|
||||
projectDownloadsById: new Map(),
|
||||
projectVersionDownloadsById: new Map(),
|
||||
gameVersionDownloadsByVersion: new Map(),
|
||||
countryDownloadsByCode: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
function getAnalyticsFacetValues<T>(
|
||||
facets: Labrinth.Analytics.v3.AnalyticsFacet<T>[] | null | undefined,
|
||||
): T[] {
|
||||
return facets?.map((facet) => facet.value) ?? []
|
||||
}
|
||||
|
||||
function getAnalyticsFacetDownloadsByValue<T>(
|
||||
facets: Labrinth.Analytics.v3.AnalyticsFacet<T>[] | null | undefined,
|
||||
getKey: (value: T) => string,
|
||||
): Map<string, number> {
|
||||
const downloadsByValue = new Map<string, number>()
|
||||
for (const facet of facets ?? []) {
|
||||
const key = getKey(facet.value)
|
||||
if (key.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const downloads = Number.isFinite(facet.downloads) ? facet.downloads : 0
|
||||
downloadsByValue.set(key, (downloadsByValue.get(key) ?? 0) + downloads)
|
||||
}
|
||||
|
||||
return downloadsByValue
|
||||
}
|
||||
|
||||
export function getAnalyticsFacetsFilterOptionSummary(
|
||||
facets: Labrinth.Analytics.v3.AnalyticsFacets | null | undefined,
|
||||
): AnalyticsFacetsFilterOptionSummary {
|
||||
if (!facets) {
|
||||
return getEmptyAnalyticsFacetsFilterOptionSummary()
|
||||
}
|
||||
|
||||
const downloadCountries = getAnalyticsFacetValues(facets.project_downloads.country)
|
||||
const downloadGameVersions = getAnalyticsFacetValues(facets.project_downloads.game_version)
|
||||
const downloadLoaders = getAnalyticsFacetValues(facets.project_downloads.loader)
|
||||
const downloadVersionIds = getAnalyticsFacetValues(facets.project_downloads.version_id)
|
||||
const viewCountries = getAnalyticsFacetValues(facets.project_views.country)
|
||||
const playtimeCountries = getAnalyticsFacetValues(facets.project_playtime.country)
|
||||
const playtimeGameVersions = getAnalyticsFacetValues(facets.project_playtime.game_version)
|
||||
const playtimeLoaders = getAnalyticsFacetValues(facets.project_playtime.loader)
|
||||
const playtimeVersionIds = getAnalyticsFacetValues(facets.project_playtime.version_id)
|
||||
const countries = new Set([...viewCountries, ...downloadCountries, ...playtimeCountries])
|
||||
const gameVersions = new Set([...downloadGameVersions, ...playtimeGameVersions])
|
||||
const loaderTypes = new Set<string>()
|
||||
for (const loader of [...downloadLoaders, ...playtimeLoaders]) {
|
||||
const normalizedLoader = loader.trim().toLowerCase()
|
||||
if (normalizedLoader.length > 0 && normalizedLoader !== 'mrpack') {
|
||||
loaderTypes.add(normalizedLoader)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
countries: sortStringValues(
|
||||
[...countries]
|
||||
.map((country) => country.trim().toUpperCase())
|
||||
.filter((country) => country.length > 0),
|
||||
),
|
||||
downloadSources: sortStringValues(getAnalyticsFacetValues(facets.project_downloads.user_agent)),
|
||||
downloadReasons: sortStringValues(getAnalyticsFacetValues(facets.project_downloads.reason)),
|
||||
gameVersions: sortStringValues(
|
||||
[...gameVersions]
|
||||
.map((gameVersion) => gameVersion.trim())
|
||||
.filter((gameVersion) => gameVersion.length > 0),
|
||||
),
|
||||
loaderTypes: sortStringValues([...loaderTypes]),
|
||||
versionIds: sortStringValues([...new Set([...downloadVersionIds, ...playtimeVersionIds])]),
|
||||
projectDownloadsById: getAnalyticsFacetDownloadsByValue(
|
||||
facets.project_downloads.project_id,
|
||||
(projectId) => projectId.trim(),
|
||||
),
|
||||
projectVersionDownloadsById: getAnalyticsFacetDownloadsByValue(
|
||||
facets.project_downloads.version_id,
|
||||
(versionId) => versionId.trim(),
|
||||
),
|
||||
gameVersionDownloadsByVersion: getAnalyticsFacetDownloadsByValue(
|
||||
facets.project_downloads.game_version,
|
||||
(gameVersion) => gameVersion.trim(),
|
||||
),
|
||||
countryDownloadsByCode: getAnalyticsFacetDownloadsByValue(
|
||||
facets.project_downloads.country,
|
||||
(country) => country.trim().toUpperCase(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function doesAnalyticsPointMatchFilters(
|
||||
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
filters: AnalyticsSelectedFilters,
|
||||
): boolean {
|
||||
return doesAnalyticsPointMatchNormalizedFilters(
|
||||
dataPoint,
|
||||
normalizeAnalyticsSelectedFilters(filters),
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizeAnalyticsSelectedFilters(
|
||||
filters: AnalyticsSelectedFilters,
|
||||
): NormalizedAnalyticsSelectedFilters {
|
||||
return {
|
||||
country: normalizeAnalyticsFilterValues(filters.country),
|
||||
monetization: normalizeAnalyticsFilterValues(filters.monetization),
|
||||
userAgent: normalizeAnalyticsFilterValues(filters.user_agent),
|
||||
downloadReason: normalizeAnalyticsFilterValues(filters.download_reason),
|
||||
versionId: normalizeAnalyticsFilterValues(filters.version_id),
|
||||
gameVersion: normalizeAnalyticsFilterValues(filters.game_version),
|
||||
loaderType: normalizeAnalyticsFilterValues(filters.loader_type),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAnalyticsFilterValues(values: string[]): ReadonlySet<string> {
|
||||
const normalizedValues = new Set<string>()
|
||||
for (const value of values) {
|
||||
const normalizedValue = value.trim().toLowerCase()
|
||||
if (normalizedValue.length > 0) {
|
||||
normalizedValues.add(normalizedValue)
|
||||
}
|
||||
}
|
||||
return normalizedValues
|
||||
}
|
||||
|
||||
export function doesAnalyticsPointMatchNormalizedFilters(
|
||||
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
filters: NormalizedAnalyticsSelectedFilters,
|
||||
): boolean {
|
||||
switch (dataPoint.metric_kind) {
|
||||
case 'views':
|
||||
return (
|
||||
doesAnalyticsPointMatchNormalizedFilter(
|
||||
dataPoint,
|
||||
filters.country,
|
||||
getCountryFilterValue,
|
||||
) &&
|
||||
doesAnalyticsPointMatchNormalizedFilter(
|
||||
dataPoint,
|
||||
filters.monetization,
|
||||
getMonetizationFilterValue,
|
||||
)
|
||||
)
|
||||
case 'downloads':
|
||||
return (
|
||||
doesAnalyticsPointMatchNormalizedFilter(
|
||||
dataPoint,
|
||||
filters.country,
|
||||
getCountryFilterValue,
|
||||
) &&
|
||||
doesAnalyticsPointMatchNormalizedFilter(
|
||||
dataPoint,
|
||||
filters.monetization,
|
||||
getMonetizationFilterValue,
|
||||
) &&
|
||||
doesAnalyticsPointMatchNormalizedFilter(
|
||||
dataPoint,
|
||||
filters.userAgent,
|
||||
getDownloadSourceFilterValue,
|
||||
) &&
|
||||
doesAnalyticsPointMatchNormalizedFilter(
|
||||
dataPoint,
|
||||
filters.downloadReason,
|
||||
getDownloadReasonFilterValue,
|
||||
) &&
|
||||
doesAnalyticsPointMatchNormalizedFilter(
|
||||
dataPoint,
|
||||
filters.versionId,
|
||||
getVersionFilterValue,
|
||||
) &&
|
||||
doesAnalyticsPointMatchNormalizedFilter(
|
||||
dataPoint,
|
||||
filters.gameVersion,
|
||||
getGameVersionFilterValue,
|
||||
) &&
|
||||
doesAnalyticsPointMatchNormalizedFilter(dataPoint, filters.loaderType, getLoaderFilterValue)
|
||||
)
|
||||
case 'playtime':
|
||||
return (
|
||||
doesAnalyticsPointMatchNormalizedFilter(
|
||||
dataPoint,
|
||||
filters.country,
|
||||
getCountryFilterValue,
|
||||
) &&
|
||||
doesAnalyticsPointMatchNormalizedFilter(
|
||||
dataPoint,
|
||||
filters.versionId,
|
||||
getVersionFilterValue,
|
||||
) &&
|
||||
doesAnalyticsPointMatchNormalizedFilter(
|
||||
dataPoint,
|
||||
filters.gameVersion,
|
||||
getGameVersionFilterValue,
|
||||
) &&
|
||||
doesAnalyticsPointMatchNormalizedFilter(dataPoint, filters.loaderType, getLoaderFilterValue)
|
||||
)
|
||||
case 'revenue':
|
||||
return true
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function doesAnalyticsPointMatchNormalizedFilter(
|
||||
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
filterValues: ReadonlySet<string>,
|
||||
getPointValue: (dataPoint: Labrinth.Analytics.v3.ProjectAnalytics) => string | null | undefined,
|
||||
): boolean {
|
||||
if (filterValues.size === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const pointValue = getPointValue(dataPoint)
|
||||
if (pointValue === undefined) {
|
||||
return true
|
||||
}
|
||||
if (pointValue === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const normalizedPointValue = pointValue.trim().toLowerCase()
|
||||
return filterValues.has(normalizedPointValue)
|
||||
}
|
||||
|
||||
function getCountryFilterValue(
|
||||
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
): string | null | undefined {
|
||||
if (
|
||||
dataPoint.metric_kind !== 'views' &&
|
||||
dataPoint.metric_kind !== 'downloads' &&
|
||||
dataPoint.metric_kind !== 'playtime'
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return dataPoint.country ?? null
|
||||
}
|
||||
|
||||
function getMonetizationFilterValue(
|
||||
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
): string | null | undefined {
|
||||
if (dataPoint.metric_kind !== 'views' && dataPoint.metric_kind !== 'downloads') {
|
||||
return undefined
|
||||
}
|
||||
if (typeof dataPoint.monetized !== 'boolean') {
|
||||
return null
|
||||
}
|
||||
|
||||
return dataPoint.monetized ? 'monetized' : 'unmonetized'
|
||||
}
|
||||
|
||||
function getDownloadSourceFilterValue(
|
||||
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
): string | null | undefined {
|
||||
if (dataPoint.metric_kind !== 'downloads') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return dataPoint.user_agent ?? null
|
||||
}
|
||||
|
||||
function getDownloadReasonFilterValue(
|
||||
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
): string | null | undefined {
|
||||
if (dataPoint.metric_kind !== 'downloads') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return dataPoint.reason ?? null
|
||||
}
|
||||
|
||||
function getVersionFilterValue(
|
||||
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
): string | null | undefined {
|
||||
if (dataPoint.metric_kind !== 'downloads' && dataPoint.metric_kind !== 'playtime') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return dataPoint.version_id ?? null
|
||||
}
|
||||
|
||||
function getGameVersionFilterValue(
|
||||
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
): string | null | undefined {
|
||||
if (dataPoint.metric_kind !== 'downloads' && dataPoint.metric_kind !== 'playtime') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return dataPoint.game_version ?? null
|
||||
}
|
||||
|
||||
function getLoaderFilterValue(
|
||||
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
): string | null | undefined {
|
||||
if (dataPoint.metric_kind !== 'downloads' && dataPoint.metric_kind !== 'playtime') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return dataPoint.loader ?? null
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
getProjectStatusFilterValue,
|
||||
type ProjectStatusFilterValue,
|
||||
} from '~/components/analytics-dashboard/query-builder/query-filter'
|
||||
|
||||
import type {
|
||||
AnalyticsDashboardProject,
|
||||
AnalyticsDashboardProjectSource,
|
||||
AnalyticsSelectedFilters,
|
||||
ProjectTypeMetadata,
|
||||
} from './analytics-types'
|
||||
|
||||
const MINECRAFT_JAVA_SERVER_PROJECT_TYPE = 'minecraft_java_server'
|
||||
|
||||
export const UNKNOWN_ORGANIZATION_NAME = 'Organization'
|
||||
|
||||
function isServerProject(project: ProjectTypeMetadata): boolean {
|
||||
if (project.project_type === MINECRAFT_JAVA_SERVER_PROJECT_TYPE) {
|
||||
return true
|
||||
}
|
||||
|
||||
return project.project_types?.includes(MINECRAFT_JAVA_SERVER_PROJECT_TYPE) ?? false
|
||||
}
|
||||
|
||||
export function isAnalyticsEligibleProject(
|
||||
project: ProjectTypeMetadata & { status?: string | null },
|
||||
): boolean {
|
||||
return !isServerProject(project) && getProjectStatusFilterValue(project.status) !== 'draft'
|
||||
}
|
||||
|
||||
export function getSingleQueryValue(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const normalizedValue = value.trim()
|
||||
return normalizedValue.length > 0 ? normalizedValue : undefined
|
||||
}
|
||||
|
||||
export function toAnalyticsDashboardProject(
|
||||
project: AnalyticsDashboardProjectSource,
|
||||
): AnalyticsDashboardProject {
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.name ?? project.title ?? project.id,
|
||||
iconUrl: project.icon_url ?? undefined,
|
||||
downloads: project.downloads ?? 0,
|
||||
status: getProjectStatusFilterValue(project.status),
|
||||
}
|
||||
}
|
||||
|
||||
export function getUniqueAnalyticsDashboardProjects(
|
||||
projects: AnalyticsDashboardProjectSource[],
|
||||
seenProjectIds: Set<string>,
|
||||
): AnalyticsDashboardProject[] {
|
||||
const analyticsProjects: AnalyticsDashboardProject[] = []
|
||||
|
||||
for (const project of projects) {
|
||||
if (seenProjectIds.has(project.id) || !isAnalyticsEligibleProject(project)) {
|
||||
continue
|
||||
}
|
||||
|
||||
seenProjectIds.add(project.id)
|
||||
analyticsProjects.push(toAnalyticsDashboardProject(project))
|
||||
}
|
||||
|
||||
return analyticsProjects
|
||||
}
|
||||
|
||||
export function getProjectOrganizationId(
|
||||
project: AnalyticsDashboardProjectSource,
|
||||
): string | undefined {
|
||||
return typeof project.organization === 'string' && project.organization.trim().length > 0
|
||||
? project.organization
|
||||
: undefined
|
||||
}
|
||||
|
||||
export function doesProjectStatusMatchFilters(
|
||||
status: string | null | undefined,
|
||||
filters: AnalyticsSelectedFilters,
|
||||
): boolean {
|
||||
if (filters.project_status.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return filters.project_status.includes(getProjectStatusFilterValue(status))
|
||||
}
|
||||
|
||||
export function getProjectIdsMatchingStatusFilter(
|
||||
projectIds: string[],
|
||||
projectStatusById: Map<string, ProjectStatusFilterValue>,
|
||||
filters: AnalyticsSelectedFilters,
|
||||
): string[] {
|
||||
if (filters.project_status.length === 0) {
|
||||
return projectIds
|
||||
}
|
||||
|
||||
return projectIds.filter((projectId) =>
|
||||
doesProjectStatusMatchFilters(projectStatusById.get(projectId), filters),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { LocationQueryValueRaw } from 'vue-router'
|
||||
|
||||
import type { ProjectStatusFilterValue } from '~/components/analytics-dashboard/query-builder/query-filter'
|
||||
|
||||
export type AnalyticsQueryFilterCategory =
|
||||
| 'project'
|
||||
| 'project_status'
|
||||
| 'country'
|
||||
| 'monetization'
|
||||
| 'user_agent'
|
||||
| 'download_reason'
|
||||
| 'version_id'
|
||||
| 'game_version'
|
||||
| 'loader_type'
|
||||
|
||||
export type AnalyticsTimeframePreset =
|
||||
| 'today'
|
||||
| 'yesterday'
|
||||
| 'last_7_days'
|
||||
| 'last_14_days'
|
||||
| 'last_30_days'
|
||||
| 'last_90_days'
|
||||
| 'last_180_days'
|
||||
| 'year_to_date'
|
||||
| 'all_time'
|
||||
|
||||
export type AnalyticsTimeframeMode = 'preset' | 'last' | 'custom_range' | 'custom_datetime_range'
|
||||
export type AnalyticsLastTimeframeUnit = 'hours' | 'days' | 'weeks' | 'months'
|
||||
|
||||
export type AnalyticsGroupByPreset = '1h' | '6h' | 'day' | 'week' | 'month' | 'year'
|
||||
|
||||
export type AnalyticsBreakdownPreset =
|
||||
| 'none'
|
||||
| 'project'
|
||||
| 'country'
|
||||
| 'monetization'
|
||||
| 'user_agent'
|
||||
| 'download_reason'
|
||||
| 'version_id'
|
||||
| 'loader'
|
||||
| 'game_version'
|
||||
|
||||
export type AnalyticsSelectedBreakdowns = Exclude<AnalyticsBreakdownPreset, 'none'>[]
|
||||
export type AnalyticsDashboardStat = 'views' | 'downloads' | 'revenue' | 'playtime'
|
||||
export type AnalyticsGraphViewMode = 'line' | 'area' | 'bar'
|
||||
export type AnalyticsTableSortColumn =
|
||||
| 'date'
|
||||
| 'project'
|
||||
| 'breakdown'
|
||||
| `breakdown_${Exclude<AnalyticsBreakdownPreset, 'none'>}`
|
||||
| 'views'
|
||||
| 'downloads'
|
||||
| 'revenue'
|
||||
| 'playtime'
|
||||
export type AnalyticsTableSortDirection = 'asc' | 'desc'
|
||||
|
||||
export type AnalyticsSelectedFilters = Record<AnalyticsQueryFilterCategory, string[]>
|
||||
|
||||
export type AnalyticsQueryBuilderState = {
|
||||
selectedProjectIds: string[]
|
||||
selectedTimeframeMode: AnalyticsTimeframeMode
|
||||
selectedTimeframe: AnalyticsTimeframePreset
|
||||
selectedLastTimeframeAmount: number
|
||||
selectedLastTimeframeUnit: AnalyticsLastTimeframeUnit
|
||||
selectedCustomTimeframeStartDate: string
|
||||
selectedCustomTimeframeEndDate: string
|
||||
selectedGroupBy: AnalyticsGroupByPreset
|
||||
selectedBreakdowns: AnalyticsSelectedBreakdowns
|
||||
selectedFilters: AnalyticsSelectedFilters
|
||||
}
|
||||
|
||||
export type AnalyticsGraphState = {
|
||||
activeStat: AnalyticsDashboardStat
|
||||
activeGraphViewMode: AnalyticsGraphViewMode
|
||||
isRatioMode: boolean
|
||||
showChartEvents: boolean
|
||||
showProjectEvents: boolean
|
||||
showPreviousPeriod: boolean
|
||||
hiddenGraphDatasetIds: string[]
|
||||
selectedGraphDatasetIds: string[] | null
|
||||
}
|
||||
|
||||
export type AnalyticsTableSortState = {
|
||||
sortColumn: AnalyticsTableSortColumn | undefined
|
||||
sortDirection: AnalyticsTableSortDirection
|
||||
}
|
||||
|
||||
export type MutableRouteQuery = Record<
|
||||
string,
|
||||
LocationQueryValueRaw | LocationQueryValueRaw[] | undefined
|
||||
>
|
||||
|
||||
export type ProjectTypeMetadata = {
|
||||
project_type?: string | null
|
||||
project_types?: readonly string[] | null
|
||||
}
|
||||
|
||||
export type AnalyticsProjectFetchRequest = Labrinth.Analytics.v3.FetchRequest & {
|
||||
project_ids: string[]
|
||||
}
|
||||
|
||||
export type AnalyticsDashboardProjectSource = ProjectTypeMetadata & {
|
||||
id: string
|
||||
name?: string | null
|
||||
title?: string | null
|
||||
organization?: string | null
|
||||
icon_url?: string | null
|
||||
downloads?: number | null
|
||||
status?: string | null
|
||||
}
|
||||
|
||||
export type AnalyticsProjectVersionSource = {
|
||||
id: string
|
||||
versions?: readonly string[] | null
|
||||
}
|
||||
|
||||
export interface AnalyticsDashboardProject {
|
||||
id: string
|
||||
name: string
|
||||
iconUrl?: string
|
||||
downloads: number
|
||||
status: ProjectStatusFilterValue
|
||||
}
|
||||
|
||||
export interface AnalyticsDashboardProjectGroup {
|
||||
key?: string
|
||||
title?: string
|
||||
projects: AnalyticsDashboardProject[]
|
||||
}
|
||||
|
||||
export interface AnalyticsDashboardTotals {
|
||||
views: number
|
||||
downloads: number
|
||||
revenue: number
|
||||
playtime: number
|
||||
}
|
||||
|
||||
export interface AnalyticsDashboardPercentChanges {
|
||||
views: number
|
||||
downloads: number
|
||||
revenue: number
|
||||
playtime: number
|
||||
}
|
||||
|
||||
export interface AnalyticsDashboardFilterOptions {
|
||||
countries: string[]
|
||||
downloadSources: string[]
|
||||
downloadReasons: string[]
|
||||
gameVersions: string[]
|
||||
loaderTypes: string[]
|
||||
versionIds: string[]
|
||||
}
|
||||
|
||||
export interface NormalizedAnalyticsSelectedFilters {
|
||||
country: ReadonlySet<string>
|
||||
monetization: ReadonlySet<string>
|
||||
userAgent: ReadonlySet<string>
|
||||
downloadReason: ReadonlySet<string>
|
||||
versionId: ReadonlySet<string>
|
||||
gameVersion: ReadonlySet<string>
|
||||
loaderType: ReadonlySet<string>
|
||||
}
|
||||
|
||||
export interface AnalyticsFacetsFilterOptionSummary {
|
||||
countries: string[]
|
||||
downloadSources: string[]
|
||||
downloadReasons: string[]
|
||||
gameVersions: string[]
|
||||
loaderTypes: string[]
|
||||
versionIds: string[]
|
||||
projectDownloadsById: Map<string, number>
|
||||
projectVersionDownloadsById: Map<string, number>
|
||||
gameVersionDownloadsByVersion: Map<string, number>
|
||||
countryDownloadsByCode: Map<string, number>
|
||||
}
|
||||
|
||||
export interface ProjectVersionFilterOptionSummary {
|
||||
gameVersions: string[]
|
||||
loaderTypes: string[]
|
||||
versionIds: string[]
|
||||
}
|
||||
|
||||
export interface AnalyticsVersionMetadata {
|
||||
id: string
|
||||
versionNumber: string
|
||||
datePublished: string
|
||||
projectId: string
|
||||
downloads: number
|
||||
gameVersions: string[]
|
||||
loaders: string[]
|
||||
}
|
||||
|
||||
export type AnalyticsTimeSliceSplit = {
|
||||
currentTimeSlices: Labrinth.Analytics.v3.TimeSlice[]
|
||||
previousTimeSlices: Labrinth.Analytics.v3.TimeSlice[]
|
||||
}
|
||||
|
||||
export type AnalyticsFetchData = {
|
||||
metrics: Labrinth.Analytics.v3.TimeSlice[]
|
||||
project_events: Labrinth.Analytics.v3.ProjectAnalyticsEvent[]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user