feat: implement dependent projects + members breakdown/filter (#6395)

* feat: implement dependent project breakdown

* implement dependent project type filter

* feat: show dependent on column if there are multiple projects selected

* feat: different tooltip for when both project version and dependent breakdowns are on

* feat: implement dependents query filter

* feat: add project icon to query filter

* fix: dont show dependent on column when project breakdown is selected

* feat: add org icon

* remove: dependent on project column and project version project column

* refactor: pnpm prepr

* fix: all project selection gets unselected when reload page

* pnpm prepr

* fix: still using old search formatter

* feat: implement projects in table click to link to project page

* feat: handle analytics values that dont have a dependent project as a "No dependent" row

* pnpm prepr

* feat: remove org icon for dependents projects and change unknown label

* feat: do not include unknown rows for showing top 8

* feat: separate out no dependents based on download reason

* fix: use compatible dependencies for query filter options

* feat: implement members breakdown/filter frontend (#6470)

* feat: add link to user in table

* fix: do not allow breakdowns to occur without shared stats

* pnpm prepr

* fix: i18n and passing params for formatter
This commit is contained in:
Truman Gao
2026-06-22 18:09:00 +00:00
committed by GitHub
parent 23edb2351d
commit 1f8b1bd2bb
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,