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
@@ -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,
}