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:
Truman Gao
2026-05-29 19:39:55 +00:00
committed by GitHub
co-authored by Calum H.
parent f49951084e
commit 11b2b6e6c0
100 changed files with 23707 additions and 2981 deletions
@@ -0,0 +1,150 @@
<template>
<div class="flex flex-wrap items-center gap-3">
<span class="shrink-0 whitespace-nowrap text-sm font-semibold text-primary">
{{ props.label }}
</span>
<input
v-model="inputValue"
type="text"
inputmode="numeric"
placeholder="0"
class="h-8 rounded-lg border border-solid border-surface-5 bg-surface-3 px-2 text-center text-sm font-semibold text-primary outline-none transition-[box-shadow,color] focus:text-contrast focus:ring-4 focus:ring-brand-shadow max-sm:text-base"
:class="props.inputWidthClass"
:aria-label="props.inputAriaLabel"
@blur="formatInput"
@keydown.enter.prevent.stop="submitInput"
/>
<span class="shrink-0 text-sm font-semibold text-primary">{{ suffixLabel }}</span>
</div>
</template>
<script setup lang="ts">
import { useVIntl } from '@modrinth/ui'
import { analyticsMessages } from '../analytics-messages'
const props = withDefaults(
defineProps<{
label: string
inputAriaLabel: string
threshold?: number | null
suffix?: string
inputWidthClass?: string
}>(),
{
inputWidthClass: 'w-20',
},
)
const { formatMessage } = useVIntl()
const emit = defineEmits<{
'update:threshold': [threshold: number | null]
submit: [event: KeyboardEvent]
}>()
const suffixLabel = computed(() => props.suffix ?? formatMessage(analyticsMessages.downloadsSuffix))
const inputValue = ref('')
let isSyncingThreshold = false
let hasPendingEmittedThreshold = false
let pendingEmittedThreshold: number | null = null
function parseDownloadsThreshold(value: string): number | null | undefined {
const normalizedValue = value.trim().toLowerCase().replace(/,/g, '')
if (!normalizedValue) {
return null
}
const match = normalizedValue.match(/^(\d+(?:\.\d+)?)([kmb])?$/)
if (!match) {
return undefined
}
const amount = Number.parseFloat(match[1])
if (!Number.isFinite(amount)) {
return undefined
}
const multiplierBySuffix: Record<string, number> = {
k: 1_000,
m: 1_000_000,
b: 1_000_000_000,
}
const multiplier = match[2] ? multiplierBySuffix[match[2]] : 1
return Math.max(0, Math.floor(amount * multiplier))
}
function formatCompactNumber(value: number): string {
const formatWithSuffix = (divisor: number, suffix: string) => {
const dividedValue = value / divisor
const fractionDigits = Number.isInteger(dividedValue) ? 0 : 1
return `${dividedValue.toFixed(fractionDigits).replace(/\.0$/, '')}${suffix}`
}
if (value >= 1_000_000_000) return formatWithSuffix(1_000_000_000, 'B')
if (value >= 1_000_000) return formatWithSuffix(1_000_000, 'M')
if (value >= 1_000) return formatWithSuffix(1_000, 'k')
return String(value)
}
function formatInput() {
const threshold = parseDownloadsThreshold(inputValue.value)
if (threshold === undefined || threshold === null) {
return
}
inputValue.value = formatCompactNumber(threshold)
}
function submitInput(event: KeyboardEvent) {
const threshold = parseDownloadsThreshold(inputValue.value)
if (threshold === undefined) {
return
}
if (threshold !== null) {
inputValue.value = formatCompactNumber(threshold)
}
emit('update:threshold', threshold)
emit('submit', event)
}
watch(inputValue, (value) => {
if (isSyncingThreshold) {
return
}
const threshold = parseDownloadsThreshold(value)
if (threshold === undefined) {
return
}
hasPendingEmittedThreshold = true
pendingEmittedThreshold = threshold
emit('update:threshold', threshold)
nextTick(() => {
if (hasPendingEmittedThreshold && pendingEmittedThreshold === threshold) {
hasPendingEmittedThreshold = false
}
})
})
watch(
() => props.threshold,
(threshold) => {
if (hasPendingEmittedThreshold && threshold === pendingEmittedThreshold) {
hasPendingEmittedThreshold = false
return
}
isSyncingThreshold = true
inputValue.value =
threshold === null || threshold === undefined ? '' : formatCompactNumber(threshold)
nextTick(() => {
isSyncingThreshold = false
})
},
{ immediate: true },
)
</script>
@@ -0,0 +1,944 @@
<template>
<DropdownFilterBar
v-model="selectedFilterValue"
:categories="filterCategories"
:show-clear="showClearAction && canClearSelectedBreakdown"
:show-label="showLabel"
:show-preview-filter-icon="showPreviewFilterIcon"
:preview-trigger-class="previewTriggerClass"
:add-button-class="addButtonClass"
:clear-label="formatMessage(analyticsMessages.resetButton)"
:add-label="resolvedAddLabel"
checkbox-position="right"
@clear="clearFilterBar"
>
<template #search-actions="{ category, setSelectedValues }">
<div v-if="category.key === 'game_version'" class="mr-2 flex min-w-[124px] justify-end">
<Tabs
:value="gameVersionType"
:tabs="gameVersionTypeTabs"
:aria-label="formatMessage(analyticsMessages.gameVersionTypeAria)"
@update:value="(type) => setGameVersionType(type, setSelectedValues)"
/>
</div>
</template>
<template #option="{ category, option, selected }">
<div class="flex min-w-0 flex-1 items-center gap-2">
<template v-if="category.key === 'version_id'">
<span
v-for="metadata in getProjectVersionOptionProjectMetadata(option.value)"
:key="`${option.value}-${metadata.name}`"
v-tooltip="metadata.name"
class="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded text-primary"
>
<img
v-if="metadata.iconUrl"
:src="metadata.iconUrl"
:alt="formatMessage(analyticsMessages.projectIconAlt, { name: metadata.name })"
class="h-6 w-6 rounded object-cover"
/>
<BoxIcon v-else class="h-full w-full" />
</span>
</template>
<span
class="min-w-0 truncate font-semibold leading-tight"
:class="selected ? 'text-contrast' : 'text-primary'"
>
{{ option.label }}
</span>
</div>
</template>
<template #category-footer="{ category, setSelectedValues, closeMenu }">
<DownloadsThresholdInput
v-if="category.key === 'country'"
class="border-0 border-t border-solid border-surface-5 px-3 py-2.5"
:label="formatMessage(analyticsMessages.countriesAbove)"
:input-aria-label="formatMessage(analyticsMessages.countryDownloadsThresholdAria)"
:threshold="countryDownloadsThreshold"
input-width-class="w-16"
@update:threshold="
(threshold) => setCountryDownloadsThreshold(threshold, setSelectedValues)
"
@submit="
(event) =>
runDownloadsThresholdQuery(
applyCountryDownloadsThreshold,
setSelectedValues,
closeMenu,
event,
)
"
/>
<DownloadsThresholdInput
v-else-if="category.key === 'version_id'"
class="border-0 border-t border-solid border-surface-5 px-3 py-2.5"
:label="formatMessage(analyticsMessages.projectVersionsAbove)"
:input-aria-label="formatMessage(analyticsMessages.projectVersionDownloadsThresholdAria)"
:threshold="projectVersionDownloadsThreshold"
input-width-class="w-16"
@update:threshold="
(threshold) => setProjectVersionDownloadsThreshold(threshold, setSelectedValues)
"
@submit="
(event) =>
runDownloadsThresholdQuery(
applyProjectVersionDownloadsThreshold,
setSelectedValues,
closeMenu,
event,
)
"
/>
<DownloadsThresholdInput
v-else-if="category.key === 'game_version'"
class="border-0 border-t border-solid border-surface-5 px-3 py-2.5"
:label="formatMessage(analyticsMessages.gameVersionsAbove)"
:input-aria-label="formatMessage(analyticsMessages.gameVersionDownloadsThresholdAria)"
:threshold="gameVersionDownloadsThreshold"
input-width-class="w-16"
@update:threshold="
(threshold) => setGameVersionDownloadsThreshold(threshold, setSelectedValues)
"
@submit="
(event) =>
runDownloadsThresholdQuery(
applyGameVersionDownloadsThreshold,
setSelectedValues,
closeMenu,
event,
)
"
/>
</template>
<template #preview-footer="{ category, setSelectedValues, closeMenu }">
<DownloadsThresholdInput
v-if="category.key === 'country'"
class="border-0 border-t border-solid border-surface-5 px-3 py-2.5"
:label="formatMessage(analyticsMessages.countriesAbove)"
:input-aria-label="formatMessage(analyticsMessages.countryDownloadsThresholdAria)"
:threshold="countryDownloadsThreshold"
input-width-class="w-16"
@update:threshold="
(threshold) => setCountryDownloadsThreshold(threshold, setSelectedValues)
"
@submit="
(event) =>
runDownloadsThresholdQuery(
applyCountryDownloadsThreshold,
setSelectedValues,
closeMenu,
event,
)
"
/>
<DownloadsThresholdInput
v-else-if="category.key === 'version_id'"
class="border-0 border-t border-solid border-surface-5 px-3 py-2.5"
:label="formatMessage(analyticsMessages.projectVersionsAbove)"
:input-aria-label="formatMessage(analyticsMessages.projectVersionDownloadsThresholdAria)"
:threshold="projectVersionDownloadsThreshold"
input-width-class="w-16"
@update:threshold="
(threshold) => setProjectVersionDownloadsThreshold(threshold, setSelectedValues)
"
@submit="
(event) =>
runDownloadsThresholdQuery(
applyProjectVersionDownloadsThreshold,
setSelectedValues,
closeMenu,
event,
)
"
/>
<DownloadsThresholdInput
v-else-if="category.key === 'game_version'"
class="border-0 border-t border-solid border-surface-5 px-3 py-2.5"
:label="formatMessage(analyticsMessages.gameVersionsAbove)"
:input-aria-label="formatMessage(analyticsMessages.gameVersionDownloadsThresholdAria)"
:threshold="gameVersionDownloadsThreshold"
input-width-class="w-16"
@update:threshold="
(threshold) => setGameVersionDownloadsThreshold(threshold, setSelectedValues)
"
@submit="
(event) =>
runDownloadsThresholdQuery(
applyGameVersionDownloadsThreshold,
setSelectedValues,
closeMenu,
event,
)
"
/>
</template>
</DropdownFilterBar>
</template>
<script setup lang="ts">
import { BoxIcon } from '@modrinth/assets'
import {
DropdownFilterBar,
type DropdownFilterBarCategory,
type DropdownFilterBarOption,
Tabs,
type TabsTab,
type TabsValue,
useVIntl,
} from '@modrinth/ui'
import { useFormattedCountries } from '@/composables/country.ts'
import {
areStringArraysEqual,
getDefaultAnalyticsBreakdownPresets,
} from '~/components/analytics-dashboard/analytics-route-query'
import { useGeneratedState } from '~/composables/generated'
import {
type AnalyticsQueryFilterCategory,
type AnalyticsSelectedFilters,
doesProjectStatusMatchFilters,
injectAnalyticsDashboardContext,
} from '~/providers/analytics/analytics'
import {
analyticsBreakdownMessages,
analyticsMessages,
analyticsMonetizationMessages,
formatAnalyticsDownloadReasonLabel,
formatAnalyticsLoaderLabel,
formatAnalyticsProjectStatusLabel,
} from '../analytics-messages.ts'
import { getDownloadSourceLabel } from '../breakdown.ts'
import DownloadsThresholdInput from './DownloadsThresholdInput.vue'
import {
areSelectedFiltersEqual,
buildProjectVersionFilterOptionProjectMetadataById,
buildProjectVersionFilterOptions,
cloneSelectedFilters,
FILTER_VALUE_CATEGORIES,
getOptionsWithSelectedValues,
getProjectVersionFilterOptionMetadataIds,
getProjectVersionFilterOptionProjectMetadataCacheKey,
getProjectVersionFilterOptionsCacheKey,
getVisibleAnalyticsFilterCategoriesForState,
normalizeSelectedValues as normalizeSelectedFilterValues,
type ProjectVersionFilterOption,
type ProjectVersionFilterOptionProjectMetadata,
} from './query-filter.ts'
type AnalyticsFilterValueCategory = Exclude<AnalyticsQueryFilterCategory, 'project'>
type GameVersionType = 'release' | 'all'
type SetDropdownFilterValues = (values: string[]) => void
type ApplyDownloadsThreshold = (setSelectedValues: SetDropdownFilterValues) => void
type CloseDownloadsThresholdMenu = (event?: Event) => void
const props = withDefaults(
defineProps<{
addLabel?: string
showLabel?: boolean
showPreviewFilterIcon?: boolean
previewTriggerClass?: string
addButtonClass?: string
showClearAction?: boolean
}>(),
{
showLabel: true,
showPreviewFilterIcon: false,
showClearAction: true,
},
)
const { formatMessage } = useVIntl()
const {
hasProjectContext,
projects,
selectedProjectIds,
availableProjectStatuses,
filterOptions,
projectVersionDownloadsById,
gameVersionDownloadsByVersion,
countryDownloadsByCode,
isAnalyticsFilterOptionsLoading,
selectedBreakdowns,
selectedFilters,
queryResetToken,
refreshAnalyticsQuery,
hasCompletedAnalyticsLoading,
versionNumbersById,
versionPublishedDatesById,
versionProjectNamesById,
versionProjectIconUrlsById,
getVersionDisplayName,
} = injectAnalyticsDashboardContext()
const formattedCountries = useFormattedCountries()
const generatedState = useGeneratedState()
const gameVersionType = ref<GameVersionType>('release')
const countryDownloadsThreshold = ref<number | null>(null)
const projectVersionDownloadsThreshold = ref<number | null>(null)
const gameVersionDownloadsThreshold = ref<number | null>(null)
const gameVersionTypeTabs = computed<TabsTab[]>(() => [
{ value: 'release', label: formatMessage(analyticsMessages.releaseTab) },
{ value: 'all', label: formatMessage(analyticsMessages.allTab) },
])
const resolvedAddLabel = computed(
() => props.addLabel ?? formatMessage(analyticsMessages.addButton),
)
const filterValueCategoryKeys = new Set<string>(FILTER_VALUE_CATEGORIES)
const downloadsThresholdFilterCategories = ['country', 'version_id', 'game_version'] as const
type DownloadsThresholdFilterCategory = (typeof downloadsThresholdFilterCategories)[number]
const downloadsThresholdSelections = ref<
Partial<Record<DownloadsThresholdFilterCategory, string[]>>
>({})
const projectStatusFilterOptions = computed<DropdownFilterBarOption[]>(() =>
availableProjectStatuses.value.map((status) => ({
value: status,
label: getProjectStatusFilterOptionLabel(status),
})),
)
const selectedProjectIdSet = computed(() => new Set(selectedProjectIds.value))
const effectiveSelectedProjectCount = computed(
() =>
projects.value.filter(
(project) =>
selectedProjectIdSet.value.has(project.id) &&
doesProjectStatusMatchFilters(project.status, selectedFilters.value),
).length,
)
const showProjectVersionProjectIcons = computed(() => effectiveSelectedProjectCount.value > 1)
const defaultSelectedBreakdown = computed(() =>
getDefaultAnalyticsBreakdownPresets(selectedProjectIds.value),
)
const canClearSelectedBreakdown = computed(
() => !areStringArraysEqual(selectedBreakdowns.value, defaultSelectedBreakdown.value),
)
const analyticsFilterOptionsEmptyLabel = computed(() =>
isAnalyticsFilterOptionsLoading.value
? formatMessage(analyticsMessages.loadingOptions)
: undefined,
)
const projectVersionFilterOptions = shallowRef<ProjectVersionFilterOption[]>([])
const projectVersionFilterOptionProjectMetadataById = shallowRef(
new Map<string, ProjectVersionFilterOptionProjectMetadata[]>(),
)
const draftSelectedFilters = ref<AnalyticsSelectedFilters>(
cloneSelectedFilters(selectedFilters.value),
)
let selectedFiltersCommitRequestId = 0
let projectVersionFilterOptionsCacheKey = ''
let projectVersionFilterOptionProjectMetadataCacheKey = ''
const selectedFilterValue = computed<Record<string, string[]>>({
get: () => getSelectedFilterBarValue(),
set: (nextValue) => {
const nextFilters = cloneSelectedFilters(draftSelectedFilters.value)
for (const [categoryKey, values] of Object.entries(nextValue)) {
if (!isAnalyticsFilterValueCategory(categoryKey)) {
continue
}
nextFilters[categoryKey] = normalizeSelectedFilterValues(categoryKey, values, [])
}
draftSelectedFilters.value = nextFilters
void scheduleSelectedFiltersCommit()
},
})
function getSelectedFilterBarValue(): AnalyticsSelectedFilters {
return cloneSelectedFilters(draftSelectedFilters.value)
}
function clearSelectedBreakdown() {
selectedBreakdowns.value = defaultSelectedBreakdown.value
}
function clearFilterBar() {
clearSelectedBreakdown()
clearDownloadsThresholds()
}
watch(queryResetToken, () => {
selectedFiltersCommitRequestId++
draftSelectedFilters.value = cloneSelectedFilters(selectedFilters.value)
clearDownloadsThresholds()
})
watch(
selectedFilters,
(nextFilters, previousFilters) => {
selectedFiltersCommitRequestId++
draftSelectedFilters.value = cloneSelectedFilters(nextFilters)
clearDownloadsThresholdsForChangedFilters(previousFilters, nextFilters)
},
{ deep: true },
)
watch(
[
hasCompletedAnalyticsLoading,
filterOptions,
versionNumbersById,
versionPublishedDatesById,
versionProjectNamesById,
],
([
hasCompletedLoading,
nextFilterOptions,
nextVersionNumbersById,
nextVersionPublishedDatesById,
nextVersionProjectNamesById,
]) => {
if (!hasCompletedLoading) {
projectVersionFilterOptionsCacheKey = ''
if (projectVersionFilterOptions.value.length > 0) {
projectVersionFilterOptions.value = []
}
return
}
const nextCacheKey = getProjectVersionFilterOptionsCacheKey(
nextFilterOptions.versionIds,
nextVersionNumbersById,
nextVersionPublishedDatesById,
nextVersionProjectNamesById,
)
if (nextCacheKey === projectVersionFilterOptionsCacheKey) {
return
}
projectVersionFilterOptionsCacheKey = nextCacheKey
projectVersionFilterOptions.value = buildProjectVersionFilterOptions(
nextFilterOptions.versionIds,
nextVersionNumbersById,
nextVersionPublishedDatesById,
nextVersionProjectNamesById,
)
},
{ immediate: true },
)
watch(
[
hasCompletedAnalyticsLoading,
filterOptions,
selectedFilters,
versionProjectNamesById,
versionProjectIconUrlsById,
],
([
hasCompletedLoading,
nextFilterOptions,
nextSelectedFilters,
nextVersionProjectNamesById,
nextVersionProjectIconUrlsById,
]) => {
if (!hasCompletedLoading) {
projectVersionFilterOptionProjectMetadataCacheKey = ''
if (projectVersionFilterOptionProjectMetadataById.value.size > 0) {
projectVersionFilterOptionProjectMetadataById.value = new Map()
}
return
}
const metadataIds = getProjectVersionFilterOptionMetadataIds(
nextFilterOptions.versionIds,
nextSelectedFilters.version_id,
)
const nextCacheKey = getProjectVersionFilterOptionProjectMetadataCacheKey(
metadataIds,
nextVersionProjectNamesById,
nextVersionProjectIconUrlsById,
)
if (nextCacheKey === projectVersionFilterOptionProjectMetadataCacheKey) {
return
}
projectVersionFilterOptionProjectMetadataCacheKey = nextCacheKey
projectVersionFilterOptionProjectMetadataById.value =
buildProjectVersionFilterOptionProjectMetadataById(
metadataIds,
nextVersionProjectNamesById,
nextVersionProjectIconUrlsById,
)
},
{ immediate: true },
)
async function scheduleSelectedFiltersCommit() {
const requestId = ++selectedFiltersCommitRequestId
const nextFilters = cloneSelectedFilters(draftSelectedFilters.value)
await waitForDeferredQueryFilterCommit()
if (requestId !== selectedFiltersCommitRequestId) {
return
}
if (!areSelectedFiltersEqual(selectedFilters.value, nextFilters)) {
selectedFilters.value = nextFilters
}
}
function waitForDeferredQueryFilterCommit(): Promise<void> {
if (!import.meta.client) {
return nextTick()
}
return new Promise((resolve) => {
nextTick(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve())
})
})
})
}
const filterCategories = computed<DropdownFilterBarCategory[]>(() => {
const visibleCategoryKeys = new Set(
getVisibleAnalyticsFilterCategoriesForState(selectedBreakdowns.value, selectedFilters.value),
)
const categories: DropdownFilterBarCategory[] = []
if (!hasProjectContext.value) {
categories.push({
key: 'project_status',
label: formatMessage(analyticsBreakdownMessages.projectStatus),
options: withSelectedOptions('project_status', projectStatusFilterOptions.value),
})
}
categories.push(
{
key: 'country',
label: formatMessage(analyticsBreakdownMessages.country),
searchable: countryFilterOptions.value.length > 6,
searchPlaceholder: formatMessage(analyticsMessages.searchCountriesPlaceholder),
emptyOptionsLabel: analyticsFilterOptionsEmptyLabel.value,
emptySearchLabel: analyticsFilterOptionsEmptyLabel.value,
options: withSelectedOptions('country', countryFilterOptions.value),
submenuClass: 'w-fit',
previewDropdownWidth: 'fit-content',
},
{
key: 'monetization',
label: formatMessage(analyticsBreakdownMessages.monetization),
options: withSelectedOptions('monetization', [
{ value: 'monetized', label: formatMessage(analyticsMonetizationMessages.monetized) },
{ value: 'unmonetized', label: formatMessage(analyticsMonetizationMessages.unmonetized) },
]),
},
{
key: 'user_agent',
label: formatMessage(analyticsBreakdownMessages.userAgent),
searchable: downloadSourceFilterOptions.value.length > 6,
searchPlaceholder: formatMessage(analyticsMessages.searchDownloadSourcesPlaceholder),
emptyOptionsLabel: analyticsFilterOptionsEmptyLabel.value,
emptySearchLabel: analyticsFilterOptionsEmptyLabel.value,
options: withSelectedOptions('user_agent', downloadSourceFilterOptions.value),
},
{
key: 'download_reason',
label: formatMessage(analyticsBreakdownMessages.downloadReason),
emptyOptionsLabel: analyticsFilterOptionsEmptyLabel.value,
emptySearchLabel: analyticsFilterOptionsEmptyLabel.value,
options: withSelectedOptions('download_reason', downloadReasonFilterOptions.value),
},
{
key: 'version_id',
label: formatMessage(analyticsBreakdownMessages.versionId),
searchable: projectVersionFilterOptions.value.length > 6,
searchPlaceholder: formatMessage(analyticsMessages.searchProjectVersionsPlaceholder),
submenuClass: 'w-fit',
previewDropdownWidth: 'fit-content',
options: withSelectedOptions('version_id', projectVersionFilterOptions.value),
},
{
key: 'game_version',
label: formatMessage(analyticsBreakdownMessages.gameVersion),
searchable: true,
searchPlaceholder: formatMessage(analyticsMessages.searchVersionsPlaceholder),
submenuClass: 'w-fit max-w-[338px]',
previewDropdownWidth: '338px',
options: withSelectedOptions('game_version', gameVersionFilterOptions.value),
},
{
key: 'loader_type',
label: formatMessage(analyticsBreakdownMessages.loader),
options: withSelectedOptions('loader_type', loaderTypeFilterOptions.value),
},
)
return categories.filter((category) =>
visibleCategoryKeys.has(category.key as AnalyticsFilterValueCategory),
)
})
const countryLabelsByCode = computed(
() =>
new Map(
formattedCountries.value.map(
(country) => [country.value.toUpperCase(), country.label] as const,
),
),
)
const countryFilterOptions = computed<DropdownFilterBarOption[]>(() =>
filterOptions.value.countries
.map((countryCode) => ({
value: countryCode,
label: getCountryFilterOptionLabel(countryCode),
searchTerms: [countryCode],
}))
.sort((left, right) => left.label.localeCompare(right.label)),
)
const gameVersionReleaseDatesByVersion = computed(
() =>
new Map(
generatedState.value.gameVersions.map(
(gameVersion) => [gameVersion.version, gameVersion.date] as const,
),
),
)
const gameVersionTypesByVersion = computed(
() =>
new Map(
generatedState.value.gameVersions.map(
(gameVersion) => [gameVersion.version, gameVersion.version_type] as const,
),
),
)
const downloadSourceFilterOptions = computed<DropdownFilterBarOption[]>(() =>
filterOptions.value.downloadSources
.map((downloadSource) => ({
value: downloadSource,
label: getDownloadSourceLabel(downloadSource, formatMessage),
}))
.sort((left, right) => left.label.localeCompare(right.label)),
)
const downloadReasonFilterOptions = computed<DropdownFilterBarOption[]>(() =>
filterOptions.value.downloadReasons.map((downloadReason) => ({
value: downloadReason,
label: getDownloadReasonFilterOptionLabel(downloadReason),
})),
)
const gameVersionFilterOptions = computed<DropdownFilterBarOption[]>(() =>
filterOptions.value.gameVersions
.filter((gameVersion) => {
const versionType = gameVersionTypesByVersion.value.get(gameVersion)
return (
gameVersionType.value === 'all' || versionType === undefined || versionType === 'release'
)
})
.map((gameVersion) => ({
value: gameVersion,
label: gameVersion,
}))
.sort((left, right) =>
compareOptionalDateStringsDescending(
gameVersionReleaseDatesByVersion.value.get(left.value),
gameVersionReleaseDatesByVersion.value.get(right.value),
left.label,
right.label,
),
),
)
const loaderTypeFilterOptions = computed<DropdownFilterBarOption[]>(() =>
filterOptions.value.loaderTypes
.map((loaderType) => ({
value: loaderType,
label: getLoaderTypeFilterOptionLabel(loaderType),
searchTerms: [loaderType],
}))
.sort((left, right) => left.label.localeCompare(right.label)),
)
function isAnalyticsFilterValueCategory(
categoryKey: string,
): categoryKey is AnalyticsFilterValueCategory {
return filterValueCategoryKeys.has(categoryKey)
}
function withSelectedOptions(
categoryKey: AnalyticsFilterValueCategory,
options: DropdownFilterBarOption[],
): DropdownFilterBarOption[] {
return getOptionsWithSelectedValues(
options,
selectedFilters.value[categoryKey],
getMissingSelectedOptionLabel(categoryKey),
)
}
function getMissingSelectedOptionLabel(
categoryKey: AnalyticsFilterValueCategory,
): ((value: string) => string) | undefined {
if (categoryKey === 'country') {
return getCountryFilterOptionLabel
}
if (categoryKey === 'version_id') {
return getVersionDisplayName
}
if (categoryKey === 'download_reason') {
return getDownloadReasonFilterOptionLabel
}
if (categoryKey === 'user_agent') {
return (value) => getDownloadSourceLabel(value, formatMessage)
}
if (categoryKey === 'loader_type') {
return getLoaderTypeFilterOptionLabel
}
return undefined
}
function getProjectVersionOptionProjectMetadata(versionId: string) {
if (!showProjectVersionProjectIcons.value) {
return []
}
return projectVersionFilterOptionProjectMetadataById.value.get(versionId) ?? []
}
function getCountryFilterOptionLabel(countryCode: string): string {
const normalizedCode = countryCode.trim().toUpperCase()
if (normalizedCode === 'XX') {
return formatMessage(analyticsMessages.other)
}
return countryLabelsByCode.value.get(normalizedCode) ?? countryCode
}
function getProjectStatusFilterOptionLabel(status: string): string {
return formatAnalyticsProjectStatusLabel(status, formatMessage)
}
function getLoaderTypeFilterOptionLabel(loaderType: string): string {
return formatAnalyticsLoaderLabel(loaderType, formatMessage)
}
function getDownloadReasonFilterOptionLabel(reason: string): string {
return formatAnalyticsDownloadReasonLabel(reason, formatMessage)
}
function getDateTimestamp(date: string | undefined): number | undefined {
if (!date) {
return undefined
}
const timestamp = new Date(date).getTime()
return Number.isFinite(timestamp) ? timestamp : undefined
}
function compareOptionalDateStringsDescending(
leftDate: string | undefined,
rightDate: string | undefined,
leftFallback: string,
rightFallback: string,
): number {
const leftTimestamp = getDateTimestamp(leftDate)
const rightTimestamp = getDateTimestamp(rightDate)
if (leftTimestamp !== undefined && rightTimestamp !== undefined) {
return rightTimestamp - leftTimestamp
}
if (leftTimestamp !== undefined) {
return -1
}
if (rightTimestamp !== undefined) {
return 1
}
return leftFallback.localeCompare(rightFallback)
}
function applyGameVersionDownloadsThreshold(setSelectedValues: SetDropdownFilterValues) {
const threshold = gameVersionDownloadsThreshold.value
if (threshold === null) {
return
}
const selectedValues = gameVersionFilterOptions.value
.filter((gameVersion) => {
return (gameVersionDownloadsByVersion.value.get(gameVersion.value) ?? 0) >= threshold
})
.map((gameVersion) => gameVersion.value)
setDownloadsThresholdSelectedValues('game_version', selectedValues, setSelectedValues)
}
function applyCountryDownloadsThreshold(setSelectedValues: SetDropdownFilterValues) {
const threshold = countryDownloadsThreshold.value
if (threshold === null) {
return
}
const selectedValues = countryFilterOptions.value
.filter((country) => {
return (
(countryDownloadsByCode.value.get(country.value.trim().toUpperCase()) ?? 0) >= threshold
)
})
.map((country) => country.value)
setDownloadsThresholdSelectedValues('country', selectedValues, setSelectedValues)
}
function applyProjectVersionDownloadsThreshold(setSelectedValues: SetDropdownFilterValues) {
const threshold = projectVersionDownloadsThreshold.value
if (threshold === null) {
return
}
const selectedValues = projectVersionFilterOptions.value
.filter((version) => {
return (projectVersionDownloadsById.value.get(version.value) ?? 0) >= threshold
})
.map((version) => version.value)
setDownloadsThresholdSelectedValues('version_id', selectedValues, setSelectedValues)
}
function setCountryDownloadsThreshold(
threshold: number | null,
setSelectedValues: SetDropdownFilterValues,
) {
countryDownloadsThreshold.value = threshold
if (threshold === null) {
clearDownloadsThreshold('country')
setSelectedValues([])
return
}
applyCountryDownloadsThreshold(setSelectedValues)
}
function setProjectVersionDownloadsThreshold(
threshold: number | null,
setSelectedValues: SetDropdownFilterValues,
) {
projectVersionDownloadsThreshold.value = threshold
if (threshold === null) {
clearDownloadsThreshold('version_id')
setSelectedValues([])
return
}
applyProjectVersionDownloadsThreshold(setSelectedValues)
}
function setGameVersionDownloadsThreshold(
threshold: number | null,
setSelectedValues: SetDropdownFilterValues,
) {
gameVersionDownloadsThreshold.value = threshold
if (threshold === null) {
clearDownloadsThreshold('game_version')
setSelectedValues([])
return
}
applyGameVersionDownloadsThreshold(setSelectedValues)
}
function clearDownloadsThresholdsForChangedFilters(
previousFilters: AnalyticsSelectedFilters,
nextFilters: AnalyticsSelectedFilters,
) {
for (const categoryKey of downloadsThresholdFilterCategories) {
if (areFilterSelectionsEqual(previousFilters[categoryKey], nextFilters[categoryKey])) {
continue
}
const thresholdSelection = downloadsThresholdSelections.value[categoryKey]
if (
thresholdSelection &&
areFilterSelectionsEqual(thresholdSelection, nextFilters[categoryKey])
) {
continue
}
if (previousFilters[categoryKey].length > 0 || nextFilters[categoryKey].length > 0) {
clearDownloadsThreshold(categoryKey)
}
}
}
function setDownloadsThresholdSelectedValues(
categoryKey: DownloadsThresholdFilterCategory,
selectedValues: string[],
setSelectedValues: SetDropdownFilterValues,
) {
downloadsThresholdSelections.value = {
...downloadsThresholdSelections.value,
[categoryKey]: normalizeSelectedFilterValues(categoryKey, selectedValues, []),
}
setSelectedValues(selectedValues)
}
function clearDownloadsThreshold(categoryKey: DownloadsThresholdFilterCategory) {
switch (categoryKey) {
case 'country':
countryDownloadsThreshold.value = null
break
case 'version_id':
projectVersionDownloadsThreshold.value = null
break
case 'game_version':
gameVersionDownloadsThreshold.value = null
break
}
const { [categoryKey]: _removedSelection, ...nextSelections } = downloadsThresholdSelections.value
downloadsThresholdSelections.value = nextSelections
}
function clearDownloadsThresholds() {
for (const categoryKey of downloadsThresholdFilterCategories) {
clearDownloadsThreshold(categoryKey)
}
}
function areFilterSelectionsEqual(left: string[], right: string[]): boolean {
const leftValues = new Set(left)
const rightValues = new Set(right)
if (leftValues.size !== rightValues.size) {
return false
}
return [...leftValues].every((value) => rightValues.has(value))
}
async function runDownloadsThresholdQuery(
applyDownloadsThreshold: ApplyDownloadsThreshold,
setSelectedValues: SetDropdownFilterValues,
closeMenu: CloseDownloadsThresholdMenu,
event?: KeyboardEvent,
) {
applyDownloadsThreshold(setSelectedValues)
closeMenu(event)
await scheduleSelectedFiltersCommit()
await refreshAnalyticsQuery()
}
function setGameVersionType(type: TabsValue, setSelectedValues: SetDropdownFilterValues) {
if (!isGameVersionType(type)) {
return
}
gameVersionType.value = type
applyGameVersionDownloadsThreshold(setSelectedValues)
}
function isGameVersionType(type: TabsValue): type is GameVersionType {
return type === 'release' || type === 'all'
}
</script>
@@ -0,0 +1,131 @@
<template>
<BaseTimeFramePicker
v-model:mode="selectedTimeframeMode"
v-model:preset="selectedTimeframe"
v-model:last-amount="selectedLastTimeframeAmount"
v-model:last-unit="selectedLastTimeframeUnit"
v-model:custom-start-date="selectedCustomTimeframeStartDate"
v-model:custom-end-date="selectedCustomTimeframeEndDate"
:min-date="ANALYTICS_START_DATE_INPUT_VALUE"
:now-timestamp="queryRefreshTimestamp"
:trigger-class="triggerClass"
@open="handleTimeframeOpen"
@commit="handleTimeframeCommit"
@apply="handleTimeframeApply"
@draft-change="handleTimeframeDraftChange"
@preset-select="handleTimeframePresetSelect"
>
<template #prefix>
<slot name="prefix"></slot>
</template>
</BaseTimeFramePicker>
</template>
<script setup lang="ts">
import {
type ComboboxOption,
TimeFramePicker as BaseTimeFramePicker,
type TimeFramePickerSelection,
type TimeFramePreset,
} from '@modrinth/ui'
import {
ANALYTICS_START_DATE_INPUT_VALUE,
type AnalyticsGroupByPreset,
type AnalyticsTimeframePreset,
injectAnalyticsDashboardContext,
} from '~/providers/analytics/analytics'
import {
ensureMinimumTimeRange,
getAnalyticsTimeRange,
getDateInputValue,
getDefaultAnalyticsGroupByForDurationMinutes,
} from './timeframe'
const {
selectedTimeframeMode,
selectedTimeframe,
selectedLastTimeframeAmount,
selectedLastTimeframeUnit,
selectedCustomTimeframeStartDate,
selectedCustomTimeframeEndDate,
selectedGroupBy,
queryRefreshTimestamp,
refreshAnalyticsQuery,
} = injectAnalyticsDashboardContext()
defineProps<{
triggerClass?: string
}>()
const draftSelectedGroupBy = ref(selectedGroupBy.value)
const defaultGroupByForPreset: Partial<Record<AnalyticsTimeframePreset, AnalyticsGroupByPreset>> = {
today: '1h',
yesterday: '1h',
last_7_days: '6h',
last_14_days: 'day',
last_30_days: 'day',
last_90_days: 'day',
last_180_days: 'week',
year_to_date: 'week',
}
function handleTimeframeOpen() {
draftSelectedGroupBy.value = selectedGroupBy.value
}
function handleTimeframeCommit() {
selectedGroupBy.value = draftSelectedGroupBy.value
}
async function handleTimeframeApply() {
await refreshAnalyticsQuery()
}
function handleTimeframePresetSelect(option: ComboboxOption<TimeFramePreset>) {
const defaultGroupBy = defaultGroupByForPreset[option.value as AnalyticsTimeframePreset]
if (defaultGroupBy) {
draftSelectedGroupBy.value = defaultGroupBy
}
}
function handleTimeframeDraftChange(selection: TimeFramePickerSelection) {
if (selection.mode !== 'last' && selection.mode !== 'custom_range') {
return
}
if (selection.mode === 'custom_range' && !hasCompleteCustomDateRange(selection)) {
return
}
const range = getAnalyticsTimeRange({
mode: selection.mode,
preset: selection.preset,
lastAmount: selection.lastAmount,
lastUnit: selection.lastUnit,
customStartDate: selection.customStartDate,
customEndDate: selection.customEndDate,
nowTimestamp: queryRefreshTimestamp.value,
})
const { start, end } = ensureMinimumTimeRange(range.start, range.end)
const durationMinutes = Math.max(1, Math.floor((end.getTime() - start.getTime()) / 60000))
draftSelectedGroupBy.value = getDefaultAnalyticsGroupByForDurationMinutes(durationMinutes)
}
function hasCompleteCustomDateRange(selection: TimeFramePickerSelection) {
return Boolean(
getDateFromInputValue(selection.customStartDate) &&
getDateFromInputValue(selection.customEndDate),
)
}
function getDateFromInputValue(value: string): Date | undefined {
const date = new Date(`${value}T00:00:00`)
if (Number.isNaN(date.getTime()) || getDateInputValue(date) !== value) {
return undefined
}
return date
}
</script>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,496 @@
import type {
AnalyticsBreakdownPreset,
AnalyticsDashboardStat,
AnalyticsQueryFilterCategory,
AnalyticsSelectedFilters,
} from '~/providers/analytics/analytics'
export type AnalyticsDashboardDimension =
| 'project'
| 'project_status'
| 'version_id'
| 'country'
| 'monetization'
| 'user_agent'
| 'download_reason'
| 'game_version'
| 'loader_type'
export const ALL_FILTER_VALUE = '__all__'
export const FILTER_VALUE_CATEGORIES: Exclude<AnalyticsQueryFilterCategory, 'project'>[] = [
'project_status',
'country',
'monetization',
'user_agent',
'download_reason',
'version_id',
'game_version',
'loader_type',
]
const ANALYTICS_DASHBOARD_STAT_ORDER: AnalyticsDashboardStat[] = [
'views',
'downloads',
'revenue',
'playtime',
]
const ANALYTICS_STATS_BY_DIMENSION: Record<
AnalyticsDashboardDimension,
readonly AnalyticsDashboardStat[]
> = {
project: ANALYTICS_DASHBOARD_STAT_ORDER,
version_id: ['downloads', 'playtime'],
country: ['views', 'downloads', 'playtime'],
monetization: ['views', 'downloads'],
user_agent: ['downloads'],
download_reason: ['downloads'],
game_version: ['downloads', 'playtime'],
loader_type: ['downloads', 'playtime'],
project_status: ANALYTICS_DASHBOARD_STAT_ORDER,
}
const ANALYTICS_DIMENSION_BY_BREAKDOWN: Record<
AnalyticsBreakdownPreset,
AnalyticsDashboardDimension
> = {
none: 'project',
project: 'project',
country: 'country',
monetization: 'monetization',
user_agent: 'user_agent',
download_reason: 'download_reason',
version_id: 'version_id',
loader: 'loader_type',
game_version: 'game_version',
}
const ANALYTICS_DIMENSION_BY_FILTER_CATEGORY: Record<
Exclude<AnalyticsQueryFilterCategory, 'project'>,
AnalyticsDashboardDimension
> = {
project_status: 'project_status',
country: 'country',
monetization: 'monetization',
user_agent: 'user_agent',
download_reason: 'download_reason',
version_id: 'version_id',
game_version: 'game_version',
loader_type: 'loader_type',
}
const ANALYTICS_FILTER_CATEGORY_BY_BREAKDOWN: Record<
AnalyticsBreakdownPreset,
Exclude<AnalyticsQueryFilterCategory, 'project'> | null
> = {
none: null,
project: null,
country: 'country',
monetization: 'monetization',
user_agent: 'user_agent',
download_reason: 'download_reason',
version_id: 'version_id',
loader: 'loader_type',
game_version: 'game_version',
}
export type FilterOption = {
value: string
label: string
searchTerms?: string[]
}
export type ProjectVersionFilterOption = FilterOption
export type ProjectVersionFilterOptionProjectMetadata = {
name: string
iconUrl?: string
}
type AnalyticsBreakdownInput = AnalyticsBreakdownPreset | readonly AnalyticsBreakdownPreset[]
function getOptionalDateTimestamp(date: string | undefined): number | undefined {
if (!date) {
return undefined
}
const timestamp = new Date(date).getTime()
return Number.isFinite(timestamp) ? timestamp : undefined
}
export function getProjectVersionFilterOptionsCacheKey(
versionIds: string[],
versionNumbersById: Map<string, string>,
versionPublishedDatesById: Map<string, string>,
versionProjectNamesById: Map<string, string>,
): string {
return versionIds
.map(
(versionId) =>
`${versionId}\x1f${versionNumbersById.get(versionId) ?? ''}\x1f${
versionPublishedDatesById.get(versionId) ?? ''
}\x1f${versionProjectNamesById.get(versionId) ?? ''}`,
)
.join('\x1e')
}
export function getProjectVersionFilterOptionProjectMetadataCacheKey(
versionIds: string[],
versionProjectNamesById: Map<string, string>,
versionProjectIconUrlsById: Map<string, string>,
): string {
return versionIds
.map(
(versionId) =>
`${versionId}\x1f${versionProjectNamesById.get(versionId) ?? ''}\x1f${
versionProjectIconUrlsById.get(versionId) ?? ''
}`,
)
.join('\x1e')
}
export function getProjectVersionFilterOptionMetadataIds(
versionIds: string[],
selectedVersionIds: string[],
): string[] {
const knownVersionIds = new Set(versionIds)
const metadataIds = [...versionIds]
for (const versionId of selectedVersionIds) {
if (!knownVersionIds.has(versionId)) {
metadataIds.push(versionId)
knownVersionIds.add(versionId)
}
}
return metadataIds
}
export function buildProjectVersionFilterOptions(
versionIds: string[],
versionNumbersById: Map<string, string>,
versionPublishedDatesById: Map<string, string>,
versionProjectNamesById: Map<string, string>,
): ProjectVersionFilterOption[] {
return versionIds
.map((versionId) => {
const projectName = versionProjectNamesById.get(versionId)
return {
option: {
value: versionId,
label: versionNumbersById.get(versionId) ?? versionId,
searchTerms: projectName ? [versionId, projectName] : [versionId],
},
publishedTimestamp: getOptionalDateTimestamp(versionPublishedDatesById.get(versionId)),
}
})
.sort((left, right) => {
if (left.publishedTimestamp !== undefined && right.publishedTimestamp !== undefined) {
return right.publishedTimestamp - left.publishedTimestamp
}
if (left.publishedTimestamp !== undefined) {
return -1
}
if (right.publishedTimestamp !== undefined) {
return 1
}
return left.option.label.localeCompare(right.option.label)
})
.map(({ option }) => option)
}
export function buildProjectVersionFilterOptionProjectMetadataById(
versionIds: string[],
versionProjectNamesById: Map<string, string>,
versionProjectIconUrlsById: Map<string, string>,
): Map<string, ProjectVersionFilterOptionProjectMetadata[]> {
const metadataById = new Map<string, ProjectVersionFilterOptionProjectMetadata[]>()
for (const versionId of versionIds) {
const projectName = versionProjectNamesById.get(versionId)
if (!projectName) {
continue
}
const metadata: ProjectVersionFilterOptionProjectMetadata = { name: projectName }
const iconUrl = versionProjectIconUrlsById.get(versionId)
if (iconUrl) {
metadata.iconUrl = iconUrl
}
metadataById.set(versionId, [metadata])
}
return metadataById
}
function intersectAnalyticsStats(
left: readonly AnalyticsDashboardStat[],
right: readonly AnalyticsDashboardStat[],
): AnalyticsDashboardStat[] {
const rightStats = new Set(right)
return left.filter((stat) => rightStats.has(stat))
}
function haveAnalyticsStatOverlap(
left: readonly AnalyticsDashboardStat[],
right: readonly AnalyticsDashboardStat[],
): boolean {
return left.some((stat) => right.includes(stat))
}
export function getAnalyticsStatsForDimension(
dimension: AnalyticsDashboardDimension,
): readonly AnalyticsDashboardStat[] {
return ANALYTICS_STATS_BY_DIMENSION[dimension]
}
export function getAnalyticsStatsForBreakdown(
breakdown: AnalyticsBreakdownPreset,
): readonly AnalyticsDashboardStat[] {
return getAnalyticsStatsForDimension(ANALYTICS_DIMENSION_BY_BREAKDOWN[breakdown])
}
function normalizeAnalyticsBreakdowns(
breakdowns: AnalyticsBreakdownInput,
): Exclude<AnalyticsBreakdownPreset, 'none'>[] {
const values = Array.isArray(breakdowns) ? breakdowns : [breakdowns]
const normalizedBreakdowns: Exclude<AnalyticsBreakdownPreset, 'none'>[] = []
for (const breakdown of values) {
if (breakdown === 'none') {
continue
}
if (!normalizedBreakdowns.includes(breakdown)) {
normalizedBreakdowns.push(breakdown)
}
}
return normalizedBreakdowns
}
export function getAnalyticsStatsForBreakdowns(
breakdowns: AnalyticsBreakdownInput,
): readonly AnalyticsDashboardStat[] {
const normalizedBreakdowns = normalizeAnalyticsBreakdowns(breakdowns)
if (normalizedBreakdowns.length === 0) {
return getAnalyticsStatsForBreakdown('none')
}
let stats = [...getAnalyticsStatsForBreakdown(normalizedBreakdowns[0])]
for (const breakdown of normalizedBreakdowns.slice(1)) {
stats = intersectAnalyticsStats(stats, getAnalyticsStatsForBreakdown(breakdown))
}
return stats
}
export function getAnalyticsStatsForFilterCategory(
category: AnalyticsQueryFilterCategory,
): readonly AnalyticsDashboardStat[] {
if (category === 'project') {
return ANALYTICS_DASHBOARD_STAT_ORDER
}
return getAnalyticsStatsForDimension(ANALYTICS_DIMENSION_BY_FILTER_CATEGORY[category])
}
export function getAnalyticsFilterCategoryForBreakdown(
breakdown: AnalyticsBreakdownPreset,
): Exclude<AnalyticsQueryFilterCategory, 'project'> | null {
return ANALYTICS_FILTER_CATEGORY_BY_BREAKDOWN[breakdown]
}
function getAnalyticsStatsForFilterScope(
breakdowns: AnalyticsBreakdownInput,
filters: AnalyticsSelectedFilters,
ignoredCategory?: AnalyticsQueryFilterCategory,
): readonly AnalyticsDashboardStat[] {
let stats = [...getAnalyticsStatsForBreakdowns(breakdowns)]
for (const category of FILTER_VALUE_CATEGORIES) {
if (category === ignoredCategory || filters[category].length === 0) {
continue
}
stats = intersectAnalyticsStats(stats, getAnalyticsStatsForFilterCategory(category))
}
return stats
}
export function getEnabledAnalyticsStatsForState(
breakdowns: AnalyticsBreakdownInput,
filters: AnalyticsSelectedFilters,
): readonly AnalyticsDashboardStat[] {
return getAnalyticsStatsForFilterScope(breakdowns, filters)
}
export function getVisibleAnalyticsFilterCategoriesForState(
breakdowns: AnalyticsBreakdownInput,
filters: AnalyticsSelectedFilters,
): readonly Exclude<AnalyticsQueryFilterCategory, 'project'>[] {
return FILTER_VALUE_CATEGORIES.filter((category) =>
haveAnalyticsStatOverlap(
getAnalyticsStatsForFilterScope(breakdowns, filters, category),
getAnalyticsStatsForFilterCategory(category),
),
)
}
export function sanitizeAnalyticsSelectedFilters(
breakdowns: AnalyticsBreakdownInput,
filters: AnalyticsSelectedFilters,
): AnalyticsSelectedFilters {
const nextFilters = cloneSelectedFilters(filters)
let availableStats = [...getAnalyticsStatsForBreakdowns(breakdowns)]
for (const category of FILTER_VALUE_CATEGORIES) {
if (filters[category].length === 0) {
continue
}
const categoryStats = getAnalyticsStatsForFilterCategory(category)
if (!haveAnalyticsStatOverlap(availableStats, categoryStats)) {
nextFilters[category] = []
continue
}
availableStats = intersectAnalyticsStats(availableStats, categoryStats)
}
return nextFilters
}
export function cloneSelectedFilters(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 areStringArraysEqual(left: string[], right: string[]): boolean {
if (left.length !== right.length) {
return false
}
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) {
return false
}
}
return true
}
export function areSelectedFiltersEqual(
left: AnalyticsSelectedFilters,
right: AnalyticsSelectedFilters,
): boolean {
if (!areStringArraysEqual(left.project, right.project)) {
return false
}
for (const categoryKey of FILTER_VALUE_CATEGORIES) {
if (!areStringArraysEqual(left[categoryKey], right[categoryKey])) {
return false
}
}
return true
}
export function getOptionsWithSelectedValues(
options: FilterOption[],
selectedValues: string[],
getMissingSelectedOptionLabel: (value: string) => string = (value) => value,
): FilterOption[] {
if (selectedValues.length === 0) {
return options
}
const knownValues = new Set(options.map((option) => option.value))
const missingSelectedOptions = selectedValues
.filter((value) => !knownValues.has(value))
.map((value) => ({
value,
label: getMissingSelectedOptionLabel(value),
}))
return missingSelectedOptions.length === 0 ? options : [...options, ...missingSelectedOptions]
}
export function normalizeSelectedValues(
categoryKey: AnalyticsQueryFilterCategory,
values: string[],
projectIds: string[],
): string[] {
const uniqueValues = Array.from(new Set(values))
if (categoryKey === 'project') {
if (uniqueValues.includes(ALL_FILTER_VALUE)) {
return projectIds
}
const allProjectIds = new Set(projectIds)
const selectedProjects = uniqueValues.filter((value) => allProjectIds.has(value))
return selectedProjects.length > 0 ? selectedProjects : projectIds
}
if (uniqueValues.includes(ALL_FILTER_VALUE) || uniqueValues.length === 0) {
return []
}
const selectedValues = uniqueValues.filter((value) => value !== ALL_FILTER_VALUE)
if (categoryKey === 'project_status') {
return selectedValues
.map((value) => value.trim().toLowerCase())
.filter(isProjectStatusFilterValue)
}
if (categoryKey === 'loader_type') {
return Array.from(
new Set(
selectedValues
.map((value) => value.trim().toLowerCase())
.filter((value) => value.length > 0),
),
)
}
return selectedValues
}
export const PROJECT_STATUS_FILTER_VALUES = [
'approved',
'archived',
'rejected',
'draft',
'unlisted',
'withheld',
'private',
'other',
] as const
export type ProjectStatusFilterValue = (typeof PROJECT_STATUS_FILTER_VALUES)[number]
const projectStatusFilterValueSet = new Set<string>(PROJECT_STATUS_FILTER_VALUES)
export function isProjectStatusFilterValue(value: string): value is ProjectStatusFilterValue {
return projectStatusFilterValueSet.has(value)
}
export function getProjectStatusFilterValue(
status: string | null | undefined,
): ProjectStatusFilterValue {
const normalizedStatus = status?.trim().toLowerCase() ?? ''
return isProjectStatusFilterValue(normalizedStatus) ? normalizedStatus : 'other'
}
@@ -0,0 +1,299 @@
import {
type AnalyticsGroupByPreset,
type AnalyticsLastTimeframeUnit,
type AnalyticsTimeframeMode,
type AnalyticsTimeframePreset,
injectAnalyticsDashboardContext,
} from '~/providers/analytics/analytics'
const MIN_RANGE_MS = 60 * 60 * 1000
const TIME_RANGE_ROUNDING_MS = 60 * 1000
export const MAX_ANALYTICS_TIME_SLICES = 256
const GROUP_BY_PRESET_MINUTES: Record<AnalyticsGroupByPreset, number> = {
'1h': 60,
'6h': 360,
day: 24 * 60,
week: 7 * 24 * 60,
month: 30 * 24 * 60,
year: 365 * 24 * 60,
}
export type AnalyticsTimeRange = {
start: Date
end: Date
}
export function startOfDay(date: Date): Date {
const nextDate = new Date(date)
nextDate.setHours(0, 0, 0, 0)
return nextDate
}
export function getRoundedNow(timestamp: number): Date {
const roundedTimestamp = Math.floor(timestamp / TIME_RANGE_ROUNDING_MS) * TIME_RANGE_ROUNDING_MS
return new Date(roundedTimestamp)
}
export function getDateInputValue(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export function parseDateInputValue(value: string): Date {
const parsedDate = new Date(`${value}T00:00:00`)
return Number.isNaN(parsedDate.getTime()) ? startOfDay(new Date()) : parsedDate
}
export function parseDateTimeInputValue(value: string): Date {
const parsedDate = new Date(value)
return Number.isNaN(parsedDate.getTime()) ? getRoundedNow(Date.now()) : parsedDate
}
export function addDays(date: Date, days: number): Date {
const nextDate = new Date(date)
nextDate.setDate(nextDate.getDate() + days)
return nextDate
}
function isStartOfDay(date: Date): boolean {
return (
date.getHours() === 0 &&
date.getMinutes() === 0 &&
date.getSeconds() === 0 &&
date.getMilliseconds() === 0
)
}
export function getInclusiveEndDateInputValue(end: Date): string {
return getDateInputValue(isStartOfDay(end) ? addDays(end, -1) : end)
}
function subtractCalendarMonths(date: Date, months: number): Date {
const nextDate = new Date(date)
const day = nextDate.getDate()
nextDate.setDate(1)
nextDate.setMonth(nextDate.getMonth() - months)
const daysInMonth = new Date(nextDate.getFullYear(), nextDate.getMonth() + 1, 0).getDate()
nextDate.setDate(Math.min(day, daysInMonth))
return nextDate
}
export function getTimeRangeForPreset(
preset: AnalyticsTimeframePreset,
nowTimestamp: number,
): AnalyticsTimeRange {
const now = getRoundedNow(nowTimestamp)
const end = new Date(now)
switch (preset) {
case 'today':
return { start: startOfDay(now), end }
case 'yesterday': {
const todayStart = startOfDay(now)
return {
start: new Date(todayStart.getTime() - 24 * 60 * 60 * 1000),
end: todayStart,
}
}
case 'last_7_days':
return {
start: new Date(end.getTime() - 7 * 24 * 60 * 60 * 1000),
end,
}
case 'last_14_days':
return {
start: new Date(end.getTime() - 14 * 24 * 60 * 60 * 1000),
end,
}
case 'last_30_days':
return {
start: new Date(end.getTime() - 30 * 24 * 60 * 60 * 1000),
end,
}
case 'last_90_days':
return {
start: new Date(end.getTime() - 90 * 24 * 60 * 60 * 1000),
end,
}
case 'last_180_days':
return {
start: new Date(end.getTime() - 180 * 24 * 60 * 60 * 1000),
end,
}
case 'year_to_date': {
const yearStart = new Date(now.getFullYear(), 0, 1)
yearStart.setHours(0, 0, 0, 0)
return { start: yearStart, end }
}
case 'all_time':
return {
start: new Date(Date.UTC(2023, 0, 1, 0, 0, 0, 0)),
end,
}
default:
return {
start: new Date(end.getTime() - 24 * 60 * 60 * 1000),
end,
}
}
}
export function getTimeRangeForLastTimeframe(
amountValue: number,
unit: AnalyticsLastTimeframeUnit,
nowTimestamp: number,
): AnalyticsTimeRange {
const end = getRoundedNow(nowTimestamp)
const amount = Math.max(1, Math.floor(amountValue))
switch (unit) {
case 'hours':
return { start: new Date(end.getTime() - amount * 60 * 60 * 1000), end }
case 'days':
return { start: new Date(end.getTime() - amount * 24 * 60 * 60 * 1000), end }
case 'weeks':
return { start: new Date(end.getTime() - amount * 7 * 24 * 60 * 60 * 1000), end }
case 'months':
return { start: subtractCalendarMonths(end, amount), end }
default:
return { start: new Date(end.getTime() - 24 * 60 * 60 * 1000), end }
}
}
export function getTimeRangeForCustomDateRange(
startDate: string,
endDate: string,
): AnalyticsTimeRange {
const start = parseDateInputValue(startDate)
const inclusiveEnd = parseDateInputValue(endDate)
return {
start,
end: addDays(inclusiveEnd, 1),
}
}
export function getTimeRangeForCustomDateTimeRange(
startDateTime: string,
endDateTime: string,
): AnalyticsTimeRange {
return {
start: parseDateTimeInputValue(startDateTime),
end: parseDateTimeInputValue(endDateTime),
}
}
export function getAnalyticsTimeRange({
mode,
preset,
lastAmount,
lastUnit,
customStartDate,
customEndDate,
nowTimestamp,
}: {
mode: AnalyticsTimeframeMode
preset: AnalyticsTimeframePreset
lastAmount: number
lastUnit: AnalyticsLastTimeframeUnit
customStartDate: string
customEndDate: string
nowTimestamp: number
}): AnalyticsTimeRange {
switch (mode) {
case 'last':
return getTimeRangeForLastTimeframe(lastAmount, lastUnit, nowTimestamp)
case 'custom_range':
return getTimeRangeForCustomDateRange(customStartDate, customEndDate)
case 'custom_datetime_range':
return getTimeRangeForCustomDateTimeRange(customStartDate, customEndDate)
case 'preset':
default:
return getTimeRangeForPreset(preset, nowTimestamp)
}
}
export function getDefaultAnalyticsGroupByForDurationMinutes(
durationMinutes: number,
): AnalyticsGroupByPreset {
const days = durationMinutes / (24 * 60)
if (days <= 2) return '1h'
if (days <= 7) return '6h'
if (days <= 90) return 'day'
if (days <= 365) return 'week'
if (days <= 365 * 3) return 'month'
return 'year'
}
export function getAnalyticsGroupByPresetMinutes(preset: AnalyticsGroupByPreset): number {
return GROUP_BY_PRESET_MINUTES[preset]
}
export function isAnalyticsGroupByAvailableForDurationMinutes(
preset: AnalyticsGroupByPreset,
durationMinutes: number,
): boolean {
const groupByMinutes = getAnalyticsGroupByPresetMinutes(preset)
const isTooCoarse = groupByMinutes >= durationMinutes
const isTooFine = durationMinutes / groupByMinutes > MAX_ANALYTICS_TIME_SLICES
return !isTooCoarse && !isTooFine
}
export function ensureMinimumTimeRange(start: Date, end: Date): AnalyticsTimeRange {
if (end.getTime() <= start.getTime()) {
return {
start: new Date(end.getTime() - MIN_RANGE_MS),
end,
}
}
if (end.getTime() - start.getTime() < MIN_RANGE_MS) {
return {
start: new Date(end.getTime() - MIN_RANGE_MS),
end,
}
}
return { start, end }
}
export function useSelectedAnalyticsTimeRange() {
const {
selectedTimeframeMode,
selectedTimeframe,
selectedLastTimeframeAmount,
selectedLastTimeframeUnit,
selectedCustomTimeframeStartDate,
selectedCustomTimeframeEndDate,
queryRefreshTimestamp,
} = injectAnalyticsDashboardContext()
const selectedTimeRange = computed(() =>
getAnalyticsTimeRange({
mode: selectedTimeframeMode.value,
preset: selectedTimeframe.value,
lastAmount: selectedLastTimeframeAmount.value,
lastUnit: selectedLastTimeframeUnit.value,
customStartDate: selectedCustomTimeframeStartDate.value,
customEndDate: selectedCustomTimeframeEndDate.value,
nowTimestamp: queryRefreshTimestamp.value,
}),
)
const selectedTimeframeDurationMinutes = computed(() => {
const { start, end } = ensureMinimumTimeRange(
selectedTimeRange.value.start,
selectedTimeRange.value.end,
)
const durationMs = end.getTime() - start.getTime()
return Math.max(1, Math.floor(durationMs / (60 * 1000)))
})
return {
selectedTimeRange,
selectedTimeframeDurationMinutes,
}
}