mirror of
https://github.com/modrinth/code.git
synced 2026-08-26 09:34:50 +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:
+150
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user