mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 09:04:55 +00:00
feat: various analytics updates (#6330)
* feat: add button to view user analytics * feat: add "Your projects" preset selection * feat: fix revenue rounding for values under 1 and show full values for all statcards with tooltip * fix: sum rounded value instead of raw value for tooltip total if it's under 1 * fix: show decimal in playtime statcard if under 1 hrs * feat: disable playtime statcard for purely plugin projects * refactor: pnpm prepr
This commit is contained in:
@@ -11,9 +11,29 @@ import type {
|
||||
} from './analytics-types'
|
||||
|
||||
const MINECRAFT_JAVA_SERVER_PROJECT_TYPE = 'minecraft_java_server'
|
||||
const PLUGIN_PROJECT_TYPE = 'plugin'
|
||||
|
||||
export const UNKNOWN_ORGANIZATION_NAME = 'Organization'
|
||||
|
||||
function getProjectTypes(project: ProjectTypeMetadata): string[] {
|
||||
const projectTypes = new Set<string>()
|
||||
const projectType = project.project_type?.trim()
|
||||
if (projectType) {
|
||||
projectTypes.add(projectType)
|
||||
}
|
||||
|
||||
for (const types of [project.project_types, project.projectTypes]) {
|
||||
for (const type of types ?? []) {
|
||||
const projectType = type.trim()
|
||||
if (projectType) {
|
||||
projectTypes.add(projectType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...projectTypes]
|
||||
}
|
||||
|
||||
function isServerProject(project: ProjectTypeMetadata): boolean {
|
||||
if (project.project_type === MINECRAFT_JAVA_SERVER_PROJECT_TYPE) {
|
||||
return true
|
||||
@@ -28,6 +48,11 @@ export function isAnalyticsEligibleProject(
|
||||
return !isServerProject(project) && getProjectStatusFilterValue(project.status) !== 'draft'
|
||||
}
|
||||
|
||||
export function isPluginProject(project: ProjectTypeMetadata): boolean {
|
||||
const projectTypes = getProjectTypes(project)
|
||||
return projectTypes.length > 0 && projectTypes.every((type) => type === PLUGIN_PROJECT_TYPE)
|
||||
}
|
||||
|
||||
export function getSingleQueryValue(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined
|
||||
@@ -47,6 +72,7 @@ export function toAnalyticsDashboardProject(
|
||||
downloads: project.downloads ?? 0,
|
||||
status: getProjectStatusFilterValue(project.status),
|
||||
publishedAt: project.published ?? undefined,
|
||||
projectTypes: getProjectTypes(project),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ export type MutableRouteQuery = Record<
|
||||
export type ProjectTypeMetadata = {
|
||||
project_type?: string | null
|
||||
project_types?: readonly string[] | null
|
||||
projectTypes?: readonly string[] | null
|
||||
}
|
||||
|
||||
export type AnalyticsProjectFetchRequest = Labrinth.Analytics.v3.FetchRequest & {
|
||||
@@ -123,6 +124,7 @@ export interface AnalyticsDashboardProject {
|
||||
downloads: number
|
||||
status: ProjectStatusFilterValue
|
||||
publishedAt?: string
|
||||
projectTypes: string[]
|
||||
}
|
||||
|
||||
export interface AnalyticsDashboardProjectGroup {
|
||||
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
getSingleQueryValue,
|
||||
getUniqueAnalyticsDashboardProjects,
|
||||
isAnalyticsEligibleProject,
|
||||
isPluginProject,
|
||||
toAnalyticsDashboardProject,
|
||||
UNKNOWN_ORGANIZATION_NAME,
|
||||
} from './analytics-project-utils'
|
||||
@@ -169,6 +170,11 @@ export interface AnalyticsDashboardContextValue {
|
||||
hasProjectContext: ComputedRef<boolean>
|
||||
projectGroups: ComputedRef<AnalyticsDashboardProjectGroup[]>
|
||||
projects: ComputedRef<AnalyticsDashboardProject[]>
|
||||
dashboardUserProjectIds: ComputedRef<string[]>
|
||||
dashboardOrganizationProjectIds: ComputedRef<string[]>
|
||||
defaultProjectIds: ComputedRef<string[]>
|
||||
isUsingDashboardUserOverride: ComputedRef<boolean>
|
||||
dashboardProjectUserName: ComputedRef<string>
|
||||
selectedProjectIds: Ref<string[]>
|
||||
selectedTimeframeMode: Ref<AnalyticsTimeframeMode>
|
||||
selectedTimeframe: Ref<AnalyticsTimeframePreset>
|
||||
@@ -198,6 +204,7 @@ export interface AnalyticsDashboardContextValue {
|
||||
versionProjectIconUrlsById: ComputedRef<Map<string, string>>
|
||||
projectStatusById: ComputedRef<Map<string, ProjectStatusFilterValue>>
|
||||
availableProjectStatuses: ComputedRef<ProjectStatusFilterValue[]>
|
||||
availableProjectDownloadsById: ComputedRef<Map<string, number>>
|
||||
projectDownloadsById: ComputedRef<Map<string, number>>
|
||||
projectVersionDownloadsById: ComputedRef<Map<string, number>>
|
||||
gameVersionDownloadsByVersion: ComputedRef<Map<string, number>>
|
||||
@@ -519,6 +526,45 @@ export function createAnalyticsDashboardContext(
|
||||
)
|
||||
|
||||
const availableProjectIds = computed(() => projects.value.map((project) => project.id))
|
||||
const dashboardUserProjectIds = computed(() => {
|
||||
if (!shouldFetchDashboardAllProjects.value) {
|
||||
return [...availableProjectIds.value]
|
||||
}
|
||||
|
||||
const response = dashboardAllProjects.value
|
||||
if (!response) {
|
||||
return []
|
||||
}
|
||||
|
||||
const availableProjectIdSet = new Set(availableProjectIds.value)
|
||||
return response.projects
|
||||
.filter(
|
||||
(project) => !getProjectOrganizationId(project) && availableProjectIdSet.has(project.id),
|
||||
)
|
||||
.map((project) => project.id)
|
||||
})
|
||||
const dashboardOrganizationProjectIds = computed(() => {
|
||||
if (!shouldFetchDashboardAllProjects.value) {
|
||||
return []
|
||||
}
|
||||
|
||||
const response = dashboardAllProjects.value
|
||||
if (!response) {
|
||||
return []
|
||||
}
|
||||
|
||||
const availableProjectIdSet = new Set(availableProjectIds.value)
|
||||
return response.projects
|
||||
.filter(
|
||||
(project) => getProjectOrganizationId(project) && availableProjectIdSet.has(project.id),
|
||||
)
|
||||
.map((project) => project.id)
|
||||
})
|
||||
const defaultProjectIds = computed(() =>
|
||||
dashboardOrganizationProjectIds.value.length > 0 && dashboardUserProjectIds.value.length > 0
|
||||
? dashboardUserProjectIds.value
|
||||
: availableProjectIds.value,
|
||||
)
|
||||
const projectNamesById = computed(
|
||||
() => new Map(projects.value.map((project) => [project.id, project.name])),
|
||||
)
|
||||
@@ -537,6 +583,7 @@ export function createAnalyticsDashboardContext(
|
||||
const presentStatuses = new Set(projects.value.map((project) => project.status))
|
||||
return PROJECT_STATUS_FILTER_VALUES.filter((status) => presentStatuses.has(status))
|
||||
})
|
||||
const sortedAvailableProjectIds = computed(() => sortStringValues(availableProjectIds.value))
|
||||
const sortedSelectedProjectIds = computed(() => sortStringValues(selectedProjectIds.value))
|
||||
const filterOptionProjectSources = computed<AnalyticsProjectVersionSource[] | null>(() => {
|
||||
if (hasProjectContext.value && options.projectPageContext) {
|
||||
@@ -642,6 +689,7 @@ export function createAnalyticsDashboardContext(
|
||||
selectedFilters: selectedFilters.value,
|
||||
},
|
||||
availableProjectIds.value,
|
||||
defaultProjectIds.value,
|
||||
)
|
||||
const isGraphDefault = isAnalyticsGraphStateDefault(
|
||||
{
|
||||
@@ -674,17 +722,35 @@ export function createAnalyticsDashboardContext(
|
||||
allTimeStartTimestamp: analyticsAllTimeStartDate.value.getTime(),
|
||||
}) > REVENUE_MIN_TIMEFRAME_MS,
|
||||
)
|
||||
const isPlaytimeAvailableForProjectSelection = computed(() => {
|
||||
const selectedProjectIdSet = new Set(selectedProjectIds.value)
|
||||
const selectedProjects = projects.value.filter((project) =>
|
||||
selectedProjectIdSet.has(project.id),
|
||||
)
|
||||
|
||||
return (
|
||||
selectedProjects.length === 0 || selectedProjects.some((project) => !isPluginProject(project))
|
||||
)
|
||||
})
|
||||
|
||||
function isAnalyticsDashboardStatAvailableForTimeframe(stat: AnalyticsDashboardStat): boolean {
|
||||
return stat !== 'revenue' || isRevenueTimeframeAvailable.value
|
||||
}
|
||||
|
||||
function isAnalyticsDashboardStatAvailableForProjectSelection(
|
||||
stat: AnalyticsDashboardStat,
|
||||
): boolean {
|
||||
return stat !== 'playtime' || isPlaytimeAvailableForProjectSelection.value
|
||||
}
|
||||
|
||||
function getRelevantAnalyticsDashboardStats(
|
||||
breakdowns: readonly AnalyticsBreakdownPreset[],
|
||||
filters: AnalyticsSelectedFilters = selectedFilters.value,
|
||||
): readonly AnalyticsDashboardStat[] {
|
||||
return getEnabledAnalyticsStatsForState(breakdowns, filters).filter((stat) =>
|
||||
isAnalyticsDashboardStatAvailableForTimeframe(stat),
|
||||
return getEnabledAnalyticsStatsForState(breakdowns, filters).filter(
|
||||
(stat) =>
|
||||
isAnalyticsDashboardStatAvailableForTimeframe(stat) &&
|
||||
isAnalyticsDashboardStatAvailableForProjectSelection(stat),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -741,6 +807,7 @@ export function createAnalyticsDashboardContext(
|
||||
selectedGraphDatasetIds,
|
||||
},
|
||||
availableProjectIds,
|
||||
defaultProjectIds,
|
||||
sanitizeSelectedFilters: sanitizeAnalyticsSelectedFiltersForContext,
|
||||
})
|
||||
|
||||
@@ -768,7 +835,13 @@ export function createAnalyticsDashboardContext(
|
||||
}
|
||||
|
||||
watch(
|
||||
[selectedBreakdowns, selectedFilters, activeStat, isRevenueTimeframeAvailable],
|
||||
[
|
||||
selectedBreakdowns,
|
||||
selectedFilters,
|
||||
activeStat,
|
||||
isRevenueTimeframeAvailable,
|
||||
isPlaytimeAvailableForProjectSelection,
|
||||
],
|
||||
([nextBreakdowns, nextFilters, nextActiveStat]) => {
|
||||
if (isAnalyticsDashboardStatRelevant(nextActiveStat, nextBreakdowns, nextFilters)) {
|
||||
return
|
||||
@@ -846,9 +919,10 @@ export function createAnalyticsDashboardContext(
|
||||
return
|
||||
}
|
||||
|
||||
const availableProjectIds = new Set(nextProjects.map((project) => project.id))
|
||||
const nextAvailableProjectIds = nextProjects.map((project) => project.id)
|
||||
const availableProjectIds = new Set(nextAvailableProjectIds)
|
||||
if (!hasExplicitProjectSelectionQuery.value) {
|
||||
const nextSelectedProjectIds = nextProjects.map((project) => project.id)
|
||||
const nextSelectedProjectIds = [...defaultProjectIds.value]
|
||||
syncSelectedBreakdownsForProjectSelection(nextSelectedProjectIds)
|
||||
syncProjectEventsVisibilityForProjectSelection(nextSelectedProjectIds)
|
||||
if (!areStringArraysEqual(selectedProjectIds.value, nextSelectedProjectIds)) {
|
||||
@@ -858,9 +932,14 @@ export function createAnalyticsDashboardContext(
|
||||
return
|
||||
}
|
||||
|
||||
const retainedSelection = selectedProjectIds.value.filter((id) => availableProjectIds.has(id))
|
||||
const queryProjectSelection = readAnalyticsQueryBuilderState(
|
||||
route.query,
|
||||
nextAvailableProjectIds,
|
||||
defaultProjectIds.value,
|
||||
).selectedProjectIds
|
||||
const retainedSelection = queryProjectSelection.filter((id) => availableProjectIds.has(id))
|
||||
const nextSelectedProjectIds =
|
||||
retainedSelection.length > 0 ? retainedSelection : nextProjects.map((project) => project.id)
|
||||
retainedSelection.length > 0 ? retainedSelection : [...defaultProjectIds.value]
|
||||
|
||||
syncSelectedBreakdownsForProjectSelection(nextSelectedProjectIds)
|
||||
syncProjectEventsVisibilityForProjectSelection(nextSelectedProjectIds)
|
||||
@@ -916,6 +995,7 @@ export function createAnalyticsDashboardContext(
|
||||
selectedBreakdowns,
|
||||
selectedFilters,
|
||||
availableProjectIds,
|
||||
defaultProjectIds,
|
||||
],
|
||||
() => {
|
||||
syncQueryBuilderRouteQuery()
|
||||
@@ -1090,6 +1170,19 @@ export function createAnalyticsDashboardContext(
|
||||
|
||||
return buildAnalyticsFacetsRequest(sortedSelectedProjectIds.value, nextFetchRequest.time_range)
|
||||
})
|
||||
const availableProjectDownloadCountRequest = computed<Labrinth.Analytics.v3.FetchRequest | null>(
|
||||
() => {
|
||||
const nextFetchRequest = fetchRequest.value
|
||||
if (!nextFetchRequest || sortedAvailableProjectIds.value.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return buildAnalyticsFacetsRequest(
|
||||
sortedAvailableProjectIds.value,
|
||||
nextFetchRequest.time_range,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const {
|
||||
data: analyticsFacetsData,
|
||||
@@ -1148,6 +1241,36 @@ export function createAnalyticsDashboardContext(
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
const { data: availableProjectDownloadCountTimeSlices } = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'analytics',
|
||||
'dashboard',
|
||||
analyticsQueryUserId.value,
|
||||
'filter-options',
|
||||
'available-project-download-counts',
|
||||
availableProjectDownloadCountRequest.value,
|
||||
queryRefreshTimestamp.value,
|
||||
]),
|
||||
queryFn: () => {
|
||||
const nextRequest = availableProjectDownloadCountRequest.value
|
||||
if (!isAnalyticsFetchRequestReady(nextRequest)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return fetchAnalyticsTimeSlices(nextRequest, (request) =>
|
||||
client.labrinth.analytics_v3.fetch(request),
|
||||
)
|
||||
},
|
||||
enabled: computed(
|
||||
() =>
|
||||
hasCompletedAnalyticsLoading.value &&
|
||||
isAnalyticsFetchRequestReady(availableProjectDownloadCountRequest.value),
|
||||
),
|
||||
placeholderData: [],
|
||||
gcTime: ANALYTICS_FILTER_OPTIONS_GC_TIME_MS,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
const { data: analyticsDownloadCountTimeSlices } = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'analytics',
|
||||
@@ -1390,6 +1513,9 @@ export function createAnalyticsDashboardContext(
|
||||
const countTimeSlices = analyticsDownloadCountTimeSlices.value ?? []
|
||||
return countTimeSlices.length > 0 ? countTimeSlices : timeSlices.value
|
||||
})
|
||||
const availableProjectDownloadsById = computed(() =>
|
||||
getProjectDownloadsByIdFromTimeSlices(availableProjectDownloadCountTimeSlices.value ?? []),
|
||||
)
|
||||
const projectDownloadsById = computed(() =>
|
||||
getProjectDownloadsByIdFromTimeSlices(downloadCountTimeSlices.value),
|
||||
)
|
||||
@@ -1484,7 +1610,10 @@ export function createAnalyticsDashboardContext(
|
||||
return
|
||||
}
|
||||
|
||||
const defaultQueryState = buildDefaultAnalyticsQueryBuilderState(availableProjectIds.value)
|
||||
const defaultQueryState = buildDefaultAnalyticsQueryBuilderState(
|
||||
availableProjectIds.value,
|
||||
defaultProjectIds.value,
|
||||
)
|
||||
const defaultGraphState = buildDefaultAnalyticsGraphState(defaultQueryState.selectedProjectIds)
|
||||
|
||||
selectedProjectIds.value = defaultQueryState.selectedProjectIds
|
||||
@@ -1550,6 +1679,11 @@ export function createAnalyticsDashboardContext(
|
||||
hasProjectContext,
|
||||
projectGroups,
|
||||
projects,
|
||||
dashboardUserProjectIds,
|
||||
dashboardOrganizationProjectIds,
|
||||
defaultProjectIds,
|
||||
isUsingDashboardUserOverride,
|
||||
dashboardProjectUserName: effectiveUsername,
|
||||
selectedProjectIds,
|
||||
selectedTimeframeMode,
|
||||
selectedTimeframe,
|
||||
@@ -1579,6 +1713,7 @@ export function createAnalyticsDashboardContext(
|
||||
versionProjectIconUrlsById,
|
||||
projectStatusById,
|
||||
availableProjectStatuses,
|
||||
availableProjectDownloadsById,
|
||||
projectDownloadsById,
|
||||
projectVersionDownloadsById,
|
||||
gameVersionDownloadsByVersion,
|
||||
|
||||
Reference in New Issue
Block a user