Compare commits

...
Author SHA1 Message Date
tdgao 0eeeb8430a fix: i18n and passing params for formatter 2026-06-22 09:51:00 -06:00
tdgao 633c60a749 pnpm prepr 2026-06-22 09:47:46 -06:00
tdgao 62064ed109 Merge branch 'main' into truman/analytics-dependents-breakdown-filter 2026-06-22 09:39:28 -06:00
tdgao 5a8ea77127 fix: do not allow breakdowns to occur without shared stats 2026-06-22 09:38:31 -06:00
tdgao 096f0c19e6 feat: add link to user in table 2026-06-22 09:27:09 -06:00
Truman GaoandGitHub 1e4e929a7a feat: implement members breakdown/filter frontend (#6470) 2026-06-22 08:26:31 -07:00
tdgao b7b349d52c fix: use compatible dependencies for query filter options 2026-06-22 09:17:02 -06:00
tdgao 8e7f11cba4 feat: separate out no dependents based on download reason 2026-06-19 11:14:29 -06:00
tdgao e557e3981a feat: do not include unknown rows for showing top 8 2026-06-19 10:52:40 -06:00
tdgao d307947f20 feat: remove org icon for dependents projects and change unknown label 2026-06-19 09:42:44 -06:00
tdgao fb5b87f5f7 pnpm prepr 2026-06-19 09:33:09 -06:00
tdgao 2d022752aa feat: handle analytics values that dont have a dependent project as a "No dependent" row 2026-06-19 09:03:20 -06:00
tdgao ba58aa39da feat: implement projects in table click to link to project page 2026-06-19 08:00:21 -06:00
tdgao 946ac1131e fix: still using old search formatter 2026-06-18 20:53:18 -06:00
tdgao 60fa3bf679 pnpm prepr 2026-06-18 20:38:01 -06:00
Truman GaoandGitHub bd8235e101 Merge branch 'main' into truman/analytics-dependents-breakdown-filter 2026-06-18 10:06:36 -06:00
tdgao 29ee5d5084 fix: all project selection gets unselected when reload page 2026-06-18 10:03:30 -06:00
tdgao 9f04d1dd53 refactor: pnpm prepr 2026-06-18 09:57:46 -06:00
tdgao e370d36721 remove: dependent on project column and project version project column 2026-06-18 09:55:39 -06:00
tdgao 806adc5908 feat: add org icon 2026-06-17 15:30:33 -06:00
tdgao f037911fc7 fix: dont show dependent on column when project breakdown is selected 2026-06-17 12:43:26 -06:00
tdgao 19ee41a90f feat: add project icon to query filter 2026-06-17 12:37:46 -06:00
tdgao cb9936d55f feat: implement dependents query filter 2026-06-17 12:17:27 -06:00
tdgao 29f7b87492 feat: different tooltip for when both project version and dependent breakdowns are on 2026-06-14 17:35:10 -07:00
tdgao b273115ee2 feat: show dependent on column if there are multiple projects selected 2026-06-13 21:24:53 -07:00
tdgao e6d1480fb7 implement dependent project type filter 2026-06-13 21:17:51 -07:00
tdgao 8f4886c703 feat: implement dependent project breakdown 2026-06-13 19:03:44 -07:00
46 changed files with 1710 additions and 254 deletions
@@ -25,7 +25,7 @@
class="inline-flex items-center"
>
<button
v-tooltip="legendEntry.projectName ?? ''"
v-tooltip="legendEntry.tooltip ?? legendEntry.projectName ?? ''"
type="button"
class="inline-flex items-center gap-1.5 px-2 py-0.5 text-sm !outline-0 transition-all focus-within:!outline-0 focus:!outline-0 focus-visible:!outline-0"
:class="[
@@ -64,6 +64,7 @@ export function useAnalyticsChartLegend({
id: dataset.projectId,
name: dataset.label,
projectName: dataset.projectName,
tooltip: dataset.tooltip,
color: dataset.borderColor,
totalValue: getChartDatasetTotal(dataset),
hidden: hiddenDatasetIds.value.has(dataset.projectId),
@@ -118,6 +119,7 @@ export function useAnalyticsChartLegend({
id: getPreviousPeriodDatasetId(entry.id),
name: formatMessage(analyticsChartMessages.previousPeriodSuffix, { name: entry.name }),
projectName: entry.projectName,
tooltip: entry.tooltip,
color: entry.color,
totalValue: previousDataset ? getChartDatasetTotal(previousDataset) : 0,
hidden: hiddenDatasetIds.value.has(getPreviousPeriodDatasetId(entry.id)),
@@ -153,6 +155,7 @@ export function useAnalyticsChartLegend({
name: dataset.label,
}),
projectName: dataset.projectName,
tooltip: dataset.tooltip,
data: previousData,
borderColor: dataset.borderColor,
backgroundColor: dataset.backgroundColor,
@@ -296,7 +299,7 @@ export function useAnalyticsChartLegend({
}
function getLegendEntryTooltip(legendEntry: AnalyticsChartLegendEntry) {
return legendEntry.projectName ?? ''
return legendEntry.tooltip ?? legendEntry.projectName ?? ''
}
function isUnmonetizedLegendEntry(legendEntry: AnalyticsChartLegendEntry) {
@@ -97,7 +97,7 @@
"
/>
<span
v-tooltip="entry.projectName ?? ''"
v-tooltip="entry.tooltip ?? entry.projectName ?? ''"
class="min-w-0 truncate"
:class="{
'line-through': entry.hidden,
@@ -145,6 +145,7 @@ export type AnalyticsChartTooltipEntry = {
projectId: string
name: string
projectName?: string
tooltip?: string
color: string
formattedValue: string
hidden: boolean
@@ -208,6 +208,7 @@ const hoverEntries = computed(() => {
projectId: legendEntry.id,
name: legendEntry.name,
projectName: legendEntry.projectName,
tooltip: legendEntry.tooltip,
color: legendEntry.color,
formattedValue: props.isRatioMode
? `${ratioValue.toFixed(1)}%`
@@ -14,6 +14,7 @@ export type AnalyticsChartLegendEntry = {
id: string
name: string
projectName?: string
tooltip?: string
color: string
totalValue: number
hidden: boolean
@@ -1,20 +1,24 @@
import type { Labrinth } from '@modrinth/api-client'
import type {
AnalyticsBreakdownPreset,
AnalyticsDashboardProject,
AnalyticsDashboardStat,
AnalyticsGroupByPreset,
import {
type AnalyticsBreakdownPreset,
type AnalyticsDashboardProject,
type AnalyticsDashboardStat,
type AnalyticsGroupByPreset,
type AnalyticsSelectedFilters,
doesAnalyticsPointMatchNormalizedFilters,
normalizeAnalyticsSelectedFilters,
} from '~/providers/analytics/analytics'
import type { FormatMessage } from '../analytics-messages'
import {
analyticsChartMessages,
analyticsMessages,
analyticsStatCardMessages,
formatAnalyticsDependentProjectFallbackLabel,
formatAnalyticsDownloadReasonLabel,
formatAnalyticsLoaderLabel,
formatAnalyticsMonetizationLabel,
type FormatMessage,
} from '../analytics-messages'
import {
ALL_BREAKDOWN_VALUE,
@@ -22,6 +26,8 @@ import {
getAnalyticsBreakdownDatasetId,
getAnalyticsBreakdownKey,
getAnalyticsBreakdownValues,
isNoDependentAnalyticsBreakdownValue,
isUnknownAnalyticsBreakdownValue,
UNKNOWN_BREAKDOWN_VALUE,
} from '../breakdown'
import { PREVIOUS_PERIOD_DATASET_ID_PREFIX } from './analytics-chart-constants'
@@ -30,6 +36,7 @@ export type ChartDataset = {
projectId: string
label: string
projectName?: string
tooltip?: string
data: number[]
borderColor: string
backgroundColor: string
@@ -136,6 +143,7 @@ export function formatBreakdownLabel(
breakdownValue: string,
selectedBreakdown: AnalyticsBreakdownPreset,
getVersionDisplayName: ((versionId: string) => string) | undefined,
userNamesById: ReadonlyMap<string, string> | undefined,
formatMessage: FormatMessage,
): string {
const normalizedValue = breakdownValue.trim()
@@ -160,6 +168,15 @@ export function formatBreakdownLabel(
if (selectedBreakdown === 'download_reason') {
return formatAnalyticsDownloadReasonLabel(normalizedLowercaseValue, formatMessage)
}
if (selectedBreakdown === 'dependent_project_download') {
if (isNoDependentAnalyticsBreakdownValue(breakdownValue)) {
return formatMessage(analyticsMessages.noDependent)
}
return breakdownValue
}
if (selectedBreakdown === 'user_id') {
return userNamesById?.get(breakdownValue) ?? breakdownValue
}
if (selectedBreakdown === 'version_id') {
return getVersionDisplayName?.(breakdownValue) ?? breakdownValue
}
@@ -174,19 +191,34 @@ export function formatBreakdownLabels(
breakdownValues: readonly string[],
selectedBreakdowns: readonly AnalyticsBreakdownPreset[],
getVersionDisplayName: ((versionId: string) => string) | undefined,
userNamesById: ReadonlyMap<string, string> | undefined,
formatMessage: FormatMessage,
): string {
const normalizedBreakdowns = selectedBreakdowns.filter((breakdown) => breakdown !== 'none')
const downloadReasonBreakdownIndex = normalizedBreakdowns.indexOf('download_reason')
return collapseRepeatedUnknownBreakdownLabels(
selectedBreakdowns
.filter((breakdown) => breakdown !== 'none')
.map((breakdown, index) =>
formatBreakdownLabel(
breakdownValues[index] ?? '',
breakdown,
getVersionDisplayName,
normalizedBreakdowns.map((breakdown, index) => {
const breakdownValue = breakdownValues[index] ?? ''
if (
breakdown === 'dependent_project_download' &&
downloadReasonBreakdownIndex !== -1 &&
isUnknownAnalyticsBreakdownValue(breakdownValue)
) {
return formatAnalyticsDependentProjectFallbackLabel(
breakdownValues[downloadReasonBreakdownIndex],
formatMessage,
),
),
)
}
return formatBreakdownLabel(
breakdownValue,
breakdown,
getVersionDisplayName,
userNamesById,
formatMessage,
)
}),
formatMessage,
).join(COMBINED_BREAKDOWN_LABEL_SEPARATOR)
}
@@ -258,6 +290,33 @@ type PaletteRankEntry = {
total: number
}
function formatDatasetTooltip(projectName: string | undefined): string | undefined {
return projectName
}
function formatDependentProjectDatasetTooltip(
versionName: string | undefined,
dependentProjectName: string | undefined,
dependencyProjectNames: readonly string[],
formatMessage: FormatMessage,
): string | undefined {
if (dependencyProjectNames.length === 0) {
return undefined
}
if (versionName && dependentProjectName) {
return formatMessage(analyticsChartMessages.dependentProjectVersionTooltip, {
dependentProject: dependentProjectName,
dependencyProject: dependencyProjectNames.join(', '),
version: versionName,
})
}
return formatMessage(analyticsChartMessages.dependentOnProjectTooltip, {
project: dependencyProjectNames.join(', '),
})
}
function getPaletteColorForIndex(index: number, palette: string[]): string {
if (palette.length === 0) return ''
@@ -320,6 +379,10 @@ export function buildChartDatasets(
activeStat: AnalyticsDashboardStat,
palette: string[],
selectedBreakdowns: readonly AnalyticsBreakdownPreset[],
selectedFilters: AnalyticsSelectedFilters,
dependentProjectTypesById: ReadonlyMap<string, readonly string[]>,
projectNamesById: ReadonlyMap<string, string>,
userNamesById: ReadonlyMap<string, string>,
getVersionDisplayName: ((versionId: string) => string) | undefined,
getVersionProjectName: ((versionId: string) => string | undefined) | undefined,
formatMessage: FormatMessage,
@@ -332,17 +395,43 @@ export function buildChartDatasets(
const dataLength = Math.max(sliceCount, timeSlices.length)
const normalizedBreakdowns = selectedBreakdowns.filter((breakdown) => breakdown !== 'none')
const projectNamesById = new Map(selectedProjects.map((project) => [project.id, project.name]))
const normalizedFilters = normalizeAnalyticsSelectedFilters(selectedFilters)
function formatChartBreakdownLabels(breakdownValues: readonly string[]): string {
const downloadReasonBreakdownIndex = normalizedBreakdowns.indexOf('download_reason')
return collapseRepeatedUnknownBreakdownLabels(
normalizedBreakdowns.map((breakdown, index) => {
const breakdownValue = breakdownValues[index] ?? ''
if (breakdown === 'project') {
if (breakdown === 'project' || breakdown === 'dependent_project_download') {
if (
breakdown === 'dependent_project_download' &&
isNoDependentAnalyticsBreakdownValue(breakdownValue)
) {
return formatMessage(analyticsMessages.noDependent)
}
if (
breakdown === 'dependent_project_download' &&
isUnknownAnalyticsBreakdownValue(breakdownValue)
) {
return downloadReasonBreakdownIndex === -1
? formatMessage(analyticsMessages.unknown)
: formatAnalyticsDependentProjectFallbackLabel(
breakdownValues[downloadReasonBreakdownIndex],
formatMessage,
)
}
return projectNamesById.get(breakdownValue) ?? breakdownValue
}
return formatBreakdownLabel(breakdownValue, breakdown, getVersionDisplayName, formatMessage)
return formatBreakdownLabel(
breakdownValue,
breakdown,
getVersionDisplayName,
userNamesById,
formatMessage,
)
}),
formatMessage,
).join(COMBINED_BREAKDOWN_LABEL_SEPARATOR)
@@ -352,13 +441,27 @@ export function buildChartDatasets(
normalizedBreakdowns.length > 0 &&
!(normalizedBreakdowns.length === 1 && normalizedBreakdowns[0] === 'project')
) {
const hasVersionBreakdown = normalizedBreakdowns.includes('version_id')
const hasDependentProjectBreakdown = normalizedBreakdowns.includes('dependent_project_download')
const shouldShowDependentProjectTooltip =
hasDependentProjectBreakdown && (selectedProjects.length > 1 || hasVersionBreakdown)
const dataByBreakdown = new Map<string, number[]>()
const breakdownValuesByKey = new Map<string, string[]>()
const downloadTotalsByBreakdown = new Map<string, number>()
const dependentOnProjectIdsByBreakdown = new Map<string, Set<string>>()
timeSlices.forEach((slice, sliceIndex) => {
for (const point of slice) {
if (!isProjectAnalyticsPointInSelectedProjects(point, selectedProjectIds)) continue
if (
!doesAnalyticsPointMatchNormalizedFilters(
point,
normalizedFilters,
dependentProjectTypesById,
)
) {
continue
}
const breakdownValues = getAnalyticsBreakdownValues(
point,
@@ -374,6 +477,11 @@ export function buildChartDatasets(
dataByBreakdown.set(breakdownKey, new Array(dataLength).fill(0))
breakdownValuesByKey.set(breakdownKey, breakdownValues)
}
if (shouldShowDependentProjectTooltip && point.metric_kind === 'downloads') {
const projectIds = dependentOnProjectIdsByBreakdown.get(breakdownKey) ?? new Set<string>()
projectIds.add(point.source_project)
dependentOnProjectIdsByBreakdown.set(breakdownKey, projectIds)
}
if (point.metric_kind === 'downloads') {
downloadTotalsByBreakdown.set(
@@ -402,6 +510,42 @@ export function buildChartDatasets(
return Array.from(dataByBreakdown.entries()).map(([breakdownKey, data]) => {
const breakdownValues = breakdownValuesByKey.get(breakdownKey) ?? []
const fallbackColor = colorsByBreakdown.get(breakdownKey) ?? ''
const versionBreakdownIndex = normalizedBreakdowns.indexOf('version_id')
const dependentProjectBreakdownIndex = normalizedBreakdowns.indexOf(
'dependent_project_download',
)
const versionName =
hasVersionBreakdown && versionBreakdownIndex !== -1
? getVersionDisplayName?.(breakdownValues[versionBreakdownIndex] ?? '')
: undefined
const dependentProjectId =
dependentProjectBreakdownIndex !== -1
? breakdownValues[dependentProjectBreakdownIndex]
: undefined
const dependentProjectName = dependentProjectId
? isMissingDependentProjectValue(dependentProjectId)
? undefined
: (projectNamesById.get(dependentProjectId) ?? dependentProjectId)
: undefined
const versionProjectName =
normalizedBreakdowns.length === 1 && normalizedBreakdowns[0] === 'version_id'
? getVersionProjectName?.(breakdownValues[0] ?? '')
: undefined
const dependencyProjectNames = [...(dependentOnProjectIdsByBreakdown.get(breakdownKey) ?? [])]
.map((projectId) => projectNamesById.get(projectId) ?? projectId)
.sort((left, right) => left.localeCompare(right))
const dependentProjectTooltip = dependentProjectId
? isNoDependentAnalyticsBreakdownValue(dependentProjectId)
? formatMessage(analyticsMessages.noDependentTooltip)
: isUnknownAnalyticsBreakdownValue(dependentProjectId)
? formatMessage(analyticsMessages.unknown)
: formatDependentProjectDatasetTooltip(
versionName,
dependentProjectName,
dependencyProjectNames,
formatMessage,
)
: undefined
const color =
normalizedBreakdowns.length === 1
? getBreakdownColor(
@@ -414,10 +558,8 @@ export function buildChartDatasets(
return {
projectId: getAnalyticsBreakdownDatasetId(breakdownValues, normalizedBreakdowns),
label: formatChartBreakdownLabels(breakdownValues),
projectName:
normalizedBreakdowns.length === 1 && normalizedBreakdowns[0] === 'version_id'
? getVersionProjectName?.(breakdownValues[0] ?? '')
: undefined,
projectName: versionProjectName,
tooltip: dependentProjectTooltip ?? formatDatasetTooltip(versionProjectName),
data,
borderColor: color,
backgroundColor: color,
@@ -432,6 +574,15 @@ export function buildChartDatasets(
timeSlices.forEach((slice, sliceIndex) => {
for (const point of slice) {
if (!isProjectAnalyticsPointInSelectedProjects(point, selectedProjectIds)) continue
if (
!doesAnalyticsPointMatchNormalizedFilters(
point,
normalizedFilters,
dependentProjectTypesById,
)
) {
continue
}
if (point.metric_kind === 'downloads') {
downloadTotal += getMetricValue(point, 'downloads')
@@ -481,6 +632,15 @@ export function buildChartDatasets(
timeSlices.forEach((slice, sliceIndex) => {
for (const point of slice) {
if (!isProjectAnalyticsPointInSelectedProjects(point, selectedProjectIds)) continue
if (
!doesAnalyticsPointMatchNormalizedFilters(
point,
normalizedFilters,
dependentProjectTypesById,
)
) {
continue
}
const breakdownValues = getAnalyticsBreakdownValues(
point,
@@ -526,6 +686,10 @@ export function buildChartDatasets(
return Array.from(dataByProjectBreakdown.entries()).map(([breakdownKey, data]) => {
const breakdownValues = breakdownValuesByKey.get(breakdownKey) ?? []
const fallbackColor = colorsByBreakdown.get(breakdownKey) ?? ''
const versionProjectName =
normalizedBreakdowns.length === 1 && normalizedBreakdowns[0] === 'version_id'
? getVersionProjectName?.(breakdownValues[0] ?? '')
: undefined
const color =
normalizedBreakdowns.length === 1
? getBreakdownColor(
@@ -538,10 +702,8 @@ export function buildChartDatasets(
return {
projectId: getAnalyticsBreakdownDatasetId(breakdownValues, normalizedBreakdowns),
label: formatChartBreakdownLabels(breakdownValues),
projectName:
normalizedBreakdowns.length === 1 && normalizedBreakdowns[0] === 'version_id'
? getVersionProjectName?.(breakdownValues[0] ?? '')
: undefined,
projectName: versionProjectName,
tooltip: formatDatasetTooltip(versionProjectName),
data,
borderColor: color,
backgroundColor: color,
@@ -549,6 +711,10 @@ export function buildChartDatasets(
})
}
function isMissingDependentProjectValue(value: string | undefined): boolean {
return isUnknownAnalyticsBreakdownValue(value) || isNoDependentAnalyticsBreakdownValue(value)
}
export function getSliceCount(
timeRange: Labrinth.Analytics.v3.TimeRange,
fallback: number,
@@ -1,12 +1,15 @@
import type { Labrinth } from '@modrinth/api-client'
import { useVIntl } from '@modrinth/ui'
import { computed, type ComputedRef, ref, watch } from 'vue'
import { useTheme } from '~/composables/nuxt-accessors'
import { isDarkTheme } from '~/plugins/theme/index.ts'
import type {
AnalyticsBreakdownPreset,
AnalyticsDashboardContextValue,
AnalyticsDashboardProject,
AnalyticsDashboardStat,
AnalyticsSelectedFilters,
} from '~/providers/analytics/analytics'
import {
@@ -47,12 +50,16 @@ export function useAnalyticsChartDatasets(
| 'displayedPreviousTimeSlices'
| 'displayedSelectedGroupBy'
| 'displayedSelectedBreakdowns'
| 'displayedSelectedFilters'
| 'hiddenGraphDatasetIds'
| 'hasExplicitGraphDatasetSelection'
| 'isGraphDatasetSelectionActive'
| 'selectedGraphDatasetIds'
| 'defaultGraphDatasetIds'
| 'topGraphDatasetIds'
| 'projectNamesById'
| 'userNamesById'
| 'dependentProjectTypesById'
| 'getVersionDisplayName'
| 'getVersionProjectName'
>,
@@ -160,6 +167,10 @@ export function useAnalyticsChartDatasets(
selectedProjects.value,
legendPalette.value,
context.displayedSelectedBreakdowns.value,
context.displayedSelectedFilters.value,
context.dependentProjectTypesById.value,
context.projectNamesById.value,
context.userNamesById.value,
context.getVersionDisplayName,
showProjectVersionNames.value ? context.getVersionProjectName : undefined,
formatMessage,
@@ -172,6 +183,10 @@ export function useAnalyticsChartDatasets(
selectedProjects.value,
legendPalette.value,
context.displayedSelectedBreakdowns.value,
context.displayedSelectedFilters.value,
context.dependentProjectTypesById.value,
context.projectNamesById.value,
context.userNamesById.value,
context.getVersionDisplayName,
showProjectVersionNames.value ? context.getVersionProjectName : undefined,
formatMessage,
@@ -357,12 +372,16 @@ export function useAnalyticsChartDatasets(
}
function buildDatasetsByStat(
timeSlices: Parameters<typeof buildChartDatasets>[0],
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
selectedProjects: AnalyticsDashboardProject[],
palette: string[],
selectedBreakdowns: Parameters<typeof buildChartDatasets>[4],
getVersionDisplayName: Parameters<typeof buildChartDatasets>[5],
getVersionProjectName: Parameters<typeof buildChartDatasets>[6],
selectedBreakdowns: readonly AnalyticsBreakdownPreset[],
selectedFilters: AnalyticsSelectedFilters,
dependentProjectTypesById: ReadonlyMap<string, readonly string[]>,
projectNamesById: ReadonlyMap<string, string>,
userNamesById: ReadonlyMap<string, string>,
getVersionDisplayName: (versionId: string) => string,
getVersionProjectName: ((versionId: string) => string | undefined) | undefined,
formatMessage: FormatMessage,
sliceCount: number,
) {
@@ -374,6 +393,10 @@ function buildDatasetsByStat(
stat,
palette,
selectedBreakdowns,
selectedFilters,
dependentProjectTypesById,
projectNamesById,
userNamesById,
getVersionDisplayName,
getVersionProjectName,
formatMessage,
@@ -13,6 +13,7 @@ export type AnalyticsBreakdownItemType =
| 'downloadSource'
| 'gameVersion'
| 'loader'
| 'member'
| 'monetization'
| 'project'
| 'projectVersion'
@@ -83,6 +84,14 @@ export const analyticsMessages = defineMessages({
id: 'analytics.value.unknown',
defaultMessage: 'Unknown',
},
noDependent: {
id: 'analytics.value.no-dependent',
defaultMessage: 'No dependents',
},
noDependentTooltip: {
id: 'analytics.value.no-dependent-tooltip',
defaultMessage: 'Downloaded for reasons other than being a dependency',
},
other: {
id: 'analytics.value.other',
defaultMessage: 'Other',
@@ -175,6 +184,14 @@ export const analyticsMessages = defineMessages({
id: 'analytics.filter.search.project-versions',
defaultMessage: 'Search project versions...',
},
searchDependentProjectsPlaceholder: {
id: 'analytics.filter.search.dependent-projects',
defaultMessage: 'Search projects...',
},
searchMembersPlaceholder: {
id: 'analytics.filter.search.members',
defaultMessage: 'Search members...',
},
searchVersionsPlaceholder: {
id: 'analytics.filter.search.versions',
defaultMessage: 'Search versions...',
@@ -351,6 +368,22 @@ export const analyticsBreakdownMessages = defineMessages({
id: 'analytics.breakdown.download-reason',
defaultMessage: 'Download reason',
},
members: {
id: 'analytics.breakdown.members',
defaultMessage: 'Members',
},
dependentProjectDownload: {
id: 'analytics.breakdown.dependent-project-download',
defaultMessage: 'Dependent project',
},
dependentProjectType: {
id: 'analytics.breakdown.dependent-project-type',
defaultMessage: 'Dependent project type',
},
dependentOn: {
id: 'analytics.breakdown.dependent-on',
defaultMessage: 'Dependent on',
},
versionId: {
id: 'analytics.breakdown.project-version',
defaultMessage: 'Project version',
@@ -517,25 +550,33 @@ export const analyticsChartMessages = defineMessages({
id: 'analytics.chart.action.show-top-eight',
defaultMessage: 'Show top 8',
},
dependentOnProjectTooltip: {
id: 'analytics.chart.tooltip.dependent-on-project',
defaultMessage: 'Dependent on {project}',
},
dependentProjectVersionTooltip: {
id: 'analytics.chart.tooltip.dependent-project-version',
defaultMessage: '{dependentProject} dependent on {dependencyProject}, {version}',
},
tableSelectionLimited: {
id: 'analytics.chart.table-selection.limited',
defaultMessage:
'Showing {limit} {itemType, select, project {{limit, plural, one {project} other {projects}}} country {{limit, plural, one {country} other {countries}}} monetization {{limit, plural, one {monetization value} other {monetization values}}} downloadSource {{limit, plural, one {download source} other {download sources}}} downloadReason {{limit, plural, one {download reason} other {download reasons}}} projectVersion {{limit, plural, one {project version} other {project versions}}} loader {{limit, plural, one {loader} other {loaders}}} gameVersion {{limit, plural, one {game version} other {game versions}}} other {{limit, plural, one {item} other {items}}}} from table',
'Showing {limit} {itemType, select, project {{limit, plural, one {project} other {projects}}} country {{limit, plural, one {country} other {countries}}} monetization {{limit, plural, one {monetization value} other {monetization values}}} downloadSource {{limit, plural, one {download source} other {download sources}}} downloadReason {{limit, plural, one {download reason} other {download reasons}}} member {{limit, plural, one {member} other {members}}} projectVersion {{limit, plural, one {project version} other {project versions}}} loader {{limit, plural, one {loader} other {loaders}}} gameVersion {{limit, plural, one {game version} other {game versions}}} other {{limit, plural, one {item} other {items}}}} from table',
},
tableSelectionAll: {
id: 'analytics.chart.table-selection.all',
defaultMessage:
'Showing all {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table',
'Showing all {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} member {{count, plural, one {member} other {members}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table',
},
tableSelectionTop: {
id: 'analytics.chart.table-selection.top',
defaultMessage:
'Showing top {count} {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table',
'Showing top {count} {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} member {{count, plural, one {member} other {members}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table',
},
tableSelectionCount: {
id: 'analytics.chart.table-selection.count',
defaultMessage:
'Showing {count} {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table',
'Showing {count} {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} member {{count, plural, one {member} other {members}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table',
},
lineView: {
id: 'analytics.chart.view.line',
@@ -781,6 +822,10 @@ export function formatAnalyticsBreakdownLabel(
return formatMessage(analyticsBreakdownMessages.userAgent)
case 'download_reason':
return formatMessage(analyticsBreakdownMessages.downloadReason)
case 'user_id':
return formatMessage(analyticsBreakdownMessages.members)
case 'dependent_project_download':
return formatMessage(analyticsBreakdownMessages.dependentProjectDownload)
case 'version_id':
return formatMessage(analyticsBreakdownMessages.versionId)
case 'loader':
@@ -810,6 +855,10 @@ export function getAnalyticsBreakdownItemType(
return 'downloadSource'
case 'download_reason':
return 'downloadReason'
case 'user_id':
return 'member'
case 'dependent_project_download':
return 'project'
case 'version_id':
return 'projectVersion'
case 'loader':
@@ -853,6 +902,18 @@ export function formatAnalyticsDownloadReasonLabel(
}
}
export function formatAnalyticsDependentProjectFallbackLabel(
downloadReason: string | undefined,
formatMessage: FormatMessage,
): string {
const normalizedReason = downloadReason?.trim().toLowerCase()
if (normalizedReason === 'standalone' || normalizedReason === 'update') {
return formatMessage(analyticsMessages.noDependent)
}
return formatMessage(analyticsMessages.unknown)
}
export function formatAnalyticsDownloadSourceLabel(
source: string,
formatMessage: FormatMessage,
@@ -19,6 +19,8 @@ import type {
MutableRouteQuery,
} from '~/providers/analytics/analytics-types'
import { getAnalyticsBreakdownsWithSharedStats } from './query-builder/query-filter-utils'
export const DEFAULT_TIMEFRAME_PRESET: AnalyticsTimeframePreset = 'last_30_days'
export const DEFAULT_TIMEFRAME_MODE: AnalyticsTimeframeMode = 'preset'
export const DEFAULT_LAST_TIMEFRAME_AMOUNT = 1
@@ -73,9 +75,11 @@ const BREAKDOWN_PRESET_VALUES: AnalyticsBreakdownPreset[] = [
'monetization',
'user_agent',
'download_reason',
'user_id',
'version_id',
'loader',
'game_version',
'dependent_project_download',
]
const ANALYTICS_DASHBOARD_STAT_VALUES: AnalyticsDashboardStat[] = [
@@ -89,15 +93,18 @@ const ANALYTICS_GRAPH_VIEW_MODE_VALUES: AnalyticsGraphViewMode[] = ['line', 'are
const ANALYTICS_TABLE_SORT_COLUMN_VALUES: AnalyticsTableSortColumn[] = [
'date',
'project',
'dependent_on',
'breakdown',
'breakdown_project',
'breakdown_country',
'breakdown_monetization',
'breakdown_user_agent',
'breakdown_download_reason',
'breakdown_user_id',
'breakdown_version_id',
'breakdown_loader',
'breakdown_game_version',
'breakdown_dependent_project_download',
'views',
'downloads',
'revenue',
@@ -131,9 +138,12 @@ const QUERY_KEY_FILTER_MONETIZATION = 'a_monetization'
const QUERY_KEY_FILTER_USER_AGENT = 'a_user_agent'
const QUERY_KEY_FILTER_LEGACY_DOWNLOAD_SOURCE = 'a_download_source'
const QUERY_KEY_FILTER_DOWNLOAD_REASON = 'a_download_reason'
const QUERY_KEY_FILTER_USER_ID = 'a_user_id'
const QUERY_KEY_FILTER_VERSION_ID = 'a_version_id'
const QUERY_KEY_FILTER_GAME_VERSION = 'a_game_version'
const QUERY_KEY_FILTER_LOADER_TYPE = 'a_loader_type'
const QUERY_KEY_FILTER_DEPENDENT_PROJECT_ID = 'a_dependent_project_id'
const QUERY_KEY_FILTER_DEPENDENT_PROJECT_TYPE = 'a_dependent_project_type'
const QUERY_KEY_STAT = 'a_stat'
const QUERY_KEY_GRAPH_VIEW_MODE = 'a_chart'
const QUERY_KEY_GRAPH_RATIO_MODE = 'a_ratio'
@@ -154,9 +164,12 @@ const URL_FILTER_CATEGORIES: Exclude<AnalyticsQueryFilterCategory, 'project'>[]
'monetization',
'user_agent',
'download_reason',
'user_id',
'version_id',
'game_version',
'loader_type',
'dependent_project_id',
'dependent_project_type',
]
const FILTER_QUERY_KEY_BY_CATEGORY: Record<
@@ -168,9 +181,12 @@ const FILTER_QUERY_KEY_BY_CATEGORY: Record<
monetization: QUERY_KEY_FILTER_MONETIZATION,
user_agent: QUERY_KEY_FILTER_USER_AGENT,
download_reason: QUERY_KEY_FILTER_DOWNLOAD_REASON,
user_id: QUERY_KEY_FILTER_USER_ID,
version_id: QUERY_KEY_FILTER_VERSION_ID,
game_version: QUERY_KEY_FILTER_GAME_VERSION,
loader_type: QUERY_KEY_FILTER_LOADER_TYPE,
dependent_project_id: QUERY_KEY_FILTER_DEPENDENT_PROJECT_ID,
dependent_project_type: QUERY_KEY_FILTER_DEPENDENT_PROJECT_TYPE,
}
const ANALYTICS_QUERY_KEYS = [
@@ -189,9 +205,12 @@ const ANALYTICS_QUERY_KEYS = [
QUERY_KEY_FILTER_USER_AGENT,
QUERY_KEY_FILTER_LEGACY_DOWNLOAD_SOURCE,
QUERY_KEY_FILTER_DOWNLOAD_REASON,
QUERY_KEY_FILTER_USER_ID,
QUERY_KEY_FILTER_VERSION_ID,
QUERY_KEY_FILTER_GAME_VERSION,
QUERY_KEY_FILTER_LOADER_TYPE,
QUERY_KEY_FILTER_DEPENDENT_PROJECT_ID,
QUERY_KEY_FILTER_DEPENDENT_PROJECT_TYPE,
QUERY_KEY_STAT,
QUERY_KEY_GRAPH_VIEW_MODE,
QUERY_KEY_GRAPH_RATIO_MODE,
@@ -212,9 +231,12 @@ export function buildEmptySelectedFilters(): AnalyticsSelectedFilters {
monetization: [],
user_agent: [],
download_reason: [],
user_id: [],
version_id: [],
game_version: [],
loader_type: [],
dependent_project_id: [],
dependent_project_type: [],
}
}
@@ -255,7 +277,7 @@ function normalizeFilterQueryValues(
.filter((value) => PROJECT_STATUS_FILTER_VALUES.includes(value))
}
if (category !== 'loader_type') {
if (category !== 'loader_type' && category !== 'dependent_project_type') {
return values
}
@@ -456,7 +478,7 @@ export function getAnalyticsBreakdownPresetsForProjectSelection(
}
}
return normalizedBreakdowns
return getAnalyticsBreakdownsWithSharedStats(normalizedBreakdowns)
}
export function getAnalyticsBreakdownPresetForProjectSelection(
@@ -776,6 +798,12 @@ export function hasAnalyticsProjectSelectionQuery(query: LocationQuery): boolean
return parseListQueryValue(query[QUERY_KEY_PROJECT_IDS]).length > 0
}
export function hasAnalyticsAllProjectSelectionQuery(query: LocationQuery): boolean {
return parseListQueryValue(query[QUERY_KEY_PROJECT_IDS]).includes(
PROJECT_SELECTION_ALL_QUERY_VALUE,
)
}
export function hasAnalyticsGraphProjectEventsVisibilityQuery(query: LocationQuery): boolean {
return query[QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY] !== undefined
}
@@ -0,0 +1,62 @@
<template>
<div class="mr-2.5 flex min-w-0 items-center gap-2">
<span
v-if="!hideIcon"
v-tooltip="iconTooltip"
class="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded text-primary"
>
<img
v-if="iconUrl"
:src="iconUrl"
:alt="formatMessage(analyticsMessages.projectIconAlt, { name: label })"
class="h-6 w-6 rounded object-cover"
/>
<BoxIcon v-else class="h-full w-full" />
</span>
<component
:is="labelHref ? 'a' : 'span'"
v-tooltip="labelTooltip"
:href="labelHref"
:target="labelHref ? '_blank' : undefined"
:rel="labelHref ? 'noopener noreferrer' : undefined"
class="line-clamp-2 min-w-0 truncate text-wrap font-semibold leading-tight text-primary"
:class="{ 'hover:underline': labelHref }"
:title="label"
>
{{ label }}
</component>
<component
:is="organizationHref ? 'a' : 'span'"
v-if="organizationTooltip"
v-tooltip="organizationTooltip"
:href="organizationHref"
:target="organizationHref ? '_blank' : undefined"
:rel="organizationHref ? 'noopener noreferrer' : undefined"
:aria-label="organizationTooltip"
class="flex size-4 shrink-0 items-center text-primary"
:class="{ 'hover:underline': organizationHref }"
>
<OrganizationIcon class="size-4" />
</component>
</div>
</template>
<script setup lang="ts">
import { BoxIcon, OrganizationIcon } from '@modrinth/assets'
import { useVIntl } from '@modrinth/ui'
import { analyticsMessages } from '../analytics-messages.ts'
defineProps<{
label: string
iconUrl?: string
iconTooltip?: string
hideIcon?: boolean
labelHref?: string
labelTooltip?: string
organizationHref?: string
organizationTooltip?: string
}>()
const { formatMessage } = useVIntl()
</script>
@@ -23,7 +23,6 @@ type BuildAnalyticsTableColumnsOptions = {
selectedBreakdowns: readonly AnalyticsTableBreakdownPreset[]
selectedFilters: AnalyticsSelectedFilters
showBreakdownColumn: boolean
showProjectVersionProjectColumn: boolean
formatMessage: FormatMessage
getRelevantAnalyticsDashboardStats: (
breakdowns: readonly AnalyticsBreakdownPreset[],
@@ -43,7 +42,6 @@ export function buildAnalyticsTableColumns({
selectedBreakdowns,
selectedFilters,
showBreakdownColumn,
showProjectVersionProjectColumn,
formatMessage,
getRelevantAnalyticsDashboardStats,
}: BuildAnalyticsTableColumnsOptions): TableColumn<AnalyticsTableColumnKey>[] {
@@ -66,20 +64,11 @@ export function buildAnalyticsTableColumns({
key: getAnalyticsTableBreakdownColumnKey(breakdown),
label: getAnalyticsTableBreakdownColumnLabel(breakdown, formatMessage),
enableSorting: true,
width: breakdown === 'project' ? '25%' : undefined,
width: breakdown === 'project' && selectedBreakdowns.length === 1 ? '45%' : undefined,
})
}
}
if (showProjectVersionProjectColumn) {
nextColumns.push({
key: 'project',
label: formatAnalyticsBreakdownLabel('project', formatMessage),
enableSorting: true,
width: '25%',
})
}
for (const stat of stats) {
const column = getAnalyticsTableMetricColumn(stat, formatMessage)
if (column) {
@@ -102,6 +91,7 @@ export function getAnalyticsTableMetricColumn(
enableSorting: true,
defaultSortDirection: 'desc',
align: 'right',
width: '20%',
}
case 'downloads':
return {
@@ -110,6 +100,7 @@ export function getAnalyticsTableMetricColumn(
enableSorting: true,
defaultSortDirection: 'desc',
align: 'right',
width: '20%',
}
case 'revenue':
return {
@@ -118,6 +109,7 @@ export function getAnalyticsTableMetricColumn(
enableSorting: true,
defaultSortDirection: 'desc',
align: 'right',
width: '20%',
}
case 'playtime':
return {
@@ -126,6 +118,7 @@ export function getAnalyticsTableMetricColumn(
enableSorting: true,
defaultSortDirection: 'desc',
align: 'right',
width: '20%',
}
default:
return null
@@ -67,6 +67,8 @@ function getAnalyticsTableCsvCellValue(
return row.date
case 'project':
return row.project
case 'dependent_on':
return row.dependent_on
case 'breakdown':
return row.breakdownDisplay
case 'views':
@@ -1,8 +1,11 @@
import type { Labrinth } from '@modrinth/api-client'
import type {
AnalyticsBreakdownPreset,
AnalyticsDashboardStat,
import {
type AnalyticsBreakdownPreset,
type AnalyticsDashboardStat,
type AnalyticsSelectedFilters,
doesAnalyticsPointMatchNormalizedFilters,
normalizeAnalyticsSelectedFilters,
} from '~/providers/analytics/analytics'
import {
@@ -12,13 +15,18 @@ import {
getSliceCount,
} from '../analytics-chart/analytics-chart-utils'
import type { FormatMessage } from '../analytics-messages'
import { analyticsMessages } from '../analytics-messages'
import {
analyticsMessages,
formatAnalyticsDependentProjectFallbackLabel,
} from '../analytics-messages'
import {
ALL_BREAKDOWN_VALUE,
COMBINED_BREAKDOWN_LABEL_SEPARATOR,
getAnalyticsBreakdownDatasetId,
getAnalyticsBreakdownKey,
getAnalyticsBreakdownValues,
isNoDependentAnalyticsBreakdownValue,
isUnknownAnalyticsBreakdownValue,
} from '../breakdown'
import { getAnalyticsTableBreakdownColumnKey } from './analytics-table-columns'
import type {
@@ -36,8 +44,12 @@ type BuildAnalyticsTableRowsOptions = {
timeSlices: Labrinth.Analytics.v3.TimeSlice[]
selectedBreakdowns: readonly AnalyticsTableBreakdownPreset[]
selectedProjectIds: ReadonlySet<string>
selectedFilters: AnalyticsSelectedFilters
dependentProjectTypesById: ReadonlyMap<string, readonly string[]>
includeDependentProjectTooltipContext: boolean
relevantStats: ReadonlySet<AnalyticsDashboardStat>
projectNamesById: ReadonlyMap<string, string>
userNamesById: ReadonlyMap<string, string>
getVersionDisplayName: (versionId: string) => string
getVersionProjectName: (versionId: string) => string | undefined
showTimeInBucketLabel: boolean
@@ -51,8 +63,12 @@ export function buildAnalyticsTableRows({
timeSlices,
selectedBreakdowns,
selectedProjectIds,
selectedFilters,
dependentProjectTypesById,
includeDependentProjectTooltipContext,
relevantStats,
projectNamesById,
userNamesById,
getVersionDisplayName,
getVersionProjectName,
showTimeInBucketLabel,
@@ -72,6 +88,7 @@ export function buildAnalyticsTableRows({
const projectDisplayValues = new Map<string, string>()
const nextRows = new Map<string, AnalyticsTableRow>()
const bucketLabelsBySliceIndex = new Map<number, { date: string; dateMs: number }>()
const normalizedFilters = normalizeAnalyticsSelectedFilters(selectedFilters)
function getBreakdownDisplayValue(
breakdownValue: string,
@@ -84,6 +101,7 @@ export function buildAnalyticsTableRows({
breakdownValue,
breakdown,
projectNamesById,
userNamesById,
getVersionDisplayName,
formatMessage,
)
@@ -111,6 +129,15 @@ export function buildAnalyticsTableRows({
return displayValue
}
function getProjectVersionIdForBreakdownValues(breakdownValues: readonly string[]) {
const versionBreakdownIndex = selectedBreakdowns.indexOf('version_id')
if (versionBreakdownIndex === -1) {
return ''
}
return breakdownValues[versionBreakdownIndex] ?? ''
}
function getBreakdownDisplays(breakdownValues: readonly string[]) {
const displays: AnalyticsTableBreakdownDisplayValues = {}
@@ -118,6 +145,19 @@ export function buildAnalyticsTableRows({
displays[breakdown] = getBreakdownDisplayValue(breakdownValues[index] ?? '', breakdown)
})
const dependentProjectBreakdownIndex = selectedBreakdowns.indexOf('dependent_project_download')
const downloadReasonBreakdownIndex = selectedBreakdowns.indexOf('download_reason')
if (
dependentProjectBreakdownIndex !== -1 &&
downloadReasonBreakdownIndex !== -1 &&
isUnknownAnalyticsBreakdownValue(breakdownValues[dependentProjectBreakdownIndex])
) {
displays.dependent_project_download = formatAnalyticsDependentProjectFallbackLabel(
breakdownValues[downloadReasonBreakdownIndex],
formatMessage,
)
}
return displays
}
@@ -157,6 +197,7 @@ export function buildAnalyticsTableRows({
function createRow(
rowId: string,
breakdownValues: readonly string[],
dependentOnProjectId?: string,
bucketLabel?: { date: string; dateMs: number },
) {
const breakdownKey =
@@ -169,6 +210,12 @@ export function buildAnalyticsTableRows({
date: bucketLabel?.date ?? '',
dateMs: bucketLabel?.dateMs ?? 0,
project: getProjectDisplayValueForBreakdownValues(breakdownValues),
projectVersionId: getProjectVersionIdForBreakdownValues(breakdownValues),
dependent_on: dependentOnProjectId
? (projectNamesById.get(dependentOnProjectId) ?? dependentOnProjectId)
: '',
dependentOnProjectId: dependentOnProjectId ?? '',
dependentOnProjectIds: dependentOnProjectId ? [dependentOnProjectId] : [],
breakdown: breakdownKey,
breakdownValues: Object.fromEntries(
selectedBreakdowns.map((breakdown, index) => [breakdown, breakdownValues[index] ?? '']),
@@ -190,6 +237,18 @@ export function buildAnalyticsTableRows({
return row
}
function addDependentOnProjectIdToRow(row: AnalyticsTableRow, projectId: string | undefined) {
if (!projectId || row.dependentOnProjectIds.includes(projectId)) {
return
}
row.dependentOnProjectIds.push(projectId)
if (!row.dependentOnProjectId) {
row.dependentOnProjectId = projectId
row.dependent_on = projectNamesById.get(projectId) ?? projectId
}
}
if (!includeDate && selectedBreakdowns.length === 0) {
createRow(ALL_PROJECTS_BREAKDOWN_VALUE, [])
}
@@ -211,6 +270,15 @@ export function buildAnalyticsTableRows({
if (!selectedProjectIds.has(point.source_project)) {
continue
}
if (
!doesAnalyticsPointMatchNormalizedFilters(
point,
normalizedFilters,
dependentProjectTypesById,
)
) {
continue
}
const pointStat = getAnalyticsTableStatForMetric(point.metric_kind)
if (!pointStat || !relevantStats.has(pointStat)) {
@@ -226,12 +294,21 @@ export function buildAnalyticsTableRows({
}
const nextBucketLabel = includeDate ? (bucketLabel ?? getBucketLabel(sliceIndex)) : undefined
const dependentOnProjectId = includeDependentProjectTooltipContext
? point.source_project
: undefined
const dependentTooltipProjectId = selectedBreakdowns.includes('dependent_project_download')
? point.source_project
: undefined
const breakdownKey =
breakdownValues.length === 0
? ALL_PROJECTS_BREAKDOWN_VALUE
: getAnalyticsBreakdownKey(breakdownValues)
const rowId = includeDate ? `${nextBucketLabel?.dateMs ?? 0}::${breakdownKey}` : breakdownKey
const row = nextRows.get(rowId) ?? createRow(rowId, breakdownValues, nextBucketLabel)
const row =
nextRows.get(rowId) ??
createRow(rowId, breakdownValues, dependentOnProjectId, nextBucketLabel)
addDependentOnProjectIdToRow(row, dependentTooltipProjectId)
addAnalyticsMetricToTableRow(row, point)
}
})
@@ -292,11 +369,21 @@ function formatAnalyticsTableBreakdownDisplayValue(
value: string,
breakdown: AnalyticsTableBreakdownPreset,
projectNamesById: ReadonlyMap<string, string>,
userNamesById: ReadonlyMap<string, string>,
getVersionDisplayName: (versionId: string) => string,
formatMessage: FormatMessage,
): string {
if (breakdown === 'project') {
if (breakdown === 'project' || breakdown === 'dependent_project_download') {
if (breakdown === 'dependent_project_download') {
if (isNoDependentAnalyticsBreakdownValue(value)) {
return formatMessage(analyticsMessages.noDependent)
}
if (isUnknownAnalyticsBreakdownValue(value)) {
return formatMessage(analyticsMessages.unknown)
}
}
return projectNamesById.get(value) ?? value
}
return formatBreakdownLabel(value, breakdown, getVersionDisplayName, formatMessage)
return formatBreakdownLabel(value, breakdown, getVersionDisplayName, userNamesById, formatMessage)
}
@@ -3,7 +3,7 @@ import type { TableColumn } from '@modrinth/ui'
import { isAnalyticsTableBreakdownColumnKey } from './analytics-table-columns'
import type { AnalyticsTableColumnKey, AnalyticsTableRow } from './analytics-table-types'
const SEARCHABLE_COLUMN_KEYS = new Set<AnalyticsTableColumnKey>(['date', 'project'])
const SEARCHABLE_COLUMN_KEYS = new Set<AnalyticsTableColumnKey>(['date', 'project', 'dependent_on'])
export function getAnalyticsTableSearchableColumns(
columns: TableColumn<AnalyticsTableColumnKey>[],
@@ -39,6 +39,8 @@ function getAnalyticsTableSearchableCellValue(
return row.date
case 'project':
return row.project
case 'dependent_on':
return row.dependent_on
case 'breakdown':
return row.breakdownDisplay
default:
@@ -129,6 +129,15 @@ function getAnalyticsTableRowComparator(
directionFactor,
sortCollator,
)
case 'dependent_on':
return (left, right) =>
compareAnalyticsTableRows(
left,
right,
sortCollator.compare(left.dependent_on, right.dependent_on),
directionFactor,
sortCollator,
)
case 'breakdown':
return (left, right) =>
compareAnalyticsTableRows(
@@ -18,11 +18,15 @@ export type AnalyticsTableSortState = {
export type AnalyticsTableSortDirectionValue = AnalyticsTableSortDirection
export type AnalyticsTableRow = {
[key: string]: string | number | AnalyticsTableBreakdownDisplayValues
[key: string]: string | number | string[] | AnalyticsTableBreakdownDisplayValues
id: string
date: string
dateMs: number
project: string
projectVersionId: string
dependent_on: string
dependentOnProjectId: string
dependentOnProjectIds: string[]
breakdown: string
breakdownValues: AnalyticsTableBreakdownDisplayValues
breakdownDisplays: AnalyticsTableBreakdownDisplayValues
@@ -55,32 +55,92 @@
<template #cell-date="{ value }">
<span class="text-primary">{{ value }}</span>
</template>
<template #cell-breakdown_project="{ value }">
<span class="text-primary">{{ value }}</span>
<template #cell-breakdown_project="{ row, value }">
<ProjectCell
:label="getProjectCellLabel(value)"
:icon-url="getProjectIconUrl(row.breakdownValues.project)"
:icon-tooltip="getProjectCellLabel(value)"
:label-href="getProjectPageHref(row.breakdownValues.project)"
:organization-href="getProjectOrganizationPageHref(row.breakdownValues.project)"
:organization-tooltip="getProjectOrganizationName(row.breakdownValues.project)"
/>
</template>
<template #cell-breakdown_country="{ value }">
<span class="text-primary">{{ value }}</span>
<span class="mr-2.5 text-primary">{{ value }}</span>
</template>
<template #cell-breakdown_monetization="{ value }">
<span class="text-primary">{{ value }}</span>
<span class="mr-2.5 text-primary">{{ value }}</span>
</template>
<template #cell-breakdown_user_agent="{ value }">
<span class="text-primary">{{ value }}</span>
<span class="mr-2.5 text-primary">{{ value }}</span>
</template>
<template #cell-breakdown_download_reason="{ value }">
<span class="text-primary">{{ value }}</span>
<span class="mr-2.5 text-primary">{{ value }}</span>
</template>
<template #cell-breakdown_version_id="{ value }">
<span class="text-primary">{{ value }}</span>
<template #cell-breakdown_user_id="{ row, value }">
<component
:is="getUserPageHref(row.breakdownValues.user_id) ? 'a' : 'span'"
:href="getUserPageHref(row.breakdownValues.user_id)"
:target="getUserPageHref(row.breakdownValues.user_id) ? '_blank' : undefined"
:rel="getUserPageHref(row.breakdownValues.user_id) ? 'noopener noreferrer' : undefined"
class="mr-2.5 flex min-w-0 items-center gap-2 text-primary"
:class="{ 'hover:underline': getUserPageHref(row.breakdownValues.user_id) }"
>
<span
v-tooltip="getUserCellLabel(value)"
class="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full text-primary"
>
<img
v-if="getUserAvatarUrl(row.breakdownValues.user_id)"
:src="getUserAvatarUrl(row.breakdownValues.user_id)"
:alt="getUserCellLabel(value)"
class="h-6 w-6 rounded-full object-cover"
/>
<UserIcon v-else class="h-full w-full" />
</span>
<span class="min-w-0 truncate font-semibold leading-tight text-primary">
{{ value }}
</span>
</component>
</template>
<template #cell-breakdown_dependent_project_download="{ row, value }">
<ProjectCell
:label="getProjectCellLabel(value)"
:icon-url="
isMissingDependentProjectValue(row.breakdownValues.dependent_project_download)
? undefined
: getProjectIconUrl(row.breakdownValues.dependent_project_download)
"
:icon-tooltip="getProjectCellLabel(value)"
:hide-icon="
isMissingDependentProjectValue(row.breakdownValues.dependent_project_download)
"
:label-href="
isMissingDependentProjectValue(row.breakdownValues.dependent_project_download)
? undefined
: getProjectPageHref(row.breakdownValues.dependent_project_download)
"
:label-tooltip="getDependentProjectTooltip(row)"
/>
</template>
<template #cell-breakdown_version_id="{ row, value }">
<component
:is="getVersionPageHref(row.projectVersionId) ? 'a' : 'span'"
v-tooltip="getVersionProjectName(row.projectVersionId)"
:href="getVersionPageHref(row.projectVersionId)"
:target="getVersionPageHref(row.projectVersionId) ? '_blank' : undefined"
:rel="getVersionPageHref(row.projectVersionId) ? 'noopener noreferrer' : undefined"
class="mr-2.5 text-primary"
:class="{ 'hover:underline': getVersionPageHref(row.projectVersionId) }"
>
{{ value }}
</component>
</template>
<template #cell-breakdown_loader="{ value }">
<span class="text-primary">{{ value }}</span>
<span class="mr-2.5 text-primary">{{ value }}</span>
</template>
<template #cell-breakdown_game_version="{ value }">
<span class="text-primary">{{ value }}</span>
</template>
<template #cell-project="{ value }">
<span class="text-primary">{{ value }}</span>
<span class="mr-2.5 text-primary">{{ value }}</span>
</template>
<template #cell-views="{ row }">
<span>{{ formatInteger(row.views) }}</span>
@@ -130,7 +190,7 @@
</template>
<script setup lang="ts">
import { DownloadIcon, DropdownIcon, SearchIcon } from '@modrinth/assets'
import { DownloadIcon, DropdownIcon, SearchIcon, UserIcon } from '@modrinth/assets'
import {
ButtonStyled,
OverflowMenu,
@@ -159,10 +219,15 @@ import {
} from '../analytics-chart/analytics-chart-utils.ts'
import {
analyticsBreakdownMessages,
analyticsChartMessages,
analyticsMessages,
analyticsTableMessages,
} from '../analytics-messages.ts'
import AnalyticsLoadingBar from '../AnalyticsLoadingBar.vue'
import {
isNoDependentAnalyticsBreakdownValue,
isUnknownAnalyticsBreakdownValue,
} from '../breakdown.ts'
import {
buildAnalyticsTableColumns,
getAnalyticsTableBreakdownColumnLabel,
@@ -194,8 +259,10 @@ import { sortAnalyticsTableRows } from './analytics-table-sorting.ts'
import type {
AnalyticsTableColumnKey,
AnalyticsTableMode,
AnalyticsTableRow,
AnalyticsTableSortDirectionValue,
} from './analytics-table-types.ts'
import ProjectCell from './ProjectCell.vue'
import { useAnalyticsTableGraphSelection } from './use-analytics-table-graph-selection.ts'
import { useAnalyticsTablePagination } from './use-analytics-table-pagination.ts'
import { useAnalyticsTableRowCache } from './use-analytics-table-row-cache.ts'
@@ -221,7 +288,15 @@ const {
getRelevantAnalyticsDashboardStats,
isLoading,
versionNumbersById,
versionProjectIdsById,
versionProjectNamesById,
projectNamesById,
projectIconUrlsById,
projectOrganizationIdsById,
projectOrganizationNamesById,
userNamesById,
userAvatarUrlsById,
dependentProjectTypesById,
getVersionDisplayName,
getVersionProjectName,
} = injectAnalyticsDashboardContext()
@@ -263,11 +338,10 @@ const showGraphDatasetSelection = computed(() =>
? selectedProjectIdSet.value.size > 1
: selectedBreakdowns.value.length > 0,
)
const showProjectVersionProjectColumn = computed(
const includeDependentProjectTooltipContext = computed(
() =>
selectedBreakdownSet.value.has('version_id') &&
!selectedBreakdownSet.value.has('project') &&
selectedProjectIdSet.value.size > 1,
selectedBreakdownSet.value.has('dependent_project_download') &&
!selectedBreakdownSet.value.has('project'),
)
const includeDateColumn = computed(
() =>
@@ -314,9 +388,6 @@ const csvExportOptions = computed<OverflowMenuOption[]>(() => {
},
]
})
const projectNamesById = computed(
() => new Map(projects.value.map((project) => [project.id, project.name])),
)
const hasAvailableProjects = computed(() => projects.value.length > 0)
const analyticsPointCount = computed(() =>
timeSlices.value.reduce((sum, slice) => sum + slice.length, 0),
@@ -360,8 +431,12 @@ function buildTableRows(mode: AnalyticsTableMode) {
timeSlices: timeSlices.value,
selectedBreakdowns: selectedBreakdowns.value,
selectedProjectIds: selectedProjectIdSet.value,
selectedFilters: selectedFilters.value,
dependentProjectTypesById: dependentProjectTypesById.value,
includeDependentProjectTooltipContext: includeDependentProjectTooltipContext.value,
relevantStats: relevantStats.value,
projectNamesById: projectNamesById.value,
userNamesById: userNamesById.value,
getVersionDisplayName,
getVersionProjectName,
showTimeInBucketLabel: showTimeInBucketLabel.value,
@@ -379,12 +454,86 @@ function buildColumns(includeDate: boolean) {
selectedBreakdowns: selectedBreakdowns.value,
selectedFilters: selectedFilters.value,
showBreakdownColumn: showBreakdownColumn.value,
showProjectVersionProjectColumn: showProjectVersionProjectColumn.value,
formatMessage,
getRelevantAnalyticsDashboardStats,
})
}
function getProjectIconUrl(projectId: string | undefined) {
return projectId ? projectIconUrlsById.value.get(projectId) : undefined
}
function getProjectOrganizationName(projectId: string | undefined) {
return projectId ? projectOrganizationNamesById.value.get(projectId) : undefined
}
function getProjectCellLabel(value: unknown) {
return typeof value === 'string' ? value : String(value ?? '')
}
function getUserAvatarUrl(userId: string | undefined) {
return userId ? userAvatarUrlsById.value.get(userId) : undefined
}
function getUserCellLabel(value: unknown) {
return typeof value === 'string' ? value : String(value ?? '')
}
function getUserPageHref(userId: string | undefined) {
if (!userId) return undefined
const username = userNamesById.value.get(userId) ?? userId
return `/user/${encodeURIComponent(username)}`
}
function getProjectPageHref(projectId: string | undefined) {
return projectId ? `/project/${encodeURIComponent(projectId)}` : undefined
}
function getProjectOrganizationPageHref(projectId: string | undefined) {
if (!projectId) return undefined
const organizationId = projectOrganizationIdsById.value.get(projectId)
if (!organizationId) return undefined
return `/organization/${encodeURIComponent(organizationId)}`
}
function getVersionPageHref(versionId: string | undefined) {
if (!versionId) return undefined
const projectId = versionProjectIdsById.value.get(versionId)
if (!projectId) return undefined
return `/project/${encodeURIComponent(projectId)}/version/${encodeURIComponent(versionId)}`
}
function isMissingDependentProjectValue(value: string | undefined) {
return isUnknownAnalyticsBreakdownValue(value) || isNoDependentAnalyticsBreakdownValue(value)
}
function getDependentProjectTooltip(row: AnalyticsTableRow) {
if (isNoDependentAnalyticsBreakdownValue(row.breakdownValues.dependent_project_download)) {
return formatMessage(analyticsMessages.noDependentTooltip)
}
if (isUnknownAnalyticsBreakdownValue(row.breakdownValues.dependent_project_download)) {
return formatMessage(analyticsMessages.unknown)
}
const dependencyProjectIds = new Set(row.dependentOnProjectIds)
if (row.dependentOnProjectId) {
dependencyProjectIds.add(row.dependentOnProjectId)
}
const dependencyProjectNames = [...dependencyProjectIds]
.map((projectId) => projectNamesById.value.get(projectId) ?? projectId)
.sort((left, right) => left.localeCompare(right))
return dependencyProjectNames.length > 0
? formatMessage(analyticsChartMessages.dependentOnProjectTooltip, {
project: dependencyProjectNames.join(', '),
})
: undefined
}
watch(
activeColumns,
(nextColumns) => {
@@ -445,6 +594,9 @@ watch(
selectedBreakdowns,
selectedFilters,
projects,
dependentProjectTypesById,
projectNamesById,
userNamesById,
versionNumbersById,
versionProjectNamesById,
],
@@ -7,6 +7,10 @@ import type {
AnalyticsSelectedBreakdowns,
} from '~/providers/analytics/analytics'
import {
isNoDependentAnalyticsBreakdownValue,
isUnknownAnalyticsBreakdownValue,
} from '../breakdown'
import { getAnalyticsTableMetricSortedGraphDatasetIds } from './analytics-table-sorting'
import type { AnalyticsTableColumnKey, AnalyticsTableRow } from './analytics-table-types'
@@ -58,6 +62,9 @@ export function useAnalyticsTableGraphSelection({
const filteredSelectableGraphDatasetIds = computed(() =>
getAnalyticsTableSelectableGraphDatasetIds(filteredRows.value),
)
const excludedGraphDatasetIds = computed(() =>
getAnalyticsTableExcludedGraphDatasetIds(sortedRows.value),
)
const sortedMetricGraphDatasetIds = computed(() =>
getAnalyticsTableMetricSortedGraphDatasetIds(sortedRows.value, sortColumn.value, sortCollator),
)
@@ -65,7 +72,9 @@ export function useAnalyticsTableGraphSelection({
const sortedMetricIds = sortedMetricGraphDatasetIds.value
const defaultIds =
sortedMetricIds.length > 0 ? sortedMetricIds : selectableGraphDatasetIds.value
return defaultIds.slice(0, graphDatasetSelectionLimit)
return defaultIds
.filter((id) => !excludedGraphDatasetIds.value.has(id))
.slice(0, graphDatasetSelectionLimit)
})
const tableSelectedGraphDatasetIds = computed<unknown[]>({
get: () => selectedGraphDatasetIds.value,
@@ -149,7 +158,9 @@ export function useAnalyticsTableGraphSelection({
defaultGraphDatasetIds.value = nextShowGraphDatasetSelection
? [...nextDefaultGraphDatasetIds]
: []
topGraphDatasetIds.value = nextShowGraphDatasetSelection ? [...nextTopGraphDatasetIds] : []
topGraphDatasetIds.value = nextShowGraphDatasetSelection
? nextTopGraphDatasetIds.filter((id) => !excludedGraphDatasetIds.value.has(id))
: []
},
{ immediate: true },
)
@@ -184,6 +195,20 @@ export function useAnalyticsTableGraphSelection({
return Array.from(new Set(rows.map((row) => row.graphDatasetId)))
}
function getAnalyticsTableExcludedGraphDatasetIds(rows: AnalyticsTableRow[]): Set<string> {
return new Set(
rows
.filter((row) =>
Object.values(row.breakdownValues).some(
(value) =>
isUnknownAnalyticsBreakdownValue(value) ||
isNoDependentAnalyticsBreakdownValue(value),
),
)
.map((row) => row.graphDatasetId),
)
}
return {
filteredSelectableGraphDatasetIds,
tableSelectedGraphDatasetIds,
@@ -6,6 +6,7 @@ import { formatAnalyticsDownloadSourceLabel, type FormatMessage } from './analyt
export const ALL_BREAKDOWN_VALUE = '__all__'
export const UNKNOWN_BREAKDOWN_VALUE = '__unknown__'
export const NO_DEPENDENT_BREAKDOWN_VALUE = '__no_dependent__'
export const COMBINED_BREAKDOWN_LABEL_SEPARATOR = ' + '
export const COMBINED_BREAKDOWN_DATASET_ID_PREFIX = 'breakdowns:'
@@ -41,6 +42,19 @@ export function getAnalyticsBreakdownValue(
'reason' in point ? point.reason : undefined,
UNKNOWN_BREAKDOWN_VALUE,
)
case 'user_id':
return normalizeBreakdownValue('user_id' in point ? point.user_id : undefined)
case 'dependent_project_download': {
const dependentProjectId = normalizeBreakdownValue(
'dependent_project_id' in point ? point.dependent_project_id : undefined,
UNKNOWN_BREAKDOWN_VALUE,
)
const downloadReason = 'reason' in point ? point.reason?.trim().toLowerCase() : undefined
return dependentProjectId === UNKNOWN_BREAKDOWN_VALUE &&
(downloadReason === 'standalone' || downloadReason === 'update')
? NO_DEPENDENT_BREAKDOWN_VALUE
: dependentProjectId
}
case 'version_id':
return normalizeBreakdownValue('version_id' in point ? point.version_id : undefined)
case 'loader':
@@ -94,16 +108,30 @@ export function getDownloadSourceLabel(value: string, formatMessage: FormatMessa
return formatAnalyticsDownloadSourceLabel(value, formatMessage)
}
export function isUnknownAnalyticsBreakdownValue(value: string | null | undefined): boolean {
const normalized = value?.trim()
if (!normalized) {
return false
}
const normalizedLowercase = normalized.toLowerCase()
return (
normalized === UNKNOWN_BREAKDOWN_VALUE ||
normalizedLowercase === 'unknown' ||
normalizedLowercase === 'other'
)
}
export function isNoDependentAnalyticsBreakdownValue(value: string | null | undefined): boolean {
return value?.trim() === NO_DEPENDENT_BREAKDOWN_VALUE
}
function normalizeBreakdownValue(
value: string | undefined,
fallback = ALL_BREAKDOWN_VALUE,
): string {
const normalized = value?.trim()
const normalizedLowercase = normalized?.toLowerCase()
if (
fallback === UNKNOWN_BREAKDOWN_VALUE &&
(normalizedLowercase === 'unknown' || normalizedLowercase === 'other')
) {
if (fallback === UNKNOWN_BREAKDOWN_VALUE && isUnknownAnalyticsBreakdownValue(normalized)) {
return fallback
}
return normalized && normalized.length > 0 ? normalized : fallback
@@ -25,10 +25,25 @@
<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-if="category.key === 'user_id'"
v-tooltip="option.label"
class="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full text-primary"
:class="selected ? 'text-contrast' : 'text-primary'"
>
<img
v-if="getUserAvatarUrl(option.value)"
:src="getUserAvatarUrl(option.value)"
:alt="option.label"
class="h-6 w-6 rounded-full object-cover"
/>
<UserIcon v-else class="h-full w-full" />
</span>
<template
v-for="metadata in getFilterOptionProjectMetadata(category.key, option.value)"
:key="`${category.key}-${option.value}-${metadata.name}`"
>
<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"
>
@@ -42,6 +57,8 @@
</span>
</template>
<span
:ref="(element) => setFilterOptionLabelRef(category.key, option.value, element)"
v-tooltip="getFilterOptionLabelTooltip(category.key, option.value, option.label)"
class="min-w-0 truncate font-semibold leading-tight"
:class="selected ? 'text-contrast' : 'text-primary'"
>
@@ -179,16 +196,24 @@
</template>
<script setup lang="ts">
import { BoxIcon } from '@modrinth/assets'
import { BoxIcon, UserIcon } from '@modrinth/assets'
import {
buildDependentsSearchFilters,
DropdownFilterBar,
type DropdownFilterBarCategory,
type DropdownFilterBarOption,
injectModrinthClient,
injectNotificationManager,
type ProjectType,
Tabs,
type TabsTab,
type TabsValue,
truncatedTooltip,
useVIntl,
} from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import type { ComponentPublicInstance } from 'vue'
import { useFormattedCountries } from '@/composables/country.ts'
import {
@@ -258,6 +283,8 @@ const props = withDefaults(
)
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const {
hasProjectContext,
projects,
@@ -277,6 +304,9 @@ const {
versionPublishedDatesById,
versionProjectNamesById,
versionProjectIconUrlsById,
projectNamesById,
userNamesById,
userAvatarUrlsById,
getVersionDisplayName,
} = injectAnalyticsDashboardContext()
const formattedCountries = useFormattedCountries()
@@ -290,6 +320,17 @@ const gameVersionTypeTabs = computed<TabsTab[]>(() => [
{ value: 'release', label: formatMessage(analyticsMessages.releaseTab) },
{ value: 'all', label: formatMessage(analyticsMessages.allTab) },
])
const dependentProjectSearchProjectTypes: readonly ProjectType[] = [
'mod',
'modpack',
'resourcepack',
'shader',
'datapack',
'plugin',
]
const dependentProjectInitialSearchLimit = 100
const dependentProjectQuerySearchLimit = 500
const dependentProjectSearchDebounceMs = 250
const resolvedAddLabel = computed(
() => props.addLabel ?? formatMessage(analyticsMessages.addButton),
)
@@ -306,14 +347,16 @@ const projectStatusFilterOptions = computed<DropdownFilterBarOption[]>(() =>
})),
)
const selectedProjectIdSet = computed(() => new Set(selectedProjectIds.value))
const effectiveSelectedProjectCount = computed(
() =>
projects.value.filter(
const effectiveSelectedProjectIds = computed(() =>
projects.value
.filter(
(project) =>
selectedProjectIdSet.value.has(project.id) &&
doesProjectStatusMatchFilters(project.status, selectedFilters.value),
).length,
)
.map((project) => project.id),
)
const effectiveSelectedProjectCount = computed(() => effectiveSelectedProjectIds.value.length)
const showProjectVersionProjectIcons = computed(() => effectiveSelectedProjectCount.value > 1)
const defaultSelectedBreakdown = computed(() =>
getDefaultAnalyticsBreakdownPresets(selectedProjectIds.value),
@@ -330,12 +373,72 @@ const projectVersionFilterOptions = shallowRef<ProjectVersionFilterOption[]>([])
const projectVersionFilterOptionProjectMetadataById = shallowRef(
new Map<string, ProjectVersionFilterOptionProjectMetadata[]>(),
)
const dependentProjectSearchInput = ref('')
const dependentProjectSearchQuery = ref('')
const draftSelectedFilters = ref<AnalyticsSelectedFilters>(
cloneSelectedFilters(selectedFilters.value),
)
let selectedFiltersCommitRequestId = 0
let projectVersionFilterOptionsCacheKey = ''
let projectVersionFilterOptionProjectMetadataCacheKey = ''
let dependentProjectSearchDebounceTimeout: ReturnType<typeof setTimeout> | null = null
const filterOptionLabelElements = new Map<string, HTMLElement>()
const filterOptionLabelRefUpdateToken = ref(0)
const dependentProjectSearchFilters = computed(() =>
buildDependentsSearchFilters(
dependentProjectSearchProjectTypes,
effectiveSelectedProjectIds.value,
),
)
const dependentProjectSearchQueryValue = computed(() => dependentProjectSearchQuery.value.trim())
const dependentProjectSearchResultLimit = computed(() =>
dependentProjectSearchQueryValue.value.length > 0
? dependentProjectQuerySearchLimit
: dependentProjectInitialSearchLimit,
)
const selectedDependentProjectIds = computed(() => [
...new Set([
...selectedFilters.value.dependent_project_id,
...draftSelectedFilters.value.dependent_project_id,
]),
])
const { data: dependentProjectSearchResults, error: dependentProjectSearchError } = useQuery({
queryKey: computed(() => [
'analytics',
'query-filter',
'dependent-project-search',
dependentProjectSearchFilters.value,
dependentProjectSearchQueryValue.value,
dependentProjectSearchResultLimit.value,
]),
queryFn: () =>
client.labrinth.projects_v2.search({
query: dependentProjectSearchQueryValue.value || undefined,
new_filters: dependentProjectSearchFilters.value,
limit: dependentProjectSearchResultLimit.value,
index: 'downloads',
}),
enabled: computed(
() =>
effectiveSelectedProjectIds.value.length > 0 &&
dependentProjectSearchFilters.value.length > 0,
),
placeholderData: (previousData) => previousData,
refetchOnWindowFocus: false,
})
const { data: selectedDependentProjects } = useQuery({
queryKey: computed(() => [
'analytics',
'query-filter',
'selected-dependent-projects',
selectedDependentProjectIds.value,
]),
queryFn: () => client.labrinth.projects_v2.getMultiple(selectedDependentProjectIds.value),
enabled: computed(() => selectedDependentProjectIds.value.length > 0),
placeholderData: [],
refetchOnWindowFocus: false,
})
const selectedFilterValue = computed<Record<string, string[]>>({
get: () => getSelectedFilterBarValue(),
@@ -374,6 +477,27 @@ watch(queryResetToken, () => {
clearDownloadsThresholds()
})
watch(dependentProjectSearchInput, (query) => {
if (dependentProjectSearchDebounceTimeout) {
clearTimeout(dependentProjectSearchDebounceTimeout)
}
dependentProjectSearchDebounceTimeout = setTimeout(() => {
dependentProjectSearchQuery.value = query.trim()
dependentProjectSearchDebounceTimeout = null
}, dependentProjectSearchDebounceMs)
})
watch(dependentProjectSearchError, (error) => {
if (!error) return
addNotification({
title: 'Dependent projects failed to load',
text: getDependentProjectSearchErrorMessage(error),
type: 'error',
})
})
watch(
selectedFilters,
(nextFilters, previousFilters) => {
@@ -475,6 +599,12 @@ watch(
{ immediate: true },
)
onBeforeUnmount(() => {
if (dependentProjectSearchDebounceTimeout) {
clearTimeout(dependentProjectSearchDebounceTimeout)
}
})
async function scheduleSelectedFiltersCommit() {
const requestId = ++selectedFiltersCommitRequestId
const nextFilters = cloneSelectedFilters(draftSelectedFilters.value)
@@ -577,6 +707,40 @@ const filterCategories = computed<DropdownFilterBarCategory[]>(() => {
label: formatMessage(analyticsBreakdownMessages.loader),
options: withSelectedOptions('loader_type', loaderTypeFilterOptions.value),
},
{
key: 'dependent_project_id',
label: formatMessage(analyticsBreakdownMessages.dependentProjectDownload),
searchable: true,
disableLocalOptionsFilter: true,
searchPlaceholder: formatMessage(analyticsMessages.searchDependentProjectsPlaceholder),
emptyOptionsLabel: analyticsFilterOptionsEmptyLabel.value,
emptySearchLabel: analyticsFilterOptionsEmptyLabel.value,
onSearchQueryChange: setDependentProjectSearchInput,
options: withSelectedOptions('dependent_project_id', dependentProjectFilterOptions.value),
submenuClass: 'w-fit min-w-[18rem]',
previewDropdownWidth: 'fit-content',
previewDropdownMinWidth: '18rem',
},
{
key: 'dependent_project_type',
label: formatMessage(analyticsBreakdownMessages.dependentProjectType),
options: withSelectedOptions(
'dependent_project_type',
dependentProjectTypeFilterOptions.value,
),
},
{
key: 'user_id',
label: formatMessage(analyticsBreakdownMessages.members),
searchable: memberFilterOptions.value.length > 6,
searchPlaceholder: formatMessage(analyticsMessages.searchMembersPlaceholder),
emptyOptionsLabel: analyticsFilterOptionsEmptyLabel.value,
emptySearchLabel: analyticsFilterOptionsEmptyLabel.value,
options: withSelectedOptions('user_id', memberFilterOptions.value),
submenuClass: 'w-fit min-w-[14rem]',
previewDropdownWidth: 'fit-content',
previewDropdownMinWidth: '14rem',
},
)
return categories.filter((category) =>
@@ -636,6 +800,16 @@ const downloadReasonFilterOptions = computed<DropdownFilterBarOption[]>(() =>
})),
)
const memberFilterOptions = computed<DropdownFilterBarOption[]>(() =>
filterOptions.value.userIds
.map((userId) => ({
value: userId,
label: getUserFilterOptionLabel(userId),
searchTerms: [userId],
}))
.sort((left, right) => left.label.localeCompare(right.label)),
)
const gameVersionFilterOptions = computed<DropdownFilterBarOption[]>(() =>
filterOptions.value.gameVersions
.filter((gameVersion) => {
@@ -668,6 +842,45 @@ const loaderTypeFilterOptions = computed<DropdownFilterBarOption[]>(() =>
.sort((left, right) => left.label.localeCompare(right.label)),
)
const dependentProjectSearchResultsById = computed(
() =>
new Map(
(dependentProjectSearchResults.value?.hits ?? []).map((project) => [
project.project_id,
project,
]),
),
)
const selectedDependentProjectsById = computed(
() => new Map((selectedDependentProjects.value ?? []).map((project) => [project.id, project])),
)
const dependentProjectFilterOptions = computed<DropdownFilterBarOption[]>(() => {
const optionsById = new Map<string, DropdownFilterBarOption>()
for (const project of dependentProjectSearchResults.value?.hits ?? []) {
optionsById.set(project.project_id, {
value: project.project_id,
label: project.title,
searchTerms: [project.project_id, project.slug, project.author].filter(
(term): term is string => Boolean(term),
),
})
}
return [...optionsById.values()]
})
const dependentProjectTypeFilterOptions = computed<DropdownFilterBarOption[]>(() =>
filterOptions.value.dependentProjectTypes
.map((projectType) => ({
value: projectType,
label: getProjectTypeFilterOptionLabel(projectType),
searchTerms: [projectType],
}))
.sort((left, right) => left.label.localeCompare(right.label)),
)
function isAnalyticsFilterValueCategory(
categoryKey: string,
): categoryKey is AnalyticsFilterValueCategory {
@@ -697,15 +910,77 @@ function getMissingSelectedOptionLabel(
if (categoryKey === 'download_reason') {
return getDownloadReasonFilterOptionLabel
}
if (categoryKey === 'user_id') {
return getUserFilterOptionLabel
}
if (categoryKey === 'user_agent') {
return (value) => getDownloadSourceLabel(value, formatMessage)
}
if (categoryKey === 'loader_type') {
return getLoaderTypeFilterOptionLabel
}
if (categoryKey === 'dependent_project_id') {
return getDependentProjectFilterOptionLabel
}
if (categoryKey === 'dependent_project_type') {
return getProjectTypeFilterOptionLabel
}
return undefined
}
function getUserAvatarUrl(userId: string): string | undefined {
return userAvatarUrlsById.value.get(userId)
}
function setFilterOptionLabelRef(
categoryKey: string,
optionValue: string,
element: Element | ComponentPublicInstance | null,
) {
const key = getFilterOptionLabelElementKey(categoryKey, optionValue)
if (element instanceof HTMLElement) {
if (filterOptionLabelElements.get(key) === element) {
return
}
filterOptionLabelElements.set(key, element)
filterOptionLabelRefUpdateToken.value++
return
}
if (filterOptionLabelElements.delete(key)) {
filterOptionLabelRefUpdateToken.value++
}
}
function getFilterOptionLabelTooltip(
categoryKey: string,
optionValue: string,
label: string,
): string | undefined {
void filterOptionLabelRefUpdateToken.value
return truncatedTooltip(
filterOptionLabelElements.get(getFilterOptionLabelElementKey(categoryKey, optionValue)),
label,
)
}
function getFilterOptionLabelElementKey(categoryKey: string, optionValue: string) {
return `${categoryKey}\x1f${optionValue}`
}
function getFilterOptionProjectMetadata(categoryKey: string, optionValue: string) {
if (categoryKey === 'version_id') {
return getProjectVersionOptionProjectMetadata(optionValue)
}
if (categoryKey === 'dependent_project_id') {
return getDependentProjectOptionProjectMetadata(optionValue)
}
return []
}
function getProjectVersionOptionProjectMetadata(versionId: string) {
if (!showProjectVersionProjectIcons.value) {
return []
@@ -714,6 +989,24 @@ function getProjectVersionOptionProjectMetadata(versionId: string) {
return projectVersionFilterOptionProjectMetadataById.value.get(versionId) ?? []
}
function getDependentProjectOptionProjectMetadata(projectId: string) {
const searchResult = dependentProjectSearchResultsById.value.get(projectId)
const selectedProject = selectedDependentProjectsById.value.get(projectId)
const name =
searchResult?.title ??
selectedProject?.title ??
projectNamesById.value.get(projectId) ??
projectId
const iconUrl = searchResult?.icon_url ?? selectedProject?.icon_url
return [
{
name,
...(iconUrl ? { iconUrl } : {}),
},
]
}
function getCountryFilterOptionLabel(countryCode: string): string {
const normalizedCode = countryCode.trim().toUpperCase()
if (normalizedCode === 'XX') {
@@ -731,10 +1024,51 @@ function getLoaderTypeFilterOptionLabel(loaderType: string): string {
return formatAnalyticsLoaderLabel(loaderType, formatMessage)
}
function getProjectTypeFilterOptionLabel(projectType: string): string {
return formatProjectType(projectType)
}
function getDependentProjectFilterOptionLabel(projectId: string): string {
return (
dependentProjectSearchResultsById.value.get(projectId)?.title ??
selectedDependentProjectsById.value.get(projectId)?.title ??
projectNamesById.value.get(projectId) ??
projectId
)
}
function getDownloadReasonFilterOptionLabel(reason: string): string {
return formatAnalyticsDownloadReasonLabel(reason, formatMessage)
}
function getUserFilterOptionLabel(userId: string): string {
return userNamesById.value.get(userId) ?? userId
}
function setDependentProjectSearchInput(query: string) {
dependentProjectSearchInput.value = query
}
function getDependentProjectSearchErrorMessage(error: unknown): string {
if (error && typeof error === 'object') {
const dataDescription = (error as { data?: { description?: unknown } }).data?.description
if (typeof dataDescription === 'string' && dataDescription.length > 0) {
return dataDescription
}
const message = (error as { message?: unknown }).message
if (typeof message === 'string' && message.length > 0) {
return message
}
}
if (typeof error === 'string' && error.length > 0) {
return error
}
return 'Please try searching again or changing the selected projects.'
}
function getDateTimestamp(date: string | undefined): number | undefined {
if (!date) {
return undefined
@@ -79,7 +79,7 @@
@keydown.enter.stop
@keydown.space.stop
>
<LayersIcon
<UserIcon
class="h-5 w-5 shrink-0 text-primary"
:class="isUserProjectsOptionSelected ? 'text-contrast' : 'text-primary'"
/>
@@ -290,7 +290,7 @@
@keydown.enter.stop
@keydown.space.stop
>
<LayersIcon
<UserIcon
class="h-5 w-5 shrink-0 text-primary"
:class="isUserProjectsOptionSelected ? 'text-contrast' : 'text-primary'"
/>
@@ -449,6 +449,7 @@ import {
ClockIcon,
FolderOpenIcon,
LayersIcon,
UserIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
@@ -499,7 +500,7 @@ import {
import TimeFramePicker from './TimeframePicker.vue'
const QUERY_BUILDER_DROPDOWN_MAX_HEIGHT = 500
const QUERY_BUILDER_DROPDOWN_MIN_WIDTH = '12rem'
const QUERY_BUILDER_DROPDOWN_MIN_WIDTH = '14rem'
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' })
@@ -1008,15 +1009,34 @@ const breakdownOptions = computed<MultiSelectOption<Exclude<AnalyticsBreakdownPr
value: 'game_version',
label: formatAnalyticsBreakdownLabel('game_version', formatMessage),
},
{
value: 'dependent_project_download',
label: formatAnalyticsBreakdownLabel('dependent_project_download', formatMessage),
},
{
value: 'user_id',
label: formatAnalyticsBreakdownLabel('user_id', formatMessage),
},
)
return options.map((option) => ({
...option,
disabled: hasReachedBreakdownLimit && !selectedBreakdownSet.has(option.value),
}))
return options.map((option) => {
const isSelected = selectedBreakdownSet.has(option.value)
return {
...option,
disabled:
!isSelected && (hasReachedBreakdownLimit || !canSelectBreakdownOption(option.value)),
}
})
},
)
function canSelectBreakdownOption(breakdown: Exclude<AnalyticsBreakdownPreset, 'none'>): boolean {
return getAnalyticsBreakdownPresetsForProjectSelection(
[...selectedBreakdownValue.value, breakdown],
selectedProjectIds.value,
).includes(breakdown)
}
function getBreakdownOptionLabel(breakdown: Exclude<AnalyticsBreakdownPreset, 'none'>): string {
return (
breakdownOptions.value.find((option) => option.value === breakdown)?.label ??
@@ -1136,6 +1156,17 @@ function withBreakdownFields(
downloads.push('reason')
}
break
case 'user_id':
if (includesStat(breakdownStats, 'revenue') && includesStat(enabledStats, 'revenue')) {
revenue.push('user_id')
}
break
case 'dependent_project_download':
if (includesStat(breakdownStats, 'downloads') && includesStat(enabledStats, 'downloads')) {
downloads.push('dependent_project_id')
downloads.push('reason')
}
break
case 'version_id':
if (includesStat(breakdownStats, 'downloads') && includesStat(enabledStats, 'downloads')) {
downloads.push('version_id')
@@ -1264,6 +1295,12 @@ function buildMetricFilters(
filters.game_version,
),
loader: getFilterValuesForStat('loader_type', 'downloads', enabledStats, filters.loader_type),
dependent_project_id: getFilterValuesForStat(
'dependent_project_id',
'downloads',
enabledStats,
filters.dependent_project_id,
),
},
playtime: {
country: getFilterValuesForStat('country', 'playtime', enabledStats, filters.country),
@@ -1281,7 +1318,9 @@ function buildMetricFilters(
),
loader: getFilterValuesForStat('loader_type', 'playtime', enabledStats, filters.loader_type),
},
revenue: {},
revenue: {
user_id: getFilterValuesForStat('user_id', 'revenue', enabledStats, filters.user_id),
},
}
}
@@ -1295,6 +1334,15 @@ const fetchRequest = computed<Labrinth.Analytics.v3.FetchRequest>(() => {
const bucketBy = withBreakdownFields(selectedBreakdowns.value, selectedFilters.value)
const filterBy = buildMetricFilters(selectedBreakdowns.value, selectedFilters.value)
if (
includesStat(
getEnabledAnalyticsStatsForState(selectedBreakdowns.value, selectedFilters.value),
'revenue',
) &&
!bucketBy.revenue.includes('user_id')
) {
bucketBy.revenue.push('user_id')
}
const filteredProjectIds = getProjectIdsMatchingStatusFilter(
selectedProjectIds.value,
projectStatusById.value,
@@ -2,6 +2,7 @@ import type {
AnalyticsBreakdownPreset,
AnalyticsDashboardStat,
AnalyticsQueryFilterCategory,
AnalyticsSelectedBreakdowns,
AnalyticsSelectedFilters,
} from '~/providers/analytics/analytics'
@@ -13,6 +14,10 @@ export type AnalyticsDashboardDimension =
| 'monetization'
| 'user_agent'
| 'download_reason'
| 'user_id'
| 'dependent_project_download'
| 'dependent_project_id'
| 'dependent_project_type'
| 'game_version'
| 'loader_type'
@@ -23,9 +28,12 @@ export const FILTER_VALUE_CATEGORIES: Exclude<AnalyticsQueryFilterCategory, 'pro
'monetization',
'user_agent',
'download_reason',
'user_id',
'version_id',
'game_version',
'loader_type',
'dependent_project_id',
'dependent_project_type',
]
const ANALYTICS_DASHBOARD_STAT_ORDER: AnalyticsDashboardStat[] = [
@@ -45,8 +53,12 @@ const ANALYTICS_STATS_BY_DIMENSION: Record<
monetization: ['views', 'downloads'],
user_agent: ['downloads'],
download_reason: ['downloads'],
user_id: ['revenue'],
dependent_project_download: ['downloads'],
dependent_project_type: ['downloads'],
game_version: ['downloads', 'playtime'],
loader_type: ['downloads', 'playtime'],
dependent_project_id: ['downloads'],
project_status: ANALYTICS_DASHBOARD_STAT_ORDER,
}
@@ -60,6 +72,8 @@ const ANALYTICS_DIMENSION_BY_BREAKDOWN: Record<
monetization: 'monetization',
user_agent: 'user_agent',
download_reason: 'download_reason',
user_id: 'user_id',
dependent_project_download: 'dependent_project_download',
version_id: 'version_id',
loader: 'loader_type',
game_version: 'game_version',
@@ -74,9 +88,12 @@ const ANALYTICS_DIMENSION_BY_FILTER_CATEGORY: Record<
monetization: 'monetization',
user_agent: 'user_agent',
download_reason: 'download_reason',
user_id: 'user_id',
version_id: 'version_id',
game_version: 'game_version',
loader_type: 'loader_type',
dependent_project_id: 'dependent_project_id',
dependent_project_type: 'dependent_project_type',
}
const ANALYTICS_FILTER_CATEGORY_BY_BREAKDOWN: Record<
@@ -89,6 +106,8 @@ const ANALYTICS_FILTER_CATEGORY_BY_BREAKDOWN: Record<
monetization: 'monetization',
user_agent: 'user_agent',
download_reason: 'download_reason',
user_id: 'user_id',
dependent_project_download: null,
version_id: 'version_id',
loader: 'loader_type',
game_version: 'game_version',
@@ -286,6 +305,33 @@ export function getAnalyticsStatsForBreakdowns(
return stats
}
export function getAnalyticsBreakdownsWithSharedStats(
breakdowns: AnalyticsBreakdownInput,
): AnalyticsSelectedBreakdowns {
const normalizedBreakdowns = normalizeAnalyticsBreakdowns(breakdowns)
const compatibleBreakdowns: AnalyticsSelectedBreakdowns = []
let sharedStats: readonly AnalyticsDashboardStat[] | null = null
for (const breakdown of normalizedBreakdowns) {
const breakdownStats = getAnalyticsStatsForBreakdown(breakdown)
if (sharedStats === null) {
compatibleBreakdowns.push(breakdown)
sharedStats = breakdownStats
continue
}
const nextSharedStats = intersectAnalyticsStats(sharedStats, breakdownStats)
if (nextSharedStats.length === 0) {
continue
}
compatibleBreakdowns.push(breakdown)
sharedStats = nextSharedStats
}
return compatibleBreakdowns
}
export function getAnalyticsStatsForFilterCategory(
category: AnalyticsQueryFilterCategory,
): readonly AnalyticsDashboardStat[] {
@@ -331,12 +377,20 @@ export function getVisibleAnalyticsFilterCategoriesForState(
breakdowns: AnalyticsBreakdownInput,
filters: AnalyticsSelectedFilters,
): readonly Exclude<AnalyticsQueryFilterCategory, 'project'>[] {
return FILTER_VALUE_CATEGORIES.filter((category) =>
haveAnalyticsStatOverlap(
const normalizedBreakdowns = normalizeAnalyticsBreakdowns(breakdowns)
return FILTER_VALUE_CATEGORIES.filter((category) => {
if (
category === 'dependent_project_type' &&
!normalizedBreakdowns.includes('dependent_project_download')
) {
return false
}
return haveAnalyticsStatOverlap(
getAnalyticsStatsForFilterScope(breakdowns, filters, category),
getAnalyticsStatsForFilterCategory(category),
),
)
)
})
}
export function sanitizeAnalyticsSelectedFilters(
@@ -345,6 +399,7 @@ export function sanitizeAnalyticsSelectedFilters(
): AnalyticsSelectedFilters {
const nextFilters = cloneSelectedFilters(filters)
let availableStats = [...getAnalyticsStatsForBreakdowns(breakdowns)]
const normalizedBreakdowns = normalizeAnalyticsBreakdowns(breakdowns)
for (const category of FILTER_VALUE_CATEGORIES) {
if (filters[category].length === 0) {
@@ -352,7 +407,11 @@ export function sanitizeAnalyticsSelectedFilters(
}
const categoryStats = getAnalyticsStatsForFilterCategory(category)
if (!haveAnalyticsStatOverlap(availableStats, categoryStats)) {
if (
!haveAnalyticsStatOverlap(availableStats, categoryStats) ||
(category === 'dependent_project_type' &&
!normalizedBreakdowns.includes('dependent_project_download'))
) {
nextFilters[category] = []
continue
}
@@ -371,9 +430,12 @@ export function cloneSelectedFilters(filters: AnalyticsSelectedFilters): Analyti
monetization: [...filters.monetization],
user_agent: [...filters.user_agent],
download_reason: [...filters.download_reason],
user_id: [...filters.user_id],
version_id: [...filters.version_id],
game_version: [...filters.game_version],
loader_type: [...filters.loader_type],
dependent_project_id: [...filters.dependent_project_id],
dependent_project_type: [...filters.dependent_project_type],
}
}
@@ -456,7 +518,7 @@ export function normalizeSelectedValues(
.map((value) => value.trim().toLowerCase())
.filter(isProjectStatusFilterValue)
}
if (categoryKey === 'loader_type') {
if (categoryKey === 'loader_type' || categoryKey === 'dependent_project_type') {
return Array.from(
new Set(
selectedValues
@@ -6,6 +6,7 @@ import {
areStringArraysEqual,
buildAnalyticsQueryBuilderRouteQuery,
getAnalyticsBreakdownPresetsForProjectSelection,
hasAnalyticsAllProjectSelectionQuery,
hasAnalyticsQueryBuilderRouteChange,
readAnalyticsGraphState,
readAnalyticsQueryBuilderState,
@@ -54,6 +55,7 @@ export interface UseAnalyticsRouteSyncOptions {
graph: AnalyticsGraphRefs
availableProjectIds: Ref<string[]>
defaultProjectIds: Ref<string[]>
areProjectsLoaded: Ref<boolean>
sanitizeSelectedFilters: (
breakdowns: readonly AnalyticsBreakdownPreset[],
filters: AnalyticsSelectedFilters,
@@ -61,8 +63,14 @@ export interface UseAnalyticsRouteSyncOptions {
}
export function useAnalyticsRouteSync(options: UseAnalyticsRouteSyncOptions) {
const { queryBuilder, graph, availableProjectIds, defaultProjectIds, sanitizeSelectedFilters } =
options
const {
queryBuilder,
graph,
availableProjectIds,
defaultProjectIds,
areProjectsLoaded,
sanitizeSelectedFilters,
} = options
const route = useRoute()
const router = useRouter()
@@ -113,6 +121,10 @@ export function useAnalyticsRouteSync(options: UseAnalyticsRouteSyncOptions) {
return
}
if (hasAnalyticsAllProjectSelectionQuery(route.query) && !areProjectsLoaded.value) {
return
}
const nextRouteQuery = buildAnalyticsQueryBuilderRouteQuery(
route.query,
getSelectedAnalyticsQueryBuilderState(),
@@ -147,6 +159,10 @@ export function useAnalyticsRouteSync(options: UseAnalyticsRouteSyncOptions) {
}
function applyRouteQueryToState(nextQuery: LocationQuery) {
if (hasAnalyticsAllProjectSelectionQuery(nextQuery) && !areProjectsLoaded.value) {
return
}
const nextQueryState = readAnalyticsQueryBuilderState(
nextQuery,
availableProjectIds.value,
@@ -134,18 +134,6 @@
"analytics.chart.render-limit.header": {
"message": "Alle {count} Linien im Diagramm anzeigen?"
},
"analytics.chart.table-selection.all": {
"message": "Zeige alle {itemType, select, project {{count, plural, one {Projekt} other {Projekte}}} country {{count, plural, one {Land} other {Länder}}} monetization {{count, plural, one {Monetarisierungswert} other {Monetarisierungswerte}}} downloadSource {{count, plural, one {Download-Quelle} other {Download-Quellen}}} downloadReason {{count, plural, one {Download-Grund} other {Download-Gründe}}} projectVersion {{count, plural, one {Projektversion} other {Projektversionen}}} loader {{count, plural, one {Loader} other {Loader}}} gameVersion {{count, plural, one {Spielversion} other {Spielversionen}}} other {{count, plural, one {Element} other {Elemente}}}} aus der Tabelle an"
},
"analytics.chart.table-selection.count": {
"message": "Zeige {count} {itemType, select, project {{count, plural, one {Projekt} other {Projekte}}} country {{count, plural, one {Land} other {Länder}}} monetization {{count, plural, one {Monetarisierungswert} other {Monetarisierungswerte}}} downloadSource {{count, plural, one {Download-Quelle} other {Download-Quellen}}} downloadReason {{count, plural, one {Download-Grund} other {Download-Gründe}}} projectVersion {{count, plural, one {Projektversion} other {Projektversionen}}} loader {{count, plural, one {Loader} other {Loader}}} gameVersion {{count, plural, one {Spielversion} other {Spielversionen}}} other {{count, plural, one {Element} other {Elemente}}}} aus der Tabelle an"
},
"analytics.chart.table-selection.limited": {
"message": "Zeige {limit} {itemType, select, project {{limit, plural, one {Projekt} other {Projekte}}} country {{limit, plural, one {Land} other {Länder}}} monetization {{limit, plural, one {Monetarisierungswert} other {Monetarisierungswerte}}} downloadSource {{limit, plural, one {Download-Quelle} other {Download-Quellen}}} downloadReason {{limit, plural, one {Download-Grund} other {Download-Gründe}}} projectVersion {{limit, plural, one {Projektversion} other {Projektversionen}}} loader {{limit, plural, one {Loader} other {Loader}}} gameVersion {{limit, plural, one {Spielversion} other {Spielversionen}}} other {{limit, plural, one {Element} other {Elemente}}}} aus der Tabelle an"
},
"analytics.chart.table-selection.top": {
"message": "Zeige die Top {count} {itemType, select, project {{count, plural, one {Projekt} other {Projekte}}} country {{count, plural, one {Land} other {Länder}}} monetization {{count, plural, one {Monetarisierungswert} other {Monetarisierungswerte}}} downloadSource {{count, plural, one {Download-Quelle} other {Download-Quellen}}} downloadReason {{count, plural, one {Download-Grund} other {Download-Gründe}}} projectVersion {{count, plural, one {Projektversion} other {Projektversionen}}} loader {{count, plural, one {Loader} other {Loader}}} gameVersion {{count, plural, one {Spielversion} other {Spielversionen}}} other {{count, plural, one {Element} other {Elemente}}}} aus der Tabelle an"
},
"analytics.chart.tooltip.duration.days": {
"message": "{count, plural, one {# Tag} other {# Tage}}"
},
@@ -134,18 +134,6 @@
"analytics.chart.render-limit.header": {
"message": "Alle {count} Zeilen im Graphen anzeigen?"
},
"analytics.chart.table-selection.all": {
"message": "Zeige alle {itemType, select, project {{count, plural, one {Projekt} other {Projekte}}} country {{count, plural, one {Land} other {Länder}}} monetization {{count, plural, one {Monetarisierungswert} other {Monetarisierungswerte}}} downloadSource {{count, plural, one {Downloadquelle} other {Downloadquellen}}} downloadReason {{count, plural, one {Downloadgrund} other {Downloadgründe}}} projectVersion {{count, plural, one {Projektversion} other {Projektversionen}}} loader {{count, plural, one {Loader} other {Loader}}} gameVersion {{count, plural, one {Spielversion} other {Spielversionen}}} other {{count, plural, one {Artikel} other {Artikel}}}} aus der Tabelle"
},
"analytics.chart.table-selection.count": {
"message": "Zeige {count} {itemType, select, project {{count, plural, one {Projekt} other {Projekte}}} country {{count, plural, one {Land} other {Länder}}} monetization {{count, plural, one {Monetarisierungswert} other {Monetarisierungswerte}}} downloadSource {{count, plural, one {Downloadquelle} other {Downloadquellen}}} downloadReason {{count, plural, one {Downloadgrund} other {Downloadgründe}}} projectVersion {{count, plural, one {Projektversion} other {Projektversionen}}} loader {{count, plural, one {Loader} other {Loader}}} gameVersion {{count, plural, one {Spielversion} other {Spielversionen}}} other {{count, plural, one {Artikel} other {Artikel}}}} aus der Tabelle"
},
"analytics.chart.table-selection.limited": {
"message": "Zeige {limit} {itemType, select, project {{limit, plural, one {Projekt} other {Projekte}}} country {{limit, plural, one {Land} other {Länder}}} monetization {{limit, plural, one {Monetarisierungswert} other {Monetarisierungswerte}}} downloadSource {{limit, plural, one {Downloadquelle} other {Downloadquellen}}} downloadReason {{limit, plural, one {Downloadgrund} other {Downloadgründe}}} projectVersion {{limit, plural, one {Projektversion} other {Projektversionen}}} loader {{limit, plural, one {Loader} other {Loader}}} gameVersion {{limit, plural, one {Spielversion} other {Spielversionen}}} other {{limit, plural, one {Artikel} other {Artikel}}}} aus der Tabelle"
},
"analytics.chart.table-selection.top": {
"message": "Zeige die top {count} {itemType, select, project {{count, plural, one {Projekt} other {Projekte}}} country {{count, plural, one {Land} other {Länder}}} monetization {{count, plural, one {Monetarisierungswert} other {Monetarisierungswerte}}} downloadSource {{count, plural, one {Downloadquelle} other {Downloadquellen}}} downloadReason {{count, plural, one {Downloadgrund} other {Downloadgründe}}} projectVersion {{count, plural, one {Projektversion} other {Projektversionen}}} loader {{count, plural, one {Loader} other {Loader}}} gameVersion {{count, plural, one {Spielversion} other {Spielversionen}}} other {{count, plural, one {Artikel} other {Artikel}}}} aus der Tabelle"
},
"analytics.chart.tooltip.duration.days": {
"message": "{count, plural, one {# Tag} other {# Tage}}"
},
+34 -4
View File
@@ -23,6 +23,15 @@
"analytics.breakdown.country": {
"message": "Country"
},
"analytics.breakdown.dependent-on": {
"message": "Dependent on"
},
"analytics.breakdown.dependent-project-download": {
"message": "Dependent project"
},
"analytics.breakdown.dependent-project-type": {
"message": "Dependent project type"
},
"analytics.breakdown.download-reason": {
"message": "Download reason"
},
@@ -38,6 +47,9 @@
"analytics.breakdown.loader": {
"message": "Loader"
},
"analytics.breakdown.members": {
"message": "Members"
},
"analytics.breakdown.monetization": {
"message": "Monetization"
},
@@ -135,16 +147,22 @@
"message": "Show all {count} lines in graph?"
},
"analytics.chart.table-selection.all": {
"message": "Showing all {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table"
"message": "Showing all {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} member {{count, plural, one {member} other {members}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table"
},
"analytics.chart.table-selection.count": {
"message": "Showing {count} {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table"
"message": "Showing {count} {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} member {{count, plural, one {member} other {members}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table"
},
"analytics.chart.table-selection.limited": {
"message": "Showing {limit} {itemType, select, project {{limit, plural, one {project} other {projects}}} country {{limit, plural, one {country} other {countries}}} monetization {{limit, plural, one {monetization value} other {monetization values}}} downloadSource {{limit, plural, one {download source} other {download sources}}} downloadReason {{limit, plural, one {download reason} other {download reasons}}} projectVersion {{limit, plural, one {project version} other {project versions}}} loader {{limit, plural, one {loader} other {loaders}}} gameVersion {{limit, plural, one {game version} other {game versions}}} other {{limit, plural, one {item} other {items}}}} from table"
"message": "Showing {limit} {itemType, select, project {{limit, plural, one {project} other {projects}}} country {{limit, plural, one {country} other {countries}}} monetization {{limit, plural, one {monetization value} other {monetization values}}} downloadSource {{limit, plural, one {download source} other {download sources}}} downloadReason {{limit, plural, one {download reason} other {download reasons}}} member {{limit, plural, one {member} other {members}}} projectVersion {{limit, plural, one {project version} other {project versions}}} loader {{limit, plural, one {loader} other {loaders}}} gameVersion {{limit, plural, one {game version} other {game versions}}} other {{limit, plural, one {item} other {items}}}} from table"
},
"analytics.chart.table-selection.top": {
"message": "Showing top {count} {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table"
"message": "Showing top {count} {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} member {{count, plural, one {member} other {members}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table"
},
"analytics.chart.tooltip.dependent-on-project": {
"message": "Dependent on {project}"
},
"analytics.chart.tooltip.dependent-project-version": {
"message": "{dependentProject} dependent on {dependencyProject}, {version}"
},
"analytics.chart.tooltip.duration.days": {
"message": "{count, plural, one {# day} other {# days}}"
@@ -230,9 +248,15 @@
"analytics.filter.search.countries": {
"message": "Search countries..."
},
"analytics.filter.search.dependent-projects": {
"message": "Search projects..."
},
"analytics.filter.search.download-sources": {
"message": "Search download sources..."
},
"analytics.filter.search.members": {
"message": "Search members..."
},
"analytics.filter.search.project-versions": {
"message": "Search project versions..."
},
@@ -479,6 +503,12 @@
"analytics.value.monetized": {
"message": "Monetized"
},
"analytics.value.no-dependent": {
"message": "No dependents"
},
"analytics.value.no-dependent-tooltip": {
"message": "Downloaded for reasons other than being a dependency"
},
"analytics.value.none": {
"message": "None"
},
@@ -134,15 +134,6 @@
"analytics.chart.render-limit.header": {
"message": "¿Mostrar todas las {count} líneas en el gráfico?"
},
"analytics.chart.table-selection.all": {
"message": "Mostrando {itemType, select, project {{count, plural, one {el proyecto} other {todos los # proyectos}}} country {{count, plural, one {el país} other {todos los # países}}} monetization {{count, plural, one {el valor de monetización} other {todos los valores de monetización}}} downloadSource {{count, plural, one {la fuente de descarga} other {todas las # fuentes de descarga}}} downloadReason {{count, plural, one {la razón de descarga} other {todas las # razones de descarga}}} projectVersion {{count, plural, one {la versión del proyecto} other {todas las # versiones del proyecto}}} loader {{count, plural, one {el loader} other {todos los # loaders}}} gameVersion {{count, plural, one {la versión del juego} other {todas las # versiones del juego}}} other {{count, plural, one {el ítem} other {todos los # ítems}}}} de la tabla"
},
"analytics.chart.table-selection.limited": {
"message": "Mostrando {limit} {itemType, select, project {{limit, plural, one {proyecto} other {proyectos}}} country {{limit, plural, one {país} other {países}}} monetization {{limit, plural, one {valor de monetización} other {valores de monetización}}} downloadSource {{limit, plural, one {fuente de descarga} other {fuentes de descarga}}} downloadReason {{limit, plural, one {razón de descarga} other {razones de descarga}}} projectVersion {{limit, plural, one {versión del proyecto} other {versiones del proyecto}}} loader {{limit, plural, one {loader} other {loaders}}} gameVersion {{limit, plural, one {version de juego} other {versiones de juego}}} other {{limit, plural, one {ítem} other {ítems}}}} de la tabla"
},
"analytics.chart.table-selection.top": {
"message": "Mostrando el top {count} {itemType, select, project {{count, plural, one {del proyecto} other {de todos los # proyectos}}} country {{count, plural, one {del país} other {de todos los # países}}} monetization {{count, plural, one {del valor de monetización} other {de todos los valores de monetización}}} downloadSource {{count, plural, one {de la fuente de descarga} other {de todas las # fuentes de descarga}}} downloadReason {{count, plural, one {de la razón de descarga} other {de todas las # razones de descarga}}} projectVersion {{count, plural, one {de la versión del proyecto} other {de todas las # versiones del proyecto}}} loader {{count, plural, one {del loader} other {de todos los # loaders}}} gameVersion {{count, plural, one {de la versión del juego} other {de todas las # versiones del juego}}} other {{count, plural, one {del ítem} other {de todos los # ítems}}}} de la tabla"
},
"analytics.chart.tooltip.duration.days": {
"message": "{count, plural, one {# día} other {# días}}"
},
@@ -134,15 +134,6 @@
"analytics.chart.render-limit.header": {
"message": "¿Mostrar todas las {count} líneas en el gráfico?"
},
"analytics.chart.table-selection.all": {
"message": "Mostrando {itemType, select, project {{count, plural, one {el proyecto} other {todos los # proyectos}}} country {{count, plural, one {el país} other {todos los # países}}} monetization {{count, plural, one {el valor de monetización} other {todos los valores de monetización}}} downloadSource {{count, plural, one {la fuente de descarga} other {todas las # fuentes de descarga}}} downloadReason {{count, plural, one {la razón de descarga} other {todas las # razones de descarga}}} projectVersion {{count, plural, one {la versión del proyecto} other {todas las # versiones del proyecto}}} loader {{count, plural, one {el loader} other {todos los # loaders}}} gameVersion {{count, plural, one {la versión del juego} other {todas las # versiones del juego}}} other {{count, plural, one {el ítem} other {todos los # ítems}}}} de la tabla"
},
"analytics.chart.table-selection.limited": {
"message": "Mostrando {limit} {itemType, select, project {{limit, plural, one {proyecto} other {proyectos}}} country {{limit, plural, one {país} other {países}}} monetization {{limit, plural, one {valor de monetización} other {valores de monetización}}} downloadSource {{limit, plural, one {fuente de descarga} other {fuentes de descarga}}} downloadReason {{limit, plural, one {razón de descarga} other {razones de descarga}}} projectVersion {{limit, plural, one {versión del proyecto} other {versiones del proyecto}}} loader {{limit, plural, one {loader} other {loaders}}} gameVersion {{limit, plural, one {version de juego} other {versiones de juego}}} other {{limit, plural, one {ítem} other {ítems}}}} de la tabla"
},
"analytics.chart.table-selection.top": {
"message": "Mostrando el top {count} {itemType, select, project {{count, plural, one {del proyecto} other {de todos los # proyectos}}} country {{count, plural, one {del país} other {de todos los # países}}} monetization {{count, plural, one {del valor de monetización} other {de todos los valores de monetización}}} downloadSource {{count, plural, one {de la fuente de descarga} other {de todas las # fuentes de descarga}}} downloadReason {{count, plural, one {de la razón de descarga} other {de todas las # razones de descarga}}} projectVersion {{count, plural, one {de la versión del proyecto} other {de todas las # versiones del proyecto}}} loader {{count, plural, one {del loader} other {de todos los # loaders}}} gameVersion {{count, plural, one {de la versión del juego} other {de todas las # versiones del juego}}} other {{count, plural, one {del ítem} other {de todos los # ítems}}}} de la tabla"
},
"analytics.chart.tooltip.duration.days": {
"message": "{count, plural, one {# día} other {# días}}"
},
@@ -134,12 +134,6 @@
"analytics.chart.render-limit.header": {
"message": "Afficher les {count} lignes sur le graphique ?"
},
"analytics.chart.table-selection.all": {
"message": "Montrer {itemType, select, project {{count, plural, one {1 projet} other {tous les projets}}} country {{count, plural, one {1 pays} other {tous les pays}}} monetization {{count, plural, one {1 valeur de monétisation} other {toutes les valeurs de monétisation}}} downloadSource {{count, plural, one {1 source de téléchargement} other {toutes les sources de téléchargement}}} downloadReason {{count, plural, one {1 raison de téléchargement} other {toutes les raisons de téléchargement}}} projectVersion {{count, plural, one {1 version de projet} other {toutes les versions de projets}}} loader {{count, plural, one {1 loader} other {tous les loaders}}} gameVersion {{count, plural, one {1 version du jeu} other {toutes les version versions du jeu}}} other {{count, plural, one {l'objet} other {tous les objets}}}} du tableau"
},
"analytics.chart.table-selection.count": {
"message": "Affichage de {count} {itemType, select, project {{count, plural, one {projet} other {projets}}} country {{count, plural, one {pays} other {pays}}} monetization {{count, plural, one {valeur de monétisation} other {valeurs de monétisation}}} downloadSource {{count, plural, one {source de téléchargement} other {sources de téléchargement}}} downloadReason {{count, plural, one {raison de téléchargement} other {raisons de téléchargement}}} projectVersion {{count, plural, one {version de projet} other {versions de projet}}} loader {{count, plural, one {chargeur} other {chargeurs}}} gameVersion {{count, plural, one {version de jeu} other {versions de jeu}}} other {{count, plural, one {élément} other {éléments}}}} du tableau"
},
"analytics.chart.tooltip.duration.days": {
"message": "{count, plural, one {# jour} other {# jours}}"
},
@@ -134,12 +134,6 @@
"analytics.chart.render-limit.header": {
"message": "Megjelenítse a grafikonon mind a {count} sort?"
},
"analytics.chart.table-selection.limited": {
"message": "Mutatás {limit} {itemType, select, project {{limit, plural, one {project} other {projects}}} country {{limit, plural, one {country} other {countries}}} monetization {{limit, plural, one {monetization value} other {monetization values}}} downloadSource {{limit, plural, one {download source} other {download sources}}} downloadReason {{limit, plural, one {download reason} other {download reasons}}} projectVersion {{limit, plural, one {project version} other {project versions}}} loader {{limit, plural, one {loader} other {loaders}}} gameVersion {{limit, plural, one {game version} other {game versions}}} other {{limit, plural, one {item} other {items}}}} from table"
},
"analytics.chart.table-selection.top": {
"message": "Mutatás top {count} {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table"
},
"analytics.chart.tooltip.hide-entry": {
"message": "{name} elrejtése a grafikonon"
},
@@ -128,18 +128,6 @@
"analytics.chart.render-limit.header": {
"message": "Mostrare tutte le {count} linee nel grafico?"
},
"analytics.chart.table-selection.all": {
"message": "Mostrando {itemType, select, project {{count, plural, one {l'unico progetto} other {tutti i progetti}}} country {{count, plural, one {l'unico paese} other {tutti i paesi}}} monetization {{count, plural, one {l'unico valore} other {tutti i valori}} di monetizzazione} downloadSource {{count, plural, one {l'unica fonte} other {tutte le fonti}}} downloadReason {{count, plural, one {l'unico motivo} other {tutti i motivi}} di download} projectVersion {{count, plural, one {l'unica versione} other {tutte le versioni}} del progetto} loader {{count, plural, one {l'unico loader} other {tutti i loader}}} gameVersion {{count, plural, one {l'unica versione} other {tutte le versioni}} del gioco} other {{count, plural, one {l'unico elemento} other {tutti gli elementi}}}} dalla tabella"
},
"analytics.chart.table-selection.count": {
"message": "Mostrando {count} {itemType, select, project {{count, plural, one {progetto} other {progetti}}} country {{count, plural, one {paese} other {paesi}}} monetization {{count, plural, one {valore di monetizzazione} other {valori di monetizzazione}}} downloadSource {{count, plural, one {fonte} other {fonti}}} downloadReason {{count, plural, one {motivo} other {motivi}}} projectVersion {{count, plural, one {versione del progetto} other {versioni del progetto}}} loader {loader} gameVersion {{count, plural, one {versione di gioco} other {versioni di gioco}}} other {{count, plural, one {elemento} other {elementi}}}} dalla tabella"
},
"analytics.chart.table-selection.limited": {
"message": "Mostrando {limit} {itemType, select, project {{limit, plural, one {progetto} other {progetti}}} country {{limit, plural, one {paese} other {paesi}}} monetization {{limit, plural, one {valore di monetizzazione} other {valori di monetizzazione}}} downloadSource {{limit, plural, one {fonte} other {fonti}}} downloadReason {{limit, plural, one {motivo} other {motivi}}} projectVersion {{limit, plural, one {versione del progetto} other {versioni del progetto}}} loader {loader} gameVersion {{limit, plural, one {versione di gioco} other {versioni di gioco}}} other {{limit, plural, one {elemento} other {elementi}}}} dalla tabella"
},
"analytics.chart.table-selection.top": {
"message": "Mostrando {itemType, select, project {{count, plural, one {il primo progetto} other {i primi # progetti}}} country {{count, plural, one {il primo paese} other {i primi # paesi}}} monetization {{count, plural, one {il primo valore di monetizzazione} other {i primi # valori di monetizzazione}}} downloadSource {{count, plural, one {la prima fonte} other {le prime # fonti}}} downloadReason {{count, plural, one {il primo motivo} other {i primi # motivi}}} projectVersion {{count, plural, one {la prima versione del progetto} other {le prime # versioni del progetto}}} loader {{count, plural, one {il primo loader} other {i primi # loader}}} gameVersion {{count, plural, one {la prima versione di gioco} other {le prime # versioni di gioco}}} other {{count, plural, one {il primo elemento} other {i primi {count} elementi}}}} dalla tabella"
},
"analytics.chart.tooltip.duration.days": {
"message": "{count, plural, one {# giorno} other {# giorni}}"
},
@@ -125,9 +125,6 @@
"analytics.chart.render-limit.header": {
"message": "Pokazać wszystkie {count} linii na wykresie?"
},
"analytics.chart.table-selection.all": {
"message": "Pokazano {itemType, select, project {{count, plural, one {projekt} other {wszystkie projekty}}} country {{count, plural, one {kraj} other {wszystkie kraje}}} monetization {{count, plural, one {wartość z monetyzacji} other {wszystkie wartości z monetyzacji}}} downloadSource {{count, plural, one {żródło pobrań} other {wszystkie źródła pobrań}}} downloadReason {{count, plural, one {powód pobrań} other {wszystkie powody pobrań}}} projectVersion {{count, plural, one {wersję projektu} other {wszystkie wersje projektu}}} loader {{count, plural, one {loader} other {wszystkie loadery}}} gameVersion {{count, plural, one {wersję gry} other {wszystkie wersje gry}}} other {{count, plural, one {pozycję} other {wszystkie pozycje}}}} z tabeli"
},
"analytics.chart.tooltip.duration.days": {
"message": "{count, plural, one {# dzień} other {# dni}}"
},
@@ -134,18 +134,6 @@
"analytics.chart.render-limit.header": {
"message": "Exibir todas as {count} linhas no gráfico?"
},
"analytics.chart.table-selection.all": {
"message": "Exibindo {itemType, select,project {{count, plural, one {todo o projeto} other {todos os projetos}}}country {{count, plural, one {todo o país} other {todos os países}}}monetization {{count, plural, one {todo o valor de monetização} other {todos os valores de monetização}}}downloadSource {{count, plural, one {toda a fonte de download} other {todas as fontes de download}}} downloadReason {{count, plural, one {todo o motivo de download} other {todos os motivos de download}}} projectVersion {{count, plural, one {toda a versão do projeto} other {todas as versões do projeto}}} loader {{count, plural, one {todo loader} other {todos os loaders}}} gameVersion {{count, plural, one {toda a versão do jogo} other {todas as versões do jogo}}} other {{count, plural, one {todo o item} other {todos os itens}}}\n} da tabela"
},
"analytics.chart.table-selection.count": {
"message": "Exibindo {count} {itemType, select, project {{count, plural, one {projeto} other {projetos}}} country {{count, plural, one {país} other {países}}} monetization {{count, plural, one {valor de monetização} other {valores de monetização}}} downloadSource {{count, plural, one {fonte de download} other {fontes de download}}} downloadReason {{count, plural, one {motivo de download} other {motivos de download}}} projectVersion {{count, plural, one {versão do projeto} other {versões do projeto}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {versão do jogo} other {versões do jogo}}} other {{count, plural, one {item} other {itens}}}} da tabela"
},
"analytics.chart.table-selection.limited": {
"message": "Exibindo {limit} {itemType, select, project {{limit, plural, one {projeto} other {projetos}}} country {{limit, plural, one {país} other {países}}} monetization {{limit, plural, one {valor de monetização} other {valores de monetização}}} downloadSource {{limit, plural, one {fonte de download} other {fontes de download}}} downloadReason {{limit, plural, one {motivo de download} other {motivos de download}}} projectVersion {{limit, plural, one {versão do projeto} other {versões do projeto}}} loader {{limit, plural, one {loader} other {loaders}}} gameVersion {{limit, plural, one {versão do jogo} other {versões do jogo}}} other {{limit, plural, one {item} other {itens}}}} da tabela"
},
"analytics.chart.table-selection.top": {
"message": "Exibindo os {count} {itemType, select,\nproject {{count, plural, one {projeto principal} other {principais projetos}}}\ncountry {{count, plural, one {país principal} other {principais países}}}\nmonetization {{count, plural, one {valor de monetização principal} other {principais valores de monetização}}}\ndownloadSource {{count, plural, one {fonte de download principal} other {principais fontes de download}}}\ndownloadReason {{count, plural, one {motivo de download principal} other {principais motivos de download}}}\nprojectVersion {{count, plural, one {versão principal do projeto} other {principais versões do projeto}}}\nloader {{count, plural, one {loader principal} other {loaders principais}}}\ngameVersion {{count, plural, one {versão principal do jogo} other {principais versões do jogo}}}\nother {{count, plural, one {item principal} other {principais itens}}}\n} da tabela"
},
"analytics.chart.tooltip.duration.days": {
"message": "{count, plural, one {# dia} other {# dias}}"
},
@@ -101,15 +101,6 @@
"analytics.chart.render-limit.header": {
"message": "Visa alla {count} linjer i diagram?"
},
"analytics.chart.table-selection.count": {
"message": "Visar {count} {itemType, select, project {projekt} country {{count, plural, one {land} other {länder}}} monetization {{count, plural, one {intäktvärde} other {intäktvärden}}} downloadSource {{count, plural, one {nedladdningskälla} other {nedladdningskällor}}} downloadReason {{count, plural, one {nedladdningsanledning} other {nedladdningsanledningar}}} projectVersion {{count, plural, one {projektversion} other {projektversioner}}} loader {laddare} gameVersion {{count, plural, one {spelversion} other {spelversioner}}} other {föremål}} från tabell"
},
"analytics.chart.table-selection.limited": {
"message": "Visar {limit} {itemType, select, project {projekt} country {{limit, plural, one {land} other {länder}}} monetization {{limit, plural, one {intäktvärde} other {intäktvärden}}} downloadSource {{limit, plural, one {nedladdningskälla} other {nedladdningskällor}}} downloadReason {{limit, plural, one {nedladdningsanledning} other {nedladdningsanledningar}}} projectVersion {{limit, plural, one {projektversion} other {projektversioner}}} loader {laddare} gameVersion {{limit, plural, one {spelversion} other {spelversioner}}} other {föremål}} från tabell"
},
"analytics.chart.table-selection.top": {
"message": "Visar topp {count} {itemType, select, project {projekt} country {{count, plural, one {land} other {länder}}} monetization {{count, plural, one {intäktvärde} other {intäktvärden}}} downloadSource {{count, plural, one {nedladdningskälla} other {nedladdningskällor}}} downloadReason {{count, plural, one {nedladdningsanledning} other {nedladdningsanledningar}}} projectVersion {{count, plural, one {projektversion} other {projektversioner}}}loader {laddare} gameVersion {{count, plural, one {spelversion} other {spelversioner}}} other {föremål}} från tabell"
},
"analytics.chart.tooltip.duration.days": {
"message": "{count, plural, one {# dag} other {# dagar}}"
},
@@ -134,18 +134,6 @@
"analytics.chart.render-limit.header": {
"message": "Grafikteki {count} satırın tümünü göstermek ister misiniz?"
},
"analytics.chart.table-selection.all": {
"message": "Tablodaki tüm {itemType, select, project {{count, plural, one {proje} other {projeler}}} country {{count, plural, one {ülke} other {ülkeler}}} monetization {{count, plural, one {gelir elde etme değeri} other {gelir elde etme değerleri}}} downloadSource {{count, plural, one {indirme kaynağı} other {indirme kaynakları}}} downloadReason {{count, plural, one {indirme nedeni} other {indirme nedenleri}}} projectVersion {{count, plural, one {proje sürümü} other {proje sürümleri}}} loader {{count, plural, one {yükleyici} other {yükleyiciler}}} gameVersion {{count, plural, one {oyun sürümü} other {oyun sürümleri}}} other {{count, plural, one {öğe} other {öğeler}}}} gösteriliyor"
},
"analytics.chart.table-selection.count": {
"message": "Tablodan {count} {itemType, select, project {{count, plural, one {proje} other {projeler}}} country {{count, plural, one {ülke} other {ülkeler}}} monetization {{count, plural, one {gelir elde etme değeri} other {gelir elde etme değerleri}}} downloadSource {{count, plural, one {indirme kaynağı} other {indirme kaynakları}}} downloadReason {{count, plural, one {indirme nedeni} other {indirme nedenleri}}} projectVersion {{count, plural, one {proje sürümü} other {proje sürümleri}}} loader {{count, plural, one {yükleyici} other {yükleyiciler}}} gameVersion {{count, plural, one {oyun sürümü} other {oyun sürümleri}}} other {{count, plural, one {öğe} other {öğeler}}}} seçildi"
},
"analytics.chart.table-selection.limited": {
"message": "Tablodan {limit} {itemType, select, project {{limit, plural, one {proje} other {projeler}}} country {{limit, plural, one {ülke} other {ülkeler}}} monetization {{limit, plural, one {gelir elde etme değeri} other {gelir elde etme değerleri}}} downloadSource {{limit, plural, one {indirme kaynağı} other {indirme kaynakları}}} downloadReason {{limit, plural, one {indirme nedeni} other {indirme nedenleri}}} projectVersion {{limit, plural, one {proje sürümü} other {proje sürümleri}}} loader {{limit, plural, one {yükleyici} other {yükleyiciler}}} gameVersion {{limit, plural, one {oyun sürümü} other {oyun sürümleri}}} other {{limit, plural, one {öğe} other {öğeler}}}} gösteriliyor"
},
"analytics.chart.table-selection.top": {
"message": "Tablodan en iyi {count} {itemType, select, project {{count, plural, one {proje} other {projeler}}} country {{count, plural, one {ülke} other {ülkeler}}} monetization {{count, plural, one {gelir elde etme değeri} other {gelir elde etme değerleri}}} downloadSource {{count, plural, one {indirme kaynağı} other {indirme kaynakları}}} downloadReason {{count, plural, one {indirme nedeni} other {indirme nedenleri}}} projectVersion {{count, plural, one {proje sürümü} other {proje sürümleri}}} loader {{count, plural, one {yükleyici} other {yükleyiciler}}} gameVersion {{count, plural, one {oyun sürümü} other {oyun sürümleri}}} other {{count, plural, one {öğe} other {öğeler}}}} gösteriliyor"
},
"analytics.chart.tooltip.duration.days": {
"message": "{count, plural, one {# gün} other {# gün}}"
},
@@ -131,9 +131,6 @@
"analytics.chart.render-limit.header": {
"message": "Hiện thị tất cả {count} đường trong biểu đồ?"
},
"analytics.chart.table-selection.limited": {
"message": "Đang hiển thị {limit} {itemType, select, project {{limit, plural, one {dự án} other {dự án}}} country {{limit, plural, one {quốc gia} other {quốc gia}}} monetization {{limit, plural, one {doanh thu} other {doanh thu}}} downloadSource {{limit, plural, one {nơi tải gốc} other {nơi tải gốc}}} downloadReason {{limit, plural, one {lý do tải} other {lý do tải}}} projectVersion {{limit, plural, one {phiên bản dự án} other {phiên bản dự án}}} loader {{limit, plural, one {loader} other {loaders}}} gameVersion {{limit, plural, one {phiên bản game} other {phiên bản game}}} other {{limit, plural, one {mục} other {mục}}}} từ bảng"
},
"analytics.chart.tooltip.hide-entry": {
"message": "Che {name} trong biểu đồ"
},
@@ -131,18 +131,6 @@
"analytics.chart.render-limit.header": {
"message": "是否在图中显示全部 {count} 行?"
},
"analytics.chart.table-selection.all": {
"message": "展示全部 {itemType, select, project {{count, plural, one {项目} other {项目}}} country {{count, plural, one {国家} other {国家}}} monetization {{count, plural, one {收益项} other {收益项}}} downloadSource {{count, plural, one {下载来源} other {下载来源}}} downloadReason {{count, plural, one {下载原因} other {下载原因}}} projectVersion {{count, plural, one {项目版本} other {项目版本}}} loader {{count, plural, one {加载器} other {加载器}}} gameVersion {{count, plural, one {游戏版本} other {游戏版本}}} other {{count, plural, one {条目} other {条目}}}} 表格数据"
},
"analytics.chart.table-selection.count": {
"message": "正在展示{count}{itemType, select,project {{count, plural,one {项目}other {项目}}}country {{count, plural,one {国家}other {国家}}} monetization {{count, plural,one {变现收益}other {变现收益}}} downloadSource {{count, plural,one {下载来源}other {下载来源}}} downloadReason {{count, plural,one {下载原因}other {下载原因}}} projectVersion {{count, plural,one {项目版本}other {项目版本}}} loader {{count, plural,one {启动器}other {启动器}}} gameVersion {{count, plural,one {游戏版本}other {游戏版本}}} other {{count, plural,one {项目}other {项目}}}}"
},
"analytics.chart.table-selection.limited": {
"message": "正在展示{limit}{itemType, select,project {{limit, plural,one {项目}other {项目}}}country {{limit, plural,one {国家}other {国家}}} monetization {{limit, plural,one {变现收益}other {变现收益}}} downloadSource {{limit, plural,one {下载来源}other {下载来源}}} downloadReason {{limit, plural,one {下载原因}other {下载原因}}} projectVersion {{limit, plural,one {项目版本}other {项目版本}}} loader {{limit, plural,one {启动器}other {启动器}}} gameVersion {{limit, plural,one {游戏版本}other {游戏版本}}}other {{limit, plural,one {项目}other {项目}}}}"
},
"analytics.chart.table-selection.top": {
"message": "从表格中展示前{count}\n{itemType, select,\nproject {{count, plural, one {个项目} other {个项目}}}\ncountry {{count, plural, one {个国家} other {个国家}}}\nmonetization {{count, plural, one {项变现金额} other {项变现金额}}}\ndownloadSource {{count, plural, one {个下载来源} other {个下载来源}}}\ndownloadReason {{count, plural, one {条下载原因} other {条下载原因}}}\nprojectVersion {{count, plural, one {个项目版本} other {个项目版本}}}\nloader {{count, plural, one {个加载器} other {个加载器}}}\ngameVersion {{count, plural, one {个游戏版本} other {个游戏版本}}}\nother {{count, plural, one {条数据} other {条数据}}}\n}"
},
"analytics.chart.tooltip.duration.days": {
"message": "{count, plural, one {# 天} other {# 天}}"
},
@@ -2,6 +2,10 @@ import type { Labrinth } from '@modrinth/api-client'
import type { ProjectStatusFilterValue } from '~/components/analytics-dashboard/query-builder/query-filter-utils'
import {
doesAnalyticsPointMatchNormalizedFilters,
normalizeAnalyticsSelectedFilters,
} from './analytics-filter-utils'
import { getProjectIdsMatchingStatusFilter } from './analytics-project-utils'
import type {
AnalyticsDashboardTotals,
@@ -203,6 +207,30 @@ function mergeAnalyticsProjectEvents(
})
}
function mergeAnalyticsProjects(
projectGroups: Record<string, Labrinth.Projects.v3.Project>[],
): Record<string, Labrinth.Projects.v3.Project> {
const projects: Record<string, Labrinth.Projects.v3.Project> = {}
for (const projectGroup of projectGroups) {
Object.assign(projects, projectGroup)
}
return projects
}
function mergeAnalyticsUsers(
userGroups: Record<string, Labrinth.Users.v3.User>[],
): Record<string, Labrinth.Users.v3.User> {
const users: Record<string, Labrinth.Users.v3.User> = {}
for (const userGroup of userGroups) {
Object.assign(users, userGroup)
}
return users
}
function waitForAnalyticsFetchBatchDelay(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ANALYTICS_PROJECT_IDS_FETCH_BATCH_DELAY_MS))
}
@@ -215,6 +243,8 @@ export async function fetchAnalyticsData(
): Promise<AnalyticsFetchData> {
const fetchRequests = buildAnalyticsFetchRequestBatches(fetchRequest)
const timeSliceGroups: Labrinth.Analytics.v3.TimeSlice[][] = []
const projectGroups: Record<string, Labrinth.Projects.v3.Project>[] = []
const userGroups: Record<string, Labrinth.Users.v3.User>[] = []
const projectEventGroups: Labrinth.Analytics.v3.ProjectAnalyticsEvent[][] = []
for (let index = 0; index < fetchRequests.length; index++) {
@@ -224,11 +254,15 @@ export async function fetchAnalyticsData(
const response = await fetchAnalytics(fetchRequests[index])
timeSliceGroups.push(response.metrics)
projectGroups.push(response.projects ?? {})
userGroups.push(response.users ?? {})
projectEventGroups.push(response.project_events ?? [])
}
return {
metrics: mergeAnalyticsTimeSlices(timeSliceGroups),
projects: mergeAnalyticsProjects(projectGroups),
users: mergeAnalyticsUsers(userGroups),
project_events: mergeAnalyticsProjectEvents(projectEventGroups),
}
}
@@ -433,6 +467,7 @@ export function computeTotals(
availableProjectIds: Set<string>,
projectStatusById: Map<string, ProjectStatusFilterValue>,
filters: AnalyticsSelectedFilters,
dependentProjectTypesById?: ReadonlyMap<string, readonly string[]>,
): AnalyticsDashboardTotals {
const totals: AnalyticsDashboardTotals = {
views: 0,
@@ -452,6 +487,7 @@ export function computeTotals(
if (filteredProjectIds.size === 0) {
return totals
}
const normalizedFilters = normalizeAnalyticsSelectedFilters(filters)
for (const timeSlice of timeSlices) {
for (const dataPoint of timeSlice) {
@@ -462,6 +498,15 @@ export function computeTotals(
if (!filteredProjectIds.has(dataPoint.source_project)) {
continue
}
if (
!doesAnalyticsPointMatchNormalizedFilters(
dataPoint,
normalizedFilters,
dependentProjectTypesById,
)
) {
continue
}
switch (dataPoint.metric_kind) {
case 'views':
@@ -119,6 +119,7 @@ export function sanitizeAnalyticsSelectedFiltersForAvailableOptions(
filters.download_reason,
filterOptions.downloadReasons,
),
user_id: retainAvailableSelectedFilterValues(filters.user_id, filterOptions.userIds),
game_version: retainAvailableSelectedFilterValues(
filters.game_version,
filterOptions.gameVersions,
@@ -140,9 +141,12 @@ export function cloneAnalyticsSelectedFilters(
monetization: [...filters.monetization],
user_agent: [...filters.user_agent],
download_reason: [...filters.download_reason],
user_id: [...filters.user_id],
version_id: [...filters.version_id],
game_version: [...filters.game_version],
loader_type: [...filters.loader_type],
dependent_project_id: [...filters.dependent_project_id],
dependent_project_type: [...filters.dependent_project_type],
}
}
@@ -153,8 +157,10 @@ export function cloneAnalyticsFilterOptions(
countries: [...filterOptions.countries],
downloadSources: [...filterOptions.downloadSources],
downloadReasons: [...filterOptions.downloadReasons],
userIds: [...filterOptions.userIds],
gameVersions: [...filterOptions.gameVersions],
loaderTypes: [...filterOptions.loaderTypes],
dependentProjectTypes: [...filterOptions.dependentProjectTypes],
versionIds: [...filterOptions.versionIds],
}
}
@@ -164,8 +170,10 @@ function getEmptyAnalyticsFacetsFilterOptionSummary(): AnalyticsFacetsFilterOpti
countries: [],
downloadSources: [],
downloadReasons: [],
userIds: [],
gameVersions: [],
loaderTypes: [],
dependentProjectTypes: [],
versionIds: [],
projectDownloadsById: new Map(),
projectVersionDownloadsById: new Map(),
@@ -215,12 +223,14 @@ export function getAnalyticsFacetsFilterOptionSummary(
),
downloadSources: sortStringValues(getAnalyticsFacetValues(projectDownloadFacets?.user_agent)),
downloadReasons: sortStringValues(getAnalyticsFacetValues(projectDownloadFacets?.reason)),
userIds: [],
gameVersions: sortStringValues(
[...gameVersions]
.map((gameVersion) => gameVersion.trim())
.filter((gameVersion) => gameVersion.length > 0),
),
loaderTypes: sortStringValues([...loaderTypes]),
dependentProjectTypes: [],
versionIds: sortStringValues([...new Set([...downloadVersionIds, ...playtimeVersionIds])]),
projectDownloadsById: new Map(),
projectVersionDownloadsById: new Map(),
@@ -232,10 +242,12 @@ export function getAnalyticsFacetsFilterOptionSummary(
export function doesAnalyticsPointMatchFilters(
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
filters: AnalyticsSelectedFilters,
dependentProjectTypesById?: ReadonlyMap<string, readonly string[]>,
): boolean {
return doesAnalyticsPointMatchNormalizedFilters(
dataPoint,
normalizeAnalyticsSelectedFilters(filters),
dependentProjectTypesById,
)
}
@@ -247,9 +259,12 @@ export function normalizeAnalyticsSelectedFilters(
monetization: normalizeAnalyticsFilterValues(filters.monetization),
userAgent: normalizeAnalyticsFilterValues(filters.user_agent),
downloadReason: normalizeAnalyticsFilterValues(filters.download_reason),
userId: normalizeAnalyticsFilterValues(filters.user_id),
versionId: normalizeAnalyticsFilterValues(filters.version_id),
gameVersion: normalizeAnalyticsFilterValues(filters.game_version),
loaderType: normalizeAnalyticsFilterValues(filters.loader_type),
dependentProjectId: normalizeAnalyticsFilterValues(filters.dependent_project_id),
dependentProjectType: normalizeAnalyticsFilterValues(filters.dependent_project_type),
}
}
@@ -267,6 +282,7 @@ function normalizeAnalyticsFilterValues(values: string[]): ReadonlySet<string> {
export function doesAnalyticsPointMatchNormalizedFilters(
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
filters: NormalizedAnalyticsSelectedFilters,
dependentProjectTypesById?: ReadonlyMap<string, readonly string[]>,
): boolean {
switch (dataPoint.metric_kind) {
case 'views':
@@ -314,7 +330,21 @@ export function doesAnalyticsPointMatchNormalizedFilters(
filters.gameVersion,
getGameVersionFilterValue,
) &&
doesAnalyticsPointMatchNormalizedFilter(dataPoint, filters.loaderType, getLoaderFilterValue)
doesAnalyticsPointMatchNormalizedFilter(
dataPoint,
filters.loaderType,
getLoaderFilterValue,
) &&
doesAnalyticsPointMatchNormalizedFilter(
dataPoint,
filters.dependentProjectId,
getDependentProjectIdFilterValue,
) &&
doesAnalyticsDownloadPointMatchDependentProjectTypeFilter(
dataPoint,
filters.dependentProjectType,
dependentProjectTypesById,
)
)
case 'playtime':
return (
@@ -336,7 +366,11 @@ export function doesAnalyticsPointMatchNormalizedFilters(
doesAnalyticsPointMatchNormalizedFilter(dataPoint, filters.loaderType, getLoaderFilterValue)
)
case 'revenue':
return true
return doesAnalyticsPointMatchNormalizedFilter(
dataPoint,
filters.userId,
getUserIdFilterValue,
)
default:
return true
}
@@ -363,6 +397,28 @@ function doesAnalyticsPointMatchNormalizedFilter(
return filterValues.has(normalizedPointValue)
}
function doesAnalyticsDownloadPointMatchDependentProjectTypeFilter(
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
filterValues: ReadonlySet<string>,
dependentProjectTypesById: ReadonlyMap<string, readonly string[]> | undefined,
): boolean {
if (filterValues.size === 0) {
return true
}
if (dataPoint.metric_kind !== 'downloads') {
return true
}
const dependentProjectId =
'dependent_project_id' in dataPoint ? dataPoint.dependent_project_id?.trim() : undefined
if (!dependentProjectId) {
return false
}
const projectTypes = dependentProjectTypesById?.get(dependentProjectId) ?? []
return projectTypes.some((projectType) => filterValues.has(projectType.trim().toLowerCase()))
}
function getCountryFilterValue(
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
): string | null | undefined {
@@ -390,6 +446,16 @@ function getMonetizationFilterValue(
return dataPoint.monetized ? 'monetized' : 'unmonetized'
}
function getDependentProjectIdFilterValue(
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
): string | null | undefined {
if (dataPoint.metric_kind !== 'downloads') {
return undefined
}
return 'dependent_project_id' in dataPoint ? (dataPoint.dependent_project_id ?? null) : undefined
}
function getDownloadSourceFilterValue(
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
): string | null | undefined {
@@ -410,6 +476,16 @@ function getDownloadReasonFilterValue(
return dataPoint.reason ?? null
}
function getUserIdFilterValue(
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
): string | null | undefined {
if (dataPoint.metric_kind !== 'revenue') {
return undefined
}
return dataPoint.user_id ?? null
}
function getVersionFilterValue(
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
): string | null | undefined {
@@ -15,7 +15,7 @@ const PLUGIN_PROJECT_TYPE = 'plugin'
export const UNKNOWN_ORGANIZATION_NAME = 'Organization'
function getProjectTypes(project: ProjectTypeMetadata): string[] {
export function getProjectTypes(project: ProjectTypeMetadata): string[] {
const projectTypes = new Set<string>()
const projectType = project.project_type?.trim()
if (projectType) {
@@ -69,6 +69,7 @@ export function toAnalyticsDashboardProject(
id: project.id,
name: project.name ?? project.title ?? project.id,
iconUrl: project.icon_url ?? undefined,
organizationId: getProjectOrganizationId(project),
downloads: project.downloads ?? 0,
status: getProjectStatusFilterValue(project.status),
publishedAt: project.published ?? undefined,
@@ -10,9 +10,12 @@ export type AnalyticsQueryFilterCategory =
| 'monetization'
| 'user_agent'
| 'download_reason'
| 'user_id'
| 'version_id'
| 'game_version'
| 'loader_type'
| 'dependent_project_id'
| 'dependent_project_type'
export type AnalyticsTimeframePreset =
| 'today'
@@ -37,9 +40,11 @@ export type AnalyticsBreakdownPreset =
| 'monetization'
| 'user_agent'
| 'download_reason'
| 'user_id'
| 'version_id'
| 'loader'
| 'game_version'
| 'dependent_project_download'
export type AnalyticsSelectedBreakdowns = Exclude<AnalyticsBreakdownPreset, 'none'>[]
export type AnalyticsDashboardStat = 'views' | 'downloads' | 'revenue' | 'playtime'
@@ -47,6 +52,7 @@ export type AnalyticsGraphViewMode = 'line' | 'area' | 'bar'
export type AnalyticsTableSortColumn =
| 'date'
| 'project'
| 'dependent_on'
| 'breakdown'
| `breakdown_${Exclude<AnalyticsBreakdownPreset, 'none'>}`
| 'views'
@@ -121,6 +127,7 @@ export interface AnalyticsDashboardProject {
id: string
name: string
iconUrl?: string
organizationId?: string
downloads: number
status: ProjectStatusFilterValue
publishedAt?: string
@@ -151,8 +158,10 @@ export interface AnalyticsDashboardFilterOptions {
countries: string[]
downloadSources: string[]
downloadReasons: string[]
userIds: string[]
gameVersions: string[]
loaderTypes: string[]
dependentProjectTypes: string[]
versionIds: string[]
}
@@ -161,17 +170,22 @@ export interface NormalizedAnalyticsSelectedFilters {
monetization: ReadonlySet<string>
userAgent: ReadonlySet<string>
downloadReason: ReadonlySet<string>
userId: ReadonlySet<string>
versionId: ReadonlySet<string>
gameVersion: ReadonlySet<string>
loaderType: ReadonlySet<string>
dependentProjectId: ReadonlySet<string>
dependentProjectType: ReadonlySet<string>
}
export interface AnalyticsFacetsFilterOptionSummary {
countries: string[]
downloadSources: string[]
downloadReasons: string[]
userIds: string[]
gameVersions: string[]
loaderTypes: string[]
dependentProjectTypes: string[]
versionIds: string[]
projectDownloadsById: Map<string, number>
projectVersionDownloadsById: Map<string, number>
@@ -202,5 +216,7 @@ export type AnalyticsTimeSliceSplit = {
export type AnalyticsFetchData = {
metrics: Labrinth.Analytics.v3.TimeSlice[]
projects: Record<string, Labrinth.Projects.v3.Project>
project_events: Labrinth.Analytics.v3.ProjectAnalyticsEvent[]
users: Record<string, Labrinth.Users.v3.User>
}
@@ -71,6 +71,7 @@ import {
import {
getProjectIdsMatchingStatusFilter,
getProjectOrganizationId,
getProjectTypes,
getSingleQueryValue,
getUniqueAnalyticsDashboardProjects,
isAnalyticsEligibleProject,
@@ -200,8 +201,17 @@ export interface AnalyticsDashboardContextValue {
isAnalyticsFilterOptionsLoading: ComputedRef<boolean>
versionNumbersById: ComputedRef<Map<string, string>>
versionPublishedDatesById: ComputedRef<Map<string, string>>
versionProjectIdsById: ComputedRef<Map<string, string>>
versionProjectNamesById: ComputedRef<Map<string, string>>
versionProjectIconUrlsById: ComputedRef<Map<string, string>>
versionProjectOrganizationNamesById: ComputedRef<Map<string, string>>
projectNamesById: ComputedRef<Map<string, string>>
projectIconUrlsById: ComputedRef<Map<string, string>>
projectOrganizationIdsById: ComputedRef<Map<string, string>>
projectOrganizationNamesById: ComputedRef<Map<string, string>>
userNamesById: ComputedRef<Map<string, string>>
userAvatarUrlsById: ComputedRef<Map<string, string>>
dependentProjectTypesById: ComputedRef<Map<string, string[]>>
projectStatusById: ComputedRef<Map<string, ProjectStatusFilterValue>>
availableProjectStatuses: ComputedRef<ProjectStatusFilterValue[]>
availableProjectDownloadsById: ComputedRef<Map<string, number>>
@@ -250,6 +260,7 @@ export interface AnalyticsDashboardContextValue {
getVersionPublishedDate: (versionId: string) => string | undefined
getVersionProjectName: (versionId: string) => string | undefined
getVersionProjectIconUrl: (versionId: string) => string | undefined
getVersionProjectOrganizationName: (versionId: string) => string | undefined
setFetchRequest: (fetchRequest: Labrinth.Analytics.v3.FetchRequest) => void
setActiveStat: (stat: AnalyticsDashboardStat) => void
}
@@ -565,17 +576,6 @@ export function createAnalyticsDashboardContext(
? dashboardUserProjectIds.value
: availableProjectIds.value,
)
const projectNamesById = computed(
() => new Map(projects.value.map((project) => [project.id, project.name])),
)
const projectIconUrlsById = computed(
() =>
new Map(
projects.value
.filter((project) => project.iconUrl)
.map((project) => [project.id, project.iconUrl as string]),
),
)
const projectStatusById = computed(
() => new Map(projects.value.map((project) => [project.id, project.status])),
)
@@ -808,6 +808,7 @@ export function createAnalyticsDashboardContext(
},
availableProjectIds,
defaultProjectIds,
areProjectsLoaded,
sanitizeSelectedFilters: sanitizeAnalyticsSelectedFiltersForContext,
})
@@ -996,6 +997,7 @@ export function createAnalyticsDashboardContext(
selectedFilters,
availableProjectIds,
defaultProjectIds,
areProjectsLoaded,
],
() => {
syncQueryBuilderRouteQuery()
@@ -1056,6 +1058,8 @@ export function createAnalyticsDashboardContext(
if (!isAnalyticsFetchRequestReady(nextFetchRequest)) {
return {
metrics: [],
projects: {},
users: {},
project_events: [],
}
}
@@ -1307,10 +1311,185 @@ export function createAnalyticsDashboardContext(
const projectVersionFilterOptionSummary = computed(() =>
getProjectVersionFilterOptionSummary(filterOptionProjectVersions.value ?? []),
)
const timeSlices = shallowRef<Labrinth.Analytics.v3.TimeSlice[]>([])
const previousTimeSlices = shallowRef<Labrinth.Analytics.v3.TimeSlice[]>([])
const analyticsProjects = shallowRef<Record<string, Labrinth.Projects.v3.Project>>({})
const analyticsUsers = shallowRef<Record<string, Labrinth.Users.v3.User>>({})
const baseOrganizationNamesById = computed(() => {
const organizationNames = new Map<string, string>()
const organization = options.organizationContext?.organization.value
if (organization) {
organizationNames.set(organization.id, organization.name)
}
for (const [organizationId, organization] of Object.entries(
dashboardAllProjects.value?.organizations ?? {},
)) {
organizationNames.set(organizationId, organization.name)
}
return organizationNames
})
const missingOrganizationIds = computed(() => {
const knownOrganizationNames = baseOrganizationNamesById.value
const organizationIds = new Set<string>()
for (const project of projects.value) {
if (project.organizationId && !knownOrganizationNames.has(project.organizationId)) {
organizationIds.add(project.organizationId)
}
}
for (const project of Object.values(analyticsProjects.value)) {
const organizationId = getProjectOrganizationId(project)
if (organizationId && !knownOrganizationNames.has(organizationId)) {
organizationIds.add(organizationId)
}
}
return sortStringValues([...organizationIds])
})
const { data: missingOrganizations } = useQuery({
queryKey: computed(() => [
'analytics',
'dashboard',
'missing-organizations',
missingOrganizationIds.value,
]),
queryFn: () => client.labrinth.organizations_v3.getMultiple(missingOrganizationIds.value),
enabled: computed(() => missingOrganizationIds.value.length > 0),
placeholderData: [],
refetchOnWindowFocus: false,
})
const organizationNamesById = computed(() => {
const organizationNames = new Map(baseOrganizationNamesById.value)
for (const organization of missingOrganizations.value ?? []) {
organizationNames.set(organization.id, organization.name)
}
return organizationNames
})
const projectIconUrlsById = computed(() => {
const projectIconUrls = new Map(
projects.value
.filter((project) => project.iconUrl)
.map((project) => [project.id, project.iconUrl as string]),
)
for (const [projectId, project] of Object.entries(analyticsProjects.value)) {
if (project.icon_url) {
projectIconUrls.set(projectId, project.icon_url)
}
}
return projectIconUrls
})
const projectOrganizationIdsById = computed(() => {
const contextOrganizationId = hasOrganizationContext.value
? options.organizationContext?.organization.value?.id
: undefined
const projectOrganizationIds = new Map<string, string>()
for (const project of projects.value) {
if (project.organizationId) {
projectOrganizationIds.set(project.id, project.organizationId)
} else if (contextOrganizationId) {
projectOrganizationIds.set(project.id, contextOrganizationId)
}
}
for (const [projectId, project] of Object.entries(analyticsProjects.value)) {
const organizationId = getProjectOrganizationId(project)
if (organizationId) {
projectOrganizationIds.set(projectId, organizationId)
}
}
return projectOrganizationIds
})
const projectOrganizationNamesById = computed(() => {
const organizationNames = organizationNamesById.value
const contextOrganizationName = hasOrganizationContext.value
? options.organizationContext?.organization.value?.name
: undefined
const projectOrganizationNames = new Map<string, string>()
for (const project of projects.value) {
if (project.organizationId) {
const organizationName = organizationNames.get(project.organizationId)
if (organizationName) {
projectOrganizationNames.set(project.id, organizationName)
}
} else if (contextOrganizationName) {
projectOrganizationNames.set(project.id, contextOrganizationName)
}
}
for (const [projectId, project] of Object.entries(analyticsProjects.value)) {
const organizationId = getProjectOrganizationId(project)
if (organizationId) {
const organizationName = organizationNames.get(organizationId)
if (organizationName) {
projectOrganizationNames.set(projectId, organizationName)
}
}
}
return projectOrganizationNames
})
const dependentProjectTypesById = computed(() => {
const projectTypesById = new Map<string, string[]>()
for (const project of projects.value) {
projectTypesById.set(project.id, project.projectTypes)
}
for (const [projectId, project] of Object.entries(analyticsProjects.value)) {
projectTypesById.set(projectId, getProjectTypes(project))
}
return projectTypesById
})
const dependentProjectTypeFilterOptions = computed(() => {
const projectTypes = new Set<string>()
const dependentProjectIds = new Set<string>()
for (const timeSlice of [...timeSlices.value, ...previousTimeSlices.value]) {
for (const dataPoint of timeSlice) {
const dependentProjectId =
'dependent_project_id' in dataPoint ? dataPoint.dependent_project_id?.trim() : undefined
if (dependentProjectId) {
dependentProjectIds.add(dependentProjectId)
}
}
}
for (const projectId of dependentProjectIds) {
const types = dependentProjectTypesById.value.get(projectId) ?? []
for (const type of types) {
const normalizedType = type.trim().toLowerCase()
if (normalizedType.length > 0) {
projectTypes.add(normalizedType)
}
}
}
return sortStringValues([...projectTypes])
})
const userIdFilterOptions = computed(() => {
const userIds = new Set<string>()
for (const userId of selectedFilters.value.user_id) {
const normalizedUserId = userId.trim()
if (normalizedUserId.length > 0) {
userIds.add(normalizedUserId)
}
}
for (const userId of Object.keys(analyticsUsers.value)) {
userIds.add(userId)
}
for (const timeSlice of timeSlices.value) {
for (const dataPoint of timeSlice) {
const userId =
dataPoint.metric_kind === 'revenue' && 'user_id' in dataPoint
? dataPoint.user_id?.trim()
: undefined
if (userId) {
userIds.add(userId)
}
}
}
return sortStringValues([...userIds])
})
const filterOptions = computed<AnalyticsDashboardFilterOptions>(() => ({
countries: analyticsFacetsFilterOptionSummary.value.countries,
downloadSources: analyticsFacetsFilterOptionSummary.value.downloadSources,
downloadReasons: analyticsFacetsFilterOptionSummary.value.downloadReasons,
userIds: userIdFilterOptions.value,
gameVersions: sortStringValues([
...new Set([
...projectVersionFilterOptionSummary.value.gameVersions,
@@ -1323,6 +1502,7 @@ export function createAnalyticsDashboardContext(
...analyticsFacetsFilterOptionSummary.value.loaderTypes,
]),
]),
dependentProjectTypes: dependentProjectTypeFilterOptions.value,
versionIds: sortStringValues([
...new Set([
...projectVersionFilterOptionSummary.value.versionIds,
@@ -1362,8 +1542,6 @@ export function createAnalyticsDashboardContext(
{ deep: true },
)
const timeSlices = shallowRef<Labrinth.Analytics.v3.TimeSlice[]>([])
const previousTimeSlices = shallowRef<Labrinth.Analytics.v3.TimeSlice[]>([])
const projectEvents = shallowRef<Labrinth.Analytics.v3.ProjectAnalyticsEvent[]>([])
const displayedSelectedProjectIds = ref<string[]>([...selectedProjectIds.value])
const displayedSelectedGroupBy = ref<AnalyticsGroupByPreset>(selectedGroupBy.value)
@@ -1408,6 +1586,8 @@ export function createAnalyticsDashboardContext(
)
timeSlices.value = splitTimeSlices.currentTimeSlices
previousTimeSlices.value = splitTimeSlices.previousTimeSlices
analyticsProjects.value = nextAnalyticsData.projects
analyticsUsers.value = nextAnalyticsData.users
projectEvents.value = getAnalyticsProjectEventsInTimeRange(
nextAnalyticsData.project_events,
fetchRequest.value,
@@ -1423,9 +1603,38 @@ export function createAnalyticsDashboardContext(
}
timeSlices.value = []
previousTimeSlices.value = []
analyticsProjects.value = {}
analyticsUsers.value = {}
projectEvents.value = []
})
const projectNamesById = computed(() => {
const projectNames = new Map(projects.value.map((project) => [project.id, project.name]))
for (const [projectId, project] of Object.entries(analyticsProjects.value)) {
projectNames.set(projectId, project.name ?? projectNames.get(projectId) ?? projectId)
}
return projectNames
})
const userNamesById = computed(
() =>
new Map(
Object.entries(analyticsUsers.value).map(([userId, user]) => [
userId,
user.username ?? userId,
]),
),
)
const userAvatarUrlsById = computed(
() =>
new Map(
Object.entries(analyticsUsers.value)
.filter((entry): entry is [string, Labrinth.Users.v3.User & { avatar_url: string }] =>
Boolean(entry[1].avatar_url),
)
.map(([userId, user]) => [userId, user.avatar_url]),
),
)
const analyticsVersionIds = computed(() => {
const versionIds = new Set<string>()
for (const versionId of selectedFilters.value.version_id) {
@@ -1481,6 +1690,26 @@ export function createAnalyticsDashboardContext(
const versionPublishedDatesById = computed(
() => new Map(allVersionMetadata.value.map((version) => [version.id, version.datePublished])),
)
const versionProjectIdsById = computed(() => {
const versionProjectIds = new Map(
allVersionMetadata.value.map((version) => [version.id, version.projectId]),
)
for (const timeSlice of [...timeSlices.value, ...previousTimeSlices.value]) {
for (const dataPoint of timeSlice) {
if (
'source_project' in dataPoint &&
(dataPoint.metric_kind === 'downloads' || dataPoint.metric_kind === 'playtime') &&
dataPoint.version_id
) {
const versionId = dataPoint.version_id.trim()
if (versionId.length > 0 && !versionProjectIds.has(versionId)) {
versionProjectIds.set(versionId, dataPoint.source_project)
}
}
}
}
return versionProjectIds
})
const versionProjectNamesById = computed(() => {
const projectNames = projectNamesById.value
const versionProjectNames = new Map<string, string>()
@@ -1509,6 +1738,17 @@ export function createAnalyticsDashboardContext(
}
return versionProjectIconUrls
})
const versionProjectOrganizationNamesById = computed(() => {
const projectOrganizationNames = projectOrganizationNamesById.value
const versionProjectOrganizationNames = new Map<string, string>()
for (const version of allVersionMetadata.value) {
const organizationName = projectOrganizationNames.get(version.projectId)
if (organizationName) {
versionProjectOrganizationNames.set(version.id, organizationName)
}
}
return versionProjectOrganizationNames
})
const downloadCountTimeSlices = computed(() => {
const countTimeSlices = analyticsDownloadCountTimeSlices.value ?? []
return countTimeSlices.length > 0 ? countTimeSlices : timeSlices.value
@@ -1539,6 +1779,7 @@ export function createAnalyticsDashboardContext(
availableProjectIdSet.value,
projectStatusById.value,
selectedFilters.value,
dependentProjectTypesById.value,
),
)
const previousTotals = computed<AnalyticsDashboardTotals>(() =>
@@ -1548,6 +1789,7 @@ export function createAnalyticsDashboardContext(
availableProjectIdSet.value,
projectStatusById.value,
selectedFilters.value,
dependentProjectTypesById.value,
),
)
@@ -1665,6 +1907,10 @@ export function createAnalyticsDashboardContext(
return versionProjectIconUrlsById.value.get(versionId)
}
function getVersionProjectOrganizationName(versionId: string): string | undefined {
return versionProjectOrganizationNamesById.value.get(versionId)
}
function setActiveStat(nextStat: AnalyticsDashboardStat) {
if (
!isAnalyticsDashboardStatRelevant(nextStat, selectedBreakdowns.value, selectedFilters.value)
@@ -1709,8 +1955,17 @@ export function createAnalyticsDashboardContext(
isAnalyticsFilterOptionsLoading,
versionNumbersById,
versionPublishedDatesById,
versionProjectIdsById,
versionProjectNamesById,
versionProjectIconUrlsById,
versionProjectOrganizationNamesById,
projectNamesById,
projectIconUrlsById,
projectOrganizationIdsById,
projectOrganizationNamesById,
userNamesById,
userAvatarUrlsById,
dependentProjectTypesById,
projectStatusById,
availableProjectStatuses,
availableProjectDownloadsById,
@@ -1752,6 +2007,7 @@ export function createAnalyticsDashboardContext(
getVersionPublishedDate,
getVersionProjectName,
getVersionProjectIconUrl,
getVersionProjectOrganizationName,
setFetchRequest,
setActiveStat,
}
@@ -313,6 +313,7 @@ export namespace Labrinth {
export type ProjectDownloadsField =
| 'project_id'
| 'version_id'
| 'dependent_project_id'
| 'user_agent'
| 'domain'
| 'country'
@@ -328,7 +329,7 @@ export namespace Labrinth {
| 'game_version'
| 'country'
export type ProjectRevenueField = 'project_id'
export type ProjectRevenueField = 'project_id' | 'user_id'
export type DownloadReason = 'standalone' | 'dependency' | 'modpack' | 'update'
@@ -347,6 +348,7 @@ export namespace Labrinth {
export type ProjectDownloadsFilters = {
version_id?: string[]
dependent_project_id?: string[]
domain?: string[]
user_agent?: string[]
monetized?: boolean[]
@@ -363,7 +365,9 @@ export namespace Labrinth {
country?: string[]
}
export type ProjectRevenueFilters = Record<string, never>
export type ProjectRevenueFilters = {
user_id?: string[]
}
export type AffiliateCodeClicksFilters = {
affiliate_code_id?: string[]
@@ -379,6 +383,8 @@ export namespace Labrinth {
export type FetchResponse = {
metrics: TimeSlice[]
projects: Record<string, Projects.v3.Project>
users: Record<string, Users.v3.User>
project_events: ProjectAnalyticsEvent[]
}
@@ -462,6 +468,7 @@ export namespace Labrinth {
user_agent?: string
domain?: string
version_id?: string
dependent_project_id?: string
country?: string
monetized?: boolean
reason?: DownloadReason
@@ -479,6 +486,7 @@ export namespace Labrinth {
}
export type ProjectRevenue = {
user_id?: string
revenue: string
}
@@ -414,9 +414,11 @@ export type DropdownFilterBarCategory = {
options: DropdownFilterBarItem[]
syntheticOptions?: DropdownFilterBarOption[]
searchable?: boolean
disableLocalOptionsFilter?: boolean
searchPlaceholder?: string
emptyOptionsLabel?: string
emptySearchLabel?: string
onSearchQueryChange?: (query: string) => void
submenuClass?: string
previewDropdownWidth?: string | number
previewDropdownMinWidth?: string | number
@@ -618,7 +620,7 @@ const filteredActiveCategoryOptions = computed(() => {
return []
}
if (!activeCategory.value.searchable) {
if (!activeCategory.value.searchable || activeCategory.value.disableLocalOptionsFilter) {
return activeCategory.value.options
}
@@ -1808,6 +1810,7 @@ watch(isAddMenuOpen, (isOpen) => {
})
watch(categorySearchQuery, () => {
activeCategory.value?.onSearchQueryChange?.(categorySearchQuery.value)
resetActiveCategoryOptionsScrollState()
initializeActiveCategoryOptionsOverlayScrollbars()
scheduleSubmenuPositionUpdate()
@@ -1816,6 +1819,7 @@ watch(categorySearchQuery, () => {
watch(activeCategoryKey, (categoryKey) => {
resetActiveCategoryOptionsScrollState()
if (categoryKey) {
activeCategory.value?.onSearchQueryChange?.(categorySearchQuery.value)
initializeActiveCategoryOptionsOverlayScrollbars()
} else {
destroyActiveCategoryOptionsOverlayScrollbars()
+1 -1
View File
@@ -44,7 +44,7 @@
<span
v-if="column.label || column.enableSorting"
class="inline-flex min-w-0 max-w-full items-center gap-1 font-semibold"
:class="`${sortColumn === column.key ? 'text-contrast' : ''}`"
:class="`${sortColumn === column.key ? 'text-contrast -mr-1' : ''}`"
>
<span class="min-w-0 truncate">{{ column.label ?? '' }}</span>
<template v-if="column.enableSorting">
+29
View File
@@ -823,3 +823,32 @@ function getParamValuesAsArray(x: LocationQueryValue | LocationQueryValue[]): st
return x.filter((x) => x !== null)
}
}
export function buildDependentsSearchFilters(
projectTypes: readonly ProjectType[],
dependencyProjectIds: readonly string[],
): string {
const parts: string[] = []
const mappedProjectTypes = projectTypes.map(mapProjectTypeToSearch)
if (mappedProjectTypes.length === 1) {
parts.push(`project_types = ${formatSearchFilterValue(mappedProjectTypes[0])}`)
} else if (mappedProjectTypes.length > 1) {
const quoted = mappedProjectTypes.map(formatSearchFilterValue).join(', ')
parts.push(`project_types IN [${quoted}]`)
}
const normalizedProjectIds = Array.from(
new Set(dependencyProjectIds.map((projectId) => projectId.trim()).filter(Boolean)),
)
if (normalizedProjectIds.length === 1) {
parts.push(
`compatible_dependency_project_ids = ${formatSearchFilterValue(normalizedProjectIds[0])}`,
)
} else if (normalizedProjectIds.length > 1) {
const quoted = normalizedProjectIds.map(formatSearchFilterValue).join(', ')
parts.push(`compatible_dependency_project_ids IN [${quoted}]`)
}
return parts.join(' AND ')
}