diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 737cdc3ec8..990199d25a 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -55,6 +55,7 @@ "@vueuse/core": "^11.1.0", "ace-builds": "^1.36.2", "ansi-to-html": "^0.7.2", + "chart.js": "^4.5.1", "dayjs": "^1.11.7", "dompurify": "^3.1.7", "floating-vue": "^5.2.2", diff --git a/apps/frontend/src/components/analytics/AnalyticsDashboard.vue b/apps/frontend/src/components/analytics/AnalyticsDashboard.vue new file mode 100644 index 0000000000..e3f1d6bf21 --- /dev/null +++ b/apps/frontend/src/components/analytics/AnalyticsDashboard.vue @@ -0,0 +1,35 @@ + + + diff --git a/apps/frontend/src/components/analytics/breakdown.ts b/apps/frontend/src/components/analytics/breakdown.ts new file mode 100644 index 0000000000..2596ec46c0 --- /dev/null +++ b/apps/frontend/src/components/analytics/breakdown.ts @@ -0,0 +1,38 @@ +import type { Labrinth } from '@modrinth/api-client' + +import type { AnalyticsBreakdownPreset } from '~/providers/analytics/analytics' + +export const ALL_BREAKDOWN_VALUE = 'All' + +export function getAnalyticsBreakdownValue( + point: Labrinth.Analytics.v3.ProjectAnalytics, + selectedBreakdown: AnalyticsBreakdownPreset, +): string { + switch (selectedBreakdown) { + case 'none': + return ALL_BREAKDOWN_VALUE + case 'country': + return normalizeBreakdownValue('country' in point ? point.country : undefined) + case 'monetization': { + if ('monetized' in point && typeof point.monetized === 'boolean') { + return point.monetized ? 'monetized' : 'unmonetized' + } + return ALL_BREAKDOWN_VALUE + } + case 'download_source': + return normalizeBreakdownValue('domain' in point ? point.domain : undefined) + case 'download_type': + return normalizeBreakdownValue('version_id' in point ? point.version_id : undefined) + case 'loader': + return normalizeBreakdownValue('loader' in point ? point.loader : undefined) + case 'game_version': + return normalizeBreakdownValue('game_version' in point ? point.game_version : undefined) + default: + return ALL_BREAKDOWN_VALUE + } +} + +function normalizeBreakdownValue(value: string | undefined): string { + const normalized = value?.trim() + return normalized && normalized.length > 0 ? normalized : ALL_BREAKDOWN_VALUE +} diff --git a/apps/frontend/src/components/analytics/graph/AnalyticsChart.client.vue b/apps/frontend/src/components/analytics/graph/AnalyticsChart.client.vue new file mode 100644 index 0000000000..b108e1e9d6 --- /dev/null +++ b/apps/frontend/src/components/analytics/graph/AnalyticsChart.client.vue @@ -0,0 +1,267 @@ + + + diff --git a/apps/frontend/src/components/analytics/graph/AnalyticsChartTooltip.vue b/apps/frontend/src/components/analytics/graph/AnalyticsChartTooltip.vue new file mode 100644 index 0000000000..c83e06ae00 --- /dev/null +++ b/apps/frontend/src/components/analytics/graph/AnalyticsChartTooltip.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/apps/frontend/src/components/analytics/graph/AnalyticsGraph.vue b/apps/frontend/src/components/analytics/graph/AnalyticsGraph.vue new file mode 100644 index 0000000000..ee2642319f --- /dev/null +++ b/apps/frontend/src/components/analytics/graph/AnalyticsGraph.vue @@ -0,0 +1,423 @@ + + + diff --git a/apps/frontend/src/components/analytics/graph/utils.ts b/apps/frontend/src/components/analytics/graph/utils.ts new file mode 100644 index 0000000000..7b29309750 --- /dev/null +++ b/apps/frontend/src/components/analytics/graph/utils.ts @@ -0,0 +1,302 @@ +import type { Labrinth } from '@modrinth/api-client' + +import type { + AnalyticsBreakdownPreset, + AnalyticsDashboardProject, + AnalyticsDashboardStat, + AnalyticsGroupByPreset, +} from '~/providers/analytics/analytics' + +import { getAnalyticsBreakdownValue } from '../breakdown' + +export type ChartDataset = { + projectId: string + label: string + data: number[] + borderColor: string + backgroundColor: string +} + +const REGION_CODE_PATTERN = /^[a-z]{2}$/i +const OTHER_COUNTRY_CODE = 'XX' +const OTHER_COUNTRY_LABEL = 'Other' +const regionDisplayNamesByLocale = new Map() + +function getRegionDisplayNames(locale: string): Intl.DisplayNames | null { + if (regionDisplayNamesByLocale.has(locale)) { + return regionDisplayNamesByLocale.get(locale) ?? null + } + + try { + const displayNames = new Intl.DisplayNames(locale, { type: 'region' }) + regionDisplayNamesByLocale.set(locale, displayNames) + return displayNames + } catch { + regionDisplayNamesByLocale.set(locale, null) + return null + } +} + +function formatCountryCode(countryCode: string): string { + const normalized = countryCode.trim().toUpperCase() + if (normalized === OTHER_COUNTRY_CODE) { + return OTHER_COUNTRY_LABEL + } + + if (!REGION_CODE_PATTERN.test(normalized)) { + return countryCode + } + + const locale = new Intl.DateTimeFormat().resolvedOptions().locale || 'en' + const localizedDisplayNames = getRegionDisplayNames(locale) + const localizedValue = localizedDisplayNames?.of(normalized) + if (localizedValue && localizedValue !== normalized) { + return localizedValue + } + + const englishDisplayNames = getRegionDisplayNames('en') + const englishValue = englishDisplayNames?.of(normalized) + if (englishValue && englishValue !== normalized) { + return englishValue + } + + return countryCode +} + +function formatLoaderLabel(loader: string): string { + const normalized = loader.trim() + if (normalized.length === 0) { + return loader + } + + return `${normalized[0].toUpperCase()}${normalized.slice(1)}` +} + +export function formatBreakdownLabel( + breakdownValue: string, + selectedBreakdown: AnalyticsBreakdownPreset, +): string { + if (selectedBreakdown === 'country') { + return formatCountryCode(breakdownValue) + } + if (selectedBreakdown === 'loader') { + return formatLoaderLabel(breakdownValue) + } + + return breakdownValue +} + +export function getMetricValue( + point: Labrinth.Analytics.v3.ProjectAnalytics, + activeStat: AnalyticsDashboardStat, +): number { + switch (activeStat) { + case 'views': + return point.metric_kind === 'views' ? point.views : 0 + case 'downloads': + return point.metric_kind === 'downloads' ? point.downloads : 0 + case 'playtime': + return point.metric_kind === 'playtime' ? point.seconds : 0 + case 'revenue': { + if (point.metric_kind !== 'revenue') return 0 + const value = Number.parseFloat(point.revenue) + return Number.isFinite(value) ? value : 0 + } + } +} + +export function buildChartDatasets( + timeSlices: Labrinth.Analytics.v3.TimeSlice[], + selectedProjects: AnalyticsDashboardProject[], + activeStat: AnalyticsDashboardStat, + palette: string[], + selectedBreakdown: AnalyticsBreakdownPreset, +): ChartDataset[] { + const selectedProjectIds = new Set(selectedProjects.map((project) => project.id)) + if (selectedProjectIds.size === 0) { + return [] + } + + if (selectedBreakdown !== 'none') { + const dataByBreakdown = new Map() + + timeSlices.forEach((slice, sliceIndex) => { + for (const point of slice) { + if (!('source_project' in point)) continue + if (!selectedProjectIds.has(point.source_project)) continue + + const value = getMetricValue(point, activeStat) + if (value === 0) continue + + const breakdownValue = getAnalyticsBreakdownValue(point, selectedBreakdown) + + let breakdownData = dataByBreakdown.get(breakdownValue) + if (!breakdownData) { + breakdownData = new Array(timeSlices.length).fill(0) + dataByBreakdown.set(breakdownValue, breakdownData) + } + + breakdownData[sliceIndex] += value + } + }) + + return Array.from(dataByBreakdown.entries()).map(([breakdownValue, data], index) => { + const color = palette[index % palette.length] + return { + projectId: `breakdown:${breakdownValue}`, + label: formatBreakdownLabel(breakdownValue, selectedBreakdown), + data, + borderColor: color, + backgroundColor: color, + } + }) + } + + const dataByProjectId = new Map() + for (const project of selectedProjects) { + dataByProjectId.set(project.id, new Array(timeSlices.length).fill(0)) + } + + timeSlices.forEach((slice, sliceIndex) => { + for (const point of slice) { + if (!('source_project' in point)) continue + if (!selectedProjectIds.has(point.source_project)) continue + + const projectData = dataByProjectId.get(point.source_project) + if (!projectData) continue + + projectData[sliceIndex] += getMetricValue(point, activeStat) + } + }) + + return selectedProjects.map((project, index) => { + const color = palette[index % palette.length] + return { + projectId: project.id, + label: project.name, + data: dataByProjectId.get(project.id) ?? [], + borderColor: color, + backgroundColor: color, + } + }) +} + +export function getSliceCount( + timeRange: Labrinth.Analytics.v3.TimeRange, + fallback: number, +): number { + if ('slices' in timeRange.resolution) { + return Math.max(1, timeRange.resolution.slices) + } + if ('minutes' in timeRange.resolution) { + const duration = new Date(timeRange.end).getTime() - new Date(timeRange.start).getTime() + const bucketMs = timeRange.resolution.minutes * 60 * 1000 + if (bucketMs > 0 && duration > 0) { + return Math.max(1, Math.ceil(duration / bucketMs)) + } + } + return Math.max(1, fallback) +} + +export function getSliceBucketRange( + timeRange: Labrinth.Analytics.v3.TimeRange, + sliceCount: number, + index: number, +): { start: Date; end: Date } { + const startMs = new Date(timeRange.start).getTime() + const endMs = new Date(timeRange.end).getTime() + const bucketMs = sliceCount > 0 ? (endMs - startMs) / sliceCount : 0 + + return { + start: new Date(startMs + index * bucketMs), + end: new Date(startMs + (index + 1) * bucketMs), + } +} + +export function buildTimeAxisLabels( + timeRange: Labrinth.Analytics.v3.TimeRange, + sliceCount: number, + includeTime: boolean, +): string[] { + const startMs = new Date(timeRange.start).getTime() + const endMs = new Date(timeRange.end).getTime() + const totalMs = endMs - startMs + const bucketMs = sliceCount > 0 ? totalMs / sliceCount : 0 + const formatter = getBucketEndFormatter(includeTime) + + const labels: string[] = [] + for (let i = 0; i < sliceCount; i++) { + labels.push(formatter.format(new Date(startMs + (i + 1) * bucketMs))) + } + return labels +} + +function getBucketEndFormatter(includeTime: boolean): Intl.DateTimeFormat { + if (includeTime) { + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }) + } + return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }) +} + +export function isTimeRelevantForGroupBy(groupBy: AnalyticsGroupByPreset): boolean { + return groupBy === '1h' || groupBy === '6h' +} + +export function formatBucketEndLabel(end: Date, includeTime: boolean): string { + if (includeTime) { + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(end) + } + + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + }).format(end) +} + +export function formatMetricValue( + value: number, + activeStat: AnalyticsDashboardStat, + formatNumber: (value: number) => string, +): string { + switch (activeStat) { + case 'revenue': { + const amount = Math.round(value * 100) / 100 + return `$${formatNumber(amount)}` + } + case 'playtime': { + const hours = value / 3600 + return `${hours.toFixed(1)} hrs` + } + case 'views': + case 'downloads': + default: + return formatNumber(Math.round(value)) + } +} + +export function formatAxisValue( + value: number, + activeStat: AnalyticsDashboardStat, + formatCompact: (value: number) => string, +): string { + switch (activeStat) { + case 'revenue': + return `$${formatCompact(Math.round(value * 100) / 100)}` + case 'playtime': + return `${(value / 3600).toFixed(1)}h` + case 'views': + case 'downloads': + default: + return formatCompact(Math.round(value)) + } +} diff --git a/apps/frontend/src/components/analytics/query-builder/QueryBuilder.vue b/apps/frontend/src/components/analytics/query-builder/QueryBuilder.vue new file mode 100644 index 0000000000..6cc9091d7c --- /dev/null +++ b/apps/frontend/src/components/analytics/query-builder/QueryBuilder.vue @@ -0,0 +1,422 @@ + + + diff --git a/apps/frontend/src/components/analytics/query-builder/QueryFilter.vue b/apps/frontend/src/components/analytics/query-builder/QueryFilter.vue new file mode 100644 index 0000000000..3087ce4d1f --- /dev/null +++ b/apps/frontend/src/components/analytics/query-builder/QueryFilter.vue @@ -0,0 +1,657 @@ + + + diff --git a/apps/frontend/src/components/analytics/stat-cards/StatCard.vue b/apps/frontend/src/components/analytics/stat-cards/StatCard.vue new file mode 100644 index 0000000000..93c0d14393 --- /dev/null +++ b/apps/frontend/src/components/analytics/stat-cards/StatCard.vue @@ -0,0 +1,148 @@ + + + diff --git a/apps/frontend/src/components/analytics/stat-cards/StatCards.vue b/apps/frontend/src/components/analytics/stat-cards/StatCards.vue new file mode 100644 index 0000000000..fec09526c0 --- /dev/null +++ b/apps/frontend/src/components/analytics/stat-cards/StatCards.vue @@ -0,0 +1,104 @@ + + + diff --git a/apps/frontend/src/components/analytics/table/AnalyticsTable.vue b/apps/frontend/src/components/analytics/table/AnalyticsTable.vue new file mode 100644 index 0000000000..73213666f7 --- /dev/null +++ b/apps/frontend/src/components/analytics/table/AnalyticsTable.vue @@ -0,0 +1,496 @@ + + + diff --git a/apps/frontend/src/components/ui/charts/Chart.client.vue b/apps/frontend/src/components/ui/charts/Chart.client.vue deleted file mode 100644 index 16ea42aaea..0000000000 --- a/apps/frontend/src/components/ui/charts/Chart.client.vue +++ /dev/null @@ -1,495 +0,0 @@ - - - - - diff --git a/apps/frontend/src/components/ui/charts/ChartDisplay.vue b/apps/frontend/src/components/ui/charts/ChartDisplay.vue deleted file mode 100644 index 5dbb2dbdf6..0000000000 --- a/apps/frontend/src/components/ui/charts/ChartDisplay.vue +++ /dev/null @@ -1,1009 +0,0 @@ - - - - - - - diff --git a/apps/frontend/src/components/ui/charts/CompactChart.client.vue b/apps/frontend/src/components/ui/charts/CompactChart.client.vue deleted file mode 100644 index 1a76864196..0000000000 --- a/apps/frontend/src/components/ui/charts/CompactChart.client.vue +++ /dev/null @@ -1,281 +0,0 @@ - - - - - diff --git a/apps/frontend/src/layouts/default.vue b/apps/frontend/src/layouts/default.vue index f0bf302991..3168b7fec1 100644 --- a/apps/frontend/src/layouts/default.vue +++ b/apps/frontend/src/layouts/default.vue @@ -1199,6 +1199,10 @@ async function logoutUser() { } function runAnalytics() { + if (import.meta.dev) { + return + } + const config = useRuntimeConfig() const replacedUrl = config.public.apiBaseUrl.replace('v2/', '') diff --git a/apps/frontend/src/pages/[type]/[id]/settings/analytics.vue b/apps/frontend/src/pages/[type]/[id]/settings/analytics.vue index ce57caf281..447eb7eae7 100644 --- a/apps/frontend/src/pages/[type]/[id]/settings/analytics.vue +++ b/apps/frontend/src/pages/[type]/[id]/settings/analytics.vue @@ -1,30 +1,7 @@ - - - diff --git a/apps/frontend/src/pages/dashboard/analytics.vue b/apps/frontend/src/pages/dashboard/analytics.vue index ccc5c93eb9..6b57b44eea 100644 --- a/apps/frontend/src/pages/dashboard/analytics.vue +++ b/apps/frontend/src/pages/dashboard/analytics.vue @@ -1,30 +1,14 @@ diff --git a/apps/frontend/src/pages/organization/[id]/settings/analytics.vue b/apps/frontend/src/pages/organization/[id]/settings/analytics.vue index 081bc89587..dce239756c 100644 --- a/apps/frontend/src/pages/organization/[id]/settings/analytics.vue +++ b/apps/frontend/src/pages/organization/[id]/settings/analytics.vue @@ -1,28 +1,9 @@ - - - diff --git a/apps/frontend/src/providers/analytics/analytics.ts b/apps/frontend/src/providers/analytics/analytics.ts new file mode 100644 index 0000000000..34cbf80cbe --- /dev/null +++ b/apps/frontend/src/providers/analytics/analytics.ts @@ -0,0 +1,487 @@ +import type { Labrinth } from '@modrinth/api-client' +import { createContext, injectModrinthClient, type ProjectPageContext } from '@modrinth/ui' +import { useQuery } from '@tanstack/vue-query' +import type { ComputedRef, Ref } from 'vue' + +import type { OrganizationContext } from '../organization-context' +import { + type AnalyticsBreakdownPreset, + type AnalyticsGroupByPreset, + type AnalyticsSelectedFilters, + type AnalyticsTimeframePreset, + areSelectedFiltersEqual, + areStringArraysEqual, + buildAnalyticsQueryBuilderRouteQuery, + hasAnalyticsQueryBuilderRouteChange, + readAnalyticsQueryBuilderState, +} from './query-builder-url' + +export type { + AnalyticsBreakdownPreset, + AnalyticsGroupByPreset, + AnalyticsQueryFilterCategory, + AnalyticsSelectedFilters, + AnalyticsTimeframePreset, +} from './query-builder-url' + +export type AnalyticsDashboardStat = 'views' | 'downloads' | 'revenue' | 'playtime' + +const MINECRAFT_JAVA_SERVER_PROJECT_TYPE = 'minecraft_java_server' + +type ProjectTypeMetadata = { + project_type?: string | null + project_types?: readonly string[] | null +} + +const ANALYTICS_DASHBOARD_STAT_ORDER: AnalyticsDashboardStat[] = [ + 'views', + 'downloads', + 'revenue', + 'playtime', +] + +const ANALYTICS_RELEVANT_STATS_BY_BREAKDOWN: Record< + AnalyticsBreakdownPreset, + readonly AnalyticsDashboardStat[] +> = { + none: ANALYTICS_DASHBOARD_STAT_ORDER, + country: ['views', 'downloads'], + monetization: ['views'], + download_source: ['downloads'], + download_type: ['downloads', 'playtime'], + loader: ['playtime'], + game_version: ['playtime'], +} + +export interface AnalyticsDashboardProject { + id: string + name: string +} + +export interface AnalyticsDashboardTotals { + views: number + downloads: number + revenue: number + playtime: number +} + +export interface AnalyticsDashboardPercentChanges { + views: number + downloads: number + revenue: number + playtime: number +} + +export interface AnalyticsDashboardContextValue { + projects: ComputedRef + selectedProjectIds: Ref + selectedTimeframe: Ref + selectedGroupBy: Ref + selectedBreakdown: Ref + selectedFilters: Ref + fetchRequest: Ref + timeSlices: Ref + previousTimeSlices: Ref + isLoading: ComputedRef + isRefetching: ComputedRef + activeStat: Ref + currentTotals: ComputedRef + previousTotals: ComputedRef + percentChanges: ComputedRef + getRelevantAnalyticsDashboardStats: ( + breakdown: AnalyticsBreakdownPreset, + ) => readonly AnalyticsDashboardStat[] + isAnalyticsDashboardStatRelevant: ( + stat: AnalyticsDashboardStat, + breakdown: AnalyticsBreakdownPreset, + ) => boolean + setFetchRequest: (fetchRequest: Labrinth.Analytics.v3.FetchRequest) => void + setActiveStat: (stat: AnalyticsDashboardStat) => void +} + +export type CreateAnalyticsDashboardContextOptions = { + auth: Ref<{ user?: { id?: string } | null }> + projectPageContext?: ProjectPageContext | null + organizationContext?: OrganizationContext | null +} + +export const [injectAnalyticsDashboardContext, provideAnalyticsDashboardContext] = + createContext('AnalyticsDashboard') + +function buildPreviousFetchRequest( + fetchRequest: Labrinth.Analytics.v3.FetchRequest | null, +): Labrinth.Analytics.v3.FetchRequest | null { + if (!fetchRequest) { + return null + } + + const startTimestamp = new Date(fetchRequest.time_range.start).getTime() + const endTimestamp = new Date(fetchRequest.time_range.end).getTime() + const duration = endTimestamp - startTimestamp + + if (!Number.isFinite(duration) || duration <= 0) { + return null + } + + const previousEnd = new Date(startTimestamp) + const previousStart = new Date(startTimestamp - duration) + + return { + time_range: { + start: previousStart.toISOString(), + end: previousEnd.toISOString(), + resolution: fetchRequest.time_range.resolution, + }, + return_metrics: fetchRequest.return_metrics, + } +} + +function getPercentChange(currentValue: number, previousValue: number): number { + if (previousValue === 0) { + if (currentValue === 0) { + return 0 + } + return 100 + } + + return ((currentValue - previousValue) / previousValue) * 100 +} + +function computeTotals( + timeSlices: Labrinth.Analytics.v3.TimeSlice[], + selectedProjectIds: Set, + availableProjectIds: Set, +): AnalyticsDashboardTotals { + const totals: AnalyticsDashboardTotals = { + views: 0, + downloads: 0, + revenue: 0, + playtime: 0, + } + + if (availableProjectIds.size === 0) { + return totals + } + + const effectiveProjectIds = selectedProjectIds.size > 0 ? selectedProjectIds : availableProjectIds + + for (const timeSlice of timeSlices) { + for (const dataPoint of timeSlice) { + if (!('source_project' in dataPoint)) { + continue + } + + if (!effectiveProjectIds.has(dataPoint.source_project)) { + continue + } + + switch (dataPoint.metric_kind) { + case 'views': + totals.views += dataPoint.views + break + case 'downloads': + totals.downloads += dataPoint.downloads + break + case 'playtime': + totals.playtime += dataPoint.seconds + break + case 'revenue': { + const value = Number.parseFloat(dataPoint.revenue) + totals.revenue += Number.isFinite(value) ? value : 0 + break + } + } + } + } + + return totals +} + +function isServerProject(project: ProjectTypeMetadata): boolean { + if (project.project_type === MINECRAFT_JAVA_SERVER_PROJECT_TYPE) { + return true + } + + return project.project_types?.includes(MINECRAFT_JAVA_SERVER_PROJECT_TYPE) ?? false +} + +export function createAnalyticsDashboardContext( + options: CreateAnalyticsDashboardContextOptions, +): AnalyticsDashboardContextValue { + const client = injectModrinthClient() + const route = useRoute() + const router = useRouter() + const initialQueryState = readAnalyticsQueryBuilderState(route.query, []) + + const activeStat = ref('views') + const selectedProjectIds = ref(initialQueryState.selectedProjectIds) + const selectedTimeframe = ref(initialQueryState.selectedTimeframe) + const selectedGroupBy = ref(initialQueryState.selectedGroupBy) + const selectedBreakdown = ref(initialQueryState.selectedBreakdown) + const selectedFilters = ref(initialQueryState.selectedFilters) + const fetchRequest = ref(null) + + const hasProjectContext = computed(() => Boolean(options.projectPageContext)) + const hasOrganizationContext = computed( + () => !hasProjectContext.value && Boolean(options.organizationContext), + ) + + const { data: userProjects } = useQuery({ + queryKey: computed(() => ['analytics', 'dashboard', options.auth.value?.user?.id, 'projects']), + queryFn: () => client.labrinth.users_v2.getProjects(options.auth.value.user?.id ?? ''), + enabled: computed( + () => + Boolean(options.auth.value.user?.id) && + !hasProjectContext.value && + !hasOrganizationContext.value, + ), + placeholderData: [], + }) + + const projects = computed(() => { + if (hasProjectContext.value && options.projectPageContext) { + const project = options.projectPageContext.projectV2.value + return project && !isServerProject(project) ? [{ id: project.id, name: project.title }] : [] + } + + if (hasOrganizationContext.value && options.organizationContext?.projects.value) { + return options.organizationContext.projects.value + .filter((project) => !isServerProject(project)) + .map((project) => ({ + id: project.id, + name: project.name, + })) + } + + return (userProjects.value ?? []) + .filter((project) => !isServerProject(project)) + .map((project) => ({ + id: project.id, + name: project.title, + })) + }) + + const availableProjectIds = computed(() => projects.value.map((project) => project.id)) + + function getRelevantAnalyticsDashboardStats( + breakdown: AnalyticsBreakdownPreset, + ): readonly AnalyticsDashboardStat[] { + return ANALYTICS_RELEVANT_STATS_BY_BREAKDOWN[breakdown] ?? ANALYTICS_DASHBOARD_STAT_ORDER + } + + function isAnalyticsDashboardStatRelevant( + stat: AnalyticsDashboardStat, + breakdown: AnalyticsBreakdownPreset, + ): boolean { + return getRelevantAnalyticsDashboardStats(breakdown).includes(stat) + } + + watch( + [selectedBreakdown, activeStat], + ([nextBreakdown, nextActiveStat]) => { + if (isAnalyticsDashboardStatRelevant(nextActiveStat, nextBreakdown)) { + return + } + + const fallbackStat = getRelevantAnalyticsDashboardStats(nextBreakdown)[0] + if (fallbackStat && fallbackStat !== nextActiveStat) { + activeStat.value = fallbackStat + } + }, + { immediate: true }, + ) + + watch( + projects, + (nextProjects) => { + if (nextProjects.length === 0) { + return + } + + const availableProjectIds = new Set(nextProjects.map((project) => project.id)) + const retainedSelection = selectedProjectIds.value.filter((id) => availableProjectIds.has(id)) + + selectedProjectIds.value = + retainedSelection.length > 0 ? retainedSelection : nextProjects.map((project) => project.id) + }, + { immediate: true }, + ) + + watch( + () => route.query, + (nextQuery) => { + const nextQueryState = readAnalyticsQueryBuilderState(nextQuery, availableProjectIds.value) + + if (!areStringArraysEqual(selectedProjectIds.value, nextQueryState.selectedProjectIds)) { + selectedProjectIds.value = nextQueryState.selectedProjectIds + } + if (selectedTimeframe.value !== nextQueryState.selectedTimeframe) { + selectedTimeframe.value = nextQueryState.selectedTimeframe + } + if (selectedGroupBy.value !== nextQueryState.selectedGroupBy) { + selectedGroupBy.value = nextQueryState.selectedGroupBy + } + if (selectedBreakdown.value !== nextQueryState.selectedBreakdown) { + selectedBreakdown.value = nextQueryState.selectedBreakdown + } + if (!areSelectedFiltersEqual(selectedFilters.value, nextQueryState.selectedFilters)) { + selectedFilters.value = nextQueryState.selectedFilters + } + }, + ) + + watch( + [ + selectedProjectIds, + selectedTimeframe, + selectedGroupBy, + selectedBreakdown, + selectedFilters, + availableProjectIds, + ], + () => { + if (import.meta.server) { + return + } + + const nextRouteQuery = buildAnalyticsQueryBuilderRouteQuery( + route.query, + { + selectedProjectIds: selectedProjectIds.value, + selectedTimeframe: selectedTimeframe.value, + selectedGroupBy: selectedGroupBy.value, + selectedBreakdown: selectedBreakdown.value, + selectedFilters: selectedFilters.value, + }, + availableProjectIds.value, + ) + + const hasAnalyticsQueryChange = hasAnalyticsQueryBuilderRouteChange( + route.query, + nextRouteQuery, + ) + + if (!hasAnalyticsQueryChange) return + + router.replace({ + path: route.path, + query: nextRouteQuery, + }) + }, + { deep: true, immediate: true }, + ) + + const { + data: currentTimeSliceData, + isPending: currentTimeSlicePending, + isFetching: currentFetching, + } = useQuery({ + queryKey: computed(() => ['analytics', 'dashboard', 'current', fetchRequest.value]), + queryFn: () => + client.labrinth.analytics_v3.fetch(fetchRequest.value as Labrinth.Analytics.v3.FetchRequest), + enabled: computed(() => fetchRequest.value !== null), + }) + + const previousFetchRequest = computed(() => buildPreviousFetchRequest(fetchRequest.value)) + + const { + data: previousTimeSliceData, + isPending: previousTimeSlicePending, + isFetching: previousFetching, + } = useQuery({ + queryKey: computed(() => ['analytics', 'dashboard', 'previous', previousFetchRequest.value]), + queryFn: () => + client.labrinth.analytics_v3.fetch( + previousFetchRequest.value as Labrinth.Analytics.v3.FetchRequest, + ), + enabled: computed(() => previousFetchRequest.value !== null), + }) + + const timeSlices = ref([]) + const previousTimeSlices = ref([]) + + watch( + currentTimeSliceData, + (nextTimeSlices) => { + if (nextTimeSlices === undefined) { + return + } + timeSlices.value = nextTimeSlices + }, + { immediate: true }, + ) + + watch( + previousTimeSliceData, + (nextTimeSlices) => { + previousTimeSlices.value = nextTimeSlices ?? [] + }, + { immediate: true }, + ) + + watch(fetchRequest, (nextFetchRequest) => { + if (nextFetchRequest !== null) { + return + } + timeSlices.value = [] + previousTimeSlices.value = [] + }) + + const selectedProjectIdSet = computed(() => new Set(selectedProjectIds.value)) + const availableProjectIdSet = computed(() => new Set(availableProjectIds.value)) + + const currentTotals = computed(() => + computeTotals(timeSlices.value, selectedProjectIdSet.value, availableProjectIdSet.value), + ) + const previousTotals = computed(() => + computeTotals( + previousTimeSlices.value, + selectedProjectIdSet.value, + availableProjectIdSet.value, + ), + ) + + const percentChanges = computed(() => ({ + views: getPercentChange(currentTotals.value.views, previousTotals.value.views), + downloads: getPercentChange(currentTotals.value.downloads, previousTotals.value.downloads), + revenue: getPercentChange(currentTotals.value.revenue, previousTotals.value.revenue), + playtime: getPercentChange(currentTotals.value.playtime, previousTotals.value.playtime), + })) + + const isLoading = computed(() => currentTimeSlicePending.value || previousTimeSlicePending.value) + const isRefetching = computed(() => currentFetching.value || previousFetching.value) + + function setFetchRequest(nextFetchRequest: Labrinth.Analytics.v3.FetchRequest) { + fetchRequest.value = nextFetchRequest + } + + function setActiveStat(nextStat: AnalyticsDashboardStat) { + if (!isAnalyticsDashboardStatRelevant(nextStat, selectedBreakdown.value)) { + return + } + + activeStat.value = nextStat + } + + return { + projects, + selectedProjectIds, + selectedTimeframe, + selectedGroupBy, + selectedBreakdown, + selectedFilters, + fetchRequest, + timeSlices, + previousTimeSlices, + isLoading, + isRefetching, + activeStat, + currentTotals, + previousTotals, + percentChanges, + getRelevantAnalyticsDashboardStats, + isAnalyticsDashboardStatRelevant, + setFetchRequest, + setActiveStat, + } +} diff --git a/apps/frontend/src/providers/analytics/query-builder-url.ts b/apps/frontend/src/providers/analytics/query-builder-url.ts new file mode 100644 index 0000000000..4a9991ad8e --- /dev/null +++ b/apps/frontend/src/providers/analytics/query-builder-url.ts @@ -0,0 +1,316 @@ +import type { LocationQuery, LocationQueryValue, LocationQueryValueRaw } from 'vue-router' + +export type AnalyticsQueryFilterCategory = + | 'project' + | 'country' + | 'monetization' + | 'download_source' + | 'download_type' + | 'game_version' + | 'loader_type' + +export type AnalyticsTimeframePreset = + | 'today' + | 'yesterday' + | 'last_7_days' + | 'last_14_days' + | 'last_30_days' + | 'last_90_days' + | 'last_180_days' + | 'year_to_date' + | 'all_time' + +export type AnalyticsGroupByPreset = '1h' | '6h' | 'day' | 'week' | 'month' | 'year' + +export type AnalyticsBreakdownPreset = + | 'none' + | 'country' + | 'monetization' + | 'download_source' + | 'download_type' + | 'loader' + | 'game_version' + +export type AnalyticsSelectedFilters = Record + +export type AnalyticsQueryBuilderState = { + selectedProjectIds: string[] + selectedTimeframe: AnalyticsTimeframePreset + selectedGroupBy: AnalyticsGroupByPreset + selectedBreakdown: AnalyticsBreakdownPreset + selectedFilters: AnalyticsSelectedFilters +} + +type MutableRouteQuery = Record + +export const DEFAULT_TIMEFRAME_PRESET: AnalyticsTimeframePreset = 'yesterday' +export const DEFAULT_GROUP_BY_PRESET: AnalyticsGroupByPreset = '1h' +export const DEFAULT_BREAKDOWN_PRESET: AnalyticsBreakdownPreset = 'none' + +const TIMEFRAME_PRESET_VALUES: AnalyticsTimeframePreset[] = [ + 'today', + 'yesterday', + 'last_7_days', + 'last_14_days', + 'last_30_days', + 'last_90_days', + 'last_180_days', + 'year_to_date', + 'all_time', +] + +const GROUP_BY_PRESET_VALUES: AnalyticsGroupByPreset[] = [ + '1h', + '6h', + 'day', + 'week', + 'month', + 'year', +] + +const BREAKDOWN_PRESET_VALUES: AnalyticsBreakdownPreset[] = [ + 'none', + 'country', + 'monetization', + 'download_source', + 'download_type', + 'loader', + 'game_version', +] + +const QUERY_KEY_PROJECT_IDS = 'a_projects' +const QUERY_KEY_TIMEFRAME = 'a_timeframe' +const QUERY_KEY_GROUP_BY = 'a_group_by' +const QUERY_KEY_BREAKDOWN = 'a_breakdown' +const QUERY_KEY_FILTER_COUNTRY = 'a_country' +const QUERY_KEY_FILTER_MONETIZATION = 'a_monetization' +const QUERY_KEY_FILTER_DOWNLOAD_SOURCE = 'a_download_source' +const QUERY_KEY_FILTER_DOWNLOAD_TYPE = 'a_download_type' +const QUERY_KEY_FILTER_GAME_VERSION = 'a_game_version' +const QUERY_KEY_FILTER_LOADER_TYPE = 'a_loader_type' + +const URL_FILTER_CATEGORIES: Exclude[] = [ + 'country', + 'monetization', + 'download_source', + 'download_type', + 'game_version', + 'loader_type', +] + +const FILTER_QUERY_KEY_BY_CATEGORY: Record< + Exclude, + string +> = { + country: QUERY_KEY_FILTER_COUNTRY, + monetization: QUERY_KEY_FILTER_MONETIZATION, + download_source: QUERY_KEY_FILTER_DOWNLOAD_SOURCE, + download_type: QUERY_KEY_FILTER_DOWNLOAD_TYPE, + game_version: QUERY_KEY_FILTER_GAME_VERSION, + loader_type: QUERY_KEY_FILTER_LOADER_TYPE, +} + +const ANALYTICS_QUERY_KEYS = [ + QUERY_KEY_PROJECT_IDS, + QUERY_KEY_TIMEFRAME, + QUERY_KEY_GROUP_BY, + QUERY_KEY_BREAKDOWN, + QUERY_KEY_FILTER_COUNTRY, + QUERY_KEY_FILTER_MONETIZATION, + QUERY_KEY_FILTER_DOWNLOAD_SOURCE, + QUERY_KEY_FILTER_DOWNLOAD_TYPE, + QUERY_KEY_FILTER_GAME_VERSION, + QUERY_KEY_FILTER_LOADER_TYPE, +] + +export function buildEmptySelectedFilters(): AnalyticsSelectedFilters { + return { + project: [], + country: [], + monetization: [], + download_source: [], + download_type: [], + game_version: [], + loader_type: [], + } +} + +function parseListQueryValue( + value: LocationQueryValue | LocationQueryValue[] | undefined, +): string[] { + if (value === undefined) return [] + + const values = Array.isArray(value) ? value : [value] + const parsedValues: string[] = [] + for (const item of values) { + if (!item) continue + const parts = item.split(',') + for (const part of parts) { + const trimmed = part.trim() + if (trimmed.length > 0) { + parsedValues.push(trimmed) + } + } + } + + return Array.from(new Set(parsedValues)) +} + +function parsePresetQueryValue( + value: LocationQueryValue | LocationQueryValue[] | undefined, + allowedValues: readonly T[], + fallbackValue: T, +): T { + const rawValue = Array.isArray(value) ? value[0] : value + if (!rawValue) return fallbackValue + if (!allowedValues.includes(rawValue as T)) return fallbackValue + return rawValue as T +} + +function serializeListQueryValue(values: string[]): string | undefined { + if (values.length === 0) return undefined + return values.join(',') +} + +function normalizeQueryValue( + value: + | LocationQueryValue + | LocationQueryValue[] + | LocationQueryValueRaw + | LocationQueryValueRaw[] + | undefined, +): string[] { + if (value === undefined || value === null) return [] + if (Array.isArray(value)) { + return value + .filter( + (item): item is LocationQueryValue | LocationQueryValueRaw => + item !== undefined && item !== null, + ) + .map((item) => String(item)) + } + return [String(value)] +} + +function areQueryValuesEqual( + left: + | LocationQueryValue + | LocationQueryValue[] + | LocationQueryValueRaw + | LocationQueryValueRaw[] + | undefined, + right: + | LocationQueryValue + | LocationQueryValue[] + | LocationQueryValueRaw + | LocationQueryValueRaw[] + | undefined, +): boolean { + const leftValues = normalizeQueryValue(left) + const rightValues = normalizeQueryValue(right) + + if (leftValues.length !== rightValues.length) return false + for (let index = 0; index < leftValues.length; index += 1) { + if (leftValues[index] !== rightValues[index]) return false + } + return true +} + +export function areStringArraysEqual(left: string[], right: string[]): boolean { + if (left.length !== right.length) return false + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) return false + } + return true +} + +export function areSelectedFiltersEqual( + left: AnalyticsSelectedFilters, + right: AnalyticsSelectedFilters, +): boolean { + if (!areStringArraysEqual(left.project, right.project)) return false + for (const category of URL_FILTER_CATEGORIES) { + if (!areStringArraysEqual(left[category], right[category])) return false + } + return true +} + +function areAllProjectsSelected(selectedProjectIds: string[], allProjectIds: string[]): boolean { + if (allProjectIds.length === 0 || selectedProjectIds.length !== allProjectIds.length) { + return false + } + const allProjectIdSet = new Set(allProjectIds) + return selectedProjectIds.every((projectId) => allProjectIdSet.has(projectId)) +} + +export function readAnalyticsQueryBuilderState( + query: LocationQuery, + availableProjectIds: string[], +): AnalyticsQueryBuilderState { + const selectedProjectIdsFromQuery = parseListQueryValue(query[QUERY_KEY_PROJECT_IDS]) + const selectedProjectIds = + selectedProjectIdsFromQuery.length > 0 ? selectedProjectIdsFromQuery : availableProjectIds + + const selectedFilters = buildEmptySelectedFilters() + for (const category of URL_FILTER_CATEGORIES) { + selectedFilters[category] = parseListQueryValue(query[FILTER_QUERY_KEY_BY_CATEGORY[category]]) + } + + return { + selectedProjectIds, + selectedTimeframe: parsePresetQueryValue( + query[QUERY_KEY_TIMEFRAME], + TIMEFRAME_PRESET_VALUES, + DEFAULT_TIMEFRAME_PRESET, + ), + selectedGroupBy: parsePresetQueryValue( + query[QUERY_KEY_GROUP_BY], + GROUP_BY_PRESET_VALUES, + DEFAULT_GROUP_BY_PRESET, + ), + selectedBreakdown: parsePresetQueryValue( + query[QUERY_KEY_BREAKDOWN], + BREAKDOWN_PRESET_VALUES, + DEFAULT_BREAKDOWN_PRESET, + ), + selectedFilters, + } +} + +export function buildAnalyticsQueryBuilderRouteQuery( + currentRouteQuery: LocationQuery, + state: AnalyticsQueryBuilderState, + availableProjectIds: string[], +): MutableRouteQuery { + const nextRouteQuery = { + ...currentRouteQuery, + } as MutableRouteQuery + + const projectIdsQueryValue = areAllProjectsSelected(state.selectedProjectIds, availableProjectIds) + ? undefined + : serializeListQueryValue(state.selectedProjectIds) + + nextRouteQuery[QUERY_KEY_PROJECT_IDS] = projectIdsQueryValue + nextRouteQuery[QUERY_KEY_TIMEFRAME] = + state.selectedTimeframe !== DEFAULT_TIMEFRAME_PRESET ? state.selectedTimeframe : undefined + nextRouteQuery[QUERY_KEY_GROUP_BY] = + state.selectedGroupBy !== DEFAULT_GROUP_BY_PRESET ? state.selectedGroupBy : undefined + nextRouteQuery[QUERY_KEY_BREAKDOWN] = + state.selectedBreakdown !== DEFAULT_BREAKDOWN_PRESET ? state.selectedBreakdown : undefined + + for (const category of URL_FILTER_CATEGORIES) { + const categoryQueryKey = FILTER_QUERY_KEY_BY_CATEGORY[category] + nextRouteQuery[categoryQueryKey] = serializeListQueryValue(state.selectedFilters[category]) + } + + return nextRouteQuery +} + +export function hasAnalyticsQueryBuilderRouteChange( + currentRouteQuery: LocationQuery, + nextRouteQuery: MutableRouteQuery, +): boolean { + return ANALYTICS_QUERY_KEYS.some( + (key) => !areQueryValuesEqual(currentRouteQuery[key], nextRouteQuery[key]), + ) +} diff --git a/apps/frontend/src/utils/analytics.js b/apps/frontend/src/utils/analytics.js deleted file mode 100644 index c516de1287..0000000000 --- a/apps/frontend/src/utils/analytics.js +++ /dev/null @@ -1,491 +0,0 @@ -import { injectI18n, useDebugLogger } from '@modrinth/ui' -import dayjs from 'dayjs' -import { computed, ref, watch } from 'vue' - -// note: build step can miss unix import for some reason, so -// we have to import it like this - -const { unix } = dayjs - -export function useCountryNames(style = 'long') { - const { locale } = injectI18n() - const displayNames = computed( - () => new Intl.DisplayNames([locale.value], { type: 'region', style }), - ) - return function formatCountryName(code) { - try { - return displayNames.value.of(code) ?? code - } catch { - return code - } - } -} - -export const countryCodeToName = (code) => { - const formatCountryName = useCountryNames() - - return formatCountryName(code) -} - -export const countryCodeToFlag = (code) => { - if (code === 'XX') { - return undefined - } - return `https://flagcdn.com/h240/${code.toLowerCase()}.png` -} - -export const formatTimestamp = (timestamp) => { - return unix(timestamp).format() -} - -export const formatPercent = (value, sum) => { - return `${((value / sum) * 100).toFixed(2)}%` -} - -const hashProjectId = (projectId) => { - return projectId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % 30 -} - -export const defaultColors = [ - '#ff496e', // Original: Bright pink - '#ffa347', // Original: Bright orange - '#1bd96a', // Original: Bright green - '#4f9cff', // Original: Bright blue - '#c78aff', // Original: Bright purple - '#ffeb3b', // Added: Bright yellow - '#00bcd4', // Added: Bright cyan - '#ff5722', // Added: Bright red-orange - '#9c27b0', // Added: Bright deep purple - '#3f51b5', // Added: Bright indigo - '#009688', // Added: Bright teal - '#cddc39', // Added: Bright lime - '#795548', // Added: Bright brown - '#607d8b', // Added: Bright blue-grey -] - -/** - * @param {string | number} value - * @returns {string} color - */ -export const getDefaultColor = (value) => { - if (typeof value === 'string') { - value = hashProjectId(value) - } - return defaultColors[value % defaultColors.length] -} - -export const intToRgba = (color, projectId = 'Unknown', theme = 'dark', alpha = '1') => { - const hash = hashProjectId(projectId) - - if (!color || color === 0) { - return getDefaultColor(hash) - } - - // if color is a string, return that instead - if (typeof color === 'string') { - return color - } - - // Extract RGB values - let r = (color >> 16) & 255 - let g = (color >> 8) & 255 - let b = color & 255 - - // Hash function to alter color slightly based on project_id - r = (r + hash) % 256 - g = (g + hash) % 256 - b = (b + hash) % 256 - - // Adjust brightness for theme - const brightness = r * 0.299 + g * 0.587 + b * 0.114 - const threshold = theme === 'dark' ? 50 : 200 - if (theme === 'dark' && brightness < threshold) { - // Increase brightness for dark theme - r += threshold / 2 - g += threshold / 2 - b += threshold / 2 - } else if (theme === 'light' && brightness > threshold) { - // Decrease brightness for light theme - r -= threshold / 4 - g -= threshold / 4 - b -= threshold / 4 - } - - // Ensure RGB values are within 0-255 - r = Math.min(255, Math.max(0, r)) - g = Math.min(255, Math.max(0, g)) - b = Math.min(255, Math.max(0, b)) - - return `rgba(${r}, ${g}, ${b}, ${alpha})` -} - -const emptyAnalytics = { - sum: 0, - len: 0, - chart: { - labels: [], - data: [], - sumData: [ - { - name: '', - data: [], - }, - ], - colors: [], - defaultColors: [], - }, - projectIds: [], -} - -export const analyticsSetToCSVString = (analytics) => { - if (!analytics) { - return '' - } - - const newline = '\n' - const labels = analytics.chart.labels - const projects = analytics.chart.data - - const projectNames = projects.map((p) => p.name) - - const header = ['Date', ...projectNames].join(',') - - const data = labels.map((label, i) => { - const values = projects.map((p) => p.data?.[i] || '') - return [label, ...values].join(',') - }) - - return [header, ...data].join(newline) -} - -export const processAnalytics = (category, projects, labelFn, sortFn, mapFn, chartName, theme) => { - if (!category || !projects) { - return emptyAnalytics - } - - // Get an intersection of category keys and project ids - const projectIds = projects.map((p) => p.id) - const loadedProjectIds = Object.keys(category).filter((id) => projectIds.includes(id)) - - if (!loadedProjectIds?.length) { - return emptyAnalytics - } - - const loadedProjectData = loadedProjectIds.map((id) => category[id]) - - // Convert each project's data into a list of [unix_ts_str, number] pairs - const projectData = loadedProjectData - .map((data) => Object.entries(data)) - .map((data) => data.sort(sortFn)) - .map((data) => (mapFn ? data.map(mapFn) : data)) - - // Each project may not include the same timestamps, so we should use the union of all timestamps - const timestamps = Array.from( - new Set(projectData.flatMap((data) => data.map(([ts]) => ts))), - ).sort() - - const chartData = projectData - .map((data, i) => { - const project = projects.find((p) => p.id === loadedProjectIds[i]) - if (!project) { - throw new Error(`Project ${loadedProjectIds[i]} not found`) - } - - return { - name: `${project.title}`, - data: timestamps.map((ts) => { - const entry = data.find(([ets]) => ets === ts) - return entry ? entry[1] : 0 - }), - id: project.id, - color: project.color, - } - }) - .sort( - (a, b) => - b.data.reduce((acc, cur) => acc + cur, 0) - a.data.reduce((acc, cur) => acc + cur, 0), - ) - - const projectIdsSortedBySum = chartData.map((p) => p.id) - - return { - // The total count of all the values across all projects - sum: projectData.reduce((acc, cur) => acc + cur.reduce((a, c) => a + c[1], 0), 0), - len: timestamps.length, - chart: { - labels: timestamps.map(labelFn), - data: chartData.map((x) => ({ name: x.name, data: x.data })), - sumData: [ - { - name: chartName, - data: timestamps.map((ts) => { - const entries = projectData.flat().filter(([ets]) => ets === ts) - return entries.reduce((acc, cur) => acc + cur[1], 0) - }), - }, - ], - colors: projectData.map((_, i) => { - const project = chartData[i] - - return intToRgba(project.color, project.id, theme) - }), - defaultColors: projectData.map((_, i) => { - const project = chartData[i] - return getDefaultColor(project.id) - }), - }, - projectIds: projectIdsSortedBySum, - } -} - -export const processAnalyticsByCountry = (category, projects, sortFn) => { - if (!category || !projects) { - return { - sum: 0, - len: 0, - data: [], - } - } - - // Get an intersection of category keys and project ids - const projectIds = projects.map((p) => p.id) - const loadedProjectIds = Object.keys(category).filter((id) => projectIds.includes(id)) - - if (!loadedProjectIds?.length) { - return { - sum: 0, - len: 0, - data: [], - } - } - - const loadedProjectData = loadedProjectIds.map((id) => category[id]) - - // Convert each project's data into a list of [countrycode, number] pairs - // Fold into a single list with summed values for each country over all projects - - const countrySums = new Map() - - loadedProjectData.forEach((data) => { - Object.entries(data).forEach(([country, value]) => { - const countryCode = country || 'XX' - const current = countrySums.get(countryCode) || 0 - countrySums.set(countryCode, current + value) - }) - }) - - const entries = Array.from(countrySums.entries()) - - return { - sum: entries.reduce((acc, cur) => acc + cur[1], 0), - len: entries.length, - data: entries.sort(sortFn), - } -} - -const sortCount = ([, a], [, b]) => b - a -const sortTimestamp = ([a], [b]) => a - b -const roundValue = ([ts, value]) => [ts, Math.round(parseFloat(value) * 100) / 100] - -const processCountryAnalytics = (c, projects) => processAnalyticsByCountry(c, projects, sortCount) -const processNumberAnalytics = (c, projects, theme) => - processAnalytics(c, projects, formatTimestamp, sortTimestamp, null, 'Downloads', theme) -const processRevAnalytics = (c, projects, theme) => - processAnalytics(c, projects, formatTimestamp, sortTimestamp, roundValue, 'Revenue', theme) - -const useFetchAnalytics = ( - url, - baseOptions = { - apiVersion: 3, - }, -) => { - return useBaseFetch(url, baseOptions) -} - -/** - * @param {Ref} projects - * @param {undefined | () => any} onDataRefresh - */ -export const useFetchAllAnalytics = ( - onDataRefresh, - projects, - selectedProjects, - personalRevenue = false, - startDate = ref(dayjs().subtract(30, 'days')), - endDate = ref(dayjs()), - timeResolution = ref(1440), -) => { - const debug = useDebugLogger('useFetchAllAnalytics') - debug('init', { - projectCount: projects.value?.length, - personalRevenue, - startDate: startDate.value?.toISOString(), - endDate: endDate.value?.toISOString(), - }) - - const downloadData = ref(null) - const viewData = ref(null) - const revenueData = ref(null) - const downloadsByCountry = ref(null) - const viewsByCountry = ref(null) - const loading = ref(true) - const error = ref(null) - - const formattedData = computed(() => ({ - downloads: processNumberAnalytics(downloadData.value, selectedProjects.value), - views: processNumberAnalytics(viewData.value, selectedProjects.value), - revenue: processRevAnalytics(revenueData.value, selectedProjects.value), - downloadsByCountry: processCountryAnalytics(downloadsByCountry.value, selectedProjects.value), - viewsByCountry: processCountryAnalytics(viewsByCountry.value, selectedProjects.value), - })) - - const theme = useTheme() - - const totalData = computed(() => ({ - downloads: processNumberAnalytics(downloadData.value, projects.value, theme.active), - views: processNumberAnalytics(viewData.value, projects.value, theme.active), - revenue: processRevAnalytics(revenueData.value, projects.value, theme.active), - })) - - const buildQuery = () => { - const q = { - start_date: startDate.value.toISOString(), - end_date: endDate.value.toISOString(), - resolution_minutes: timeResolution.value, - } - - if (projects.value?.length) { - q.project_ids = JSON.stringify(projects.value.map((p) => p.id)) - } - - return q - } - - const fetchData = async (query) => { - debug('fetchData called', { query }) - const normalQuery = new URLSearchParams(query) - const revenueQuery = new URLSearchParams(query) - - if (personalRevenue) { - revenueQuery.delete('project_ids') - } - - const qs = normalQuery.toString() - const revenueQs = revenueQuery.toString() - - try { - loading.value = true - error.value = null - - debug('fetching all 5 endpoints...') - const responses = await Promise.all([ - useFetchAnalytics(`analytics/downloads?${qs}`), - useFetchAnalytics(`analytics/views?${qs}`), - useFetchAnalytics(`analytics/revenue?${revenueQs}`), - useFetchAnalytics(`analytics/countries/downloads?${qs}`), - useFetchAnalytics(`analytics/countries/views?${qs}`), - ]) - debug('all 5 endpoints resolved', { - downloads: Object.keys(responses[0] || {}).length, - views: Object.keys(responses[1] || {}).length, - revenue: Object.keys(responses[2] || {}).length, - }) - - const projectIds = new Set() - if (projects.value) { - projects.value.forEach((p) => projectIds.add(p.id)) - } else { - Object.keys(responses[0] || {}).forEach((id) => projectIds.add(id)) - } - - debug('filtering to projectIds', { count: projectIds.size }) - - const filterProjectIds = (data) => { - const filtered = {} - Object.entries(data).forEach(([id, values]) => { - if (projectIds.has(id)) { - filtered[id] = values - } - }) - return filtered - } - - downloadData.value = filterProjectIds(responses[0] || {}) - viewData.value = filterProjectIds(responses[1] || {}) - revenueData.value = filterProjectIds(responses[2] || {}) - - downloadsByCountry.value = responses[3] || {} - viewsByCountry.value = responses[4] || {} - } catch (e) { - debug('fetchData error', e) - error.value = e - } finally { - loading.value = false - debug('fetchData done, loading=false') - } - } - - const fetch = async () => { - debug('fetch() called', { projectCount: projects.value?.length }) - await fetchData(buildQuery()) - if (onDataRefresh) { - onDataRefresh() - } - } - - watch( - [() => startDate.value, () => endDate.value, () => timeResolution.value, () => projects.value], - (newVals, oldVals) => { - debug('watch triggered', { new: newVals, old: oldVals }) - fetch() - }, - ) - - const validProjectIds = computed(() => { - const ids = new Set() - - if (downloadData.value) { - Object.keys(downloadData.value).forEach((id) => ids.add(id)) - } - - if (viewData.value) { - Object.keys(viewData.value).forEach((id) => ids.add(id)) - } - - if (revenueData.value) { - // revenue will always have all project ids, but the ids may have an empty object or a ton of keys below a cent (0.00...) as values. We want to filter those out - Object.entries(revenueData.value).forEach(([id, data]) => { - if (Object.keys(data).length) { - if (Object.values(data).some((v) => v >= 0.01)) { - ids.add(id) - } - } - }) - } - - return Array.from(ids) - }) - - return { - // Configuration - timeResolution, - - startDate, - endDate, - - // Data - downloadData, - viewData, - revenueData, - downloadsByCountry, - viewsByCountry, - - // Computed state - validProjectIds, - formattedData, - totalData, - loading, - error, - fetch, - } -} diff --git a/packages/api-client/src/modules/index.ts b/packages/api-client/src/modules/index.ts index c1b9b13fef..f783db1cc4 100644 --- a/packages/api-client/src/modules/index.ts +++ b/packages/api-client/src/modules/index.ts @@ -13,6 +13,7 @@ import { KyrosFilesV0Module } from './kyros/files/v0' import { KyrosLogsV1Module } from './kyros/logs/v1' import { LabrinthVersionsV2Module, LabrinthVersionsV3Module } from './labrinth' import { LabrinthAffiliateInternalModule } from './labrinth/affiliate/internal' +import { LabrinthAnalyticsV3Module } from './labrinth/analytics/v3' import { LabrinthAuthInternalModule } from './labrinth/auth/internal' import { LabrinthAuthV2Module } from './labrinth/auth/v2' import { LabrinthBillingInternalModule } from './labrinth/billing/internal' @@ -71,6 +72,7 @@ export const MODULE_REGISTRY = { kyros_files_v0: KyrosFilesV0Module, kyros_logs_v1: KyrosLogsV1Module, labrinth_affiliate_internal: LabrinthAffiliateInternalModule, + labrinth_analytics_v3: LabrinthAnalyticsV3Module, labrinth_auth_internal: LabrinthAuthInternalModule, labrinth_auth_v2: LabrinthAuthV2Module, labrinth_billing_internal: LabrinthBillingInternalModule, diff --git a/packages/api-client/src/modules/labrinth/analytics/v3.ts b/packages/api-client/src/modules/labrinth/analytics/v3.ts new file mode 100644 index 0000000000..01b5e09eb9 --- /dev/null +++ b/packages/api-client/src/modules/labrinth/analytics/v3.ts @@ -0,0 +1,41 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Labrinth } from '../types' + +export class LabrinthAnalyticsV3Module extends AbstractModule { + public getModuleID(): string { + return 'labrinth_analytics_v3' + } + + /** + * Fetch analytics data for the authenticated user's accessible projects + * and affiliate codes. + * + * @param data - Analytics request body defining time range and requested metrics + * @returns Promise resolving to analytics time slices + * + * @example + * ```typescript + * const response = await client.labrinth.analytics_v3.fetch({ + * time_range: { + * start: '2026-01-01T00:00:00Z', + * end: '2026-02-01T00:00:00Z', + * resolution: { slices: 31 }, + * }, + * project_ids: ['A1B2C3D4'], + * return_metrics: { + * project_views: { bucket_by: ['project_id'] }, + * }, + * }) + * ``` + */ + public async fetch( + data: Labrinth.Analytics.v3.FetchRequest, + ): Promise { + return this.client.request('/analytics', { + api: 'labrinth', + version: 3, + method: 'POST', + body: data, + }) + } +} diff --git a/packages/api-client/src/modules/labrinth/index.ts b/packages/api-client/src/modules/labrinth/index.ts index 123bf20684..7bccb309a8 100644 --- a/packages/api-client/src/modules/labrinth/index.ts +++ b/packages/api-client/src/modules/labrinth/index.ts @@ -1,3 +1,4 @@ +export * from './analytics/v3' export * from './auth/internal' export * from './auth/v2' export * from './billing/internal' diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index 84f92bc84d..62cd53a742 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -238,6 +238,126 @@ export namespace Labrinth { } } + export namespace Analytics { + export namespace v3 { + export type FetchRequest = { + time_range: TimeRange + return_metrics: ReturnMetrics + project_ids?: string[] + } + + export type TimeRange = { + start: string + end: string + resolution: TimeRangeResolution + } + + export type TimeRangeResolution = { slices: number } | { minutes: number } + + export type ReturnMetrics = { + project_views?: Metrics + project_downloads?: Metrics + project_playtime?: Metrics + project_revenue?: Metrics + affiliate_code_clicks?: Metrics + affiliate_code_conversions?: Metrics + affiliate_code_revenue?: Metrics + } + + export type Metrics = { + bucket_by?: F[] + } + + export type ProjectViewsField = + | 'project_id' + | 'domain' + | 'site_path' + | 'monetized' + | 'country' + + export type ProjectDownloadsField = + | 'project_id' + | 'version_id' + | 'domain' + | 'site_path' + | 'country' + + export type ProjectPlaytimeField = 'project_id' | 'version_id' | 'loader' | 'game_version' + + export type ProjectRevenueField = 'project_id' + + export type AffiliateCodeClicksField = 'affiliate_code_id' + + export type AffiliateCodeConversionsField = 'affiliate_code_id' + + export type AffiliateCodeRevenueField = 'affiliate_code_id' + + export type FetchResponse = TimeSlice[] + + export type TimeSlice = AnalyticsData[] + + export type AnalyticsData = ProjectAnalytics | AffiliateCodeAnalytics + + export type ProjectAnalytics = { + source_project: string + } & ProjectMetrics + + export type ProjectMetrics = + | ({ metric_kind: 'views' } & ProjectViews) + | ({ metric_kind: 'downloads' } & ProjectDownloads) + | ({ metric_kind: 'playtime' } & ProjectPlaytime) + | ({ metric_kind: 'revenue' } & ProjectRevenue) + + export type ProjectViews = { + domain?: string + site_path?: string + monetized?: boolean + country?: string + views: number + } + + export type ProjectDownloads = { + domain?: string + site_path?: string + version_id?: string + country?: string + downloads: number + } + + export type ProjectPlaytime = { + version_id?: string + loader?: string + game_version?: string + seconds: number + } + + export type ProjectRevenue = { + revenue: string + } + + export type AffiliateCodeAnalytics = { + source_affiliate_code: string + } & AffiliateCodeMetrics + + export type AffiliateCodeMetrics = + | ({ metric_kind: 'clicks' } & AffiliateCodeClicks) + | ({ metric_kind: 'conversions' } & AffiliateCodeConversions) + | ({ metric_kind: 'revenue' } & AffiliateCodeRevenue) + + export type AffiliateCodeClicks = { + clicks: number + } + + export type AffiliateCodeConversions = { + conversions: number + } + + export type AffiliateCodeRevenue = { + revenue: string + } + } + } + export namespace Auth { export namespace Internal { export type SubscriptionStatus = { diff --git a/packages/ui/src/components/base/MultiSelect.vue b/packages/ui/src/components/base/MultiSelect.vue index 250830bae7..09ef9f622e 100644 --- a/packages/ui/src/components/base/MultiSelect.vue +++ b/packages/ui/src/components/base/MultiSelect.vue @@ -1,11 +1,12 @@