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..a8bc022a89 --- /dev/null +++ b/apps/frontend/src/components/analytics/AnalyticsDashboard.vue @@ -0,0 +1,52 @@ + + + diff --git a/apps/frontend/src/components/analytics/breakdown.ts b/apps/frontend/src/components/analytics/breakdown.ts new file mode 100644 index 0000000000..4112a83091 --- /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 'version_id': + 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..5a027ac8e0 --- /dev/null +++ b/apps/frontend/src/components/analytics/graph/AnalyticsChart.client.vue @@ -0,0 +1,383 @@ + + + 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..b934cd5fa0 --- /dev/null +++ b/apps/frontend/src/components/analytics/graph/AnalyticsGraph.vue @@ -0,0 +1,460 @@ + + + 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..cd00fa11e5 --- /dev/null +++ b/apps/frontend/src/components/analytics/query-builder/QueryBuilder.vue @@ -0,0 +1,533 @@ + + + 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..4faa31be38 --- /dev/null +++ b/apps/frontend/src/components/analytics/query-builder/QueryFilter.vue @@ -0,0 +1,765 @@ + + + + + diff --git a/apps/frontend/src/components/analytics/query-builder/queryFilter.ts b/apps/frontend/src/components/analytics/query-builder/queryFilter.ts new file mode 100644 index 0000000000..291ff902e2 --- /dev/null +++ b/apps/frontend/src/components/analytics/query-builder/queryFilter.ts @@ -0,0 +1,283 @@ +import type { + AnalyticsQueryFilterCategory, + AnalyticsSelectedFilters, +} from '~/providers/analytics/analytics' + +export const ALL_FILTER_VALUE = '__all__' +export const ADD_MENU_WIDTH = 250 +export const DROPDOWN_GAP = 12 +export const DROPDOWN_VIEWPORT_MARGIN = 8 +export const FILTER_VALUE_CATEGORIES: Exclude[] = [ + 'country', + 'monetization', + 'download_source', + 'version_id', + 'game_version', + 'loader_type', +] + +export type FilterOption = { + value: string + label: string +} + +export type FilterCategory = { + key: AnalyticsQueryFilterCategory + label: string + allLabel: string + options: FilterOption[] +} + +export type FilterSelectionSource = 'committed' | 'draft' + +export type Point = { + x: number + y: number +} + +type MenuPositionOptions = { + triggerRect: DOMRect + dropdownRect: DOMRect + viewportWidth: number + viewportHeight: number +} + +type SubmenuPositionOptions = { + buttonRect: DOMRect + submenuWidth: number + submenuHeight: number + viewportWidth: number + viewportHeight: number +} + +export function cloneSelectedFilters(filters: AnalyticsSelectedFilters): AnalyticsSelectedFilters { + return { + project: [...filters.project], + country: [...filters.country], + monetization: [...filters.monetization], + download_source: [...filters.download_source], + 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[], +): FilterOption[] { + const knownValues = new Set(options.map((option) => option.value)) + const missingSelectedOptions = selectedValues + .filter((value) => !knownValues.has(value)) + .map((value) => ({ + value, + label: 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 [] + } + + return uniqueValues.filter((value) => value !== ALL_FILTER_VALUE) +} + +export function isFilterValueSelected( + categoryKey: AnalyticsQueryFilterCategory, + value: string, + selectedValues: string[], + projectCount: number, +): boolean { + if (categoryKey === 'project') { + if (value === ALL_FILTER_VALUE) { + return selectedValues.length === projectCount + } + + return selectedValues.includes(value) + } + + if (value === ALL_FILTER_VALUE) { + return selectedValues.length === 0 + } + return selectedValues.includes(value) +} + +export function getCategorySelectionCount( + categoryKey: AnalyticsQueryFilterCategory, + selectedValues: string[], + projectCount: number, +): number { + if (categoryKey === 'project') { + return selectedValues.length === projectCount ? 0 : selectedValues.length + } + + return selectedValues.length +} + +export function getCategorySelectionSummary( + category: FilterCategory, + selectedValues: string[], + count: number, + projectIds: string[], +): string { + if (count === 0) { + return category.allLabel + } + + if (count === 1) { + const selectedValue = + category.key === 'project' + ? selectedValues.find((projectId) => projectIds.includes(projectId)) + : selectedValues[0] + return category.options.find((option) => option.value === selectedValue)?.label ?? '1 selected' + } + + return `${count} selected` +} + +export function getAddMenuPosition({ + triggerRect, + dropdownRect, + viewportWidth, + viewportHeight, +}: MenuPositionOptions) { + const dropdownWidth = Math.max(ADD_MENU_WIDTH, triggerRect.width) + const hasSpaceBelow = + triggerRect.bottom + dropdownRect.height + DROPDOWN_GAP + DROPDOWN_VIEWPORT_MARGIN <= + viewportHeight + const hasSpaceAbove = + triggerRect.top - dropdownRect.height - DROPDOWN_GAP - DROPDOWN_VIEWPORT_MARGIN > 0 + const opensUp = !hasSpaceBelow && hasSpaceAbove + const top = opensUp + ? triggerRect.top - dropdownRect.height - DROPDOWN_GAP + : triggerRect.bottom + DROPDOWN_GAP + const left = Math.min( + triggerRect.left, + viewportWidth - dropdownWidth - DROPDOWN_VIEWPORT_MARGIN, + ) + + return { + left: `${Math.max(DROPDOWN_VIEWPORT_MARGIN, left)}px`, + minWidth: `${triggerRect.width}px`, + top: `${Math.max(DROPDOWN_VIEWPORT_MARGIN, top)}px`, + width: `${dropdownWidth}px`, + } +} + +export function getSubmenuPosition({ + buttonRect, + submenuWidth, + submenuHeight, + viewportWidth, + viewportHeight, +}: SubmenuPositionOptions): Point { + const gap = 20 + const viewportPadding = 8 + const preferredLeft = buttonRect.right + gap + const left = + preferredLeft + submenuWidth + viewportPadding <= viewportWidth + ? preferredLeft + : Math.max(viewportPadding, buttonRect.left - submenuWidth - gap) + const top = Math.min( + Math.max(viewportPadding, buttonRect.top), + Math.max(viewportPadding, viewportHeight - submenuHeight - viewportPadding), + ) + + return { + x: left, + y: top, + } +} + +export function isCursorAimingAtSubmenu( + cursor: Point | null, + origin: Point | null, + submenuRect: DOMRect | null, +): boolean { + if (!submenuRect || !cursor || !origin) { + return false + } + + const submenuTargetX = + origin.x <= submenuRect.left + ? submenuRect.left + : origin.x >= submenuRect.right + ? submenuRect.right + : cursor.x <= submenuRect.left + ? submenuRect.left + : submenuRect.right + const upperTarget: Point = { + x: submenuTargetX, + y: submenuRect.top + 20, + } + const lowerTarget: Point = { + x: submenuTargetX, + y: submenuRect.bottom + 20, + } + + return isPointInTriangle(cursor, origin, upperTarget, lowerTarget) +} + +function isPointInTriangle(point: Point, a: Point, b: Point, c: Point): boolean { + const area = triangleArea(a, b, c) + const area1 = triangleArea(point, b, c) + const area2 = triangleArea(a, point, c) + const area3 = triangleArea(a, b, point) + + return Math.abs(area - (area1 + area2 + area3)) < 0.5 +} + +function triangleArea(a: Point, b: Point, c: Point): number { + return Math.abs((a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)) / 2) +} 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..8aab1f2a4f 100644 --- a/apps/frontend/src/layouts/default.vue +++ b/apps/frontend/src/layouts/default.vue @@ -445,7 +445,7 @@