diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 624c6c9431..7dfe3f17fa 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..91ce909ffe --- /dev/null +++ b/apps/frontend/src/components/analytics/AnalyticsDashboard.vue @@ -0,0 +1,52 @@ + + + diff --git a/apps/frontend/src/components/analytics/AnalyticsLoadingBar.vue b/apps/frontend/src/components/analytics/AnalyticsLoadingBar.vue new file mode 100644 index 0000000000..addfe43f20 --- /dev/null +++ b/apps/frontend/src/components/analytics/AnalyticsLoadingBar.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/apps/frontend/src/components/analytics/breakdown.ts b/apps/frontend/src/components/analytics/breakdown.ts new file mode 100644 index 0000000000..67d3ed97a2 --- /dev/null +++ b/apps/frontend/src/components/analytics/breakdown.ts @@ -0,0 +1,53 @@ +import type { Labrinth } from '@modrinth/api-client' + +import type { AnalyticsBreakdownPreset } from '~/providers/analytics/analytics' + +export const ALL_BREAKDOWN_VALUE = 'All' +export const UNKNOWN_BREAKDOWN_VALUE = 'Unknown' + +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?.toUpperCase() : 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_reason': + return normalizeBreakdownValue( + 'reason' in point ? point.reason : undefined, + UNKNOWN_BREAKDOWN_VALUE, + ) + case 'version_id': + return normalizeBreakdownValue('version_id' in point ? point.version_id : undefined) + case 'loader': + return normalizeBreakdownValue( + 'loader' in point ? point.loader : undefined, + UNKNOWN_BREAKDOWN_VALUE, + ) + case 'game_version': + return normalizeBreakdownValue( + 'game_version' in point ? point.game_version : undefined, + UNKNOWN_BREAKDOWN_VALUE, + ) + default: + return ALL_BREAKDOWN_VALUE + } +} + +function normalizeBreakdownValue( + value: string | undefined, + fallback = ALL_BREAKDOWN_VALUE, +): string { + const normalized = value?.trim() + return normalized && normalized.length > 0 ? normalized : fallback +} 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..34f42cb820 --- /dev/null +++ b/apps/frontend/src/components/analytics/graph/AnalyticsChart.client.vue @@ -0,0 +1,457 @@ + + + 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..5430510067 --- /dev/null +++ b/apps/frontend/src/components/analytics/graph/AnalyticsChartTooltip.vue @@ -0,0 +1,187 @@ + + + + + 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..152242ea85 --- /dev/null +++ b/apps/frontend/src/components/analytics/graph/AnalyticsGraph.vue @@ -0,0 +1,702 @@ + + + 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..0bca01d6ef --- /dev/null +++ b/apps/frontend/src/components/analytics/graph/utils.ts @@ -0,0 +1,488 @@ +import type { Labrinth } from '@modrinth/api-client' + +import { + type AnalyticsBreakdownPreset, + type AnalyticsDashboardProject, + type AnalyticsDashboardStat, + type AnalyticsGroupByPreset, + type AnalyticsSelectedFilters, + doesAnalyticsPointMatchFilters, +} from '~/providers/analytics/analytics' + +import { ALL_BREAKDOWN_VALUE, getAnalyticsBreakdownValue } from '../breakdown' + +export type ChartDataset = { + projectId: string + label: string + data: number[] + borderColor: string + backgroundColor: string +} + +const LOADER_CHART_COLORS: Record = { + fabric: 'var(--color-platform-fabric)', + 'legacy-fabric': 'var(--color-platform-fabric)', + quilt: 'var(--color-platform-quilt)', + forge: 'var(--color-platform-forge)', + neoforge: 'var(--color-platform-neoforge)', + neo_forge: 'var(--color-platform-neoforge)', + liteloader: 'var(--color-platform-liteloader)', + bukkit: 'var(--color-platform-bukkit)', + bungeecord: 'var(--color-platform-bungeecord)', + folia: 'var(--color-platform-folia)', + paper: 'var(--color-platform-paper)', + purpur: 'var(--color-platform-purpur)', + spigot: 'var(--color-platform-spigot)', + velocity: 'var(--color-platform-velocity)', + waterfall: 'var(--color-platform-waterfall)', + sponge: 'var(--color-platform-sponge)', + ornithe: 'var(--color-platform-ornithe)', + 'bta-babric': 'var(--color-platform-bta-babric)', + nilloader: 'var(--color-platform-nilloader)', +} + +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, + getVersionDisplayName: (versionId: string) => string = (versionId) => versionId, +): string { + if (selectedBreakdown === 'country') { + return formatCountryCode(breakdownValue) + } + if (selectedBreakdown === 'version_id') { + return getVersionDisplayName(breakdownValue) + } + if (selectedBreakdown === 'loader') { + return formatLoaderLabel(breakdownValue) + } + if (selectedBreakdown === 'download_reason') { + return formatDownloadReasonLabel(breakdownValue) + } + + return breakdownValue +} + +function formatDownloadReasonLabel(reason: string): string { + switch (reason) { + case 'standalone': + return 'Standalone' + case 'dependency': + return 'Dependency' + case 'modpack': + return 'Modpack' + default: + return reason + } +} + +function getBreakdownColor( + breakdownValue: string, + selectedBreakdown: AnalyticsBreakdownPreset, + fallbackColor: string, +): string { + if (selectedBreakdown !== 'loader') { + return fallbackColor + } + + const normalizedLoader = breakdownValue.trim().toLowerCase() + return LOADER_CHART_COLORS[normalizedLoader] ?? fallbackColor +} + +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 + } + } +} + +function isMetricKindForStat( + point: Labrinth.Analytics.v3.ProjectAnalytics, + activeStat: AnalyticsDashboardStat, +): boolean { + return point.metric_kind === activeStat +} + +export function buildChartDatasets( + timeSlices: Labrinth.Analytics.v3.TimeSlice[], + selectedProjects: AnalyticsDashboardProject[], + activeStat: AnalyticsDashboardStat, + palette: string[], + selectedBreakdown: AnalyticsBreakdownPreset, + selectedFilters: AnalyticsSelectedFilters, + getVersionDisplayName: (versionId: string) => string = (versionId) => versionId, + sliceCount: number = timeSlices.length, +): ChartDataset[] { + const selectedProjectIds = new Set(selectedProjects.map((project) => project.id)) + if (selectedProjectIds.size === 0) { + return [] + } + + const dataLength = Math.max(sliceCount, timeSlices.length) + + 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 + if (!doesAnalyticsPointMatchFilters(point, selectedFilters)) continue + if (!isMetricKindForStat(point, activeStat)) continue + + const breakdownValue = getAnalyticsBreakdownValue(point, selectedBreakdown) + if (breakdownValue === ALL_BREAKDOWN_VALUE) continue + + let breakdownData = dataByBreakdown.get(breakdownValue) + if (!breakdownData) { + breakdownData = new Array(dataLength).fill(0) + dataByBreakdown.set(breakdownValue, breakdownData) + } + + breakdownData[sliceIndex] += getMetricValue(point, activeStat) + } + }) + + return Array.from(dataByBreakdown.entries()).map(([breakdownValue, data], index) => { + const fallbackColor = palette[index % palette.length] + const color = getBreakdownColor(breakdownValue, selectedBreakdown, fallbackColor) + return { + projectId: `breakdown:${breakdownValue}`, + label: formatBreakdownLabel(breakdownValue, selectedBreakdown, getVersionDisplayName), + data, + borderColor: color, + backgroundColor: color, + } + }) + } + + const dataByProjectId = new Map() + for (const project of selectedProjects) { + dataByProjectId.set(project.id, new Array(dataLength).fill(0)) + } + + timeSlices.forEach((slice, sliceIndex) => { + for (const point of slice) { + if (!('source_project' in point)) continue + if (!selectedProjectIds.has(point.source_project)) continue + if (!doesAnalyticsPointMatchFilters(point, selectedFilters)) 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), + } +} + +const ONE_DAY_MS = 24 * 60 * 60 * 1000 +const ONE_MINUTE_MS = 60 * 1000 +const COMPACT_AXIS_THRESHOLD = 5 +const SHORT_HOURLY_TIME_LABEL_DURATION_MS = 6 * ONE_DAY_MS +export const DEFAULT_X_AXIS_TICK_LIMIT = 12 +export const SHORT_HOURLY_AXIS_TICK_LIMIT = 8 + +export function buildTimeAxisLabels( + timeRange: Labrinth.Analytics.v3.TimeRange, + sliceCount: number, + groupBy: AnalyticsGroupByPreset, +): 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 includeTime = shouldShowTimeForHourlyAxis(timeRange, groupBy) + const includeYear = isYearRelevantForTimeRange(timeRange) || groupBy === 'year' + + const dates: Date[] = [] + const dateKeys: string[] = [] + for (let i = 0; i < sliceCount; i++) { + const date = new Date(startMs + (i + 1) * bucketMs) + dates.push(date) + dateKeys.push(`${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`) + } + + const dateFormatter = new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + ...(includeYear ? { year: 'numeric' } : {}), + }) + + if (!includeTime) { + return dates.map((date) => dateFormatter.format(date)) + } + + const timeFormatter = new Intl.DateTimeFormat(undefined, { hour: 'numeric' }) + const uniqueDateCount = new Set(dateKeys).size + + if (uniqueDateCount <= 1 || isSingleFullDayTimeRange(new Date(startMs), new Date(endMs))) { + return dates.map((date) => timeFormatter.format(date)) + } + + if (includeTime || sliceCount <= COMPACT_AXIS_THRESHOLD) { + const dateAndTimeFormatter = new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + ...(includeYear ? { year: 'numeric' } : {}), + }) + return dates.map((date) => dateAndTimeFormatter.format(date)) + } + + return dates.map((date) => dateFormatter.format(date)) +} + +export function isTimeRelevantForGroupBy(groupBy: AnalyticsGroupByPreset): boolean { + return groupBy === '1h' || groupBy === '6h' +} + +export function shouldUseShortHourlyAxis( + timeRange: Labrinth.Analytics.v3.TimeRange, + groupBy: AnalyticsGroupByPreset, +): boolean { + if (!isTimeRelevantForGroupBy(groupBy)) { + return false + } + + const durationMs = getTimeRangeDurationMs(timeRange) + + return ( + Number.isFinite(durationMs) && + durationMs > 0 && + durationMs <= DEFAULT_X_AXIS_TICK_LIMIT * ONE_DAY_MS + ) +} + +export function getShortHourlyAxisTickLimit( + timeRange: Labrinth.Analytics.v3.TimeRange, + groupBy: AnalyticsGroupByPreset, +): number | undefined { + if (!shouldUseShortHourlyAxis(timeRange, groupBy)) { + return undefined + } + + const durationMs = getTimeRangeDurationMs(timeRange) + if (durationMs > SHORT_HOURLY_TIME_LABEL_DURATION_MS) { + return Math.min(DEFAULT_X_AXIS_TICK_LIMIT, Math.ceil(durationMs / ONE_DAY_MS)) + } + + return SHORT_HOURLY_AXIS_TICK_LIMIT +} + +function shouldShowTimeForHourlyAxis( + timeRange: Labrinth.Analytics.v3.TimeRange, + groupBy: AnalyticsGroupByPreset, +): boolean { + const durationMs = getTimeRangeDurationMs(timeRange) + return ( + isTimeRelevantForGroupBy(groupBy) && + Number.isFinite(durationMs) && + durationMs > 0 && + durationMs <= SHORT_HOURLY_TIME_LABEL_DURATION_MS + ) +} + +function getTimeRangeDurationMs(timeRange: Labrinth.Analytics.v3.TimeRange): number { + return new Date(timeRange.end).getTime() - new Date(timeRange.start).getTime() +} + +export function isYearRelevantForTimeRange(timeRange: Labrinth.Analytics.v3.TimeRange): boolean { + const startYear = new Date(timeRange.start).getFullYear() + const endYear = new Date(timeRange.end).getFullYear() + + return startYear !== endYear +} + +export function formatBucketEndLabel(end: Date, includeTime: boolean, includeYear = false): string { + if (includeTime) { + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + ...(includeYear ? { year: 'numeric' } : {}), + hour: 'numeric', + minute: '2-digit', + }).format(end) + } + + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + ...(includeYear ? { year: 'numeric' } : {}), + }).format(end) +} + +function isStartOfDay(date: Date): boolean { + return ( + date.getHours() === 0 && + date.getMinutes() === 0 && + date.getSeconds() === 0 && + date.getMilliseconds() === 0 + ) +} + +function isSingleFullDayTimeRange(start: Date, end: Date): boolean { + const durationMs = end.getTime() - start.getTime() + return ( + Math.abs(durationMs - ONE_DAY_MS) < ONE_MINUTE_MS && isStartOfDay(start) && isStartOfDay(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)) + } +} + +function formatSmallAxisNumber(value: number): string { + const rounded = Math.round(value) + if (Math.abs(value - rounded) < 0.0000001) { + return String(rounded) + } + + const formattedValue = Math.abs(value) < 1 ? value.toFixed(2) : value.toFixed(1) + return formattedValue.replace(/\.?0+$/, '') +} + +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: + if (Math.abs(value) < 10) { + return formatSmallAxisNumber(value) + } + return formatCompact(Math.round(value)) + } +} diff --git a/apps/frontend/src/components/analytics/query-builder/DownloadsThresholdInput.vue b/apps/frontend/src/components/analytics/query-builder/DownloadsThresholdInput.vue new file mode 100644 index 0000000000..18eba7b4f2 --- /dev/null +++ b/apps/frontend/src/components/analytics/query-builder/DownloadsThresholdInput.vue @@ -0,0 +1,145 @@ + + + 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..1955c945b3 --- /dev/null +++ b/apps/frontend/src/components/analytics/query-builder/QueryBuilder.vue @@ -0,0 +1,683 @@ + + + + + diff --git a/apps/frontend/src/components/analytics/query-builder/query-filter/QueryFilter.vue b/apps/frontend/src/components/analytics/query-builder/query-filter/QueryFilter.vue new file mode 100644 index 0000000000..80c45e6d42 --- /dev/null +++ b/apps/frontend/src/components/analytics/query-builder/query-filter/QueryFilter.vue @@ -0,0 +1,592 @@ + + + diff --git a/apps/frontend/src/components/analytics/query-builder/query-filter/queryFilter.ts b/apps/frontend/src/components/analytics/query-builder/query-filter/queryFilter.ts new file mode 100644 index 0000000000..a2ac99e09a --- /dev/null +++ b/apps/frontend/src/components/analytics/query-builder/query-filter/queryFilter.ts @@ -0,0 +1,331 @@ +import type { + AnalyticsBreakdownPreset, + AnalyticsDashboardStat, + AnalyticsQueryFilterCategory, + AnalyticsSelectedFilters, +} from '~/providers/analytics/analytics' + +export type AnalyticsDashboardDimension = + | 'project' + | 'project_status' + | 'version_id' + | 'country' + | 'monetization' + | 'download_source' + | 'download_reason' + | 'game_version' + | 'loader_type' + +export const ALL_FILTER_VALUE = '__all__' +export const FILTER_VALUE_CATEGORIES: Exclude[] = [ + 'project_status', + 'country', + 'monetization', + 'download_source', + 'download_reason', + 'version_id', + 'game_version', + 'loader_type', +] + +const ANALYTICS_DASHBOARD_STAT_ORDER: AnalyticsDashboardStat[] = [ + 'views', + 'downloads', + 'revenue', + 'playtime', +] + +const ANALYTICS_STATS_BY_DIMENSION: Record< + AnalyticsDashboardDimension, + readonly AnalyticsDashboardStat[] +> = { + project: ANALYTICS_DASHBOARD_STAT_ORDER, + version_id: ['downloads', 'playtime'], + country: ['views', 'downloads', 'playtime'], + monetization: ['views', 'downloads'], + download_source: ['downloads'], + download_reason: ['downloads'], + game_version: ['downloads', 'playtime'], + loader_type: ['downloads', 'playtime'], + project_status: ANALYTICS_DASHBOARD_STAT_ORDER, +} + +const ANALYTICS_DIMENSION_BY_BREAKDOWN: Record< + AnalyticsBreakdownPreset, + AnalyticsDashboardDimension +> = { + none: 'project', + country: 'country', + monetization: 'monetization', + download_source: 'download_source', + download_reason: 'download_reason', + version_id: 'version_id', + loader: 'loader_type', + game_version: 'game_version', +} + +const ANALYTICS_DIMENSION_BY_FILTER_CATEGORY: Record< + Exclude, + AnalyticsDashboardDimension +> = { + project_status: 'project_status', + country: 'country', + monetization: 'monetization', + download_source: 'download_source', + download_reason: 'download_reason', + version_id: 'version_id', + game_version: 'game_version', + loader_type: 'loader_type', +} + +const ANALYTICS_FILTER_CATEGORY_BY_BREAKDOWN: Record< + AnalyticsBreakdownPreset, + Exclude | null +> = { + none: null, + country: 'country', + monetization: 'monetization', + download_source: 'download_source', + download_reason: 'download_reason', + version_id: 'version_id', + loader: 'loader_type', + game_version: 'game_version', +} + +export type FilterOption = { + value: string + label: string + searchTerms?: string[] +} + +function intersectAnalyticsStats( + left: readonly AnalyticsDashboardStat[], + right: readonly AnalyticsDashboardStat[], +): AnalyticsDashboardStat[] { + const rightStats = new Set(right) + return left.filter((stat) => rightStats.has(stat)) +} + +function haveAnalyticsStatOverlap( + left: readonly AnalyticsDashboardStat[], + right: readonly AnalyticsDashboardStat[], +): boolean { + return left.some((stat) => right.includes(stat)) +} + +export function getAnalyticsStatsForDimension( + dimension: AnalyticsDashboardDimension, +): readonly AnalyticsDashboardStat[] { + return ANALYTICS_STATS_BY_DIMENSION[dimension] +} + +export function getAnalyticsStatsForBreakdown( + breakdown: AnalyticsBreakdownPreset, +): readonly AnalyticsDashboardStat[] { + return getAnalyticsStatsForDimension(ANALYTICS_DIMENSION_BY_BREAKDOWN[breakdown]) +} + +export function getAnalyticsStatsForFilterCategory( + category: AnalyticsQueryFilterCategory, +): readonly AnalyticsDashboardStat[] { + if (category === 'project') { + return ANALYTICS_DASHBOARD_STAT_ORDER + } + + return getAnalyticsStatsForDimension(ANALYTICS_DIMENSION_BY_FILTER_CATEGORY[category]) +} + +export function getAnalyticsFilterCategoryForBreakdown( + breakdown: AnalyticsBreakdownPreset, +): Exclude | null { + return ANALYTICS_FILTER_CATEGORY_BY_BREAKDOWN[breakdown] +} + +function getAnalyticsStatsForFilterScope( + breakdown: AnalyticsBreakdownPreset, + filters: AnalyticsSelectedFilters, + ignoredCategory?: AnalyticsQueryFilterCategory, +): readonly AnalyticsDashboardStat[] { + let stats = [...getAnalyticsStatsForBreakdown(breakdown)] + + for (const category of FILTER_VALUE_CATEGORIES) { + if (category === ignoredCategory || filters[category].length === 0) { + continue + } + + stats = intersectAnalyticsStats(stats, getAnalyticsStatsForFilterCategory(category)) + } + + return stats +} + +export function getEnabledAnalyticsStatsForState( + breakdown: AnalyticsBreakdownPreset, + filters: AnalyticsSelectedFilters, +): readonly AnalyticsDashboardStat[] { + return getAnalyticsStatsForFilterScope(breakdown, filters) +} + +export function getVisibleAnalyticsFilterCategoriesForState( + breakdown: AnalyticsBreakdownPreset, + filters: AnalyticsSelectedFilters, +): readonly Exclude[] { + return FILTER_VALUE_CATEGORIES.filter((category) => + haveAnalyticsStatOverlap( + getAnalyticsStatsForFilterScope(breakdown, filters, category), + getAnalyticsStatsForFilterCategory(category), + ), + ) +} + +export function sanitizeAnalyticsSelectedFilters( + breakdown: AnalyticsBreakdownPreset, + filters: AnalyticsSelectedFilters, +): AnalyticsSelectedFilters { + const nextFilters = cloneSelectedFilters(filters) + let availableStats = [...getAnalyticsStatsForBreakdown(breakdown)] + + for (const category of FILTER_VALUE_CATEGORIES) { + if (filters[category].length === 0) { + continue + } + + const categoryStats = getAnalyticsStatsForFilterCategory(category) + if (!haveAnalyticsStatOverlap(availableStats, categoryStats)) { + nextFilters[category] = [] + continue + } + + availableStats = intersectAnalyticsStats(availableStats, categoryStats) + } + + return nextFilters +} + +export function cloneSelectedFilters(filters: AnalyticsSelectedFilters): AnalyticsSelectedFilters { + return { + project: [...filters.project], + project_status: [...filters.project_status], + country: [...filters.country], + monetization: [...filters.monetization], + download_source: [...filters.download_source], + download_reason: [...filters.download_reason], + version_id: [...filters.version_id], + game_version: [...filters.game_version], + loader_type: [...filters.loader_type], + } +} + +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 categoryKey of FILTER_VALUE_CATEGORIES) { + if (!areStringArraysEqual(left[categoryKey], right[categoryKey])) { + return false + } + } + + return true +} + +export function getOptionsWithSelectedValues( + options: FilterOption[], + selectedValues: string[], + getMissingSelectedOptionLabel: (value: string) => string = (value) => value, +): FilterOption[] { + const knownValues = new Set(options.map((option) => option.value)) + const missingSelectedOptions = selectedValues + .filter((value) => !knownValues.has(value)) + .map((value) => ({ + value, + label: getMissingSelectedOptionLabel(value), + })) + + return [...options, ...missingSelectedOptions] +} + +export function normalizeSelectedValues( + categoryKey: AnalyticsQueryFilterCategory, + values: string[], + projectIds: string[], +): string[] { + const uniqueValues = Array.from(new Set(values)) + + if (categoryKey === 'project') { + if (uniqueValues.includes(ALL_FILTER_VALUE)) { + return projectIds + } + + const allProjectIds = new Set(projectIds) + const selectedProjects = uniqueValues.filter((value) => allProjectIds.has(value)) + + return selectedProjects.length > 0 ? selectedProjects : projectIds + } + + if (uniqueValues.includes(ALL_FILTER_VALUE) || uniqueValues.length === 0) { + return [] + } + + const selectedValues = uniqueValues.filter((value) => value !== ALL_FILTER_VALUE) + if (categoryKey === 'project_status') { + return selectedValues + .map((value) => value.trim().toLowerCase()) + .filter(isProjectStatusFilterValue) + } + if (categoryKey === 'loader_type') { + return Array.from( + new Set( + selectedValues + .map((value) => value.trim().toLowerCase()) + .filter((value) => value.length > 0), + ), + ) + } + + return selectedValues +} + +export const PROJECT_STATUS_FILTER_VALUES = [ + 'approved', + 'archived', + 'rejected', + 'draft', + 'unlisted', + 'withheld', + 'private', + 'other', +] as const + +export type ProjectStatusFilterValue = (typeof PROJECT_STATUS_FILTER_VALUES)[number] + +const projectStatusFilterValueSet = new Set(PROJECT_STATUS_FILTER_VALUES) + +export function isProjectStatusFilterValue(value: string): value is ProjectStatusFilterValue { + return projectStatusFilterValueSet.has(value) +} + +export function getProjectStatusFilterValue( + status: string | null | undefined, +): ProjectStatusFilterValue { + const normalizedStatus = status?.trim().toLowerCase() ?? '' + return isProjectStatusFilterValue(normalizedStatus) ? normalizedStatus : 'other' +} diff --git a/apps/frontend/src/components/analytics/query-builder/timeframe-picker/CustomRangeTimeframe.vue b/apps/frontend/src/components/analytics/query-builder/timeframe-picker/CustomRangeTimeframe.vue new file mode 100644 index 0000000000..e5383d8640 --- /dev/null +++ b/apps/frontend/src/components/analytics/query-builder/timeframe-picker/CustomRangeTimeframe.vue @@ -0,0 +1,138 @@ + + + diff --git a/apps/frontend/src/components/analytics/query-builder/timeframe-picker/CustomTimeframe.vue b/apps/frontend/src/components/analytics/query-builder/timeframe-picker/CustomTimeframe.vue new file mode 100644 index 0000000000..70f92cfb7a --- /dev/null +++ b/apps/frontend/src/components/analytics/query-builder/timeframe-picker/CustomTimeframe.vue @@ -0,0 +1,129 @@ + + + diff --git a/apps/frontend/src/components/analytics/query-builder/timeframe-picker/TimeFramePicker.vue b/apps/frontend/src/components/analytics/query-builder/timeframe-picker/TimeFramePicker.vue new file mode 100644 index 0000000000..60f20eba4b --- /dev/null +++ b/apps/frontend/src/components/analytics/query-builder/timeframe-picker/TimeFramePicker.vue @@ -0,0 +1,401 @@ + + + diff --git a/apps/frontend/src/components/analytics/query-builder/timeframe-picker/timeframe.ts b/apps/frontend/src/components/analytics/query-builder/timeframe-picker/timeframe.ts new file mode 100644 index 0000000000..e93d866ec0 --- /dev/null +++ b/apps/frontend/src/components/analytics/query-builder/timeframe-picker/timeframe.ts @@ -0,0 +1,257 @@ +import { + type AnalyticsGroupByPreset, + type AnalyticsLastTimeframeUnit, + type AnalyticsTimeframeMode, + type AnalyticsTimeframePreset, + injectAnalyticsDashboardContext, +} from '~/providers/analytics/analytics' + +const MIN_RANGE_MS = 60 * 60 * 1000 +const TIME_RANGE_ROUNDING_MS = 60 * 1000 + +export type AnalyticsTimeRange = { + start: Date + end: Date +} + +export function startOfDay(date: Date): Date { + const nextDate = new Date(date) + nextDate.setHours(0, 0, 0, 0) + return nextDate +} + +export function getRoundedNow(timestamp: number): Date { + const roundedTimestamp = Math.floor(timestamp / TIME_RANGE_ROUNDING_MS) * TIME_RANGE_ROUNDING_MS + return new Date(roundedTimestamp) +} + +export function getDateInputValue(date: Date): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +export function parseDateInputValue(value: string): Date { + const parsedDate = new Date(`${value}T00:00:00`) + return Number.isNaN(parsedDate.getTime()) ? startOfDay(new Date()) : parsedDate +} + +export function addDays(date: Date, days: number): Date { + const nextDate = new Date(date) + nextDate.setDate(nextDate.getDate() + days) + return nextDate +} + +function isStartOfDay(date: Date): boolean { + return ( + date.getHours() === 0 && + date.getMinutes() === 0 && + date.getSeconds() === 0 && + date.getMilliseconds() === 0 + ) +} + +export function getInclusiveEndDateInputValue(end: Date): string { + return getDateInputValue(isStartOfDay(end) ? addDays(end, -1) : end) +} + +function subtractCalendarMonths(date: Date, months: number): Date { + const nextDate = new Date(date) + const day = nextDate.getDate() + nextDate.setDate(1) + nextDate.setMonth(nextDate.getMonth() - months) + const daysInMonth = new Date(nextDate.getFullYear(), nextDate.getMonth() + 1, 0).getDate() + nextDate.setDate(Math.min(day, daysInMonth)) + return nextDate +} + +export function getTimeRangeForPreset( + preset: AnalyticsTimeframePreset, + nowTimestamp: number, +): AnalyticsTimeRange { + const now = getRoundedNow(nowTimestamp) + const end = new Date(now) + + switch (preset) { + case 'today': + return { start: startOfDay(now), end } + case 'yesterday': { + const todayStart = startOfDay(now) + return { + start: new Date(todayStart.getTime() - 24 * 60 * 60 * 1000), + end: todayStart, + } + } + case 'last_7_days': + return { + start: new Date(end.getTime() - 7 * 24 * 60 * 60 * 1000), + end, + } + case 'last_14_days': + return { + start: new Date(end.getTime() - 14 * 24 * 60 * 60 * 1000), + end, + } + case 'last_30_days': + return { + start: new Date(end.getTime() - 30 * 24 * 60 * 60 * 1000), + end, + } + case 'last_90_days': + return { + start: new Date(end.getTime() - 90 * 24 * 60 * 60 * 1000), + end, + } + case 'last_180_days': + return { + start: new Date(end.getTime() - 180 * 24 * 60 * 60 * 1000), + end, + } + case 'year_to_date': { + const yearStart = new Date(now.getFullYear(), 0, 1) + yearStart.setHours(0, 0, 0, 0) + return { start: yearStart, end } + } + case 'all_time': + return { + start: new Date(Date.UTC(2023, 0, 1, 0, 0, 0, 0)), + end, + } + default: + return { + start: new Date(end.getTime() - 24 * 60 * 60 * 1000), + end, + } + } +} + +export function getTimeRangeForLastTimeframe( + amountValue: number, + unit: AnalyticsLastTimeframeUnit, + nowTimestamp: number, +): AnalyticsTimeRange { + const end = getRoundedNow(nowTimestamp) + const amount = Math.max(1, Math.floor(amountValue)) + + switch (unit) { + case 'hours': + return { start: new Date(end.getTime() - amount * 60 * 60 * 1000), end } + case 'days': + return { start: new Date(end.getTime() - amount * 24 * 60 * 60 * 1000), end } + case 'weeks': + return { start: new Date(end.getTime() - amount * 7 * 24 * 60 * 60 * 1000), end } + case 'months': + return { start: subtractCalendarMonths(end, amount), end } + default: + return { start: new Date(end.getTime() - 24 * 60 * 60 * 1000), end } + } +} + +export function getTimeRangeForCustomDateRange( + startDate: string, + endDate: string, +): AnalyticsTimeRange { + const start = parseDateInputValue(startDate) + const inclusiveEnd = parseDateInputValue(endDate) + return { + start, + end: addDays(inclusiveEnd, 1), + } +} + +export function getAnalyticsTimeRange({ + mode, + preset, + lastAmount, + lastUnit, + customStartDate, + customEndDate, + nowTimestamp, +}: { + mode: AnalyticsTimeframeMode + preset: AnalyticsTimeframePreset + lastAmount: number + lastUnit: AnalyticsLastTimeframeUnit + customStartDate: string + customEndDate: string + nowTimestamp: number +}): AnalyticsTimeRange { + switch (mode) { + case 'last': + return getTimeRangeForLastTimeframe(lastAmount, lastUnit, nowTimestamp) + case 'custom_range': + return getTimeRangeForCustomDateRange(customStartDate, customEndDate) + case 'preset': + default: + return getTimeRangeForPreset(preset, nowTimestamp) + } +} + +export function getDefaultAnalyticsGroupByForDurationMinutes( + durationMinutes: number, +): AnalyticsGroupByPreset { + const days = durationMinutes / (24 * 60) + if (days <= 2) return '1h' + if (days <= 14) return '6h' + if (days <= 90) return 'day' + if (days <= 365) return 'week' + if (days <= 365 * 3) return 'month' + return 'year' +} + +export function ensureMinimumTimeRange(start: Date, end: Date): AnalyticsTimeRange { + if (end.getTime() <= start.getTime()) { + return { + start: new Date(end.getTime() - MIN_RANGE_MS), + end, + } + } + + if (end.getTime() - start.getTime() < MIN_RANGE_MS) { + return { + start: new Date(end.getTime() - MIN_RANGE_MS), + end, + } + } + + return { start, end } +} + +export function useSelectedAnalyticsTimeRange() { + const { + selectedTimeframeMode, + selectedTimeframe, + selectedLastTimeframeAmount, + selectedLastTimeframeUnit, + selectedCustomTimeframeStartDate, + selectedCustomTimeframeEndDate, + queryRefreshTimestamp, + } = injectAnalyticsDashboardContext() + + const selectedTimeRange = computed(() => + getAnalyticsTimeRange({ + mode: selectedTimeframeMode.value, + preset: selectedTimeframe.value, + lastAmount: selectedLastTimeframeAmount.value, + lastUnit: selectedLastTimeframeUnit.value, + customStartDate: selectedCustomTimeframeStartDate.value, + customEndDate: selectedCustomTimeframeEndDate.value, + nowTimestamp: queryRefreshTimestamp.value, + }), + ) + + const selectedTimeframeDurationMinutes = computed(() => { + const { start, end } = ensureMinimumTimeRange( + selectedTimeRange.value.start, + selectedTimeRange.value.end, + ) + const durationMs = end.getTime() - start.getTime() + return Math.max(1, Math.floor(durationMs / (60 * 1000))) + }) + + return { + selectedTimeRange, + selectedTimeframeDurationMinutes, + } +} 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..a4fd4ef36f --- /dev/null +++ b/apps/frontend/src/components/analytics/stat-cards/StatCard.vue @@ -0,0 +1,141 @@ + + + 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..0e13cc57f0 --- /dev/null +++ b/apps/frontend/src/components/analytics/stat-cards/StatCards.vue @@ -0,0 +1,113 @@ + + + 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..51e35def27 --- /dev/null +++ b/apps/frontend/src/components/analytics/table/AnalyticsTable.vue @@ -0,0 +1,623 @@ + + + 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 ea6670f932..0000000000 --- a/apps/frontend/src/components/ui/charts/ChartDisplay.vue +++ /dev/null @@ -1,1016 +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 48c723ed05..b5e0e0c1f3 100644 --- a/apps/frontend/src/layouts/default.vue +++ b/apps/frontend/src/layouts/default.vue @@ -453,7 +453,7 @@