Files
AstralRinth/apps/frontend/src/components/analytics-dashboard/query-builder/index.vue
T

1218 lines
37 KiB
Vue

<template>
<div class="md:pl-1">
<div class="flex flex-wrap items-center gap-2 md:hidden">
<MultiSelect
v-if="showProjectRow"
v-model="draftSelectedProjectIds"
class="min-w-0 max-w-full"
:options="projectSelectOptions"
:disabled="!hasProjectOptions"
:max-height="QUERY_BUILDER_DROPDOWN_MAX_HEIGHT"
dropdown-min-width="360px"
:placeholder="formatMessage(analyticsMessages.selectProjects)"
:no-options-message="noProjectsMessage"
:searchable="projectOptions.length > 6"
:max-tag-rows="1"
:trigger-class="analyticsQueryChipTriggerClass"
fit-content
checkbox-position="right"
show-selection-actions
@open="handleProjectSelectOpen"
@close="handleProjectSelectClose"
>
<template #input-content="{ isOpen, openDirection }">
<div class="flex min-h-7 min-w-0 max-w-full flex-1 items-center gap-1.5 pr-1">
<img
v-if="selectedProjectIconUrl"
:src="selectedProjectIconUrl"
:alt="formatMessage(analyticsMessages.projectIconAlt, { name: selectedProjectLabel })"
class="size-5 shrink-0 rounded object-cover"
loading="lazy"
decoding="async"
/>
<LayersIcon v-else class="size-5 shrink-0 text-primary" />
<span class="min-w-0 flex-1 truncate px-0.5 font-semibold text-primary">
{{ selectedProjectLabel }}
</span>
<ChevronLeftIcon
class="size-5 shrink-0 text-primary transition-transform duration-150"
:class="
isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'
"
/>
</div>
</template>
<template #option="{ item, selected }">
<div class="flex min-w-0 flex-1 items-center gap-2">
<img
v-if="getProjectIconUrl(item.value)"
:src="getProjectIconUrl(item.value)"
:alt="formatMessage(analyticsMessages.projectIconAlt, { name: item.label })"
class="h-5 w-5 shrink-0 rounded object-cover"
loading="lazy"
decoding="async"
/>
<BoxIcon
v-else
class="h-5 w-5 shrink-0 text-primary"
:class="selected ? 'text-contrast' : 'text-primary'"
/>
<span
v-tooltip="item.label"
class="min-w-0 truncate font-semibold leading-tight"
:class="selected ? 'text-contrast' : 'text-primary'"
>
{{ item.label }}
</span>
</div>
</template>
<template v-if="hasProjectOptions" #top>
<div>
<button
type="button"
class="flex w-full cursor-pointer items-center gap-1.5 border-0 bg-surface-4 px-4 py-3 text-left shadow-none transition-all duration-150 hover:brightness-[115%] focus:brightness-[115%]"
:aria-selected="isAllProjectsOptionSelected"
:class="isAllProjectsOptionSelected ? 'text-contrast' : 'text-primary'"
role="option"
@click="selectAllProjectsMode"
@keydown.enter.stop
@keydown.space.stop
>
<LayersIcon
class="h-5 w-5 shrink-0 text-primary"
:class="isAllProjectsOptionSelected ? 'text-contrast' : 'text-primary'"
/>
<span class="min-w-0 flex-1 font-semibold leading-tight">
{{ formatMessage(analyticsMessages.allProjects) }}
</span>
<span class="flex shrink-0 items-center justify-center text-brand">
<CheckIcon v-if="isAllProjectsOptionSelected" aria-hidden="true" class="size-5" />
</span>
</button>
</div>
</template>
<template v-if="hasProjectOptions" #bottom>
<DownloadsThresholdInput
class="border-0 border-t border-solid border-surface-5 px-6 py-2.5"
:label="formatMessage(analyticsMessages.projectsAbove)"
:input-aria-label="formatMessage(analyticsMessages.projectDownloadsThresholdAria)"
:threshold="projectDownloadsThreshold"
input-width-class="w-20"
@update:threshold="setProjectDownloadsThreshold"
@submit="runProjectDownloadsThresholdQuery"
/>
</template>
</MultiSelect>
<TimeFramePicker
class="!w-auto min-w-0 max-w-full"
:trigger-class="analyticsQueryChipTriggerClass"
>
<template #prefix>
<CalendarIcon class="size-5 shrink-0 text-primary" />
</template>
</TimeFramePicker>
<Combobox
v-model="selectedGroupBy"
class="!w-auto min-w-0 max-w-full"
:options="groupByOptions"
:max-height="QUERY_BUILDER_DROPDOWN_MAX_HEIGHT"
:dropdown-min-width="QUERY_BUILDER_DROPDOWN_MIN_WIDTH"
:display-value="selectedGroupByLabel"
:trigger-class="analyticsQueryChipTriggerClass"
>
<template #prefix>
<ClockIcon class="size-5 shrink-0 text-primary" />
</template>
</Combobox>
<MultiSelect
v-model="selectedBreakdownValue"
class="min-w-0 max-w-full"
:options="breakdownOptions"
:max-height="QUERY_BUILDER_DROPDOWN_MAX_HEIGHT"
:dropdown-width="QUERY_BUILDER_DROPDOWN_MIN_WIDTH"
:dropdown-min-width="QUERY_BUILDER_DROPDOWN_MIN_WIDTH"
:trigger-class="analyticsQueryChipTriggerClass"
fit-content
checkbox-position="right"
:placeholder="formatMessage(analyticsMessages.none)"
show-selection-actions
@open="handleBreakdownSelectOpen"
@close="handleBreakdownSelectClose"
>
<template #input-content="{ isOpen, openDirection }">
<div class="flex min-h-7 min-w-0 max-w-full flex-1 items-center gap-1.5 pr-1">
<BlocksIcon class="size-5 shrink-0 text-primary" />
<span
class="min-w-0 flex-1 truncate px-0.5 font-semibold text-primary"
:title="mobileSelectedBreakdownLabel"
>
{{ mobileSelectedBreakdownLabel }}
</span>
<ChevronLeftIcon
class="size-5 shrink-0 text-primary transition-transform duration-150"
:class="
isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'
"
/>
</div>
</template>
</MultiSelect>
<QueryBuilderFilter
:add-label="formatMessage(analyticsMessages.addFilterButton)"
:show-label="false"
show-preview-filter-icon
:show-clear-action="false"
:add-button-class="analyticsQueryAddFilterButtonClass"
/>
</div>
<div class="hidden flex-col gap-3 md:flex">
<div v-if="showProjectRow" class="flex items-start gap-2">
<div class="my-1.5 flex w-32 items-center gap-2 text-primary">
<FolderOpenIcon class="size-5" />
<span class="text-base font-medium">{{
formatMessage(analyticsMessages.projectLabel)
}}</span>
</div>
<div class="w-fit">
<MultiSelect
v-model="draftSelectedProjectIds"
:options="projectSelectOptions"
:disabled="!hasProjectOptions"
:max-height="QUERY_BUILDER_DROPDOWN_MAX_HEIGHT"
dropdown-min-width="360px"
:placeholder="formatMessage(analyticsMessages.selectProjects)"
:no-options-message="noProjectsMessage"
:searchable="projectOptions.length > 6"
:max-tag-rows="1"
checkbox-position="right"
show-selection-actions
@open="handleProjectSelectOpen"
@close="handleProjectSelectClose"
>
<template #input-content="{ isOpen, openDirection }">
<div class="flex min-h-7 min-w-0 flex-1 items-center gap-1.5 pr-1">
<div class="flex items-center gap-0.5">
<img
v-if="selectedProjectIconUrl"
:src="selectedProjectIconUrl"
:alt="
formatMessage(analyticsMessages.projectIconAlt, {
name: selectedProjectLabel,
})
"
class="size-6 shrink-0 rounded object-cover"
loading="lazy"
decoding="async"
/>
<LayersIcon
v-else-if="isAllProjectsOptionSelected || areAllProjectsSelected"
class="size-5 shrink-0 text-primary"
/>
<BoxIcon v-else class="size-5 shrink-0 text-primary" />
<span class="min-w-0 flex-1 truncate px-1.5 font-semibold text-primary">
{{ selectedProjectLabel }}
</span>
</div>
<ChevronLeftIcon
class="size-5 shrink-0 text-primary transition-transform duration-150"
:class="
isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'
"
/>
</div>
</template>
<template #option="{ item, selected }">
<div class="flex min-w-0 flex-1 items-center gap-2">
<img
v-if="getProjectIconUrl(item.value)"
:src="getProjectIconUrl(item.value)"
:alt="formatMessage(analyticsMessages.projectIconAlt, { name: item.label })"
class="h-5 w-5 shrink-0 rounded object-cover"
loading="lazy"
decoding="async"
/>
<BoxIcon
v-else
class="h-5 w-5 shrink-0 text-primary"
:class="selected ? 'text-contrast' : 'text-primary'"
/>
<span
v-tooltip="item.label"
class="min-w-0 truncate font-semibold leading-tight"
:class="selected ? 'text-contrast' : 'text-primary'"
>
{{ item.label }}
</span>
</div>
</template>
<template v-if="hasProjectOptions" #top>
<div>
<button
type="button"
class="flex w-full cursor-pointer items-center gap-2 border-0 bg-surface-4 px-4 py-3 text-left shadow-none transition-all duration-150 hover:brightness-[115%] focus:brightness-[115%]"
:aria-selected="isAllProjectsOptionSelected"
:class="isAllProjectsOptionSelected ? 'text-contrast' : 'text-primary'"
role="option"
@click="selectAllProjectsMode"
@keydown.enter.stop
@keydown.space.stop
>
<LayersIcon
class="h-5 w-5 shrink-0 text-primary"
:class="isAllProjectsOptionSelected ? 'text-contrast' : 'text-primary'"
/>
<span class="min-w-0 flex-1 font-semibold leading-tight">
{{ formatMessage(analyticsMessages.allProjects) }}
</span>
<span class="flex shrink-0 items-center justify-center text-brand">
<CheckIcon
v-if="isAllProjectsOptionSelected"
aria-hidden="true"
class="size-5"
/>
</span>
</button>
</div>
</template>
<template v-if="hasProjectOptions" #bottom>
<DownloadsThresholdInput
class="border-0 border-t border-solid border-surface-5 px-6 py-2.5"
:label="formatMessage(analyticsMessages.projectsAbove)"
:input-aria-label="formatMessage(analyticsMessages.projectDownloadsThresholdAria)"
:threshold="projectDownloadsThreshold"
input-width-class="w-20"
@update:threshold="setProjectDownloadsThreshold"
@submit="runProjectDownloadsThresholdQuery"
/>
</template>
</MultiSelect>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<div class="flex items-center gap-2">
<div class="flex w-32 items-center gap-2 text-primary">
<CalendarIcon class="size-5" />
<span class="text-base font-medium">
{{ formatMessage(analyticsMessages.timeframeLabel) }}
</span>
</div>
<div>
<TimeFramePicker />
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-base font-medium text-primary">
{{ formatMessage(analyticsMessages.groupedByLabel) }}
</span>
<div>
<Combobox
v-model="selectedGroupBy"
:options="groupByOptions"
:max-height="QUERY_BUILDER_DROPDOWN_MAX_HEIGHT"
:dropdown-min-width="QUERY_BUILDER_DROPDOWN_MIN_WIDTH"
/>
</div>
</div>
<ButtonStyled v-if="!isTimeframeAndGroupByDefault" type="transparent">
<button
type="button"
:disabled="isTimeframeAndGroupByDefault"
@click="resetTimeframeAndGroupBy"
>
{{ formatMessage(analyticsMessages.resetButton) }}
</button>
</ButtonStyled>
</div>
<div class="flex flex-wrap items-start gap-2">
<div class="flex items-center gap-2">
<div class="flex w-32 items-center gap-2 text-primary">
<BlocksIcon class="size-5" />
<span class="text-base font-medium">
{{ formatMessage(analyticsMessages.breakdownLabel) }}
</span>
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-2">
<div>
<MultiSelect
v-model="selectedBreakdownValue"
:options="breakdownOptions"
:max-height="QUERY_BUILDER_DROPDOWN_MAX_HEIGHT"
:dropdown-width="QUERY_BUILDER_DROPDOWN_MIN_WIDTH"
:dropdown-min-width="QUERY_BUILDER_DROPDOWN_MIN_WIDTH"
checkbox-position="right"
:placeholder="formatMessage(analyticsMessages.none)"
show-selection-actions
@open="handleBreakdownSelectOpen"
@close="handleBreakdownSelectClose"
>
<template #input-content="{ isOpen, openDirection }">
<div class="flex min-h-7 min-w-0 flex-1 items-center gap-1.5 pr-1">
<span
class="min-w-0 flex-1 truncate font-semibold text-primary"
:title="selectedBreakdownLabel"
>
{{ selectedBreakdownLabel }}
</span>
<ChevronLeftIcon
class="size-5 shrink-0 text-primary transition-transform duration-150"
:class="
isOpen
? openDirection === 'down'
? 'rotate-90'
: '-rotate-90'
: '-rotate-90'
"
/>
</div>
</template>
</MultiSelect>
</div>
</div>
</div>
</div>
<QueryBuilderFilter />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
BlocksIcon,
BoxIcon,
CalendarIcon,
CheckIcon,
ChevronLeftIcon,
ClockIcon,
FolderOpenIcon,
LayersIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
Combobox,
type ComboboxOption,
MultiSelect,
type MultiSelectItem,
type MultiSelectOption,
useVIntl,
} from '@modrinth/ui'
import {
buildDefaultAnalyticsQueryBuilderState,
getAnalyticsBreakdownPresetsForProjectSelection,
MAX_ANALYTICS_BREAKDOWN_PRESETS,
} from '~/components/analytics-dashboard/analytics-route-query'
import {
type AnalyticsBreakdownPreset,
type AnalyticsDashboardProject,
type AnalyticsDashboardStat,
type AnalyticsGroupByPreset,
type AnalyticsQueryFilterCategory,
type AnalyticsSelectedBreakdowns,
type AnalyticsSelectedFilters,
getProjectIdsMatchingStatusFilter,
injectAnalyticsDashboardContext,
} from '~/providers/analytics/analytics'
import {
analyticsMessages,
formatAnalyticsBreakdownLabel,
formatAnalyticsGroupByLabel,
formatAnalyticsGroupBySelectedLabel,
} from '../analytics-messages.ts'
import DownloadsThresholdInput from './DownloadsThresholdInput.vue'
import {
getAnalyticsStatsForBreakdown,
getAnalyticsStatsForFilterCategory,
getEnabledAnalyticsStatsForState,
} from './query-filter-utils.ts'
import QueryBuilderFilter from './QueryFilter.vue'
import {
ensureMinimumTimeRange,
getAnalyticsGroupByPresetMinutes,
MAX_ANALYTICS_TIME_SLICES,
useSelectedAnalyticsTimeRange,
} from './timeframe.ts'
import TimeFramePicker from './TimeframePicker.vue'
const QUERY_BUILDER_DROPDOWN_MAX_HEIGHT = 500
const QUERY_BUILDER_DROPDOWN_MIN_WIDTH = '12rem'
const analyticsQueryChipTriggerClass = 'h-10 '
const analyticsQueryAddFilterButtonClass = '!h-10 max-w-full !w-max !px-3.5 flex !gap-2'
const projectOptionCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
const {
hasProjectContext,
projectGroups,
projects,
selectedProjectIds,
selectedTimeframeMode,
selectedTimeframe,
selectedLastTimeframeAmount,
selectedLastTimeframeUnit,
selectedCustomTimeframeStartDate,
selectedCustomTimeframeEndDate,
selectedGroupBy,
selectedBreakdowns,
selectedFilters,
activeStat,
showPreviousPeriod,
projectStatusById,
projectDownloadsById,
queryResetToken,
refreshAnalyticsQuery,
setFetchRequest,
} = injectAnalyticsDashboardContext()
const route = useRoute()
const { formatMessage } = useVIntl()
const { selectedTimeRange, selectedTimeframeDurationMinutes } = useSelectedAnalyticsTimeRange()
const defaultQueryState = buildDefaultAnalyticsQueryBuilderState([])
function getProjectOption(
project: AnalyticsDashboardProject,
groupTitle?: string,
): MultiSelectOption<string> {
return {
value: project.id,
label: project.name,
searchTerms: groupTitle ? [groupTitle] : undefined,
}
}
function compareProjectOptions(
left: MultiSelectOption<string>,
right: MultiSelectOption<string>,
): number {
return projectOptionCollator.compare(left.label, right.label)
}
const projectOptions = computed<MultiSelectOption<string>[]>(() =>
projects.value.map((project) => getProjectOption(project)),
)
const projectIconUrlsById = computed(
() =>
new Map(
projects.value
.filter((project) => project.iconUrl)
.map((project) => [project.id, project.iconUrl as string]),
),
)
const projectSelectOptions = computed<MultiSelectItem<string>[]>(() => {
const options: MultiSelectItem<string>[] = []
for (const group of projectGroups.value) {
if (group.projects.length === 0) {
continue
}
if (group.title) {
options.push({
type: 'section-header',
label: group.title,
key: group.key ?? `organization-${group.title}`,
})
}
options.push(
...group.projects
.map((project) => getProjectOption(project, group.title))
.sort(compareProjectOptions),
)
}
return options
})
const allProjectIds = computed(() => projectOptions.value.map((project) => project.value))
const hasProjectOptions = computed(() => projectOptions.value.length > 0)
const noProjectsMessage = computed(() =>
hasProjectContext.value
? formatMessage(analyticsMessages.noDataAvailableForAnalytics)
: formatMessage(analyticsMessages.noProjectsAvailable),
)
const isProjectSelectOpen = ref(false)
const draftSelectedProjectIds = ref<string[]>([...selectedProjectIds.value])
const projectDownloadsThreshold = ref<number | null>(null)
const projectDownloadsThresholdProjectIds = ref<string[] | null>(null)
function isSameProjectSelection(left: string[], right: string[]) {
if (left.length !== right.length) {
return false
}
const rightProjectIds = new Set(right)
return left.every((projectId) => rightProjectIds.has(projectId))
}
function normalizeProjectSelection(projectIds: string[]) {
return projectIds.length > 0 ? [...projectIds] : [...allProjectIds.value]
}
watch(selectedProjectIds, (nextSelectedProjectIds) => {
if (isProjectSelectOpen.value) {
return
}
draftSelectedProjectIds.value = [...nextSelectedProjectIds]
})
watch(draftSelectedProjectIds, (nextSelectedProjectIds) => {
if (projectDownloadsThreshold.value === null) {
return
}
const normalizedProjectIds = normalizeProjectSelection(nextSelectedProjectIds)
if (
projectDownloadsThresholdProjectIds.value &&
isSameProjectSelection(normalizedProjectIds, projectDownloadsThresholdProjectIds.value)
) {
return
}
clearProjectDownloadsThreshold()
})
watch(queryResetToken, () => {
isProjectSelectOpen.value = false
isBreakdownSelectOpen.value = false
draftSelectedBreakdowns.value = [...selectedBreakdowns.value]
clearProjectDownloadsThreshold()
draftSelectedProjectIds.value = isSameProjectSelection(
selectedProjectIds.value,
allProjectIds.value,
)
? []
: [...selectedProjectIds.value]
})
const areAllProjectsSelected = computed(() => {
return isSameProjectSelection(draftSelectedProjectIds.value, allProjectIds.value)
})
const isAllProjectsOptionSelected = computed(() => draftSelectedProjectIds.value.length === 0)
const selectedProjectLabel = computed(() => {
if (!hasProjectOptions.value) {
return noProjectsMessage.value
}
if (isAllProjectsOptionSelected.value || areAllProjectsSelected.value) {
return formatMessage(analyticsMessages.allProjects)
}
if (draftSelectedProjectIds.value.length === 1) {
const selectedProject = projectOptions.value.find(
(project) => project.value === draftSelectedProjectIds.value[0],
)
return selectedProject?.label ?? formatMessage(analyticsMessages.projectCount, { count: 1 })
}
return formatMessage(analyticsMessages.projectCount, {
count: draftSelectedProjectIds.value.length,
})
})
const selectedProjectIconUrl = computed(() => {
if (
isAllProjectsOptionSelected.value ||
areAllProjectsSelected.value ||
draftSelectedProjectIds.value.length !== 1
) {
return undefined
}
return getProjectIconUrl(draftSelectedProjectIds.value[0])
})
function getProjectIconUrl(projectId: string): string | undefined {
return projectIconUrlsById.value.get(projectId)
}
function handleProjectSelectOpen() {
isProjectSelectOpen.value = true
draftSelectedProjectIds.value = isSameProjectSelection(
selectedProjectIds.value,
allProjectIds.value,
)
? []
: [...selectedProjectIds.value]
}
function handleProjectSelectClose(
nextSelectedProjectIds: string[] = draftSelectedProjectIds.value,
) {
isProjectSelectOpen.value = false
commitDraftSelectedProjects(nextSelectedProjectIds)
}
function commitDraftSelectedProjects(
nextSelectedProjectIds: string[] = draftSelectedProjectIds.value,
) {
const nextProjectIds = normalizeProjectSelection(nextSelectedProjectIds)
draftSelectedProjectIds.value = [...nextProjectIds]
if (!isSameProjectSelection(selectedProjectIds.value, nextProjectIds)) {
if (isSameProjectSelection(nextProjectIds, allProjectIds.value)) {
showPreviousPeriod.value = false
}
selectedProjectIds.value = nextProjectIds
}
}
function selectAllProjectsMode() {
clearProjectDownloadsThreshold()
draftSelectedProjectIds.value = []
}
const draftSelectedBreakdowns = ref<AnalyticsSelectedBreakdowns>([...selectedBreakdowns.value])
const isBreakdownSelectOpen = ref(false)
const selectedBreakdownValue = computed<AnalyticsSelectedBreakdowns>({
get: () => draftSelectedBreakdowns.value,
set: (nextBreakdowns) => {
draftSelectedBreakdowns.value = getAnalyticsBreakdownPresetsForProjectSelection(
nextBreakdowns.slice(0, MAX_ANALYTICS_BREAKDOWN_PRESETS),
selectedProjectIds.value,
)
},
})
watch(selectedBreakdowns, (nextBreakdowns) => {
if (isBreakdownSelectOpen.value) {
return
}
draftSelectedBreakdowns.value = [...nextBreakdowns]
})
function handleBreakdownSelectOpen() {
isBreakdownSelectOpen.value = true
draftSelectedBreakdowns.value = [...selectedBreakdowns.value]
}
function handleBreakdownSelectClose() {
isBreakdownSelectOpen.value = false
commitDraftSelectedBreakdowns()
}
function commitDraftSelectedBreakdowns() {
const nextBreakdowns = [...draftSelectedBreakdowns.value]
if (!areSelectedBreakdownsEqual(selectedBreakdowns.value, nextBreakdowns)) {
showPreviousPeriod.value = false
selectedBreakdowns.value = nextBreakdowns
}
}
function areSelectedBreakdownsEqual(
left: readonly AnalyticsBreakdownPreset[],
right: readonly AnalyticsBreakdownPreset[],
) {
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
}
const isDashboardAnalyticsRoute = computed(
() => route.path.replace(/\/$/, '') === '/dashboard/analytics',
)
const showProjectRow = computed(() => isDashboardAnalyticsRoute.value || projects.value.length > 1)
const isTimeframeAndGroupByDefault = computed(
() =>
selectedTimeframeMode.value === defaultQueryState.selectedTimeframeMode &&
selectedTimeframe.value === defaultQueryState.selectedTimeframe &&
selectedGroupBy.value === defaultQueryState.selectedGroupBy,
)
function resetTimeframeAndGroupBy() {
if (isTimeframeAndGroupByDefault.value) {
return
}
selectedTimeframeMode.value = defaultQueryState.selectedTimeframeMode
selectedTimeframe.value = defaultQueryState.selectedTimeframe
selectedLastTimeframeAmount.value = defaultQueryState.selectedLastTimeframeAmount
selectedLastTimeframeUnit.value = defaultQueryState.selectedLastTimeframeUnit
selectedCustomTimeframeStartDate.value = defaultQueryState.selectedCustomTimeframeStartDate
selectedCustomTimeframeEndDate.value = defaultQueryState.selectedCustomTimeframeEndDate
selectedGroupBy.value = defaultQueryState.selectedGroupBy
}
function applyProjectDownloadsThreshold(threshold: number | null) {
if (threshold === null) {
return
}
const projectIds = projects.value
.filter((project) => (projectDownloadsById.value.get(project.id) ?? 0) > threshold)
.map((project) => project.id)
projectDownloadsThresholdProjectIds.value = projectIds
draftSelectedProjectIds.value = projectIds
}
function setProjectDownloadsThreshold(threshold: number | null) {
projectDownloadsThreshold.value = threshold
if (threshold === null) {
projectDownloadsThresholdProjectIds.value = null
draftSelectedProjectIds.value = []
return
}
applyProjectDownloadsThreshold(threshold)
}
function clearProjectDownloadsThreshold() {
projectDownloadsThreshold.value = null
projectDownloadsThresholdProjectIds.value = null
}
function closeProjectSelectDropdown(event: KeyboardEvent) {
const eventTarget = event.target
if (!(eventTarget instanceof HTMLElement)) {
isProjectSelectOpen.value = false
return
}
const dropdown = eventTarget.closest('[role="listbox"][aria-multiselectable="true"]')
if (!dropdown) {
isProjectSelectOpen.value = false
return
}
dropdown.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
}
async function runProjectDownloadsThresholdQuery(event: KeyboardEvent) {
commitDraftSelectedProjects()
closeProjectSelectDropdown(event)
await nextTick()
await refreshAnalyticsQuery()
}
const groupByPresetOptions: Array<{
value: AnalyticsGroupByPreset
}> = [
{ value: '1h' },
{ value: '6h' },
{ value: 'day' },
{ value: 'week' },
{ value: 'month' },
{ value: 'year' },
]
const selectedBreakdownLabel = computed(() => {
if (selectedBreakdownValue.value.length === 0) {
return formatMessage(analyticsMessages.none)
}
return selectedBreakdownValue.value
.map((breakdown) => getBreakdownOptionLabel(breakdown))
.join(' + ')
})
const selectedGroupByLabel = computed(() => {
return formatAnalyticsGroupBySelectedLabel(selectedGroupBy.value, formatMessage)
})
const mobileSelectedBreakdownLabel = computed(() => {
if (selectedBreakdownValue.value.length === 0) {
return formatMessage(analyticsMessages.noBreakdown)
}
return formatMessage(analyticsMessages.breakdownBy, {
breakdown: selectedBreakdownLabel.value,
})
})
const breakdownOptions = computed<MultiSelectOption<Exclude<AnalyticsBreakdownPreset, 'none'>>[]>(
() => {
const selectedBreakdownSet = new Set(selectedBreakdownValue.value)
const hasReachedBreakdownLimit =
selectedBreakdownValue.value.length >= MAX_ANALYTICS_BREAKDOWN_PRESETS
const options: MultiSelectOption<Exclude<AnalyticsBreakdownPreset, 'none'>>[] = []
if (selectedProjectIds.value.length > 1) {
options.push({
value: 'project',
label: formatAnalyticsBreakdownLabel('project', formatMessage),
})
}
options.push(
{
value: 'country',
label: formatAnalyticsBreakdownLabel('country', formatMessage),
},
{
value: 'monetization',
label: formatAnalyticsBreakdownLabel('monetization', formatMessage),
},
{
value: 'user_agent',
label: formatAnalyticsBreakdownLabel('user_agent', formatMessage),
},
{
value: 'download_reason',
label: formatAnalyticsBreakdownLabel('download_reason', formatMessage),
},
{
value: 'version_id',
label: formatAnalyticsBreakdownLabel('version_id', formatMessage),
},
{
value: 'loader',
label: formatAnalyticsBreakdownLabel('loader', formatMessage),
},
{
value: 'game_version',
label: formatAnalyticsBreakdownLabel('game_version', formatMessage),
},
)
return options.map((option) => ({
...option,
disabled: hasReachedBreakdownLimit && !selectedBreakdownSet.has(option.value),
}))
},
)
function getBreakdownOptionLabel(breakdown: Exclude<AnalyticsBreakdownPreset, 'none'>): string {
return (
breakdownOptions.value.find((option) => option.value === breakdown)?.label ??
formatAnalyticsBreakdownLabel(breakdown, formatMessage)
)
}
function isRevenueHourlyGroupBy(groupBy: AnalyticsGroupByPreset): boolean {
return groupBy === '1h' || groupBy === '6h'
}
const groupByOptions = computed<ComboboxOption<AnalyticsGroupByPreset>[]>(() => {
const timeframeMinutes = selectedTimeframeDurationMinutes.value
const options = groupByPresetOptions.map((option) => {
const groupByMinutes = getAnalyticsGroupByPresetMinutes(option.value)
const isTooCoarse = groupByMinutes >= timeframeMinutes
const isTooFine = timeframeMinutes / groupByMinutes > MAX_ANALYTICS_TIME_SLICES
const isRevenueHourlyGroupByOption =
activeStat.value === 'revenue' && isRevenueHourlyGroupBy(option.value)
const isRevenueDailyFallback = activeStat.value === 'revenue' && option.value === 'day'
return {
value: option.value,
label: formatAnalyticsGroupByLabel(option.value, formatMessage),
disabled:
isRevenueHourlyGroupByOption || (!isRevenueDailyFallback && (isTooCoarse || isTooFine)),
}
})
if (options.every((option) => option.disabled)) {
const fallbackOption =
options.find((option) => activeStat.value === 'revenue' && option.value === 'day') ??
options[0]
if (fallbackOption) {
fallbackOption.disabled = false
}
}
return options
})
watch(
groupByOptions,
(nextOptions) => {
const selectedOption = nextOptions.find((option) => option.value === selectedGroupBy.value)
if (selectedOption && !selectedOption.disabled) {
return
}
const fallbackOption = nextOptions.find((option) => !option.disabled) ?? nextOptions[0]
if (fallbackOption && selectedGroupBy.value !== fallbackOption.value) {
selectedGroupBy.value = fallbackOption.value
}
},
{ immediate: true },
)
function unique<T>(values: T[]): T[] {
return Array.from(new Set(values))
}
function sortStrings<T extends string>(values: T[]): T[] {
return [...values].sort((left, right) => left.localeCompare(right))
}
function includesStat(stats: readonly string[], stat: string): boolean {
return stats.includes(stat)
}
function withBreakdownFields(
breakdowns: readonly AnalyticsBreakdownPreset[],
filters: AnalyticsSelectedFilters,
): {
views: Labrinth.Analytics.v3.ProjectViewsField[]
downloads: Labrinth.Analytics.v3.ProjectDownloadsField[]
playtime: Labrinth.Analytics.v3.ProjectPlaytimeField[]
revenue: Labrinth.Analytics.v3.ProjectRevenueField[]
} {
const views: Labrinth.Analytics.v3.ProjectViewsField[] = ['project_id']
const downloads: Labrinth.Analytics.v3.ProjectDownloadsField[] = ['project_id']
const playtime: Labrinth.Analytics.v3.ProjectPlaytimeField[] = ['project_id']
const revenue: Labrinth.Analytics.v3.ProjectRevenueField[] = ['project_id']
const enabledStats = getEnabledAnalyticsStatsForState(breakdowns, filters)
for (const breakdown of breakdowns) {
const breakdownStats = getAnalyticsStatsForBreakdown(breakdown)
switch (breakdown) {
case 'project':
case 'none':
break
case 'country':
if (includesStat(breakdownStats, 'views') && includesStat(enabledStats, 'views')) {
views.push('country')
}
if (includesStat(breakdownStats, 'downloads') && includesStat(enabledStats, 'downloads')) {
downloads.push('country')
}
if (includesStat(breakdownStats, 'playtime') && includesStat(enabledStats, 'playtime')) {
playtime.push('country')
}
break
case 'monetization':
if (includesStat(breakdownStats, 'views') && includesStat(enabledStats, 'views')) {
views.push('monetized')
}
if (includesStat(breakdownStats, 'downloads') && includesStat(enabledStats, 'downloads')) {
downloads.push('monetized')
}
break
case 'user_agent':
if (includesStat(breakdownStats, 'downloads') && includesStat(enabledStats, 'downloads')) {
downloads.push('user_agent')
}
break
case 'download_reason':
if (includesStat(breakdownStats, 'downloads') && includesStat(enabledStats, 'downloads')) {
downloads.push('reason')
}
break
case 'version_id':
if (includesStat(breakdownStats, 'downloads') && includesStat(enabledStats, 'downloads')) {
downloads.push('version_id')
}
if (includesStat(breakdownStats, 'playtime') && includesStat(enabledStats, 'playtime')) {
playtime.push('version_id')
}
break
case 'loader':
if (includesStat(breakdownStats, 'downloads') && includesStat(enabledStats, 'downloads')) {
downloads.push('loader')
}
if (includesStat(breakdownStats, 'playtime') && includesStat(enabledStats, 'playtime')) {
playtime.push('loader')
}
break
case 'game_version':
if (includesStat(breakdownStats, 'downloads') && includesStat(enabledStats, 'downloads')) {
downloads.push('game_version')
}
if (includesStat(breakdownStats, 'playtime') && includesStat(enabledStats, 'playtime')) {
playtime.push('game_version')
}
break
default:
break
}
}
return {
views: unique(views),
downloads: unique(downloads),
playtime: unique(playtime),
revenue: unique(revenue),
}
}
function getFilterValuesForStat(
category: AnalyticsQueryFilterCategory,
stat: AnalyticsDashboardStat,
enabledStats: readonly AnalyticsDashboardStat[],
values: string[],
): string[] {
if (values.length === 0) {
return []
}
const filterStats = getAnalyticsStatsForFilterCategory(category)
return includesStat(filterStats, stat) && includesStat(enabledStats, stat)
? sortStrings(values)
: []
}
function getMonetizationFilterValues(
stat: AnalyticsDashboardStat,
enabledStats: readonly AnalyticsDashboardStat[],
filters: AnalyticsSelectedFilters,
): boolean[] {
const values = getFilterValuesForStat('monetization', stat, enabledStats, filters.monetization)
const monetizedValues: boolean[] = []
if (values.includes('monetized')) {
monetizedValues.push(true)
}
if (values.includes('unmonetized')) {
monetizedValues.push(false)
}
return monetizedValues
}
function getDownloadReasonFilterValues(
enabledStats: readonly AnalyticsDashboardStat[],
filters: AnalyticsSelectedFilters,
): Labrinth.Analytics.v3.DownloadReason[] {
const validReasons = new Set<Labrinth.Analytics.v3.DownloadReason>([
'standalone',
'dependency',
'modpack',
'update',
])
return getFilterValuesForStat(
'download_reason',
'downloads',
enabledStats,
filters.download_reason,
).filter((reason): reason is Labrinth.Analytics.v3.DownloadReason =>
validReasons.has(reason as Labrinth.Analytics.v3.DownloadReason),
)
}
function buildMetricFilters(
breakdowns: readonly AnalyticsBreakdownPreset[],
filters: AnalyticsSelectedFilters,
): {
views: Labrinth.Analytics.v3.ProjectViewsFilters
downloads: Labrinth.Analytics.v3.ProjectDownloadsFilters
playtime: Labrinth.Analytics.v3.ProjectPlaytimeFilters
revenue: Labrinth.Analytics.v3.ProjectRevenueFilters
} {
const enabledStats = getEnabledAnalyticsStatsForState(breakdowns, filters)
return {
views: {
country: getFilterValuesForStat('country', 'views', enabledStats, filters.country),
monetized: getMonetizationFilterValues('views', enabledStats, filters),
},
downloads: {
country: getFilterValuesForStat('country', 'downloads', enabledStats, filters.country),
monetized: getMonetizationFilterValues('downloads', enabledStats, filters),
user_agent: getFilterValuesForStat(
'user_agent',
'downloads',
enabledStats,
filters.user_agent,
),
reason: getDownloadReasonFilterValues(enabledStats, filters),
version_id: getFilterValuesForStat(
'version_id',
'downloads',
enabledStats,
filters.version_id,
),
game_version: getFilterValuesForStat(
'game_version',
'downloads',
enabledStats,
filters.game_version,
),
loader: getFilterValuesForStat('loader_type', 'downloads', enabledStats, filters.loader_type),
},
playtime: {
country: getFilterValuesForStat('country', 'playtime', enabledStats, filters.country),
version_id: getFilterValuesForStat(
'version_id',
'playtime',
enabledStats,
filters.version_id,
),
game_version: getFilterValuesForStat(
'game_version',
'playtime',
enabledStats,
filters.game_version,
),
loader: getFilterValuesForStat('loader_type', 'playtime', enabledStats, filters.loader_type),
},
revenue: {},
}
}
const fetchRequest = computed<Labrinth.Analytics.v3.FetchRequest>(() => {
const rawRange = selectedTimeRange.value
const { start, end } = ensureMinimumTimeRange(rawRange.start, rawRange.end)
const groupByMs = getAnalyticsGroupByPresetMinutes(selectedGroupBy.value) * 60 * 1000
const desiredSlices = Math.max(1, Math.floor((end.getTime() - start.getTime()) / groupByMs))
const resolutionSlices = Math.min(MAX_ANALYTICS_TIME_SLICES, desiredSlices)
const bucketBy = withBreakdownFields(selectedBreakdowns.value, selectedFilters.value)
const filterBy = buildMetricFilters(selectedBreakdowns.value, selectedFilters.value)
const filteredProjectIds = getProjectIdsMatchingStatusFilter(
selectedProjectIds.value,
projectStatusById.value,
selectedFilters.value,
)
return {
time_range: {
start: start.toISOString(),
end: end.toISOString(),
resolution: {
slices: resolutionSlices,
},
},
project_ids: sortStrings(filteredProjectIds),
return_metrics: {
project_views: {
bucket_by: sortStrings(bucketBy.views),
filter_by: filterBy.views,
},
project_downloads: {
bucket_by: sortStrings(bucketBy.downloads),
filter_by: filterBy.downloads,
},
project_playtime: {
bucket_by: sortStrings(bucketBy.playtime),
filter_by: filterBy.playtime,
},
project_revenue: {
bucket_by: sortStrings(bucketBy.revenue),
filter_by: filterBy.revenue,
},
},
}
})
watch(
fetchRequest,
(nextFetchRequest) => {
setFetchRequest(nextFetchRequest)
},
{ deep: true, immediate: true },
)
defineExpose({
fetchRequest,
})
</script>
<style lang="scss" scoped>
.checkbox-shadow {
box-shadow: 1px 1px 2px 0 rgba(0, 0, 0, 0.08);
}
</style>