diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 59caedc3e..e835e9cfc 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/assets/styles/layout.scss b/apps/frontend/src/assets/styles/layout.scss index 096cf482b..83debb8ff 100644 --- a/apps/frontend/src/assets/styles/layout.scss +++ b/apps/frontend/src/assets/styles/layout.scss @@ -50,6 +50,7 @@ @media screen and (max-width: 1024px) { margin-top: 1.5rem; + padding: 0 1rem; } .normal-page__sidebar { diff --git a/apps/frontend/src/components/analytics-dashboard/AnalyticsLoadingBar.vue b/apps/frontend/src/components/analytics-dashboard/AnalyticsLoadingBar.vue new file mode 100644 index 000000000..addfe43f2 --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/AnalyticsLoadingBar.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-chart/AnalyticsChart.client.vue b/apps/frontend/src/components/analytics-dashboard/analytics-chart/AnalyticsChart.client.vue new file mode 100644 index 000000000..930d56c04 --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-chart/AnalyticsChart.client.vue @@ -0,0 +1,1024 @@ + + + diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-constants.ts b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-constants.ts new file mode 100644 index 000000000..20206da55 --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-constants.ts @@ -0,0 +1,78 @@ +import type { Labrinth } from '@modrinth/api-client' + +import type { AnalyticsDashboardStat } from '~/providers/analytics/analytics' + +export const ANALYTICS_DASHBOARD_STATS: readonly AnalyticsDashboardStat[] = [ + 'views', + 'downloads', + 'revenue', + 'playtime', +] + +export const TOP_GRAPH_DATASET_LIMIT = 8 +export const GRAPH_RENDER_DATASET_LIMIT = 250 +export const PREVIOUS_PERIOD_DATASET_ID_PREFIX = 'previous-period:' +export const PREVIOUS_PERIOD_BORDER_DASH = [6, 4] +export const PROJECT_VERSION_UPLOAD_DEDUPE_WINDOW_MS = 24 * 60 * 60 * 1000 +export const ALL_PROJECTS_DATASET_ID = 'all' + +export const PROJECT_EVENT_DATE_FORMATTER = new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', +}) + +export const MONETIZATION_LEGEND_ENTRY_ORDER = new Map([ + ['breakdown:monetized', 0], + ['breakdown:unmonetized', 1], +]) + +export const VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUSES = [ + 'approved', + 'unlisted', + 'private', +] as const satisfies readonly Labrinth.Projects.v2.ProjectStatus[] + +export type VisibleProjectStatusChangeEventStatus = + (typeof VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUSES)[number] + +export const VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUS_SET = + new Set(VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUSES) + +export const LIGHT_LEGEND_PALETTE = [ + 'hsl(152, 100%, 34%)', + 'hsl(26, 100%, 42%)', + 'hsl(202, 100%, 35%)', + 'hsl(327, 45%, 64%)', + 'hsl(41, 100%, 45%)', + 'hsl(250, 60%, 33%)', + 'hsl(170, 43%, 47%)', + 'hsl(330, 60%, 33%)', + 'hsl(46, 100%, 36%)', + 'hsl(167, 100%, 30%)', + 'hsl(343, 38%, 45%)', + 'hsl(222, 100%, 28%)', + 'hsl(270, 62%, 60%)', + 'hsl(32, 100%, 37%)', + 'hsl(349, 57%, 51%)', + 'hsl(191, 43%, 37%)', +] + +export const DARK_LEGEND_PALETTE = [ + 'hsl(145, 78%, 48%)', + 'hsl(41, 100%, 50%)', + 'hsl(202, 77%, 63%)', + 'hsl(323, 66%, 72%)', + 'hsl(56, 85%, 60%)', + 'hsl(255, 92%, 80%)', + 'hsl(12, 100%, 67%)', + 'hsl(176, 58%, 56%)', + 'hsl(60, 100%, 41%)', + 'hsl(165, 80%, 38%)', + 'hsl(341, 36%, 56%)', + 'hsl(226, 60%, 49%)', + 'hsl(252, 53%, 62%)', + 'hsl(75, 59%, 50%)', + 'hsl(195, 56%, 42%)', + 'hsl(30, 59%, 56%)', +] diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-header/AnalyticsChartControls.vue b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-header/AnalyticsChartControls.vue new file mode 100644 index 000000000..c1804f9ca --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-header/AnalyticsChartControls.vue @@ -0,0 +1,297 @@ + + + + + diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-header/AnalyticsChartLegend.vue b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-header/AnalyticsChartLegend.vue new file mode 100644 index 000000000..22c9580ae --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-header/AnalyticsChartLegend.vue @@ -0,0 +1,188 @@ + + + + + diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-header/AnalyticsChartRenderLimitModal.vue b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-header/AnalyticsChartRenderLimitModal.vue new file mode 100644 index 000000000..9da495ce2 --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-header/AnalyticsChartRenderLimitModal.vue @@ -0,0 +1,63 @@ + + + diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-header/index.vue b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-header/index.vue new file mode 100644 index 000000000..7ccec367f --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-header/index.vue @@ -0,0 +1,122 @@ + + + diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-header/use-analytics-chart-legend.ts b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-header/use-analytics-chart-legend.ts new file mode 100644 index 000000000..3f3ed5629 --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-header/use-analytics-chart-legend.ts @@ -0,0 +1,450 @@ +import { useVIntl } from '@modrinth/ui' +import { computed, type ComputedRef, type Ref, ref, watch } from 'vue' + +import type { + AnalyticsBreakdownPreset, + AnalyticsDashboardProject, +} from '~/providers/analytics/analytics' + +import { analyticsChartMessages } from '../../analytics-messages.ts' +import { COMBINED_BREAKDOWN_DATASET_ID_PREFIX } from '../../breakdown.ts' +import { + ALL_PROJECTS_DATASET_ID, + MONETIZATION_LEGEND_ENTRY_ORDER, + PREVIOUS_PERIOD_BORDER_DASH, +} from '../analytics-chart-constants.ts' +import type { AnalyticsChartEvent } from '../analytics-chart-plot/AnalyticsChartEvents.vue' +import type { AnalyticsChartLegendEntry } from '../analytics-chart-types.ts' +import { + areStringArraysEqual, + type ChartDataset, + decodeBreakdownDatasetValue, + getChartDatasetTotal, + getPreviousPeriodDatasetId, +} from '../analytics-chart-utils.ts' + +export function useAnalyticsChartLegend({ + selectableChartDatasets, + allChartDatasets, + previousChartDatasets, + shouldShowPreviousPeriod, + isRatioMode, + hiddenGraphDatasetIds, + selectedBreakdowns, + isGraphDatasetSelectionActive, + selectedProjects, + selectedProjectIdSet, + selectedProjectEventIdSet, +}: { + selectableChartDatasets: ComputedRef + allChartDatasets: ComputedRef + previousChartDatasets: ComputedRef + shouldShowPreviousPeriod: ComputedRef + isRatioMode: Ref + hiddenGraphDatasetIds: Ref + selectedBreakdowns: Ref + isGraphDatasetSelectionActive: Ref + selectedProjects: ComputedRef + selectedProjectIdSet: ComputedRef> + selectedProjectEventIdSet: ComputedRef> +}) { + const { formatMessage } = useVIntl() + const hoveredLegendEntryId = ref(null) + const hiddenDatasetIds = computed(() => new Set(hiddenGraphDatasetIds.value)) + const previousChartDatasetByOriginalId = computed(() => { + const datasets = new Map() + for (const dataset of previousChartDatasets.value) { + datasets.set(dataset.projectId, dataset) + } + return datasets + }) + const currentLegendEntries = computed(() => + selectableChartDatasets.value + .map((dataset) => ({ + id: dataset.projectId, + name: dataset.label, + projectName: dataset.projectName, + color: dataset.borderColor, + totalValue: getChartDatasetTotal(dataset), + hidden: hiddenDatasetIds.value.has(dataset.projectId), + })) + .sort(compareLegendEntries), + ) + const visibleProjectEventIdSet = computed(() => { + if (!selectedBreakdowns.value.includes('project')) { + return selectedProjectEventIdSet.value + } + + const visibleProjectIds = new Set() + const projectIdsWithLegendEntries = new Set() + + for (const legendEntry of currentLegendEntries.value) { + const projectId = getLegendEntryProjectId(legendEntry) + if (!projectId) { + continue + } + + projectIdsWithLegendEntries.add(projectId) + if (!legendEntry.hidden) { + visibleProjectIds.add(projectId) + } + } + + if (isGraphDatasetSelectionActive.value) { + return visibleProjectIds + } + + if (projectIdsWithLegendEntries.size === 0) { + return selectedProjectEventIdSet.value + } + + const eventProjectIds = new Set() + for (const projectId of selectedProjectEventIdSet.value) { + if (!projectIdsWithLegendEntries.has(projectId) || visibleProjectIds.has(projectId)) { + eventProjectIds.add(projectId) + } + } + + return eventProjectIds + }) + const legendEntries = computed(() => { + if (!shouldShowPreviousPeriod.value) { + return currentLegendEntries.value + } + + return currentLegendEntries.value.flatMap((entry) => { + const previousDataset = previousChartDatasetByOriginalId.value.get(entry.id) + const previousEntry: AnalyticsChartLegendEntry = { + id: getPreviousPeriodDatasetId(entry.id), + name: formatMessage(analyticsChartMessages.previousPeriodSuffix, { name: entry.name }), + projectName: entry.projectName, + color: entry.color, + totalValue: previousDataset ? getChartDatasetTotal(previousDataset) : 0, + hidden: hiddenDatasetIds.value.has(getPreviousPeriodDatasetId(entry.id)), + isPreviousPeriod: true, + } + + return [entry, previousEntry] + }) + }) + const hiddenCurrentLegendEntryIds = computed(() => + currentLegendEntries.value.filter((entry) => entry.hidden).map((entry) => entry.id), + ) + const hiddenCurrentLegendEntryIdsKey = computed(() => + hiddenCurrentLegendEntryIds.value.join('\u0000'), + ) + const chartDatasetById = computed(() => { + const datasets = new Map() + for (const dataset of selectableChartDatasets.value) { + datasets.set(dataset.projectId, dataset) + + if (!shouldShowPreviousPeriod.value) { + continue + } + + const previousDataset = previousChartDatasetByOriginalId.value.get(dataset.projectId) + const previousData = Array.from( + { length: dataset.data.length }, + (_, index) => previousDataset?.data[index] ?? 0, + ) + datasets.set(getPreviousPeriodDatasetId(dataset.projectId), { + projectId: getPreviousPeriodDatasetId(dataset.projectId), + label: formatMessage(analyticsChartMessages.previousPeriodSuffix, { + name: dataset.label, + }), + projectName: dataset.projectName, + data: previousData, + borderColor: dataset.borderColor, + backgroundColor: dataset.backgroundColor, + borderDash: PREVIOUS_PERIOD_BORDER_DASH, + }) + } + return datasets + }) + const hoverRatioSliceTotals = computed(() => { + const sliceLength = selectableChartDatasets.value.reduce( + (maxLength, dataset) => Math.max(maxLength, dataset.data.length), + 0, + ) + const totals = new Array(sliceLength).fill(0) + + for (const legendEntry of legendEntries.value) { + if (legendEntry.hidden) continue + + const dataset = chartDatasetById.value.get(legendEntry.id) + if (!dataset) continue + + for (let i = 0; i < sliceLength; i++) { + totals[i] += dataset.data[i] ?? 0 + } + } + + return totals + }) + const baseVisibleChartDatasets = computed(() => + legendEntries.value + .filter((legendEntry) => !legendEntry.hidden) + .map((legendEntry) => { + const dataset = chartDatasetById.value.get(legendEntry.id) + if (!dataset) return null + + return { + ...dataset, + borderColor: legendEntry.color, + backgroundColor: legendEntry.color, + } + }) + .filter((dataset): dataset is ChartDataset => Boolean(dataset)), + ) + const visibleChartDatasets = computed(() => { + const datasets = baseVisibleChartDatasets.value + if (!isRatioMode.value || datasets.length === 0) return datasets + + const sliceLength = datasets.reduce( + (maxLength, dataset) => Math.max(maxLength, dataset.data.length), + 0, + ) + const totals = new Array(sliceLength).fill(0) + for (const dataset of datasets) { + for (let i = 0; i < sliceLength; i++) { + totals[i] += dataset.data[i] ?? 0 + } + } + + return datasets.map((dataset) => ({ + ...dataset, + data: dataset.data.map((value, i) => (totals[i] === 0 ? 0 : (value / totals[i]) * 100)), + })) + }) + const visibleChartDatasetById = computed(() => { + const datasets = new Map() + for (const dataset of visibleChartDatasets.value) { + datasets.set(dataset.projectId, dataset) + } + return datasets + }) + const highlightedChartDatasetId = computed(() => { + const datasetId = hoveredLegendEntryId.value + if (!datasetId || !visibleChartDatasetById.value.has(datasetId)) return null + return datasetId + }) + + function compareLegendEntries(a: AnalyticsChartLegendEntry, b: AnalyticsChartLegendEntry) { + if (selectedBreakdowns.value.length === 1 && selectedBreakdowns.value[0] === 'monetization') { + const aOrder = MONETIZATION_LEGEND_ENTRY_ORDER.get(a.id) + const bOrder = MONETIZATION_LEGEND_ENTRY_ORDER.get(b.id) + + if (aOrder !== undefined || bOrder !== undefined) { + return (aOrder ?? Number.MAX_SAFE_INTEGER) - (bOrder ?? Number.MAX_SAFE_INTEGER) + } + } + + return b.totalValue - a.totalValue || a.name.localeCompare(b.name) + } + + function isProjectChartEventVisibleForLegend(event: AnalyticsChartEvent) { + return !event.projectId || visibleProjectEventIdSet.value.has(event.projectId) + } + + function getLegendEntryProjectId(legendEntry: AnalyticsChartLegendEntry) { + const projectBreakdownIndex = selectedBreakdowns.value.findIndex( + (breakdown) => breakdown === 'project', + ) + + if (projectBreakdownIndex === -1) { + if (selectedProjects.value.length === 1 && legendEntry.id === ALL_PROJECTS_DATASET_ID) { + return selectedProjects.value[0]?.id ?? null + } + + return null + } + + if (selectedBreakdowns.value.length === 1) { + return selectedProjectIdSet.value.has(legendEntry.id) ? legendEntry.id : null + } + + if (!legendEntry.id.startsWith(COMBINED_BREAKDOWN_DATASET_ID_PREFIX)) { + return null + } + + const values = legendEntry.id + .slice(COMBINED_BREAKDOWN_DATASET_ID_PREFIX.length) + .split('+') + .map(decodeBreakdownDatasetValue) + const projectId = values[projectBreakdownIndex] + return projectId && selectedProjectIdSet.value.has(projectId) ? projectId : null + } + + function hidePreviousPeriodEntriesForHiddenCurrentEntries() { + if (hiddenCurrentLegendEntryIds.value.length === 0) return + + const nextHiddenDatasetIds = new Set(hiddenGraphDatasetIds.value) + for (const datasetId of hiddenCurrentLegendEntryIds.value) { + nextHiddenDatasetIds.add(getPreviousPeriodDatasetId(datasetId)) + } + + const nextHiddenDatasetIdList = Array.from(nextHiddenDatasetIds) + if (!areStringArraysEqual(hiddenGraphDatasetIds.value, nextHiddenDatasetIdList)) { + hiddenGraphDatasetIds.value = nextHiddenDatasetIdList + } + } + + function isLegendEntryToggleDisabled(legendEntry: AnalyticsChartLegendEntry) { + if (legendEntry.hidden) return false + const visibleCount = legendEntries.value.filter((entry) => !entry.hidden).length + return visibleCount <= 1 + } + + function getLegendEntryTooltip(legendEntry: AnalyticsChartLegendEntry) { + return legendEntry.projectName ?? '' + } + + function isUnmonetizedLegendEntry(legendEntry: AnalyticsChartLegendEntry) { + return ( + selectedBreakdowns.value.length === 1 && + selectedBreakdowns.value[0] === 'monetization' && + legendEntry.id === 'breakdown:unmonetized' + ) + } + + function setHoveredLegendEntryId(datasetId: string) { + hoveredLegendEntryId.value = datasetId + } + + function clearHoveredLegendEntryId(datasetId: string) { + if (hoveredLegendEntryId.value === datasetId) { + hoveredLegendEntryId.value = null + } + } + + function clearLegendHoverState() { + hoveredLegendEntryId.value = null + } + + function toggleLegendEntryVisibility(datasetId: string) { + const nextHiddenDatasetIds = new Set(hiddenDatasetIds.value) + if (nextHiddenDatasetIds.has(datasetId)) { + nextHiddenDatasetIds.delete(datasetId) + } else { + const visibleCount = legendEntries.value.filter((entry) => !entry.hidden).length + if (visibleCount <= 1) return + nextHiddenDatasetIds.add(datasetId) + } + hiddenGraphDatasetIds.value = Array.from(nextHiddenDatasetIds) + } + + function soloLegendEntry(datasetId: string) { + const currentLegendEntryIds = new Set(legendEntries.value.map((entry) => entry.id)) + const otherIds = legendEntries.value.map((entry) => entry.id).filter((id) => id !== datasetId) + const isAlreadySolo = + !hiddenDatasetIds.value.has(datasetId) && + otherIds.every((id) => hiddenDatasetIds.value.has(id)) + + if (isAlreadySolo) { + hiddenGraphDatasetIds.value = hiddenGraphDatasetIds.value.filter( + (hiddenDatasetId) => !currentLegendEntryIds.has(hiddenDatasetId), + ) + return + } + + const nextHiddenDatasetIds = new Set(hiddenDatasetIds.value) + for (const legendEntry of legendEntries.value) { + if (legendEntry.id === datasetId) { + nextHiddenDatasetIds.delete(legendEntry.id) + } else { + nextHiddenDatasetIds.add(legendEntry.id) + } + } + hiddenGraphDatasetIds.value = Array.from(nextHiddenDatasetIds) + } + + function onLegendEntryClick(event: MouseEvent, datasetId: string) { + if (event.shiftKey) { + soloLegendEntry(datasetId) + clearLegendHoverState() + return + } + toggleLegendEntryVisibility(datasetId) + clearLegendHoverState() + } + + function onTooltipEntryClick(datasetId: string, shiftKey: boolean) { + if (!chartDatasetById.value.has(datasetId)) return + + if (shiftKey) { + soloLegendEntry(datasetId) + clearLegendHoverState() + return + } + toggleLegendEntryVisibility(datasetId) + clearLegendHoverState() + } + + watch( + [shouldShowPreviousPeriod, hiddenCurrentLegendEntryIdsKey], + ([showPreviousPeriod]) => { + if (!showPreviousPeriod) return + hidePreviousPeriodEntriesForHiddenCurrentEntries() + }, + { immediate: true }, + ) + + watch( + [allChartDatasets, legendEntries], + ([datasets]) => { + if (datasets.length === 0) return + + const availableDatasetIds = new Set(legendEntries.value.map((entry) => entry.id)) + const nextHiddenDatasetIds = hiddenGraphDatasetIds.value.filter((datasetId) => + availableDatasetIds.has(datasetId), + ) + if ( + legendEntries.value.length > 0 && + legendEntries.value.every((entry) => nextHiddenDatasetIds.includes(entry.id)) + ) { + const firstLegendEntry = legendEntries.value[0] + if (firstLegendEntry) { + const firstLegendEntryIndex = nextHiddenDatasetIds.indexOf(firstLegendEntry.id) + if (firstLegendEntryIndex !== -1) { + nextHiddenDatasetIds.splice(firstLegendEntryIndex, 1) + } + } + } + + if (!areStringArraysEqual(hiddenGraphDatasetIds.value, nextHiddenDatasetIds)) { + hiddenGraphDatasetIds.value = nextHiddenDatasetIds + } + }, + { immediate: true }, + ) + + return { + hoveredLegendEntryId, + hiddenDatasetIds, + previousChartDatasetByOriginalId, + currentLegendEntries, + visibleProjectEventIdSet, + legendEntries, + hiddenCurrentLegendEntryIds, + hiddenCurrentLegendEntryIdsKey, + chartDatasetById, + hoverRatioSliceTotals, + baseVisibleChartDatasets, + visibleChartDatasets, + visibleChartDatasetById, + highlightedChartDatasetId, + isProjectChartEventVisibleForLegend, + getLegendEntryProjectId, + hidePreviousPeriodEntriesForHiddenCurrentEntries, + isLegendEntryToggleDisabled, + getLegendEntryTooltip, + isUnmonetizedLegendEntry, + setHoveredLegendEntryId, + clearHoveredLegendEntryId, + clearLegendHoverState, + toggleLegendEntryVisibility, + soloLegendEntry, + onLegendEntryClick, + onTooltipEntryClick, + } +} diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-plot/AnalyticsChartEvents.vue b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-plot/AnalyticsChartEvents.vue new file mode 100644 index 000000000..cd3a95766 --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-plot/AnalyticsChartEvents.vue @@ -0,0 +1,1154 @@ + + + + + diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-plot/AnalyticsChartTooltip.vue b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-plot/AnalyticsChartTooltip.vue new file mode 100644 index 000000000..8f70acc3b --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-plot/AnalyticsChartTooltip.vue @@ -0,0 +1,487 @@ + + + + + diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-plot/index.vue b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-plot/index.vue new file mode 100644 index 000000000..f12b7159a --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-plot/index.vue @@ -0,0 +1,219 @@ + + + diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-plot/use-analytics-chart-events.ts b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-plot/use-analytics-chart-events.ts new file mode 100644 index 000000000..4ac5d3e9d --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-plot/use-analytics-chart-events.ts @@ -0,0 +1,232 @@ +import type { Labrinth } from '@modrinth/api-client' +import { injectModrinthClient, useVIntl } from '@modrinth/ui' +import { useQuery } from '@tanstack/vue-query' +import { computed, type ComputedRef } from 'vue' + +import type { AnalyticsDashboardContextValue } from '~/providers/analytics/analytics' + +import { analyticsProjectEventMessages, type FormatMessage } from '../../analytics-messages.ts' +import { + PROJECT_EVENT_DATE_FORMATTER, + PROJECT_VERSION_UPLOAD_DEDUPE_WINDOW_MS, + VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUS_SET, + type VisibleProjectStatusChangeEventStatus, +} from '../analytics-chart-constants.ts' +import type { AnalyticsChartRangeBounds } from '../analytics-chart-types.ts' +import type { AnalyticsChartEvent } from './AnalyticsChartEvents.vue' + +const analyticsEventsQueryKey = ['analytics-events'] as const + +export function useAnalyticsChartEvents( + context: Pick< + AnalyticsDashboardContextValue, + | 'activeStat' + | 'showChartEvents' + | 'showProjectEvents' + | 'displayedProjectEvents' + | 'hasCompletedAnalyticsLoading' + >, + chartRangeBounds: ComputedRef, + selectedProjectNameById: ComputedRef>, + selectedProjectEventIdSet: ComputedRef>, + visibleProjectEventIdSet: ComputedRef>, +) { + const client = injectModrinthClient() + const { formatMessage } = useVIntl() + const { data: analyticsEvents } = useQuery({ + queryKey: analyticsEventsQueryKey, + queryFn: () => client.labrinth.analytics_v3.getEvents(), + enabled: computed(() => context.hasCompletedAnalyticsLoading.value), + placeholderData: [], + refetchOnMount: 'always', + retry: false, + staleTime: 0, + }) + + const localAnalyticsChartEvents = computed(() => analyticsEvents.value ?? []) + const hasChartEvents = computed(() => + localAnalyticsChartEvents.value.some(isTimelineEventVisibleInCurrentGraph), + ) + const visibleModrinthChartEvents = computed(() => + context.showChartEvents.value + ? localAnalyticsChartEvents.value.map((event) => ({ + ...event, + markerIcon: 'info' as const, + groupKey: 'modrinth', + })) + : [], + ) + const localProjectChartEvents = computed(() => + dedupeProjectVersionUploadEvents( + context.displayedProjectEvents.value.filter( + (event) => + selectedProjectEventIdSet.value.has(event.project_id) && shouldShowProjectEvent(event), + ), + ).map((event) => ({ + title: getProjectEventTitle(event, formatMessage), + starts: event.timestamp, + ends: event.timestamp, + projectId: event.project_id, + projectName: selectedProjectNameById.value.get(event.project_id), + subtitle: formatProjectEventDate(event.timestamp), + markerIcon: 'flag' as const, + groupKey: 'project', + })), + ) + const hasProjectEvents = computed(() => + localProjectChartEvents.value.some( + (event) => + isProjectChartEventVisibleForLegend(event) && isTimelineEventVisibleInCurrentGraph(event), + ), + ) + const visibleProjectChartEvents = computed(() => + context.showProjectEvents.value + ? localProjectChartEvents.value.filter(isProjectChartEventVisibleForLegend) + : [], + ) + const visibleTimelineEvents = computed(() => [ + ...visibleModrinthChartEvents.value, + ...visibleProjectChartEvents.value, + ]) + const hasVisibleTimelineEvents = computed( + () => visibleModrinthChartEvents.value.length > 0 || visibleProjectChartEvents.value.length > 0, + ) + + function isTimelineEventVisibleInCurrentGraph(event: AnalyticsChartEvent) { + const rangeBounds = chartRangeBounds.value + if (!rangeBounds) return false + if (!doesTimelineEventMatchActiveStat(event)) return false + + const eventStartMs = new Date(event.starts).getTime() + const eventEndMs = new Date(event.ends).getTime() + if (!Number.isFinite(eventStartMs) || !Number.isFinite(eventEndMs)) return false + if (eventEndMs < eventStartMs) return false + + return eventEndMs >= rangeBounds.start.getTime() && eventStartMs <= rangeBounds.end.getTime() + } + + function doesTimelineEventMatchActiveStat(event: AnalyticsChartEvent) { + if (!event.for_metric_kind?.length) return true + return event.for_metric_kind.some((metricKind) => metricKind === context.activeStat.value) + } + + function isProjectChartEventVisibleForLegend(event: AnalyticsChartEvent) { + return !event.projectId || visibleProjectEventIdSet.value.has(event.projectId) + } + + return { + localAnalyticsChartEvents, + hasChartEvents, + visibleModrinthChartEvents, + localProjectChartEvents, + hasProjectEvents, + visibleProjectChartEvents, + visibleTimelineEvents, + hasVisibleTimelineEvents, + isTimelineEventVisibleInCurrentGraph, + isProjectChartEventVisibleForLegend, + } +} + +function getProjectEventTitle( + event: Labrinth.Analytics.v3.ProjectAnalyticsEvent, + formatMessage: FormatMessage, +) { + if (event.kind === 'version_uploaded') { + const versionNumber = event.version_number.trim() + return versionNumber + ? formatMessage(analyticsProjectEventMessages.versionReleased, { version: versionNumber }) + : formatMessage(analyticsProjectEventMessages.versionUploaded) + } + + if (isVisibleProjectStatusChangeEventStatus(event.status_to)) { + return getProjectStatusEventTitle(event.status_to, formatMessage) + } + + return formatMessage(analyticsProjectEventMessages.projectStatusChanged) +} + +function getProjectStatusEventTitle( + status: VisibleProjectStatusChangeEventStatus, + formatMessage: FormatMessage, +) { + switch (status) { + case 'approved': + return formatMessage(analyticsProjectEventMessages.projectApproved) + case 'unlisted': + return formatMessage(analyticsProjectEventMessages.projectUnlisted) + case 'private': + return formatMessage(analyticsProjectEventMessages.projectPrivate) + } +} + +function shouldShowProjectEvent(event: Labrinth.Analytics.v3.ProjectAnalyticsEvent) { + if (event.kind !== 'status_changed') { + return true + } + + return isVisibleProjectStatusChangeEventStatus(event.status_to) +} + +function isVisibleProjectStatusChangeEventStatus( + status: Labrinth.Projects.v2.ProjectStatus, +): status is VisibleProjectStatusChangeEventStatus { + return VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUS_SET.has(status) +} + +function dedupeProjectVersionUploadEvents(events: Labrinth.Analytics.v3.ProjectAnalyticsEvent[]) { + const keptEvents: Labrinth.Analytics.v3.ProjectAnalyticsEvent[] = [] + const keptVersionUploadEventsByKey = new Map< + string, + Labrinth.Analytics.v3.ProjectAnalyticsEvent[] + >() + + for (const event of events) { + const key = getProjectVersionUploadDedupeKey(event) + if (!key) { + keptEvents.push(event) + continue + } + + const matchingEvents = keptVersionUploadEventsByKey.get(key) ?? [] + if ( + matchingEvents.some((matchingEvent) => + areProjectEventsWithinDedupeWindow(event, matchingEvent), + ) + ) { + continue + } + + keptEvents.push(event) + matchingEvents.push(event) + keptVersionUploadEventsByKey.set(key, matchingEvents) + } + + return keptEvents +} + +function getProjectVersionUploadDedupeKey(event: Labrinth.Analytics.v3.ProjectAnalyticsEvent) { + if (event.kind !== 'version_uploaded') return null + + const versionNumber = event.version_number.trim() + if (versionNumber.length === 0) return null + + return `${event.project_id}:${versionNumber}` +} + +function areProjectEventsWithinDedupeWindow( + left: Labrinth.Analytics.v3.ProjectAnalyticsEvent, + right: Labrinth.Analytics.v3.ProjectAnalyticsEvent, +) { + const leftTimestamp = new Date(left.timestamp).getTime() + const rightTimestamp = new Date(right.timestamp).getTime() + if (!Number.isFinite(leftTimestamp) || !Number.isFinite(rightTimestamp)) return false + + return Math.abs(leftTimestamp - rightTimestamp) <= PROJECT_VERSION_UPLOAD_DEDUPE_WINDOW_MS +} + +function formatProjectEventDate(timestamp: string) { + const date = new Date(timestamp) + if (Number.isNaN(date.getTime())) return timestamp + return PROJECT_EVENT_DATE_FORMATTER.format(date) +} diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-plot/use-analytics-chart-interactions.ts b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-plot/use-analytics-chart-interactions.ts new file mode 100644 index 000000000..25c7099a4 --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-plot/use-analytics-chart-interactions.ts @@ -0,0 +1,303 @@ +import type { Labrinth } from '@modrinth/api-client' +import { computed, type ComputedRef, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' + +import type { AnalyticsGroupByPreset } from '~/providers/analytics/analytics' + +import { + ensureMinimumTimeRange, + getDefaultAnalyticsGroupByForDurationMinutes, +} from '../../query-builder/timeframe.ts' +import type { + AnalyticsChartHoverState, + AnalyticsChartRangeBounds, +} from '../analytics-chart-types.ts' +import type { ChartDataset } from '../analytics-chart-utils.ts' +import { getSliceBucketRange } from '../analytics-chart-utils.ts' +import type { + AnalyticsChartGeometryPayload, + AnalyticsChartRangeSelectPayload, +} from '../AnalyticsChart.client.vue' +import type AnalyticsChartTooltip from './AnalyticsChartTooltip.vue' + +export function useAnalyticsChartInteractions({ + isDataLoading, + fetchRequest, + sliceCount, + chartLabels, + allChartDatasets, + chartRangeBounds, + shouldShowPreviousPeriod, + onRangeSelected, +}: { + isDataLoading: ComputedRef + fetchRequest: ComputedRef + sliceCount: ComputedRef + chartLabels: ComputedRef + allChartDatasets: ComputedRef + chartRangeBounds: ComputedRef + shouldShowPreviousPeriod: ComputedRef + onRangeSelected: (start: Date, end: Date, groupBy: AnalyticsGroupByPreset) => void +}) { + const chartContainer = ref(null) + const chartTooltip = ref | null>(null) + const chartGeometry = ref(null) + const containerSize = reactive({ width: 0, height: 0 }) + const hoverState = reactive({ + visible: false, + x: 0, + y: 0, + sliceIndex: null, + }) + const isHoverPinned = ref(false) + const ignoreNextChartClick = ref(false) + const isShiftKeyPressed = ref(false) + let resizeObserver: ResizeObserver | null = null + let clearIgnoredChartClickTimeout: ReturnType | null = null + + function setHoverState(payload: AnalyticsChartHoverState) { + hoverState.visible = payload.visible + hoverState.x = payload.x + hoverState.y = payload.y + hoverState.sliceIndex = payload.sliceIndex + } + + function clearHoverState() { + hoverState.visible = false + hoverState.sliceIndex = null + } + + function unpinHoverState() { + isHoverPinned.value = false + clearHoverState() + } + + function updateShiftKeyState(event: KeyboardEvent) { + isShiftKeyPressed.value = event.shiftKey + } + + function clearShiftKeyState() { + isShiftKeyPressed.value = false + } + + function onDocumentClick(event: MouseEvent) { + if (!isHoverPinned.value) return + if (event.target instanceof Node && chartContainer.value?.contains(event.target)) return + unpinHoverState() + } + + function onChartHover(payload: AnalyticsChartHoverState) { + if (isDataLoading.value) return + if (isHoverPinned.value) return + setHoverState(payload) + } + + function ignoreUpcomingChartClick() { + ignoreNextChartClick.value = true + if (clearIgnoredChartClickTimeout) { + clearTimeout(clearIgnoredChartClickTimeout) + } + clearIgnoredChartClickTimeout = setTimeout(() => { + ignoreNextChartClick.value = false + clearIgnoredChartClickTimeout = null + }, 350) + } + + function onPinnedDrag(payload: AnalyticsChartHoverState) { + if (isDataLoading.value || !isHoverPinned.value) return + ignoreUpcomingChartClick() + setHoverState(payload) + } + + function onTouchDragEnd() { + ignoreUpcomingChartClick() + } + + function onChartGeometry(payload: AnalyticsChartGeometryPayload) { + chartGeometry.value = payload + } + + function getDefaultGroupByForRange(start: Date, end: Date) { + const ensuredRange = ensureMinimumTimeRange(start, end) + const durationMinutes = Math.max( + 1, + Math.floor((ensuredRange.end.getTime() - ensuredRange.start.getTime()) / 60000), + ) + + return getDefaultAnalyticsGroupByForDurationMinutes(durationMinutes) + } + + function onRangeSelect(payload: AnalyticsChartRangeSelectPayload) { + if (isDataLoading.value) return + + const nextFetchRequest = fetchRequest.value + if (!nextFetchRequest) return + + if (payload.startSliceIndex === payload.endSliceIndex) { + ignoreUpcomingChartClick() + return + } + + const startSliceIndex = Math.min(payload.startSliceIndex, payload.endSliceIndex) + const endSliceIndex = Math.max(payload.startSliceIndex, payload.endSliceIndex) + const startBucketRange = getSliceBucketRange( + nextFetchRequest.time_range, + sliceCount.value, + startSliceIndex, + ) + const endBucketRange = getSliceBucketRange( + nextFetchRequest.time_range, + sliceCount.value, + endSliceIndex, + ) + const start = startBucketRange.start + const end = endBucketRange.end + + if ( + !Number.isFinite(start.getTime()) || + !Number.isFinite(end.getTime()) || + end.getTime() <= start.getTime() + ) { + return + } + + ignoreUpcomingChartClick() + unpinHoverState() + onRangeSelected(start, end, getDefaultGroupByForRange(start, end)) + } + + function onChartClick() { + if (isDataLoading.value) return + if (ignoreNextChartClick.value) { + ignoreNextChartClick.value = false + return + } + + if (!hoverState.visible || hoverState.sliceIndex === null) { + if (isHoverPinned.value) { + unpinHoverState() + } + return + } + + if (isHoverPinned.value) { + unpinHoverState() + return + } + + isHoverPinned.value = true + } + + function onChartWheel(event: WheelEvent) { + if (isAnalyticsEventTooltipTrigger(event.target)) return + if (!hoverState.visible) return + chartTooltip.value?.consumeWheel(event) + } + + function isAnalyticsEventTooltipTrigger(target: EventTarget | null) { + return ( + target instanceof Element && target.closest('[data-analytics-event-tooltip-trigger]') !== null + ) + } + + const pinnedSliceIndex = computed(() => (isHoverPinned.value ? hoverState.sliceIndex : null)) + const showHoverGuide = computed( + () => + !isDataLoading.value && + !isHoverPinned.value && + hoverState.visible && + hoverState.sliceIndex !== null, + ) + const showPinnedGuide = computed( + () => + !isDataLoading.value && + isHoverPinned.value && + hoverState.visible && + hoverState.sliceIndex !== null, + ) + const hoverBucketRange = computed(() => { + const nextFetchRequest = fetchRequest.value + if (!nextFetchRequest || hoverState.sliceIndex === null) return null + return getSliceBucketRange(nextFetchRequest.time_range, sliceCount.value, hoverState.sliceIndex) + }) + const previousHoverBucketRange = computed(() => { + if (!shouldShowPreviousPeriod.value) return null + + const bucketRange = hoverBucketRange.value + const rangeBounds = chartRangeBounds.value + if (!bucketRange || !rangeBounds) return null + + const periodMs = rangeBounds.end.getTime() - rangeBounds.start.getTime() + if (!Number.isFinite(periodMs) || periodMs <= 0) return null + + return { + start: new Date(bucketRange.start.getTime() - periodMs), + end: new Date(bucketRange.end.getTime() - periodMs), + } + }) + + onMounted(() => { + if (chartContainer.value && typeof ResizeObserver !== 'undefined') { + resizeObserver = new ResizeObserver((entries) => { + const entry = entries[0] + if (!entry) return + containerSize.width = entry.contentRect.width + containerSize.height = entry.contentRect.height + }) + resizeObserver.observe(chartContainer.value) + } + + window.addEventListener('keydown', updateShiftKeyState) + window.addEventListener('keyup', updateShiftKeyState) + window.addEventListener('blur', clearShiftKeyState) + document.addEventListener('click', onDocumentClick, true) + }) + + onBeforeUnmount(() => { + resizeObserver?.disconnect() + resizeObserver = null + window.removeEventListener('keydown', updateShiftKeyState) + window.removeEventListener('keyup', updateShiftKeyState) + window.removeEventListener('blur', clearShiftKeyState) + document.removeEventListener('click', onDocumentClick, true) + if (clearIgnoredChartClickTimeout) { + clearTimeout(clearIgnoredChartClickTimeout) + clearIgnoredChartClickTimeout = null + } + }) + + watch([chartLabels, allChartDatasets], () => { + isHoverPinned.value = false + clearHoverState() + }) + + watch(isDataLoading, (loading) => { + if (!loading) return + isHoverPinned.value = false + clearHoverState() + }) + + return { + chartContainer, + chartTooltip, + chartGeometry, + containerSize, + hoverState, + isHoverPinned, + isShiftKeyPressed, + setHoverState, + clearHoverState, + unpinHoverState, + onChartHover, + onPinnedDrag, + onTouchDragEnd, + onChartGeometry, + onRangeSelect, + onChartClick, + onChartWheel, + pinnedSliceIndex, + showHoverGuide, + showPinnedGuide, + hoverBucketRange, + previousHoverBucketRange, + } +} diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-plot/use-analytics-chart-layout.ts b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-plot/use-analytics-chart-layout.ts new file mode 100644 index 000000000..ff91ff674 --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-plot/use-analytics-chart-layout.ts @@ -0,0 +1,50 @@ +import { computed, type ComputedRef, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' + +export function useAnalyticsChartLayout(showEmptyChartState: ComputedRef) { + const graphSection = ref(null) + const rememberedGraphSectionHeight = ref(0) + const graphSectionStyle = computed(() => + showEmptyChartState.value && rememberedGraphSectionHeight.value > 0 + ? { height: `${rememberedGraphSectionHeight.value}px` } + : undefined, + ) + let graphSectionResizeObserver: ResizeObserver | null = null + + function rememberGraphSectionHeight() { + if (!graphSection.value) return + + const height = graphSection.value.getBoundingClientRect().height + if (height > 0) { + rememberedGraphSectionHeight.value = height + } + } + + onMounted(() => { + if (graphSection.value && typeof ResizeObserver !== 'undefined') { + graphSectionResizeObserver = new ResizeObserver(() => { + if (showEmptyChartState.value) return + rememberGraphSectionHeight() + }) + graphSectionResizeObserver.observe(graphSection.value) + } + }) + + onBeforeUnmount(() => { + graphSectionResizeObserver?.disconnect() + graphSectionResizeObserver = null + }) + + watch(showEmptyChartState, (showEmpty) => { + if (showEmpty) { + rememberGraphSectionHeight() + } else { + nextTick(rememberGraphSectionHeight) + } + }) + + return { + graphSection, + graphSectionStyle, + rememberGraphSectionHeight, + } +} diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-types.ts b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-types.ts new file mode 100644 index 000000000..8c886dbac --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-types.ts @@ -0,0 +1,21 @@ +export type AnalyticsChartRangeBounds = { + start: Date + end: Date +} + +export type AnalyticsChartHoverState = { + visible: boolean + x: number + y: number + sliceIndex: number | null +} + +export type AnalyticsChartLegendEntry = { + id: string + name: string + projectName?: string + color: string + totalValue: number + hidden: boolean + isPreviousPeriod?: boolean +} diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-utils.ts b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-utils.ts new file mode 100644 index 000000000..2e5420755 --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-utils.ts @@ -0,0 +1,862 @@ +import type { Labrinth } from '@modrinth/api-client' + +import type { + AnalyticsBreakdownPreset, + AnalyticsDashboardProject, + AnalyticsDashboardStat, + AnalyticsGroupByPreset, +} from '~/providers/analytics/analytics' + +import { + analyticsChartMessages, + analyticsMessages, + analyticsStatCardMessages, + formatAnalyticsDownloadReasonLabel, + formatAnalyticsLoaderLabel, + formatAnalyticsMonetizationLabel, + type FormatMessage, +} from '../analytics-messages' +import { + ALL_BREAKDOWN_VALUE, + COMBINED_BREAKDOWN_LABEL_SEPARATOR, + getAnalyticsBreakdownDatasetId, + getAnalyticsBreakdownKey, + getAnalyticsBreakdownValues, + UNKNOWN_BREAKDOWN_VALUE, +} from '../breakdown' +import { PREVIOUS_PERIOD_DATASET_ID_PREFIX } from './analytics-chart-constants' + +export type ChartDataset = { + projectId: string + label: string + projectName?: string + data: number[] + borderColor: string + backgroundColor: string + borderDash?: number[] +} + +export function getChartDatasetTotal(dataset: ChartDataset) { + return dataset.data.reduce((sum, value) => sum + value, 0) +} + +export function getPreviousPeriodDatasetId(datasetId: string) { + return `${PREVIOUS_PERIOD_DATASET_ID_PREFIX}${datasetId}` +} + +export function decodeBreakdownDatasetValue(value: string) { + try { + return decodeURIComponent(value) + } catch { + return value + } +} + +export function areStringArraysEqual(left: string[], right: string[]) { + 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 +} + +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 ALL_PROJECTS_DATASET_ID = 'all' +const MONETIZATION_CHART_COLOR_INDEX: Record = { + monetized: 0, + unmonetized: 1, +} +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, formatMessage: FormatMessage): string { + const normalized = countryCode.trim().toUpperCase() + if (normalized === OTHER_COUNTRY_CODE) { + return formatMessage(analyticsMessages.unknown) + } + + 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 +} + +export function formatBreakdownLabel( + breakdownValue: string, + selectedBreakdown: AnalyticsBreakdownPreset, + getVersionDisplayName: ((versionId: string) => string) | undefined, + formatMessage: FormatMessage, +): string { + const normalizedValue = breakdownValue.trim() + const normalizedLowercaseValue = normalizedValue.toLowerCase() + + if ( + normalizedValue === UNKNOWN_BREAKDOWN_VALUE || + normalizedLowercaseValue === 'other' || + normalizedLowercaseValue === 'unknown' + ) { + return formatMessage(analyticsMessages.unknown) + } + if (selectedBreakdown === 'country') { + return formatCountryCode(breakdownValue, formatMessage) + } + if (selectedBreakdown === 'monetization') { + return formatAnalyticsMonetizationLabel(normalizedLowercaseValue, formatMessage) + } + if (selectedBreakdown === 'download_reason') { + return formatAnalyticsDownloadReasonLabel(normalizedLowercaseValue, formatMessage) + } + if (selectedBreakdown === 'version_id') { + return getVersionDisplayName?.(breakdownValue) ?? breakdownValue + } + if (selectedBreakdown === 'loader') { + return formatAnalyticsLoaderLabel(normalizedValue, formatMessage) + } + + return breakdownValue +} + +export function formatBreakdownLabels( + breakdownValues: readonly string[], + selectedBreakdowns: readonly AnalyticsBreakdownPreset[], + getVersionDisplayName: ((versionId: string) => string) | undefined, + formatMessage: FormatMessage, +): string { + return collapseRepeatedUnknownBreakdownLabels( + selectedBreakdowns + .filter((breakdown) => breakdown !== 'none') + .map((breakdown, index) => + formatBreakdownLabel( + breakdownValues[index] ?? '', + breakdown, + getVersionDisplayName, + formatMessage, + ), + ), + formatMessage, + ).join(COMBINED_BREAKDOWN_LABEL_SEPARATOR) +} + +function collapseRepeatedUnknownBreakdownLabels( + labels: string[], + formatMessage: FormatMessage, +): string[] { + let hasUnknownLabel = false + const collapsedLabels: string[] = [] + const unknownBreakdownLabel = formatMessage(analyticsMessages.unknown) + + for (const label of labels) { + if (label === unknownBreakdownLabel) { + if (hasUnknownLabel) { + continue + } + hasUnknownLabel = true + } + + collapsedLabels.push(label) + } + + return collapsedLabels +} + +export function shouldCapitalizeBreakdownLabel( + selectedBreakdown: AnalyticsBreakdownPreset | readonly AnalyticsBreakdownPreset[], +): boolean { + const selectedBreakdowns = Array.isArray(selectedBreakdown) + ? selectedBreakdown + : [selectedBreakdown] + return ( + selectedBreakdowns.length > 0 && + selectedBreakdowns.every( + (breakdown) => + breakdown === 'download_reason' || + breakdown === 'monetization' || + breakdown === 'loader' || + breakdown === 'country', + ) + ) +} + +function getBreakdownColor( + breakdownValue: string, + selectedBreakdown: AnalyticsBreakdownPreset, + fallbackColor: string, + palette: string[], +): string { + if (selectedBreakdown === 'monetization') { + const colorIndex = MONETIZATION_CHART_COLOR_INDEX[breakdownValue] + if (colorIndex !== undefined) { + return getPaletteColorForIndex(colorIndex, palette) + } + } + + if (selectedBreakdown !== 'loader') { + return fallbackColor + } + + const normalizedLoader = breakdownValue.trim().toLowerCase() + return LOADER_CHART_COLORS[normalizedLoader] ?? fallbackColor +} + +type PaletteRankEntry = { + key: string + label: string + total: number +} + +function getPaletteColorForIndex(index: number, palette: string[]): string { + if (palette.length === 0) return '' + + return palette[index % palette.length] +} + +function buildPaletteColorsByDownloadRank( + entries: PaletteRankEntry[], + palette: string[], +): Map { + const colorsByKey = new Map() + if (palette.length === 0) return colorsByKey + + const sortedEntries = [...entries].sort( + (a, b) => b.total - a.total || a.label.localeCompare(b.label) || a.key.localeCompare(b.key), + ) + sortedEntries.forEach((entry, index) => { + colorsByKey.set(entry.key, getPaletteColorForIndex(index, palette)) + }) + + return colorsByKey +} + +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 +} + +function isProjectAnalyticsPointInSelectedProjects( + point: Labrinth.Analytics.v3.AnalyticsData, + selectedProjectIds: Set, +): point is Labrinth.Analytics.v3.ProjectAnalytics { + return 'source_project' in point && selectedProjectIds.has(point.source_project) +} + +export function buildChartDatasets( + timeSlices: Labrinth.Analytics.v3.TimeSlice[], + selectedProjects: AnalyticsDashboardProject[], + activeStat: AnalyticsDashboardStat, + palette: string[], + selectedBreakdowns: readonly AnalyticsBreakdownPreset[], + getVersionDisplayName: ((versionId: string) => string) | undefined, + getVersionProjectName: ((versionId: string) => string | undefined) | undefined, + formatMessage: FormatMessage, + 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) + const normalizedBreakdowns = selectedBreakdowns.filter((breakdown) => breakdown !== 'none') + const projectNamesById = new Map(selectedProjects.map((project) => [project.id, project.name])) + + function formatChartBreakdownLabels(breakdownValues: readonly string[]): string { + return collapseRepeatedUnknownBreakdownLabels( + normalizedBreakdowns.map((breakdown, index) => { + const breakdownValue = breakdownValues[index] ?? '' + if (breakdown === 'project') { + return projectNamesById.get(breakdownValue) ?? breakdownValue + } + + return formatBreakdownLabel(breakdownValue, breakdown, getVersionDisplayName, formatMessage) + }), + formatMessage, + ).join(COMBINED_BREAKDOWN_LABEL_SEPARATOR) + } + + if ( + normalizedBreakdowns.length > 0 && + !(normalizedBreakdowns.length === 1 && normalizedBreakdowns[0] === 'project') + ) { + const dataByBreakdown = new Map() + const breakdownValuesByKey = new Map() + const downloadTotalsByBreakdown = new Map() + + timeSlices.forEach((slice, sliceIndex) => { + for (const point of slice) { + if (!isProjectAnalyticsPointInSelectedProjects(point, selectedProjectIds)) continue + + const breakdownValues = getAnalyticsBreakdownValues( + point, + normalizedBreakdowns, + formatMessage, + ) + if (breakdownValues.some((breakdownValue) => breakdownValue === ALL_BREAKDOWN_VALUE)) { + continue + } + const breakdownKey = getAnalyticsBreakdownKey(breakdownValues) + + if (!dataByBreakdown.has(breakdownKey)) { + dataByBreakdown.set(breakdownKey, new Array(dataLength).fill(0)) + breakdownValuesByKey.set(breakdownKey, breakdownValues) + } + + if (point.metric_kind === 'downloads') { + downloadTotalsByBreakdown.set( + breakdownKey, + (downloadTotalsByBreakdown.get(breakdownKey) ?? 0) + getMetricValue(point, 'downloads'), + ) + } + + if (!isMetricKindForStat(point, activeStat)) continue + + const breakdownData = dataByBreakdown.get(breakdownKey) + if (!breakdownData) continue + breakdownData[sliceIndex] += getMetricValue(point, activeStat) + } + }) + + const colorsByBreakdown = buildPaletteColorsByDownloadRank( + Array.from(dataByBreakdown.keys()).map((breakdownKey) => ({ + key: breakdownKey, + label: formatChartBreakdownLabels(breakdownValuesByKey.get(breakdownKey) ?? []), + total: downloadTotalsByBreakdown.get(breakdownKey) ?? 0, + })), + palette, + ) + + return Array.from(dataByBreakdown.entries()).map(([breakdownKey, data]) => { + const breakdownValues = breakdownValuesByKey.get(breakdownKey) ?? [] + const fallbackColor = colorsByBreakdown.get(breakdownKey) ?? '' + const color = + normalizedBreakdowns.length === 1 + ? getBreakdownColor( + breakdownValues[0] ?? '', + normalizedBreakdowns[0], + fallbackColor, + palette, + ) + : fallbackColor + return { + projectId: getAnalyticsBreakdownDatasetId(breakdownValues, normalizedBreakdowns), + label: formatChartBreakdownLabels(breakdownValues), + projectName: + normalizedBreakdowns.length === 1 && normalizedBreakdowns[0] === 'version_id' + ? getVersionProjectName?.(breakdownValues[0] ?? '') + : undefined, + data, + borderColor: color, + backgroundColor: color, + } + }) + } + + if (normalizedBreakdowns.length === 0) { + const data = new Array(dataLength).fill(0) + let downloadTotal = 0 + + timeSlices.forEach((slice, sliceIndex) => { + for (const point of slice) { + if (!isProjectAnalyticsPointInSelectedProjects(point, selectedProjectIds)) continue + + if (point.metric_kind === 'downloads') { + downloadTotal += getMetricValue(point, 'downloads') + } + + if (!isMetricKindForStat(point, activeStat)) continue + + data[sliceIndex] += getMetricValue(point, activeStat) + } + }) + + const color = + buildPaletteColorsByDownloadRank( + [ + { + key: ALL_PROJECTS_DATASET_ID, + label: formatMessage(analyticsMessages.allProjects), + total: downloadTotal, + }, + ], + palette, + ).get(ALL_PROJECTS_DATASET_ID) ?? '' + const selectedProject = selectedProjects.length === 1 ? selectedProjects[0] : undefined + + return [ + { + projectId: ALL_PROJECTS_DATASET_ID, + label: selectedProject?.name ?? formatMessage(analyticsMessages.allProjects), + data, + borderColor: color, + backgroundColor: color, + }, + ] + } + + const dataByProjectBreakdown = new Map() + const breakdownValuesByKey = new Map() + const downloadTotalsByProjectBreakdown = new Map() + for (const project of selectedProjects) { + const breakdownValues = [project.id] + const breakdownKey = getAnalyticsBreakdownKey(breakdownValues) + dataByProjectBreakdown.set(breakdownKey, new Array(dataLength).fill(0)) + breakdownValuesByKey.set(breakdownKey, breakdownValues) + downloadTotalsByProjectBreakdown.set(breakdownKey, 0) + } + + timeSlices.forEach((slice, sliceIndex) => { + for (const point of slice) { + if (!isProjectAnalyticsPointInSelectedProjects(point, selectedProjectIds)) continue + + const breakdownValues = getAnalyticsBreakdownValues( + point, + normalizedBreakdowns, + formatMessage, + ) + if (breakdownValues.some((breakdownValue) => breakdownValue === ALL_BREAKDOWN_VALUE)) { + continue + } + const breakdownKey = getAnalyticsBreakdownKey(breakdownValues) + if (!dataByProjectBreakdown.has(breakdownKey)) { + dataByProjectBreakdown.set(breakdownKey, new Array(dataLength).fill(0)) + breakdownValuesByKey.set(breakdownKey, breakdownValues) + downloadTotalsByProjectBreakdown.set(breakdownKey, 0) + } + + if (point.metric_kind === 'downloads') { + downloadTotalsByProjectBreakdown.set( + breakdownKey, + (downloadTotalsByProjectBreakdown.get(breakdownKey) ?? 0) + + getMetricValue(point, 'downloads'), + ) + } + + if (!isMetricKindForStat(point, activeStat)) continue + + const projectData = dataByProjectBreakdown.get(breakdownKey) + if (!projectData) continue + + projectData[sliceIndex] += getMetricValue(point, activeStat) + } + }) + + const colorsByBreakdown = buildPaletteColorsByDownloadRank( + Array.from(dataByProjectBreakdown.keys()).map((breakdownKey) => ({ + key: breakdownKey, + label: formatChartBreakdownLabels(breakdownValuesByKey.get(breakdownKey) ?? []), + total: downloadTotalsByProjectBreakdown.get(breakdownKey) ?? 0, + })), + palette, + ) + + return Array.from(dataByProjectBreakdown.entries()).map(([breakdownKey, data]) => { + const breakdownValues = breakdownValuesByKey.get(breakdownKey) ?? [] + const fallbackColor = colorsByBreakdown.get(breakdownKey) ?? '' + const color = + normalizedBreakdowns.length === 1 + ? getBreakdownColor( + breakdownValues[0] ?? '', + normalizedBreakdowns[0], + fallbackColor, + palette, + ) + : fallbackColor + return { + projectId: getAnalyticsBreakdownDatasetId(breakdownValues, normalizedBreakdowns), + label: formatChartBreakdownLabels(breakdownValues), + projectName: + normalizedBreakdowns.length === 1 && normalizedBreakdowns[0] === 'version_id' + ? getVersionProjectName?.(breakdownValues[0] ?? '') + : undefined, + data, + 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 YEAR_LABEL_TIME_RANGE_YEARS = 2 +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 start = new Date(timeRange.start) + const end = new Date(timeRange.end) + const yearLabelThreshold = new Date(start) + yearLabelThreshold.setFullYear(start.getFullYear() + YEAR_LABEL_TIME_RANGE_YEARS) + + return ( + Number.isFinite(start.getTime()) && + Number.isFinite(end.getTime()) && + end.getTime() > yearLabelThreshold.getTime() + ) +} + +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, + formatMessage: FormatMessage, +): string { + switch (activeStat) { + case 'revenue': { + const amount = Math.round(value * 100) / 100 + return formatMessage(analyticsStatCardMessages.revenueValue, { + value: formatNumber(amount), + }) + } + case 'playtime': { + const hours = value / 3600 + return formatMessage(analyticsStatCardMessages.playtimeHours, { + hours: hours.toFixed(1), + }) + } + 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+$/, '') +} + +const COMPACT_AXIS_UNITS = [ + { threshold: 1_000_000, divisor: 1_000_000, suffix: 'M' }, + { threshold: 1_000, divisor: 1_000, suffix: 'K' }, +] as const +const MAX_COMPACT_AXIS_DIGITS = 3 + +function getCompactAxisUnit(values: readonly number[]) { + let maxAbsoluteValue = 0 + for (const value of values) { + if (Number.isFinite(value)) { + maxAbsoluteValue = Math.max(maxAbsoluteValue, Math.abs(value)) + } + } + + return COMPACT_AXIS_UNITS.find((unit) => maxAbsoluteValue >= unit.threshold) ?? null +} + +function formatCompactAxisNumber(value: number, axisValues: readonly number[]): string | null { + if (Math.abs(value) === 0) return '0' + + const unit = getCompactAxisUnit(axisValues) + if (!unit) return null + + return `${formatCompactAxisValue(value / unit.divisor)}${unit.suffix}` +} + +function formatCompactAxisValue(value: number): string { + const absoluteValue = Math.abs(value) + if (absoluteValue === 0) return '0' + + const integerDigitCount = absoluteValue < 1 ? 1 : Math.floor(absoluteValue).toString().length + const fractionDigitCount = Math.max(0, MAX_COMPACT_AXIS_DIGITS - integerDigitCount) + const roundedValue = Number(value.toFixed(fractionDigitCount)) + const roundedIntegerDigitCount = + Math.abs(roundedValue) < 1 ? 1 : Math.floor(Math.abs(roundedValue)).toString().length + + if (roundedIntegerDigitCount > MAX_COMPACT_AXIS_DIGITS) { + const truncatedValue = Math.sign(value) * (10 ** MAX_COMPACT_AXIS_DIGITS - 1) + return String(truncatedValue) + } + + return roundedValue.toFixed(fractionDigitCount).replace(/\.?0+$/, '') +} + +export function formatAxisValue( + value: number, + activeStat: AnalyticsDashboardStat, + formatCompact: (value: number) => string, + formatMessage: FormatMessage, + axisValues: readonly number[] = [value], +): string { + switch (activeStat) { + case 'revenue': { + const amount = Math.round(value * 100) / 100 + const axisAmounts = axisValues.map((axisValue) => Math.round(axisValue * 100) / 100) + return formatMessage(analyticsStatCardMessages.revenueValue, { + value: formatCompactAxisNumber(amount, axisAmounts) ?? formatCompact(amount), + }) + } + case 'playtime': { + const formattedHours = formatCompactAxisNumber(value, axisValues) + if (formattedHours) { + return formatMessage(analyticsChartMessages.playtimeAxisHours, { hours: formattedHours }) + } + if (Math.abs(value) < 10) { + return formatMessage(analyticsChartMessages.playtimeAxisHours, { + hours: formatSmallAxisNumber(value), + }) + } + return formatMessage(analyticsChartMessages.playtimeAxisHours, { + hours: formatCompact(Math.round(value)), + }) + } + case 'views': + case 'downloads': + default: { + const roundedValue = Math.round(value) + const roundedAxisValues = axisValues.map((axisValue) => Math.round(axisValue)) + const formattedValue = formatCompactAxisNumber(roundedValue, roundedAxisValues) + if (formattedValue) return formattedValue + if (Math.abs(value) < 10) { + return formatSmallAxisNumber(value) + } + return formatCompact(roundedValue) + } + } +} diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-chart/index.vue b/apps/frontend/src/components/analytics-dashboard/analytics-chart/index.vue new file mode 100644 index 000000000..2011888a6 --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-chart/index.vue @@ -0,0 +1,252 @@ + + + diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-chart/use-analytics-chart-datasets.ts b/apps/frontend/src/components/analytics-dashboard/analytics-chart/use-analytics-chart-datasets.ts new file mode 100644 index 000000000..38d908626 --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-chart/use-analytics-chart-datasets.ts @@ -0,0 +1,395 @@ +import { useVIntl } from '@modrinth/ui' +import { computed, type ComputedRef, ref, watch } from 'vue' + +import { useTheme } from '~/composables/nuxt-accessors' +import { isDarkTheme } from '~/plugins/theme/index.ts' +import type { + AnalyticsDashboardContextValue, + AnalyticsDashboardProject, + AnalyticsDashboardStat, +} from '~/providers/analytics/analytics' + +import { + analyticsChartMessages, + analyticsMessages, + formatAnalyticsGraphTitle, + type FormatMessage, + getAnalyticsBreakdownItemType, +} from '../analytics-messages' +import { + ANALYTICS_DASHBOARD_STATS, + DARK_LEGEND_PALETTE, + GRAPH_RENDER_DATASET_LIMIT, + LIGHT_LEGEND_PALETTE, + TOP_GRAPH_DATASET_LIMIT, +} from './analytics-chart-constants' +import { + buildChartDatasets, + buildTimeAxisLabels, + type ChartDataset, + getChartDatasetTotal, + getShortHourlyAxisTickLimit, + getSliceCount, + shouldCapitalizeBreakdownLabel, +} from './analytics-chart-utils' + +export function useAnalyticsChartDatasets( + context: Pick< + AnalyticsDashboardContextValue, + | 'activeStat' + | 'activeGraphViewMode' + | 'isRatioMode' + | 'showPreviousPeriod' + | 'hasPreviousPeriodComparison' + | 'hasProjectContext' + | 'displayedFetchRequest' + | 'displayedTimeSlices' + | 'displayedPreviousTimeSlices' + | 'displayedSelectedGroupBy' + | 'displayedSelectedBreakdowns' + | 'hiddenGraphDatasetIds' + | 'hasExplicitGraphDatasetSelection' + | 'isGraphDatasetSelectionActive' + | 'selectedGraphDatasetIds' + | 'defaultGraphDatasetIds' + | 'topGraphDatasetIds' + | 'getVersionDisplayName' + | 'getVersionProjectName' + >, + selectedProjects: ComputedRef, + hasAvailableProjects: ComputedRef, +) { + const theme = useTheme() + const { formatMessage } = useVIntl() + const showAllSelectedGraphDatasets = ref(false) + + const chartRangeBounds = computed(() => { + const nextFetchRequest = context.displayedFetchRequest.value + if (!nextFetchRequest) return null + return { + start: new Date(nextFetchRequest.time_range.start), + end: new Date(nextFetchRequest.time_range.end), + } + }) + const showProjectVersionNames = computed( + () => + context.displayedSelectedBreakdowns.value.includes('version_id') && + selectedProjects.value.length > 1, + ) + const tableProjectCount = computed(() => context.selectedGraphDatasetIds.value.length) + const isTableGraphSelectionEmpty = computed( + () => + context.isGraphDatasetSelectionActive.value && + context.hasExplicitGraphDatasetSelection.value && + tableProjectCount.value === 0, + ) + const showEmptyChartState = computed( + () => selectedProjects.value.length === 0 || isTableGraphSelectionEmpty.value, + ) + const emptyChartMessage = computed(() => { + if (isTableGraphSelectionEmpty.value) { + return formatMessage(analyticsChartMessages.selectTableItemsEmpty) + } + + if (context.hasProjectContext.value) { + return formatMessage(analyticsMessages.noDataAvailableForAnalytics) + } + + return hasAvailableProjects.value + ? formatMessage(analyticsMessages.selectAtLeastOneProject) + : formatMessage(analyticsMessages.noProjectsAvailableForAnalytics) + }) + const legendPalette = computed(() => + isDarkTheme(theme.active) ? DARK_LEGEND_PALETTE : LIGHT_LEGEND_PALETTE, + ) + const graphTitle = computed(() => + formatAnalyticsGraphTitle(context.activeStat.value, formatMessage), + ) + const showTableSelectionSubheading = computed( + () => context.isGraphDatasetSelectionActive.value && tableProjectCount.value > 0, + ) + const tableBreakdownItemType = computed(() => + getAnalyticsBreakdownItemType(context.displayedSelectedBreakdowns.value), + ) + const shouldCapitalizeDatasetLabels = computed(() => + shouldCapitalizeBreakdownLabel(context.displayedSelectedBreakdowns.value), + ) + const chartType = computed<'line' | 'bar'>(() => + context.activeGraphViewMode.value === 'bar' ? 'bar' : 'line', + ) + const canShowPreviousPeriodToggle = computed( + () => context.activeGraphViewMode.value === 'line' && context.hasPreviousPeriodComparison.value, + ) + const shouldShowPreviousPeriod = computed( + () => canShowPreviousPeriodToggle.value && context.showPreviousPeriod.value, + ) + const isArea = computed(() => context.activeGraphViewMode.value === 'area') + const isStacked = computed( + () => + context.isRatioMode.value || + context.activeGraphViewMode.value === 'area' || + context.activeGraphViewMode.value === 'bar', + ) + const sliceCount = computed(() => { + const nextFetchRequest = context.displayedFetchRequest.value + const fallback = context.displayedTimeSlices.value.length + if (!nextFetchRequest) return Math.max(1, fallback) + return getSliceCount(nextFetchRequest.time_range, fallback) + }) + const chartLabels = computed(() => { + const nextFetchRequest = context.displayedFetchRequest.value + if (!nextFetchRequest) return [] + return buildTimeAxisLabels( + nextFetchRequest.time_range, + sliceCount.value, + context.displayedSelectedGroupBy.value, + ) + }) + const xAxisTickLimit = computed(() => { + const nextFetchRequest = context.displayedFetchRequest.value + return nextFetchRequest + ? getShortHourlyAxisTickLimit( + nextFetchRequest.time_range, + context.displayedSelectedGroupBy.value, + ) + : undefined + }) + const chartDatasetsByStat = computed>(() => + buildDatasetsByStat( + context.displayedTimeSlices.value, + selectedProjects.value, + legendPalette.value, + context.displayedSelectedBreakdowns.value, + context.getVersionDisplayName, + showProjectVersionNames.value ? context.getVersionProjectName : undefined, + formatMessage, + sliceCount.value, + ), + ) + const previousChartDatasetsByStat = computed>(() => + buildDatasetsByStat( + context.displayedPreviousTimeSlices.value, + selectedProjects.value, + legendPalette.value, + context.displayedSelectedBreakdowns.value, + context.getVersionDisplayName, + showProjectVersionNames.value ? context.getVersionProjectName : undefined, + formatMessage, + sliceCount.value, + ), + ) + const allChartDatasets = computed(() => chartDatasetsByStat.value[context.activeStat.value]) + const previousChartDatasets = computed( + () => previousChartDatasetsByStat.value[context.activeStat.value], + ) + const sortedChartDatasetIds = computed(() => sortDatasetsByTotal(allChartDatasets.value)) + const chartTopGraphDatasetIds = computed(() => + sortedChartDatasetIds.value.slice(0, TOP_GRAPH_DATASET_LIMIT), + ) + const fallbackDefaultGraphDatasetIds = computed(() => + context.defaultGraphDatasetIds.value.length > 0 + ? context.defaultGraphDatasetIds.value + : chartTopGraphDatasetIds.value, + ) + const isShowingAllTableItems = computed(() => { + if (context.selectedGraphDatasetIds.value.length !== sortedChartDatasetIds.value.length) { + return false + } + const selectedDatasetIds = new Set(context.selectedGraphDatasetIds.value) + return sortedChartDatasetIds.value.every((datasetId) => selectedDatasetIds.has(datasetId)) + }) + const isShowingTopGraphDatasets = computed(() => { + if ( + context.selectedGraphDatasetIds.value.length !== fallbackDefaultGraphDatasetIds.value.length + ) { + return false + } + const selectedDatasetIds = new Set(context.selectedGraphDatasetIds.value) + return fallbackDefaultGraphDatasetIds.value.every((datasetId) => + selectedDatasetIds.has(datasetId), + ) + }) + const isShowingTopTableItems = computed(() => { + const topDatasetIds = new Set( + context.topGraphDatasetIds.value.slice(0, context.selectedGraphDatasetIds.value.length), + ) + return context.selectedGraphDatasetIds.value.every((datasetId) => topDatasetIds.has(datasetId)) + }) + const isGraphRenderDatasetOverLimit = computed( + () => + context.isGraphDatasetSelectionActive.value && + selectedChartDatasets.value.length > GRAPH_RENDER_DATASET_LIMIT, + ) + const isGraphRenderDatasetLimitActive = computed( + () => isGraphRenderDatasetOverLimit.value && !showAllSelectedGraphDatasets.value, + ) + const tableSelectionSubheading = computed(() => { + if (isGraphRenderDatasetLimitActive.value) { + return formatMessage(analyticsChartMessages.tableSelectionLimited, { + limit: GRAPH_RENDER_DATASET_LIMIT, + itemType: tableBreakdownItemType.value, + }) + } + + if (isShowingAllTableItems.value) { + return formatMessage(analyticsChartMessages.tableSelectionAll, { + count: tableProjectCount.value, + itemType: tableBreakdownItemType.value, + }) + } + + if (isShowingTopTableItems.value) { + return formatMessage(analyticsChartMessages.tableSelectionTop, { + count: tableProjectCount.value, + itemType: tableBreakdownItemType.value, + }) + } + + return formatMessage(analyticsChartMessages.tableSelectionCount, { + count: tableProjectCount.value, + itemType: tableBreakdownItemType.value, + }) + }) + const shouldUseDefaultGraphDatasetSelection = computed( + () => + context.isGraphDatasetSelectionActive.value && + !context.hasExplicitGraphDatasetSelection.value && + context.selectedGraphDatasetIds.value.length === 0, + ) + const selectedGraphDatasetIdSet = computed(() => { + if (shouldUseDefaultGraphDatasetSelection.value) { + return new Set(fallbackDefaultGraphDatasetIds.value) + } + + return new Set(context.selectedGraphDatasetIds.value) + }) + const selectedChartDatasets = computed(() => { + if (!context.isGraphDatasetSelectionActive.value) { + return allChartDatasets.value + } + + return allChartDatasets.value.filter((dataset) => + selectedGraphDatasetIdSet.value.has(dataset.projectId), + ) + }) + const sortedSelectedChartDatasetIds = computed(() => + sortDatasetsByTotal(selectedChartDatasets.value), + ) + const showGraphRenderLimitButton = computed(() => isGraphRenderDatasetOverLimit.value) + const graphRenderLimitButtonLabel = computed(() => + showAllSelectedGraphDatasets.value + ? formatMessage(analyticsChartMessages.showLimited) + : formatMessage(analyticsChartMessages.showAll), + ) + const showTopGraphDatasetsButton = computed( + () => + context.isGraphDatasetSelectionActive.value && + context.topGraphDatasetIds.value.length > 0 && + !isShowingTopGraphDatasets.value, + ) + const limitedGraphDatasetIds = computed( + () => new Set(sortedSelectedChartDatasetIds.value.slice(0, GRAPH_RENDER_DATASET_LIMIT)), + ) + const selectableChartDatasets = computed(() => { + if (!isGraphRenderDatasetLimitActive.value) { + return selectedChartDatasets.value + } + + return selectedChartDatasets.value.filter((dataset) => + limitedGraphDatasetIds.value.has(dataset.projectId), + ) + }) + + function showTopGraphDatasets() { + context.selectedGraphDatasetIds.value = [] + context.hasExplicitGraphDatasetSelection.value = false + showAllSelectedGraphDatasets.value = false + } + + watch([() => context.selectedGraphDatasetIds.value.join('\u0000'), allChartDatasets], () => { + showAllSelectedGraphDatasets.value = false + }) + + return { + showAllSelectedGraphDatasets, + chartRangeBounds, + showProjectVersionNames, + tableProjectCount, + isTableGraphSelectionEmpty, + showEmptyChartState, + emptyChartMessage, + legendPalette, + graphTitle, + showTableSelectionSubheading, + shouldCapitalizeDatasetLabels, + chartType, + canShowPreviousPeriodToggle, + shouldShowPreviousPeriod, + isArea, + isStacked, + sliceCount, + chartLabels, + xAxisTickLimit, + chartDatasetsByStat, + previousChartDatasetsByStat, + allChartDatasets, + previousChartDatasets, + sortedChartDatasetIds, + chartTopGraphDatasetIds, + fallbackDefaultGraphDatasetIds, + isShowingAllTableItems, + isShowingTopGraphDatasets, + isShowingTopTableItems, + tableSelectionSubheading, + shouldUseDefaultGraphDatasetSelection, + selectedGraphDatasetIdSet, + selectedChartDatasets, + sortedSelectedChartDatasetIds, + isGraphRenderDatasetOverLimit, + showGraphRenderLimitButton, + graphRenderLimitButtonLabel, + showTopGraphDatasetsButton, + isGraphRenderDatasetLimitActive, + limitedGraphDatasetIds, + selectableChartDatasets, + showTopGraphDatasets, + } +} + +function buildDatasetsByStat( + timeSlices: Parameters[0], + selectedProjects: AnalyticsDashboardProject[], + palette: string[], + selectedBreakdowns: Parameters[4], + getVersionDisplayName: Parameters[5], + getVersionProjectName: Parameters[6], + formatMessage: FormatMessage, + sliceCount: number, +) { + const datasetsByStat = {} as Record + for (const stat of ANALYTICS_DASHBOARD_STATS) { + datasetsByStat[stat] = buildChartDatasets( + timeSlices, + selectedProjects, + stat, + palette, + selectedBreakdowns, + getVersionDisplayName, + getVersionProjectName, + formatMessage, + sliceCount, + ) + } + return datasetsByStat +} + +function sortDatasetsByTotal(datasets: ChartDataset[]) { + return [...datasets] + .sort((a, b) => { + const totalDifference = getChartDatasetTotal(b) - getChartDatasetTotal(a) + return ( + totalDifference || a.label.localeCompare(b.label) || a.projectId.localeCompare(b.projectId) + ) + }) + .map((dataset) => dataset.projectId) +} diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-chart/use-analytics-chart-projects.ts b/apps/frontend/src/components/analytics-dashboard/analytics-chart/use-analytics-chart-projects.ts new file mode 100644 index 000000000..838389a04 --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-chart/use-analytics-chart-projects.ts @@ -0,0 +1,38 @@ +import { computed } from 'vue' + +import { + type AnalyticsDashboardContextValue, + doesProjectStatusMatchFilters, +} from '~/providers/analytics/analytics' + +export function useAnalyticsChartProjects( + context: Pick< + AnalyticsDashboardContextValue, + 'displayedSelectedProjectIds' | 'projects' | 'displayedSelectedFilters' + >, +) { + const selectedProjectIdSet = computed(() => new Set(context.displayedSelectedProjectIds.value)) + const hasAvailableProjects = computed(() => context.projects.value.length > 0) + + const selectedProjects = computed(() => + context.projects.value.filter( + (project) => + selectedProjectIdSet.value.has(project.id) && + doesProjectStatusMatchFilters(project.status, context.displayedSelectedFilters.value), + ), + ) + const selectedProjectNameById = computed( + () => new Map(selectedProjects.value.map((project) => [project.id, project.name])), + ) + const selectedProjectEventIdSet = computed( + () => new Set(selectedProjects.value.map((project) => project.id)), + ) + + return { + selectedProjectIdSet, + hasAvailableProjects, + selectedProjects, + selectedProjectNameById, + selectedProjectEventIdSet, + } +} diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-messages.ts b/apps/frontend/src/components/analytics-dashboard/analytics-messages.ts new file mode 100644 index 000000000..460151bde --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-messages.ts @@ -0,0 +1,897 @@ +import { defineMessages, getLoaderMessage, type VIntlFormatters } from '@modrinth/ui' + +import type { + AnalyticsBreakdownPreset, + AnalyticsDashboardStat, + AnalyticsGroupByPreset, +} from '~/providers/analytics/analytics' + +export type FormatMessage = VIntlFormatters['formatMessage'] +export type AnalyticsBreakdownItemType = + | 'country' + | 'downloadReason' + | 'downloadSource' + | 'gameVersion' + | 'loader' + | 'monetization' + | 'project' + | 'projectVersion' + | 'other' + +export const analyticsMessages = defineMessages({ + title: { + id: 'analytics.title', + defaultMessage: 'Analytics', + }, + resetButton: { + id: 'analytics.action.reset', + defaultMessage: 'Reset', + }, + refreshButton: { + id: 'analytics.action.refresh', + defaultMessage: 'Refresh', + }, + fetchingResults: { + id: 'analytics.loading.fetching-results', + defaultMessage: 'Fetching results...', + }, + allProjects: { + id: 'analytics.project.all', + defaultMessage: 'All projects', + }, + selectProjects: { + id: 'analytics.project.select', + defaultMessage: 'Select projects', + }, + projectCount: { + id: 'analytics.project.count', + defaultMessage: '{count, plural, one {# project} other {# projects}}', + }, + projectIconAlt: { + id: 'analytics.project.icon-alt', + defaultMessage: '{name} Icon', + }, + noDataAvailable: { + id: 'analytics.empty.no-data', + defaultMessage: 'No data available', + }, + noDataAvailableForAnalytics: { + id: 'analytics.empty.no-data-for-analytics', + defaultMessage: 'No data available for analytics', + }, + noProjectsAvailable: { + id: 'analytics.empty.no-projects', + defaultMessage: 'No projects available', + }, + noProjectsAvailableForAnalytics: { + id: 'analytics.empty.no-projects-for-analytics', + defaultMessage: 'No projects available for analytics', + }, + selectAtLeastOneProject: { + id: 'analytics.empty.select-project', + defaultMessage: 'Select at least one project to view data', + }, + unknown: { + id: 'analytics.value.unknown', + defaultMessage: 'Unknown', + }, + other: { + id: 'analytics.value.other', + defaultMessage: 'Other', + }, + none: { + id: 'analytics.value.none', + defaultMessage: 'None', + }, + noBreakdown: { + id: 'analytics.breakdown.none.selected', + defaultMessage: 'No breakdown', + }, + breakdownBy: { + id: 'analytics.breakdown.selected', + defaultMessage: 'Breakdown by {breakdown}', + }, + projectLabel: { + id: 'analytics.query.label.project', + defaultMessage: 'Project:', + }, + timeframeLabel: { + id: 'analytics.query.label.timeframe', + defaultMessage: 'Timeframe:', + }, + groupedByLabel: { + id: 'analytics.query.label.grouped-by', + defaultMessage: 'Grouped by', + }, + breakdownLabel: { + id: 'analytics.query.label.breakdown', + defaultMessage: 'Breakdown:', + }, + addFilterButton: { + id: 'analytics.query.filter.add', + defaultMessage: 'Add filter', + }, + addButton: { + id: 'analytics.action.add', + defaultMessage: 'Add', + }, + downloadsSuffix: { + id: 'analytics.downloads.suffix', + defaultMessage: 'downloads', + }, + projectsAbove: { + id: 'analytics.threshold.projects-above', + defaultMessage: 'Projects above', + }, + countriesAbove: { + id: 'analytics.threshold.countries-above', + defaultMessage: 'Countries above', + }, + projectVersionsAbove: { + id: 'analytics.threshold.project-versions-above', + defaultMessage: 'Project versions above', + }, + gameVersionsAbove: { + id: 'analytics.threshold.game-versions-above', + defaultMessage: 'Game versions above', + }, + projectDownloadsThresholdAria: { + id: 'analytics.threshold.project-downloads-aria', + defaultMessage: 'Project downloads threshold', + }, + countryDownloadsThresholdAria: { + id: 'analytics.threshold.country-downloads-aria', + defaultMessage: 'Country downloads threshold', + }, + projectVersionDownloadsThresholdAria: { + id: 'analytics.threshold.project-version-downloads-aria', + defaultMessage: 'Project version downloads threshold', + }, + gameVersionDownloadsThresholdAria: { + id: 'analytics.threshold.game-version-downloads-aria', + defaultMessage: 'Game version downloads threshold', + }, + loadingOptions: { + id: 'analytics.options.loading', + defaultMessage: 'Loading...', + }, + searchCountriesPlaceholder: { + id: 'analytics.filter.search.countries', + defaultMessage: 'Search countries...', + }, + searchDownloadSourcesPlaceholder: { + id: 'analytics.filter.search.download-sources', + defaultMessage: 'Search download sources...', + }, + searchProjectVersionsPlaceholder: { + id: 'analytics.filter.search.project-versions', + defaultMessage: 'Search project versions...', + }, + searchVersionsPlaceholder: { + id: 'analytics.filter.search.versions', + defaultMessage: 'Search versions...', + }, + gameVersionTypeAria: { + id: 'analytics.filter.game-version-type', + defaultMessage: 'Game version type', + }, + releaseTab: { + id: 'analytics.filter.game-version-type.release', + defaultMessage: 'Release', + }, + allTab: { + id: 'analytics.filter.game-version-type.all', + defaultMessage: 'All', + }, +}) + +export const analyticsStatMessages = defineMessages({ + views: { + id: 'analytics.stat.views', + defaultMessage: 'Views', + }, + downloads: { + id: 'analytics.stat.downloads', + defaultMessage: 'Downloads', + }, + revenue: { + id: 'analytics.stat.revenue', + defaultMessage: 'Revenue', + }, + playtime: { + id: 'analytics.stat.playtime', + defaultMessage: 'Playtime', + }, +}) + +export const analyticsGraphTitleMessages = defineMessages({ + views: { + id: 'analytics.graph.title.views', + defaultMessage: 'Views Over Time', + }, + downloads: { + id: 'analytics.graph.title.downloads', + defaultMessage: 'Downloads Over Time', + }, + revenue: { + id: 'analytics.graph.title.revenue', + defaultMessage: 'Revenue Over Time', + }, + playtime: { + id: 'analytics.graph.title.playtime', + defaultMessage: 'Playtime Over Time', + }, +}) + +export const analyticsStatCardMessages = defineMessages({ + revenueValue: { + id: 'analytics.stat.revenue-value', + defaultMessage: '${value}', + }, + playtimeHours: { + id: 'analytics.stat.playtime-hours', + defaultMessage: '{hours} hrs', + }, + unavailableTooltip: { + id: 'analytics.stat.unavailable-tooltip', + defaultMessage: 'Stat unavailable for current query', + }, + unavailableLabel: { + id: 'analytics.stat.unavailable', + defaultMessage: 'N/A', + }, + previousPeriodComparison: { + id: 'analytics.stat.previous-period-comparison', + defaultMessage: 'vs prev. period', + }, + previousPeriodComparisonShort: { + id: 'analytics.stat.previous-period-comparison-short', + defaultMessage: 'vs prev.', + }, +}) + +export const analyticsGroupByMessages = defineMessages({ + oneHour: { + id: 'analytics.group-by.1h', + defaultMessage: '1h', + }, + sixHours: { + id: 'analytics.group-by.6h', + defaultMessage: '6h', + }, + day: { + id: 'analytics.group-by.day', + defaultMessage: 'Day', + }, + week: { + id: 'analytics.group-by.week', + defaultMessage: 'Week', + }, + month: { + id: 'analytics.group-by.month', + defaultMessage: 'Month', + }, + year: { + id: 'analytics.group-by.year', + defaultMessage: 'Year', + }, + date: { + id: 'analytics.group-by.date', + defaultMessage: 'Date', + }, + groupByHour: { + id: 'analytics.group-by.selected.hour', + defaultMessage: 'Group by hour', + }, + groupBySixHours: { + id: 'analytics.group-by.selected.six-hours', + defaultMessage: 'Group by 6 hours', + }, + groupByDay: { + id: 'analytics.group-by.selected.day', + defaultMessage: 'Group by day', + }, + groupByWeek: { + id: 'analytics.group-by.selected.week', + defaultMessage: 'Group by week', + }, + groupByMonth: { + id: 'analytics.group-by.selected.month', + defaultMessage: 'Group by month', + }, + groupByYear: { + id: 'analytics.group-by.selected.year', + defaultMessage: 'Group by year', + }, +}) + +export const analyticsBreakdownMessages = defineMessages({ + breakdown: { + id: 'analytics.breakdown.generic', + defaultMessage: 'Breakdown', + }, + project: { + id: 'analytics.breakdown.project', + defaultMessage: 'Project', + }, + country: { + id: 'analytics.breakdown.country', + defaultMessage: 'Country', + }, + monetization: { + id: 'analytics.breakdown.monetization', + defaultMessage: 'Monetization', + }, + userAgent: { + id: 'analytics.breakdown.download-source', + defaultMessage: 'Download source', + }, + downloadReason: { + id: 'analytics.breakdown.download-reason', + defaultMessage: 'Download reason', + }, + versionId: { + id: 'analytics.breakdown.project-version', + defaultMessage: 'Project version', + }, + loader: { + id: 'analytics.breakdown.loader', + defaultMessage: 'Loader', + }, + gameVersion: { + id: 'analytics.breakdown.game-version', + defaultMessage: 'Game version', + }, + projectStatus: { + id: 'analytics.breakdown.project-status', + defaultMessage: 'Project status', + }, +}) + +export const analyticsMonetizationMessages = defineMessages({ + monetized: { + id: 'analytics.value.monetized', + defaultMessage: 'Monetized', + }, + unmonetized: { + id: 'analytics.value.unmonetized', + defaultMessage: 'Unmonetized', + }, +}) + +export const analyticsDownloadReasonMessages = defineMessages({ + standalone: { + id: 'analytics.download-reason.standalone', + defaultMessage: 'Standalone', + }, + dependency: { + id: 'analytics.download-reason.dependency', + defaultMessage: 'Dependency', + }, + modpack: { + id: 'analytics.download-reason.modpack', + defaultMessage: 'Modpack', + }, + update: { + id: 'analytics.download-reason.update', + defaultMessage: 'Update', + }, +}) + +export const analyticsDownloadSourceMessages = defineMessages({ + website: { + id: 'analytics.download-source.website', + defaultMessage: 'Modrinth Website', + }, + app: { + id: 'analytics.download-source.app', + defaultMessage: 'Modrinth App', + }, +}) + +export const analyticsProjectStatusMessages = defineMessages({ + approved: { + id: 'analytics.project-status.approved', + defaultMessage: 'Approved', + }, + archived: { + id: 'analytics.project-status.archived', + defaultMessage: 'Archived', + }, + rejected: { + id: 'analytics.project-status.rejected', + defaultMessage: 'Rejected', + }, + draft: { + id: 'analytics.project-status.draft', + defaultMessage: 'Draft', + }, + unlisted: { + id: 'analytics.project-status.unlisted', + defaultMessage: 'Unlisted', + }, + withheld: { + id: 'analytics.project-status.withheld', + defaultMessage: 'Withheld', + }, + private: { + id: 'analytics.project-status.private', + defaultMessage: 'Private', + }, + other: { + id: 'analytics.project-status.other', + defaultMessage: 'Other', + }, +}) + +export const analyticsTableMessages = defineMessages({ + searchPlaceholder: { + id: 'analytics.table.search.placeholder', + defaultMessage: 'Search...', + }, + exportCsvButton: { + id: 'analytics.table.export-csv', + defaultMessage: 'Export CSV', + }, + cumulativeCsv: { + id: 'analytics.table.export.cumulative', + defaultMessage: 'Cumulative', + }, + groupedCsv: { + id: 'analytics.table.export.grouped', + defaultMessage: 'Grouped by {groupBy}', + }, + noMatchingRows: { + id: 'analytics.table.empty.no-matching-rows', + defaultMessage: 'No matching analytics rows', + }, + paginationSummary: { + id: 'analytics.table.pagination.summary', + defaultMessage: 'Showing {start} to {end} of {total}', + }, + playtimeSecondsHeader: { + id: 'analytics.table.csv.header.playtime-seconds', + defaultMessage: 'Playtime (seconds)', + }, + csvSelectedRange: { + id: 'analytics.table.csv.selected-range', + defaultMessage: 'Selected Range', + }, + csvDateRange: { + id: 'analytics.table.csv.date-range', + defaultMessage: '{start} to {end}', + }, + csvFilename: { + id: 'analytics.table.csv.filename', + defaultMessage: 'Modrinth Analytics {breakdown} Breakdown - {dateRange}', + }, + durationDays: { + id: 'analytics.table.duration.days', + defaultMessage: '{count, plural, one {# day} other {# days}}', + }, + durationHours: { + id: 'analytics.table.duration.hours', + defaultMessage: '{count, plural, one {# hour} other {# hours}}', + }, + durationMinutes: { + id: 'analytics.table.duration.minutes', + defaultMessage: '{count, plural, one {# minute} other {# minutes}}', + }, +}) + +export const analyticsChartMessages = defineMessages({ + selectTableItemsEmpty: { + id: 'analytics.chart.empty.select-table-items', + defaultMessage: 'Select items from table below to visualize your data.', + }, + showLimited: { + id: 'analytics.chart.action.show-limited', + defaultMessage: 'Show limited', + }, + showAll: { + id: 'analytics.chart.action.show-all', + defaultMessage: 'Show all', + }, + showTopEight: { + id: 'analytics.chart.action.show-top-eight', + defaultMessage: 'Show top 8', + }, + tableSelectionLimited: { + id: 'analytics.chart.table-selection.limited', + defaultMessage: + 'Showing {limit} {itemType, select, project {{limit, plural, one {project} other {projects}}} country {{limit, plural, one {country} other {countries}}} monetization {{limit, plural, one {monetization value} other {monetization values}}} downloadSource {{limit, plural, one {download source} other {download sources}}} downloadReason {{limit, plural, one {download reason} other {download reasons}}} projectVersion {{limit, plural, one {project version} other {project versions}}} loader {{limit, plural, one {loader} other {loaders}}} gameVersion {{limit, plural, one {game version} other {game versions}}} other {{limit, plural, one {item} other {items}}}} from table', + }, + tableSelectionAll: { + id: 'analytics.chart.table-selection.all', + defaultMessage: + 'Showing all {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table', + }, + tableSelectionTop: { + id: 'analytics.chart.table-selection.top', + defaultMessage: + 'Showing top {count} {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table', + }, + tableSelectionCount: { + id: 'analytics.chart.table-selection.count', + defaultMessage: + 'Showing {count} {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table', + }, + lineView: { + id: 'analytics.chart.view.line', + defaultMessage: 'Line', + }, + areaView: { + id: 'analytics.chart.view.area', + defaultMessage: 'Area', + }, + barView: { + id: 'analytics.chart.view.bar', + defaultMessage: 'Bar', + }, + controlsButton: { + id: 'analytics.chart.controls.button', + defaultMessage: 'Controls', + }, + controlsAria: { + id: 'analytics.chart.controls.aria', + defaultMessage: 'Analytics graph controls, {activeCount}', + }, + controlsDialogAria: { + id: 'analytics.chart.controls.dialog-aria', + defaultMessage: 'Analytics graph controls', + }, + activeControlCount: { + id: 'analytics.chart.controls.active-count', + defaultMessage: '{count} active', + }, + displayControls: { + id: 'analytics.chart.controls.display', + defaultMessage: 'Display', + }, + previousPeriod: { + id: 'analytics.chart.controls.previous-period', + defaultMessage: 'Previous period', + }, + ratio: { + id: 'analytics.chart.controls.ratio', + defaultMessage: 'Ratio', + }, + annotations: { + id: 'analytics.chart.controls.annotations', + defaultMessage: 'Annotations', + }, + projectEvents: { + id: 'analytics.chart.controls.project-events', + defaultMessage: 'Project events', + }, + modrinthEvents: { + id: 'analytics.chart.controls.modrinth-events', + defaultMessage: 'Modrinth events', + }, + noProjectEvents: { + id: 'analytics.chart.controls.no-project-events', + defaultMessage: 'No project events in graph.', + }, + noModrinthEvents: { + id: 'analytics.chart.controls.no-modrinth-events', + defaultMessage: 'No Modrinth events in graph.', + }, + viewMonetizedAnalyticsDetails: { + id: 'analytics.chart.legend.monetization-details.aria', + defaultMessage: 'View monetized analytics details', + }, + monetizedAnalyticsDetails: { + id: 'analytics.chart.legend.monetization-details.title', + defaultMessage: 'Monetized analytics details', + }, + monetizedAnalyticsDetailsDescription: { + id: 'analytics.chart.legend.monetization-details.description', + defaultMessage: + 'Only views and downloads made through Modrinth count toward monetization, and downloads require users to be logged in.', + }, + previousPeriodSuffix: { + id: 'analytics.chart.legend.previous-period-suffix', + defaultMessage: '{name} (Prev.)', + }, + previousPeriodShort: { + id: 'analytics.chart.tooltip.previous-period-short', + defaultMessage: '(prev.)', + }, + tooltipPinned: { + id: 'analytics.chart.tooltip.pinned', + defaultMessage: 'Chart tooltip pinned', + }, + pinned: { + id: 'analytics.chart.tooltip.pinned-aria', + defaultMessage: 'Pinned', + }, + total: { + id: 'analytics.chart.tooltip.total', + defaultMessage: 'Total', + }, + showEntryInGraph: { + id: 'analytics.chart.tooltip.show-entry', + defaultMessage: 'Show {name} in graph', + }, + hideEntryInGraph: { + id: 'analytics.chart.tooltip.hide-entry', + defaultMessage: 'Hide {name} in graph', + }, + durationDays: { + id: 'analytics.chart.tooltip.duration.days', + defaultMessage: '{count, plural, one {# day} other {# days}}', + }, + durationHours: { + id: 'analytics.chart.tooltip.duration.hours', + defaultMessage: '{count, plural, one {# hour} other {# hours}}', + }, + durationMinutes: { + id: 'analytics.chart.tooltip.duration.minutes', + defaultMessage: '{count, plural, one {# minute} other {# minutes}}', + }, + playtimeAxisHours: { + id: 'analytics.chart.axis.playtime-hours', + defaultMessage: '{hours} h', + }, + renderLimitHeader: { + id: 'analytics.chart.render-limit.header', + defaultMessage: 'Show all {count} lines in graph?', + }, + renderLimitDescription: { + id: 'analytics.chart.render-limit.description', + defaultMessage: 'Showing all selected lines from table may degrade page performance.', + }, + cancelButton: { + id: 'analytics.action.cancel', + defaultMessage: 'Cancel', + }, + analyticsEventsCount: { + id: 'analytics.chart.events.count-aria', + defaultMessage: '{count, plural, one {# analytics event} other {# analytics events}}', + }, + seeAnnouncement: { + id: 'analytics.chart.events.see-announcement', + defaultMessage: 'See announcement', + }, + projectEventTitle: { + id: 'analytics.chart.events.project-title', + defaultMessage: '{projectName}: {title}', + }, +}) + +export const analyticsProjectEventMessages = defineMessages({ + versionReleased: { + id: 'analytics.project-event.version-released', + defaultMessage: '{version} released', + }, + versionUploaded: { + id: 'analytics.project-event.version-uploaded', + defaultMessage: 'Version uploaded', + }, + projectApproved: { + id: 'analytics.project-event.project-approved', + defaultMessage: 'Project approved', + }, + projectUnlisted: { + id: 'analytics.project-event.project-unlisted', + defaultMessage: 'Project unlisted', + }, + projectPrivate: { + id: 'analytics.project-event.project-private', + defaultMessage: 'Project set to private', + }, + projectStatusChanged: { + id: 'analytics.project-event.project-status-changed', + defaultMessage: 'Project status changed', + }, +}) + +export function formatAnalyticsStatLabel( + stat: AnalyticsDashboardStat, + formatMessage: FormatMessage, +): string { + return formatMessage(analyticsStatMessages[stat]) +} + +export function formatAnalyticsGraphTitle( + stat: AnalyticsDashboardStat, + formatMessage: FormatMessage, +): string { + return formatMessage(analyticsGraphTitleMessages[stat]) +} + +export function formatAnalyticsGroupByLabel( + groupBy: AnalyticsGroupByPreset, + formatMessage: FormatMessage, +): string { + switch (groupBy) { + case '1h': + return formatMessage(analyticsGroupByMessages.oneHour) + case '6h': + return formatMessage(analyticsGroupByMessages.sixHours) + case 'day': + return formatMessage(analyticsGroupByMessages.day) + case 'week': + return formatMessage(analyticsGroupByMessages.week) + case 'month': + return formatMessage(analyticsGroupByMessages.month) + case 'year': + return formatMessage(analyticsGroupByMessages.year) + default: + return formatMessage(analyticsGroupByMessages.date) + } +} + +export function formatAnalyticsGroupBySelectedLabel( + groupBy: AnalyticsGroupByPreset, + formatMessage: FormatMessage, +): string { + switch (groupBy) { + case '1h': + return formatMessage(analyticsGroupByMessages.groupByHour) + case '6h': + return formatMessage(analyticsGroupByMessages.groupBySixHours) + case 'day': + return formatMessage(analyticsGroupByMessages.groupByDay) + case 'week': + return formatMessage(analyticsGroupByMessages.groupByWeek) + case 'month': + return formatMessage(analyticsGroupByMessages.groupByMonth) + case 'year': + return formatMessage(analyticsGroupByMessages.groupByYear) + default: + return formatMessage(analyticsGroupByMessages.groupByDay) + } +} + +export function formatAnalyticsBreakdownLabel( + breakdown: AnalyticsBreakdownPreset, + formatMessage: FormatMessage, +): string { + switch (breakdown) { + case 'none': + case 'project': + return formatMessage(analyticsBreakdownMessages.project) + case 'country': + return formatMessage(analyticsBreakdownMessages.country) + case 'monetization': + return formatMessage(analyticsBreakdownMessages.monetization) + case 'user_agent': + return formatMessage(analyticsBreakdownMessages.userAgent) + case 'download_reason': + return formatMessage(analyticsBreakdownMessages.downloadReason) + case 'version_id': + return formatMessage(analyticsBreakdownMessages.versionId) + case 'loader': + return formatMessage(analyticsBreakdownMessages.loader) + case 'game_version': + return formatMessage(analyticsBreakdownMessages.gameVersion) + default: + return formatMessage(analyticsBreakdownMessages.breakdown) + } +} + +export function getAnalyticsBreakdownItemType( + breakdowns: readonly AnalyticsBreakdownPreset[], +): AnalyticsBreakdownItemType { + if (breakdowns.length !== 1) { + return 'other' + } + + switch (breakdowns[0]) { + case 'project': + return 'project' + case 'country': + return 'country' + case 'monetization': + return 'monetization' + case 'user_agent': + return 'downloadSource' + case 'download_reason': + return 'downloadReason' + case 'version_id': + return 'projectVersion' + case 'loader': + return 'loader' + case 'game_version': + return 'gameVersion' + default: + return 'other' + } +} + +export function formatAnalyticsMonetizationLabel( + value: string, + formatMessage: FormatMessage, +): string { + switch (value.trim().toLowerCase()) { + case 'monetized': + return formatMessage(analyticsMonetizationMessages.monetized) + case 'unmonetized': + return formatMessage(analyticsMonetizationMessages.unmonetized) + default: + return value + } +} + +export function formatAnalyticsDownloadReasonLabel( + reason: string, + formatMessage: FormatMessage, +): string { + switch (reason.trim().toLowerCase()) { + case 'standalone': + return formatMessage(analyticsDownloadReasonMessages.standalone) + case 'dependency': + return formatMessage(analyticsDownloadReasonMessages.dependency) + case 'modpack': + return formatMessage(analyticsDownloadReasonMessages.modpack) + case 'update': + return formatMessage(analyticsDownloadReasonMessages.update) + default: + return reason + } +} + +export function formatAnalyticsDownloadSourceLabel( + source: string, + formatMessage: FormatMessage, +): string { + const normalized = source.trim() + const normalizedLowercase = normalized.toLowerCase() + if (normalizedLowercase === 'website') { + return formatMessage(analyticsDownloadSourceMessages.website) + } + if (normalizedLowercase === 'modrinth_app') { + return formatMessage(analyticsDownloadSourceMessages.app) + } + if (!normalized.includes('_')) { + return normalized + } + + return normalizedLowercase + .split('_') + .filter((part) => part.length > 0) + .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) + .join(' ') +} + +export function formatAnalyticsProjectStatusLabel( + status: string, + formatMessage: FormatMessage, +): string { + switch (status.trim().toLowerCase()) { + case 'approved': + return formatMessage(analyticsProjectStatusMessages.approved) + case 'archived': + return formatMessage(analyticsProjectStatusMessages.archived) + case 'rejected': + return formatMessage(analyticsProjectStatusMessages.rejected) + case 'draft': + return formatMessage(analyticsProjectStatusMessages.draft) + case 'unlisted': + return formatMessage(analyticsProjectStatusMessages.unlisted) + case 'withheld': + return formatMessage(analyticsProjectStatusMessages.withheld) + case 'private': + return formatMessage(analyticsProjectStatusMessages.private) + case 'other': + return formatMessage(analyticsProjectStatusMessages.other) + default: + return capitalizeAnalyticsValue(status) + } +} + +export function formatAnalyticsLoaderLabel(loader: string, formatMessage: FormatMessage): string { + const normalizedLoader = loader.trim() + const loaderMessage = getLoaderMessage(normalizedLoader) + return loaderMessage ? formatMessage(loaderMessage) : capitalizeAnalyticsValue(normalizedLoader) +} + +function capitalizeAnalyticsValue(value: string): string { + const normalizedValue = value.trim() + if (normalizedValue.length === 0) { + return value + } + + return `${normalizedValue.charAt(0).toUpperCase()}${normalizedValue.slice(1)}` +} diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-route-query.ts b/apps/frontend/src/components/analytics-dashboard/analytics-route-query.ts new file mode 100644 index 000000000..c3df9e8e0 --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-route-query.ts @@ -0,0 +1,908 @@ +import type { LocationQuery, LocationQueryValue, LocationQueryValueRaw } from 'vue-router' + +import type { + AnalyticsBreakdownPreset, + AnalyticsDashboardStat, + AnalyticsGraphState, + AnalyticsGraphViewMode, + AnalyticsGroupByPreset, + AnalyticsLastTimeframeUnit, + AnalyticsQueryBuilderState, + AnalyticsQueryFilterCategory, + AnalyticsSelectedBreakdowns, + AnalyticsSelectedFilters, + AnalyticsTableSortColumn, + AnalyticsTableSortDirection, + AnalyticsTableSortState, + AnalyticsTimeframeMode, + AnalyticsTimeframePreset, + MutableRouteQuery, +} from '~/providers/analytics/analytics-types' + +export const DEFAULT_TIMEFRAME_PRESET: AnalyticsTimeframePreset = 'last_30_days' +export const DEFAULT_TIMEFRAME_MODE: AnalyticsTimeframeMode = 'preset' +export const DEFAULT_LAST_TIMEFRAME_AMOUNT = 1 +export const DEFAULT_LAST_TIMEFRAME_UNIT: AnalyticsLastTimeframeUnit = 'days' +export const DEFAULT_GROUP_BY_PRESET: AnalyticsGroupByPreset = 'day' +export const DEFAULT_BREAKDOWN_PRESET: AnalyticsBreakdownPreset = 'none' +export const DEFAULT_ANALYTICS_DASHBOARD_STAT: AnalyticsDashboardStat = 'views' +export const DEFAULT_ANALYTICS_GRAPH_VIEW_MODE: AnalyticsGraphViewMode = 'line' +export const DEFAULT_ANALYTICS_GRAPH_RATIO_MODE = false +export const DEFAULT_ANALYTICS_GRAPH_EVENTS_VISIBILITY = true +export const DEFAULT_ANALYTICS_GRAPH_PREVIOUS_PERIOD_VISIBILITY = false +export const MAX_ANALYTICS_BREAKDOWN_PRESETS = 2 + +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 TIMEFRAME_MODE_VALUES: AnalyticsTimeframeMode[] = [ + 'preset', + 'last', + 'custom_range', + 'custom_datetime_range', +] +const LAST_TIMEFRAME_UNIT_VALUES: AnalyticsLastTimeframeUnit[] = [ + 'hours', + 'days', + 'weeks', + 'months', +] + +const GROUP_BY_PRESET_VALUES: AnalyticsGroupByPreset[] = [ + '1h', + '6h', + 'day', + 'week', + 'month', + 'year', +] + +const BREAKDOWN_PRESET_VALUES: AnalyticsBreakdownPreset[] = [ + 'none', + 'project', + 'country', + 'monetization', + 'user_agent', + 'download_reason', + 'version_id', + 'loader', + 'game_version', +] + +const ANALYTICS_DASHBOARD_STAT_VALUES: AnalyticsDashboardStat[] = [ + 'views', + 'downloads', + 'revenue', + 'playtime', +] + +const ANALYTICS_GRAPH_VIEW_MODE_VALUES: AnalyticsGraphViewMode[] = ['line', 'area', 'bar'] +const ANALYTICS_TABLE_SORT_COLUMN_VALUES: AnalyticsTableSortColumn[] = [ + 'date', + 'project', + 'breakdown', + 'breakdown_project', + 'breakdown_country', + 'breakdown_monetization', + 'breakdown_user_agent', + 'breakdown_download_reason', + 'breakdown_version_id', + 'breakdown_loader', + 'breakdown_game_version', + 'views', + 'downloads', + 'revenue', + 'playtime', +] +const ANALYTICS_TABLE_SORT_DIRECTION_VALUES: AnalyticsTableSortDirection[] = ['asc', 'desc'] + +const PROJECT_STATUS_FILTER_VALUES = [ + 'approved', + 'archived', + 'rejected', + 'draft', + 'unlisted', + 'withheld', + 'private', + 'other', +] + +const QUERY_KEY_PROJECT_IDS = 'a_projects' +const QUERY_KEY_TIMEFRAME_MODE = 'a_timeframe_mode' +const QUERY_KEY_TIMEFRAME = 'a_timeframe' +const QUERY_KEY_TIMEFRAME_LAST_AMOUNT = 'a_timeframe_last_amount' +const QUERY_KEY_TIMEFRAME_LAST_UNIT = 'a_timeframe_last_unit' +const QUERY_KEY_TIMEFRAME_START = 'a_timeframe_start' +const QUERY_KEY_TIMEFRAME_END = 'a_timeframe_end' +const QUERY_KEY_GROUP_BY = 'a_group_by' +const QUERY_KEY_BREAKDOWN = 'a_breakdown' +const QUERY_KEY_FILTER_PROJECT_STATUS = 'a_project_status' +const QUERY_KEY_FILTER_COUNTRY = 'a_country' +const QUERY_KEY_FILTER_MONETIZATION = 'a_monetization' +const QUERY_KEY_FILTER_USER_AGENT = 'a_user_agent' +const QUERY_KEY_FILTER_LEGACY_DOWNLOAD_SOURCE = 'a_download_source' +const QUERY_KEY_FILTER_DOWNLOAD_REASON = 'a_download_reason' +const QUERY_KEY_FILTER_VERSION_ID = 'a_version_id' +const QUERY_KEY_FILTER_GAME_VERSION = 'a_game_version' +const QUERY_KEY_FILTER_LOADER_TYPE = 'a_loader_type' +const QUERY_KEY_STAT = 'a_stat' +const QUERY_KEY_GRAPH_VIEW_MODE = 'a_chart' +const QUERY_KEY_GRAPH_RATIO_MODE = 'a_ratio' +const QUERY_KEY_GRAPH_EVENTS_VISIBILITY = 'a_events' +const QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY = 'a_project_events' +const QUERY_KEY_GRAPH_PREVIOUS_PERIOD_VISIBILITY = 'a_prev_period' +const QUERY_KEY_GRAPH_HIDDEN_SERIES = 'a_hidden_series' +const QUERY_KEY_GRAPH_SELECTED_SERIES = 'a_selected_series' +const QUERY_KEY_TABLE_SORT = 'a_table_sort' +const QUERY_KEY_TABLE_SORT_DIRECTION = 'a_table_sort_direction' +const QUERY_KEY_LEGACY_GRAPH_TOP_BREAKDOWN_FILTER = 'a_top_breakdown' +const QUERY_KEY_LEGACY_GRAPH_LEGEND_EXPANSION = 'a_legend_expanded' + +const URL_FILTER_CATEGORIES: Exclude[] = [ + 'project_status', + 'country', + 'monetization', + 'user_agent', + 'download_reason', + 'version_id', + 'game_version', + 'loader_type', +] + +const FILTER_QUERY_KEY_BY_CATEGORY: Record< + Exclude, + string +> = { + project_status: QUERY_KEY_FILTER_PROJECT_STATUS, + country: QUERY_KEY_FILTER_COUNTRY, + monetization: QUERY_KEY_FILTER_MONETIZATION, + user_agent: QUERY_KEY_FILTER_USER_AGENT, + download_reason: QUERY_KEY_FILTER_DOWNLOAD_REASON, + version_id: QUERY_KEY_FILTER_VERSION_ID, + 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_MODE, + QUERY_KEY_TIMEFRAME, + QUERY_KEY_TIMEFRAME_LAST_AMOUNT, + QUERY_KEY_TIMEFRAME_LAST_UNIT, + QUERY_KEY_TIMEFRAME_START, + QUERY_KEY_TIMEFRAME_END, + QUERY_KEY_GROUP_BY, + QUERY_KEY_BREAKDOWN, + QUERY_KEY_FILTER_PROJECT_STATUS, + QUERY_KEY_FILTER_COUNTRY, + QUERY_KEY_FILTER_MONETIZATION, + QUERY_KEY_FILTER_USER_AGENT, + QUERY_KEY_FILTER_LEGACY_DOWNLOAD_SOURCE, + QUERY_KEY_FILTER_DOWNLOAD_REASON, + QUERY_KEY_FILTER_VERSION_ID, + QUERY_KEY_FILTER_GAME_VERSION, + QUERY_KEY_FILTER_LOADER_TYPE, + QUERY_KEY_STAT, + QUERY_KEY_GRAPH_VIEW_MODE, + QUERY_KEY_GRAPH_RATIO_MODE, + QUERY_KEY_GRAPH_EVENTS_VISIBILITY, + QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY, + QUERY_KEY_GRAPH_PREVIOUS_PERIOD_VISIBILITY, + QUERY_KEY_GRAPH_HIDDEN_SERIES, + QUERY_KEY_GRAPH_SELECTED_SERIES, + QUERY_KEY_LEGACY_GRAPH_TOP_BREAKDOWN_FILTER, + QUERY_KEY_LEGACY_GRAPH_LEGEND_EXPANSION, +] + +export function buildEmptySelectedFilters(): AnalyticsSelectedFilters { + return { + project: [], + project_status: [], + country: [], + monetization: [], + user_agent: [], + download_reason: [], + version_id: [], + 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 parseSelectedSeriesQueryValue( + value: LocationQueryValue | LocationQueryValue[] | undefined, +): string[] { + return parseListQueryValue(value).filter((item) => item.toLowerCase() !== 'null') +} + +function normalizeFilterQueryValues( + category: Exclude, + values: string[], +): string[] { + if (category === 'project_status') { + return values + .map((value) => value.trim().toLowerCase()) + .filter((value) => PROJECT_STATUS_FILTER_VALUES.includes(value)) + } + + if (category !== 'loader_type') { + return values + } + + return Array.from( + new Set(values.map((value) => value.trim().toLowerCase()).filter((value) => value.length > 0)), + ) +} + +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 parseAnalyticsBreakdownsQueryValue( + value: LocationQueryValue | LocationQueryValue[] | undefined, + fallbackValues: AnalyticsSelectedBreakdowns, +): AnalyticsBreakdownPreset[] { + const rawValues = parseListQueryValue(value) + if (rawValues.length === 0) { + return [...fallbackValues] + } + + const parsedBreakdowns: AnalyticsBreakdownPreset[] = [] + for (const rawValue of rawValues) { + const normalizedValue = rawValue === 'download_source' ? 'user_agent' : rawValue + if (BREAKDOWN_PRESET_VALUES.includes(normalizedValue as AnalyticsBreakdownPreset)) { + parsedBreakdowns.push(normalizedValue as AnalyticsBreakdownPreset) + } + } + + return parsedBreakdowns +} + +function parsePositiveIntegerQueryValue( + value: LocationQueryValue | LocationQueryValue[] | undefined, + fallbackValue: number, +): number { + const rawValue = Array.isArray(value) ? value[0] : value + if (!rawValue) return fallbackValue + + const parsedValue = Number.parseInt(rawValue, 10) + if (!Number.isFinite(parsedValue) || parsedValue < 1) return fallbackValue + return parsedValue +} + +function parseEnabledQueryValue( + value: LocationQueryValue | LocationQueryValue[] | undefined, +): boolean { + const rawValue = Array.isArray(value) ? value[0] : value + return rawValue === '1' +} + +function parseVisibleQueryValue( + value: LocationQueryValue | LocationQueryValue[] | undefined, + fallbackValue: boolean, +): boolean { + const rawValue = Array.isArray(value) ? value[0] : value + if (rawValue === undefined) return fallbackValue + return rawValue !== '0' +} + +function getLocalDateQueryValue(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}` +} + +function getDefaultCustomStartDate(): string { + const date = new Date() + date.setDate(date.getDate() - 1) + return getLocalDateQueryValue(date) +} + +function getDefaultCustomEndDate(): string { + return getLocalDateQueryValue(new Date()) +} + +function getDefaultCustomDateTimeValue(value: string): string { + return new Date(`${value}T00:00:00`).toISOString() +} + +function parseDateQueryValue( + value: LocationQueryValue | LocationQueryValue[] | undefined, + fallbackValue: string, +): string { + const rawValue = Array.isArray(value) ? value[0] : value + if (!rawValue || !/^\d{4}-\d{2}-\d{2}$/.test(rawValue)) return fallbackValue + + const date = new Date(`${rawValue}T00:00:00`) + if (Number.isNaN(date.getTime())) return fallbackValue + if (getLocalDateQueryValue(date) !== rawValue) return fallbackValue + + return rawValue +} + +function parseDateTimeQueryValue( + value: LocationQueryValue | LocationQueryValue[] | undefined, + fallbackValue: string, +): string { + const rawValue = Array.isArray(value) ? value[0] : value + if (!rawValue || !/^\d{4}-\d{2}-\d{2}T/.test(rawValue)) return fallbackValue + + const date = new Date(rawValue) + if (Number.isNaN(date.getTime())) return fallbackValue + + return date.toISOString() +} + +function isTimeframeRangeEndBeforeStart( + mode: AnalyticsTimeframeMode, + startValue: string, + endValue: string, +): boolean { + if (mode === 'custom_datetime_range') { + return new Date(endValue).getTime() < new Date(startValue).getTime() + } + + return endValue < startValue +} + +export function getDefaultAnalyticsGraphProjectEventsVisibility( + selectedProjectIds: readonly string[] = [], +): boolean { + return selectedProjectIds.length <= 1 +} + +export function buildDefaultAnalyticsGraphState( + selectedProjectIds: readonly string[] = [], +): AnalyticsGraphState { + return { + activeStat: DEFAULT_ANALYTICS_DASHBOARD_STAT, + activeGraphViewMode: DEFAULT_ANALYTICS_GRAPH_VIEW_MODE, + isRatioMode: DEFAULT_ANALYTICS_GRAPH_RATIO_MODE, + showChartEvents: DEFAULT_ANALYTICS_GRAPH_EVENTS_VISIBILITY, + showProjectEvents: getDefaultAnalyticsGraphProjectEventsVisibility(selectedProjectIds), + showPreviousPeriod: DEFAULT_ANALYTICS_GRAPH_PREVIOUS_PERIOD_VISIBILITY, + hiddenGraphDatasetIds: [], + selectedGraphDatasetIds: null, + } +} + +export function buildDefaultAnalyticsQueryBuilderState( + availableProjectIds: string[], +): AnalyticsQueryBuilderState { + return { + selectedProjectIds: [...availableProjectIds], + selectedTimeframeMode: DEFAULT_TIMEFRAME_MODE, + selectedTimeframe: DEFAULT_TIMEFRAME_PRESET, + selectedLastTimeframeAmount: DEFAULT_LAST_TIMEFRAME_AMOUNT, + selectedLastTimeframeUnit: DEFAULT_LAST_TIMEFRAME_UNIT, + selectedCustomTimeframeStartDate: getDefaultCustomStartDate(), + selectedCustomTimeframeEndDate: getDefaultCustomEndDate(), + selectedGroupBy: DEFAULT_GROUP_BY_PRESET, + selectedBreakdowns: getDefaultAnalyticsBreakdownPresets(availableProjectIds), + selectedFilters: buildEmptySelectedFilters(), + } +} + +export function getDefaultAnalyticsBreakdownPresets( + selectedProjectIds: readonly string[], +): AnalyticsSelectedBreakdowns { + return selectedProjectIds.length > 1 ? ['project'] : [] +} + +export function getDefaultAnalyticsBreakdownPreset( + selectedProjectIds: readonly string[], +): AnalyticsBreakdownPreset { + return selectedProjectIds.length > 1 ? 'project' : DEFAULT_BREAKDOWN_PRESET +} + +export function getAnalyticsBreakdownPresetsForProjectSelection( + breakdowns: readonly AnalyticsBreakdownPreset[], + selectedProjectIds: readonly string[], +): AnalyticsSelectedBreakdowns { + const normalizedBreakdowns: AnalyticsSelectedBreakdowns = [] + const canBreakDownByProject = selectedProjectIds.length > 1 + + for (const breakdown of breakdowns) { + if (breakdown === 'none') { + continue + } + if (breakdown === 'project' && !canBreakDownByProject) { + continue + } + if (!normalizedBreakdowns.includes(breakdown)) { + normalizedBreakdowns.push(breakdown) + } + if (normalizedBreakdowns.length >= MAX_ANALYTICS_BREAKDOWN_PRESETS) { + break + } + } + + return normalizedBreakdowns +} + +export function getAnalyticsBreakdownPresetForProjectSelection( + breakdown: AnalyticsBreakdownPreset, + selectedProjectIds: readonly string[], +): AnalyticsBreakdownPreset { + const defaultBreakdown = getDefaultAnalyticsBreakdownPreset(selectedProjectIds) + if ( + (breakdown === 'none' && defaultBreakdown === 'project') || + (breakdown === 'project' && defaultBreakdown === 'none') + ) { + return defaultBreakdown + } + + return breakdown +} + +export function isAnalyticsQueryBuilderStateDefault( + state: AnalyticsQueryBuilderState, + availableProjectIds: string[], +): boolean { + const defaultState = buildDefaultAnalyticsQueryBuilderState(availableProjectIds) + const areDefaultProjectsSelected = + availableProjectIds.length === 0 + ? state.selectedProjectIds.length === 0 + : areAllProjectsSelected(state.selectedProjectIds, availableProjectIds) + + return ( + areDefaultProjectsSelected && + state.selectedTimeframeMode === defaultState.selectedTimeframeMode && + state.selectedTimeframe === defaultState.selectedTimeframe && + state.selectedLastTimeframeAmount === defaultState.selectedLastTimeframeAmount && + state.selectedLastTimeframeUnit === defaultState.selectedLastTimeframeUnit && + state.selectedCustomTimeframeStartDate === defaultState.selectedCustomTimeframeStartDate && + state.selectedCustomTimeframeEndDate === defaultState.selectedCustomTimeframeEndDate && + state.selectedGroupBy === defaultState.selectedGroupBy && + areStringArraysEqual( + state.selectedBreakdowns, + getDefaultAnalyticsBreakdownPresets(state.selectedProjectIds), + ) && + areSelectedFiltersEqual(state.selectedFilters, defaultState.selectedFilters) + ) +} + +export function isAnalyticsGraphStateDefault( + state: AnalyticsGraphState, + selectedProjectIds: readonly string[] = [], +): boolean { + const defaultState = buildDefaultAnalyticsGraphState(selectedProjectIds) + + return ( + state.activeStat === defaultState.activeStat && + state.activeGraphViewMode === defaultState.activeGraphViewMode && + state.isRatioMode === defaultState.isRatioMode && + state.showChartEvents === defaultState.showChartEvents && + state.showProjectEvents === defaultState.showProjectEvents && + state.showPreviousPeriod === defaultState.showPreviousPeriod && + areStringArraysEqual(state.hiddenGraphDatasetIds, defaultState.hiddenGraphDatasetIds) && + state.selectedGraphDatasetIds === defaultState.selectedGraphDatasetIds + ) +} + +function serializeListQueryValue(values: string[]): string | undefined { + if (values.length === 0) return undefined + return values.join(',') +} + +function serializeExplicitListQueryValue(values: string[]): string { + return values.join(',') +} + +function serializeVisibleQueryValue(value: boolean, defaultValue: boolean): string | undefined { + if (value === defaultValue) return undefined + return value ? '1' : '0' +} + +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 readAnalyticsGraphState( + query: LocationQuery, + selectedProjectIds: readonly string[] = [], +): AnalyticsGraphState { + const defaultState = buildDefaultAnalyticsGraphState(selectedProjectIds) + + return { + activeStat: parsePresetQueryValue( + query[QUERY_KEY_STAT], + ANALYTICS_DASHBOARD_STAT_VALUES, + defaultState.activeStat, + ), + activeGraphViewMode: parsePresetQueryValue( + query[QUERY_KEY_GRAPH_VIEW_MODE], + ANALYTICS_GRAPH_VIEW_MODE_VALUES, + defaultState.activeGraphViewMode, + ), + isRatioMode: parseEnabledQueryValue(query[QUERY_KEY_GRAPH_RATIO_MODE]), + showChartEvents: parseVisibleQueryValue( + query[QUERY_KEY_GRAPH_EVENTS_VISIBILITY], + defaultState.showChartEvents, + ), + showProjectEvents: parseVisibleQueryValue( + query[QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY], + defaultState.showProjectEvents, + ), + showPreviousPeriod: parseEnabledQueryValue(query[QUERY_KEY_GRAPH_PREVIOUS_PERIOD_VISIBILITY]), + hiddenGraphDatasetIds: parseListQueryValue(query[QUERY_KEY_GRAPH_HIDDEN_SERIES]), + selectedGraphDatasetIds: + query[QUERY_KEY_GRAPH_SELECTED_SERIES] === undefined + ? null + : parseSelectedSeriesQueryValue(query[QUERY_KEY_GRAPH_SELECTED_SERIES]), + } +} + +export function readAnalyticsTableSortState( + query: LocationQuery, + defaultState: AnalyticsTableSortState, +): AnalyticsTableSortState { + const rawSortColumn = Array.isArray(query[QUERY_KEY_TABLE_SORT]) + ? query[QUERY_KEY_TABLE_SORT][0] + : query[QUERY_KEY_TABLE_SORT] + const rawSortDirection = Array.isArray(query[QUERY_KEY_TABLE_SORT_DIRECTION]) + ? query[QUERY_KEY_TABLE_SORT_DIRECTION][0] + : query[QUERY_KEY_TABLE_SORT_DIRECTION] + + if ( + !rawSortColumn || + !rawSortDirection || + !ANALYTICS_TABLE_SORT_COLUMN_VALUES.includes(rawSortColumn as AnalyticsTableSortColumn) || + !ANALYTICS_TABLE_SORT_DIRECTION_VALUES.includes(rawSortDirection as AnalyticsTableSortDirection) + ) { + return defaultState + } + + return { + sortColumn: rawSortColumn as AnalyticsTableSortColumn, + sortDirection: rawSortDirection as AnalyticsTableSortDirection, + } +} + +export function readAnalyticsQueryBuilderState( + query: LocationQuery, + availableProjectIds: string[], +): AnalyticsQueryBuilderState { + const defaultState = buildDefaultAnalyticsQueryBuilderState(availableProjectIds) + const selectedProjectIdsFromQuery = parseListQueryValue(query[QUERY_KEY_PROJECT_IDS]) + const selectedProjectIds = + selectedProjectIdsFromQuery.length > 0 + ? selectedProjectIdsFromQuery + : defaultState.selectedProjectIds + + const selectedFilters = buildEmptySelectedFilters() + for (const category of URL_FILTER_CATEGORIES) { + const categoryQueryKey = FILTER_QUERY_KEY_BY_CATEGORY[category] + const rawQueryValue = + category === 'user_agent' && query[categoryQueryKey] === undefined + ? query[QUERY_KEY_FILTER_LEGACY_DOWNLOAD_SOURCE] + : query[categoryQueryKey] + selectedFilters[category] = normalizeFilterQueryValues( + category, + parseListQueryValue(rawQueryValue), + ) + } + + const selectedTimeframeMode = parsePresetQueryValue( + query[QUERY_KEY_TIMEFRAME_MODE], + TIMEFRAME_MODE_VALUES, + defaultState.selectedTimeframeMode, + ) + const isCustomDateTimeRange = selectedTimeframeMode === 'custom_datetime_range' + const parseTimeframeRangeQueryValue = isCustomDateTimeRange + ? parseDateTimeQueryValue + : parseDateQueryValue + const customTimeframeStartFallback = isCustomDateTimeRange + ? getDefaultCustomDateTimeValue(defaultState.selectedCustomTimeframeStartDate) + : defaultState.selectedCustomTimeframeStartDate + const customTimeframeEndFallback = isCustomDateTimeRange + ? getDefaultCustomDateTimeValue(defaultState.selectedCustomTimeframeEndDate) + : defaultState.selectedCustomTimeframeEndDate + + const selectedCustomTimeframeStartDate = parseTimeframeRangeQueryValue( + query[QUERY_KEY_TIMEFRAME_START], + customTimeframeStartFallback, + ) + const rawCustomTimeframeEndDate = parseTimeframeRangeQueryValue( + query[QUERY_KEY_TIMEFRAME_END], + customTimeframeEndFallback, + ) + const selectedCustomTimeframeEndDate = isTimeframeRangeEndBeforeStart( + selectedTimeframeMode, + selectedCustomTimeframeStartDate, + rawCustomTimeframeEndDate, + ) + ? selectedCustomTimeframeStartDate + : rawCustomTimeframeEndDate + + const selectedBreakdowns = getAnalyticsBreakdownPresetsForProjectSelection( + parseAnalyticsBreakdownsQueryValue( + query[QUERY_KEY_BREAKDOWN], + getDefaultAnalyticsBreakdownPresets(selectedProjectIds), + ), + selectedProjectIds, + ) + + return { + selectedProjectIds, + selectedTimeframeMode, + selectedTimeframe: parsePresetQueryValue( + query[QUERY_KEY_TIMEFRAME], + TIMEFRAME_PRESET_VALUES, + defaultState.selectedTimeframe, + ), + selectedLastTimeframeAmount: parsePositiveIntegerQueryValue( + query[QUERY_KEY_TIMEFRAME_LAST_AMOUNT], + defaultState.selectedLastTimeframeAmount, + ), + selectedLastTimeframeUnit: parsePresetQueryValue( + query[QUERY_KEY_TIMEFRAME_LAST_UNIT], + LAST_TIMEFRAME_UNIT_VALUES, + defaultState.selectedLastTimeframeUnit, + ), + selectedCustomTimeframeStartDate, + selectedCustomTimeframeEndDate, + selectedGroupBy: parsePresetQueryValue( + query[QUERY_KEY_GROUP_BY], + GROUP_BY_PRESET_VALUES, + defaultState.selectedGroupBy, + ), + selectedBreakdowns, + selectedFilters, + } +} + +export function hasAnalyticsBreakdownQuery(query: LocationQuery): boolean { + return parseListQueryValue(query[QUERY_KEY_BREAKDOWN]).length > 0 +} + +export function hasAnalyticsProjectSelectionQuery(query: LocationQuery): boolean { + return parseListQueryValue(query[QUERY_KEY_PROJECT_IDS]).length > 0 +} + +export function hasAnalyticsGraphProjectEventsVisibilityQuery(query: LocationQuery): boolean { + return query[QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY] !== undefined +} + +export function hasAnalyticsTableSortQuery(query: LocationQuery): boolean { + return ( + query[QUERY_KEY_TABLE_SORT] !== undefined || query[QUERY_KEY_TABLE_SORT_DIRECTION] !== undefined + ) +} + +export function buildAnalyticsQueryBuilderRouteQuery( + currentRouteQuery: LocationQuery, + state: AnalyticsQueryBuilderState, + availableProjectIds: string[], + graphState?: AnalyticsGraphState, +): MutableRouteQuery { + const nextRouteQuery = { + ...currentRouteQuery, + } as MutableRouteQuery + + const projectIdsQueryValue = areAllProjectsSelected(state.selectedProjectIds, availableProjectIds) + ? undefined + : serializeListQueryValue(state.selectedProjectIds) + const isCustomTimeframeMode = + state.selectedTimeframeMode === 'custom_range' || + state.selectedTimeframeMode === 'custom_datetime_range' + + nextRouteQuery[QUERY_KEY_PROJECT_IDS] = projectIdsQueryValue + nextRouteQuery[QUERY_KEY_TIMEFRAME_MODE] = + state.selectedTimeframeMode !== DEFAULT_TIMEFRAME_MODE ? state.selectedTimeframeMode : undefined + nextRouteQuery[QUERY_KEY_TIMEFRAME] = + state.selectedTimeframeMode === 'preset' && state.selectedTimeframe !== DEFAULT_TIMEFRAME_PRESET + ? state.selectedTimeframe + : undefined + nextRouteQuery[QUERY_KEY_TIMEFRAME_LAST_AMOUNT] = + state.selectedTimeframeMode === 'last' ? String(state.selectedLastTimeframeAmount) : undefined + nextRouteQuery[QUERY_KEY_TIMEFRAME_LAST_UNIT] = + state.selectedTimeframeMode === 'last' ? state.selectedLastTimeframeUnit : undefined + nextRouteQuery[QUERY_KEY_TIMEFRAME_START] = isCustomTimeframeMode + ? state.selectedCustomTimeframeStartDate + : undefined + nextRouteQuery[QUERY_KEY_TIMEFRAME_END] = isCustomTimeframeMode + ? state.selectedCustomTimeframeEndDate + : undefined + nextRouteQuery[QUERY_KEY_GROUP_BY] = + state.selectedGroupBy !== DEFAULT_GROUP_BY_PRESET ? state.selectedGroupBy : undefined + const defaultBreakdowns = getDefaultAnalyticsBreakdownPresets(state.selectedProjectIds) + const selectedBreakdowns = getAnalyticsBreakdownPresetsForProjectSelection( + state.selectedBreakdowns, + state.selectedProjectIds, + ) + nextRouteQuery[QUERY_KEY_BREAKDOWN] = areStringArraysEqual(selectedBreakdowns, defaultBreakdowns) + ? undefined + : selectedBreakdowns.length === 0 + ? 'none' + : serializeListQueryValue(selectedBreakdowns) + + for (const category of URL_FILTER_CATEGORIES) { + const categoryQueryKey = FILTER_QUERY_KEY_BY_CATEGORY[category] + nextRouteQuery[categoryQueryKey] = serializeListQueryValue(state.selectedFilters[category]) + } + nextRouteQuery[QUERY_KEY_FILTER_LEGACY_DOWNLOAD_SOURCE] = undefined + + if (graphState) { + const defaultGraphState = buildDefaultAnalyticsGraphState(state.selectedProjectIds) + + nextRouteQuery[QUERY_KEY_STAT] = + graphState.activeStat !== DEFAULT_ANALYTICS_DASHBOARD_STAT ? graphState.activeStat : undefined + nextRouteQuery[QUERY_KEY_GRAPH_VIEW_MODE] = + graphState.activeGraphViewMode !== DEFAULT_ANALYTICS_GRAPH_VIEW_MODE + ? graphState.activeGraphViewMode + : undefined + nextRouteQuery[QUERY_KEY_GRAPH_RATIO_MODE] = graphState.isRatioMode ? '1' : undefined + nextRouteQuery[QUERY_KEY_GRAPH_EVENTS_VISIBILITY] = serializeVisibleQueryValue( + graphState.showChartEvents, + defaultGraphState.showChartEvents, + ) + nextRouteQuery[QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY] = serializeVisibleQueryValue( + graphState.showProjectEvents, + defaultGraphState.showProjectEvents, + ) + nextRouteQuery[QUERY_KEY_GRAPH_PREVIOUS_PERIOD_VISIBILITY] = graphState.showPreviousPeriod + ? '1' + : undefined + nextRouteQuery[QUERY_KEY_LEGACY_GRAPH_TOP_BREAKDOWN_FILTER] = undefined + nextRouteQuery[QUERY_KEY_LEGACY_GRAPH_LEGEND_EXPANSION] = undefined + nextRouteQuery[QUERY_KEY_GRAPH_HIDDEN_SERIES] = serializeListQueryValue( + [...graphState.hiddenGraphDatasetIds].sort((left, right) => left.localeCompare(right)), + ) + nextRouteQuery[QUERY_KEY_GRAPH_SELECTED_SERIES] = + graphState.selectedGraphDatasetIds === null + ? undefined + : serializeExplicitListQueryValue(graphState.selectedGraphDatasetIds) + } + + return nextRouteQuery +} + +export function buildAnalyticsTableSortRouteQuery( + currentRouteQuery: LocationQuery, + state: AnalyticsTableSortState, + defaultState: AnalyticsTableSortState, +): MutableRouteQuery { + const nextRouteQuery = { + ...currentRouteQuery, + } as MutableRouteQuery + const isDefaultSort = + state.sortColumn === defaultState.sortColumn && + state.sortDirection === defaultState.sortDirection + + nextRouteQuery[QUERY_KEY_TABLE_SORT] = + isDefaultSort || state.sortColumn === undefined ? undefined : state.sortColumn + nextRouteQuery[QUERY_KEY_TABLE_SORT_DIRECTION] = + isDefaultSort || state.sortColumn === undefined ? undefined : state.sortDirection + + return nextRouteQuery +} + +export function hasAnalyticsQueryBuilderRouteChange( + currentRouteQuery: LocationQuery, + nextRouteQuery: MutableRouteQuery, +): boolean { + return ANALYTICS_QUERY_KEYS.some( + (key) => !areQueryValuesEqual(currentRouteQuery[key], nextRouteQuery[key]), + ) +} + +export function hasAnalyticsTableSortRouteChange( + currentRouteQuery: LocationQuery, + nextRouteQuery: MutableRouteQuery, +): boolean { + return ( + !areQueryValuesEqual( + currentRouteQuery[QUERY_KEY_TABLE_SORT], + nextRouteQuery[QUERY_KEY_TABLE_SORT], + ) || + !areQueryValuesEqual( + currentRouteQuery[QUERY_KEY_TABLE_SORT_DIRECTION], + nextRouteQuery[QUERY_KEY_TABLE_SORT_DIRECTION], + ) + ) +} diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-columns.ts b/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-columns.ts new file mode 100644 index 000000000..2ed74ec54 --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-columns.ts @@ -0,0 +1,143 @@ +import type { TableColumn } from '@modrinth/ui' + +import type { + AnalyticsBreakdownPreset, + AnalyticsDashboardStat, + AnalyticsSelectedFilters, +} from '~/providers/analytics/analytics' + +import { + analyticsGroupByMessages, + formatAnalyticsBreakdownLabel, + formatAnalyticsStatLabel, + type FormatMessage, +} from '../analytics-messages' +import type { + AnalyticsTableBreakdownColumnKey, + AnalyticsTableBreakdownPreset, + AnalyticsTableColumnKey, +} from './analytics-table-types' + +type BuildAnalyticsTableColumnsOptions = { + includeDate: boolean + selectedBreakdowns: readonly AnalyticsTableBreakdownPreset[] + selectedFilters: AnalyticsSelectedFilters + showBreakdownColumn: boolean + showProjectVersionProjectColumn: boolean + formatMessage: FormatMessage + getRelevantAnalyticsDashboardStats: ( + breakdowns: readonly AnalyticsBreakdownPreset[], + filters?: AnalyticsSelectedFilters, + ) => readonly AnalyticsDashboardStat[] +} + +export function getAnalyticsTableBreakdownColumnLabel( + breakdown: AnalyticsBreakdownPreset, + formatMessage: FormatMessage, +): string { + return formatAnalyticsBreakdownLabel(breakdown, formatMessage) +} + +export function buildAnalyticsTableColumns({ + includeDate, + selectedBreakdowns, + selectedFilters, + showBreakdownColumn, + showProjectVersionProjectColumn, + formatMessage, + getRelevantAnalyticsDashboardStats, +}: BuildAnalyticsTableColumnsOptions): TableColumn[] { + const nextColumns: TableColumn[] = [] + const stats = getRelevantAnalyticsDashboardStats(selectedBreakdowns, selectedFilters) + + if (includeDate) { + nextColumns.push({ + key: 'date', + label: formatMessage(analyticsGroupByMessages.date), + enableSorting: true, + defaultSortDirection: 'desc', + width: stats.length > 2 ? '20%' : '', + }) + } + + if (showBreakdownColumn) { + for (const breakdown of selectedBreakdowns) { + nextColumns.push({ + key: getAnalyticsTableBreakdownColumnKey(breakdown), + label: getAnalyticsTableBreakdownColumnLabel(breakdown, formatMessage), + enableSorting: true, + }) + } + } + + if (showProjectVersionProjectColumn) { + nextColumns.push({ + key: 'project', + label: formatAnalyticsBreakdownLabel('project', formatMessage), + enableSorting: true, + }) + } + + for (const stat of stats) { + const column = getAnalyticsTableMetricColumn(stat, formatMessage) + if (column) { + nextColumns.push(column) + } + } + + return nextColumns +} + +export function getAnalyticsTableMetricColumn( + stat: AnalyticsDashboardStat, + formatMessage: FormatMessage, +): TableColumn | null { + switch (stat) { + case 'views': + return { + key: 'views', + label: formatAnalyticsStatLabel('views', formatMessage), + enableSorting: true, + defaultSortDirection: 'desc', + align: 'right', + } + case 'downloads': + return { + key: 'downloads', + label: formatAnalyticsStatLabel('downloads', formatMessage), + enableSorting: true, + defaultSortDirection: 'desc', + align: 'right', + } + case 'revenue': + return { + key: 'revenue', + label: formatAnalyticsStatLabel('revenue', formatMessage), + enableSorting: true, + defaultSortDirection: 'desc', + align: 'right', + } + case 'playtime': + return { + key: 'playtime', + label: formatAnalyticsStatLabel('playtime', formatMessage), + enableSorting: true, + defaultSortDirection: 'desc', + align: 'right', + } + default: + return null + } +} + +export function getAnalyticsTableBreakdownColumnKey( + breakdown: AnalyticsTableBreakdownPreset, +): AnalyticsTableBreakdownColumnKey { + return `breakdown_${breakdown}` +} + +export function isAnalyticsTableBreakdownColumnKey( + key: AnalyticsTableColumnKey, +): key is AnalyticsTableBreakdownColumnKey { + return key.startsWith('breakdown_') +} diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-csv-export.ts b/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-csv-export.ts new file mode 100644 index 000000000..ee0cab3ef --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-csv-export.ts @@ -0,0 +1,147 @@ +import type { Labrinth } from '@modrinth/api-client' +import type { TableColumn } from '@modrinth/ui' + +import { analyticsTableMessages, type FormatMessage } from '../analytics-messages' +import { isAnalyticsTableBreakdownColumnKey } from './analytics-table-columns' +import type { AnalyticsTableColumnKey, AnalyticsTableRow } from './analytics-table-types' + +export function buildAnalyticsTableCsvContent( + rows: AnalyticsTableRow[], + visibleColumns: TableColumn[], + formatMessage: FormatMessage, +): string { + const header = visibleColumns + .map((column) => + escapeAnalyticsTableCsvField(getAnalyticsTableCsvHeaderLabel(column, formatMessage)), + ) + .join(',') + + const csvRows = rows.map((row) => + visibleColumns + .map((column) => escapeAnalyticsTableCsvField(getAnalyticsTableCsvCellValue(row, column.key))) + .join(','), + ) + + return [header, ...csvRows].join('\n') +} + +export function downloadAnalyticsTableCsv(filename: string, csvContent: string) { + if (!import.meta.client) { + return + } + + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }) + const url = URL.createObjectURL(blob) + + const downloadLink = document.createElement('a') + downloadLink.setAttribute('href', url) + downloadLink.setAttribute('download', filename) + downloadLink.style.visibility = 'hidden' + + document.body.appendChild(downloadLink) + downloadLink.click() + document.body.removeChild(downloadLink) + + URL.revokeObjectURL(url) +} + +export function getAnalyticsTableCsvFilename( + breakdownColumnLabel: string, + fetchRequest: Labrinth.Analytics.v3.FetchRequest | null, + formatMessage: FormatMessage, +): string { + return `${sanitizeAnalyticsTableCsvFilename( + formatMessage(analyticsTableMessages.csvFilename, { + breakdown: breakdownColumnLabel, + dateRange: getAnalyticsTableCsvFilenameDateRange(fetchRequest, formatMessage), + }), + )}.csv` +} + +function getAnalyticsTableCsvCellValue( + row: AnalyticsTableRow, + key: AnalyticsTableColumnKey, +): string | number { + switch (key) { + case 'date': + return row.date + case 'project': + return row.project + case 'breakdown': + return row.breakdownDisplay + case 'views': + return row.views + case 'downloads': + return row.downloads + case 'revenue': + return row.revenue + case 'playtime': + return row.playtime + default: + return isAnalyticsTableBreakdownColumnKey(key) ? String(row[key] ?? '') : '' + } +} + +function getAnalyticsTableCsvHeaderLabel( + column: TableColumn, + formatMessage: FormatMessage, +): string { + if (column.key === 'playtime') { + return formatMessage(analyticsTableMessages.playtimeSecondsHeader) + } + + return column.label ?? column.key +} + +function escapeAnalyticsTableCsvField(value: string | number): string { + const stringValue = String(value) + if ( + stringValue.includes(',') || + stringValue.includes('"') || + stringValue.includes('\n') || + stringValue.includes('\r') + ) { + return `"${stringValue.replace(/"/g, '""')}"` + } + return stringValue +} + +function formatAnalyticsTableCsvFilenameDate(date: Date): string { + return date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }) +} + +function getAnalyticsTableCsvFilenameDateRange( + fetchRequest: Labrinth.Analytics.v3.FetchRequest | null, + formatMessage: FormatMessage, +): string { + const timeRange = fetchRequest?.time_range + if (!timeRange) { + return formatMessage(analyticsTableMessages.csvSelectedRange) + } + + const start = new Date(timeRange.start) + const end = new Date(timeRange.end) + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { + return formatMessage(analyticsTableMessages.csvSelectedRange) + } + + const startLabel = formatAnalyticsTableCsvFilenameDate(start) + const endLabel = formatAnalyticsTableCsvFilenameDate(end) + return startLabel === endLabel + ? startLabel + : formatMessage(analyticsTableMessages.csvDateRange, { + start: startLabel, + end: endLabel, + }) +} + +function sanitizeAnalyticsTableCsvFilename(value: string): string { + return value + .replace(/[<>:"/\\|?*]/g, '') + .replace(/\s+/g, ' ') + .trim() +} diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-formatting.ts b/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-formatting.ts new file mode 100644 index 000000000..e233ef544 --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-formatting.ts @@ -0,0 +1,65 @@ +import type { AnalyticsGroupByPreset } from '~/providers/analytics/analytics' + +import { + analyticsStatCardMessages, + analyticsTableMessages, + formatAnalyticsGroupByLabel, + type FormatMessage, +} from '../analytics-messages' + +const SECONDS_PER_MINUTE = 60 +const SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE + +export function getAnalyticsTableGroupByLabel( + groupBy: AnalyticsGroupByPreset, + formatMessage: FormatMessage, +): string { + return formatAnalyticsGroupByLabel(groupBy, formatMessage) +} + +export function formatAnalyticsTableInteger( + formatNumber: (value: number) => string, + value: number, +): string { + return formatNumber(Math.round(value)) +} + +export function formatAnalyticsTableRevenue( + formatter: Intl.NumberFormat, + value: number, + formatMessage: FormatMessage, +): string { + const rounded = Math.round(value * 100) / 100 + return formatMessage(analyticsStatCardMessages.revenueValue, { + value: formatter.format(rounded), + }) +} + +export function formatAnalyticsTableCompactPlaytime( + value: number, + formatMessage: FormatMessage, +): string { + const totalSeconds = Math.max(0, Math.round(value)) + return formatMessage(analyticsStatCardMessages.playtimeHours, { + hours: (totalSeconds / SECONDS_PER_HOUR).toLocaleString(undefined, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }), + }) +} + +export function formatAnalyticsTableFullPlaytime( + value: number, + formatMessage: FormatMessage, +): string { + const totalMinutes = Math.max(0, Math.round(value / SECONDS_PER_MINUTE)) + const days = Math.floor(totalMinutes / (24 * 60)) + const hours = Math.floor((totalMinutes % (24 * 60)) / 60) + const minutes = totalMinutes % 60 + + return [ + formatMessage(analyticsTableMessages.durationDays, { count: days }), + formatMessage(analyticsTableMessages.durationHours, { count: hours }), + formatMessage(analyticsTableMessages.durationMinutes, { count: minutes }), + ].join(', ') +} diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-row-builder.ts b/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-row-builder.ts new file mode 100644 index 000000000..714506a0c --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-row-builder.ts @@ -0,0 +1,286 @@ +import type { Labrinth } from '@modrinth/api-client' + +import type { + AnalyticsBreakdownPreset, + AnalyticsDashboardStat, +} from '~/providers/analytics/analytics' + +import { + formatBreakdownLabel, + formatBucketEndLabel, + getSliceBucketRange, + getSliceCount, +} from '../analytics-chart/analytics-chart-utils' +import type { FormatMessage } from '../analytics-messages' +import { + ALL_BREAKDOWN_VALUE, + COMBINED_BREAKDOWN_LABEL_SEPARATOR, + getAnalyticsBreakdownDatasetId, + getAnalyticsBreakdownKey, + getAnalyticsBreakdownValues, +} from '../breakdown' +import { getAnalyticsTableBreakdownColumnKey } from './analytics-table-columns' +import type { + AnalyticsTableBreakdownDisplayValues, + AnalyticsTableBreakdownPreset, + AnalyticsTableMode, + AnalyticsTableRow, +} from './analytics-table-types' + +const ALL_PROJECTS_BREAKDOWN_VALUE = 'all' + +type BuildAnalyticsTableRowsOptions = { + mode: AnalyticsTableMode + fetchRequest: Labrinth.Analytics.v3.FetchRequest | null + timeSlices: Labrinth.Analytics.v3.TimeSlice[] + selectedBreakdowns: readonly AnalyticsTableBreakdownPreset[] + selectedProjectIds: ReadonlySet + relevantStats: ReadonlySet + projectNamesById: ReadonlyMap + getVersionDisplayName: (versionId: string) => string + getVersionProjectName: (versionId: string) => string | undefined + showTimeInBucketLabel: boolean + showYearInBucketLabel: boolean + formatMessage: FormatMessage +} + +export function buildAnalyticsTableRows({ + mode, + fetchRequest, + timeSlices, + selectedBreakdowns, + selectedProjectIds, + relevantStats, + projectNamesById, + getVersionDisplayName, + getVersionProjectName, + showTimeInBucketLabel, + showYearInBucketLabel, + formatMessage, +}: BuildAnalyticsTableRowsOptions): AnalyticsTableRow[] { + if (!fetchRequest || selectedProjectIds.size === 0) { + return [] + } + + const timeRange = fetchRequest.time_range + const sliceCount = getSliceCount(timeRange, timeSlices.length) + const includeDate = mode === 'date_breakdown' + const breakdownDisplayValues = new Map() + const projectDisplayValues = new Map() + const nextRows = new Map() + const bucketLabelsBySliceIndex = new Map() + + function getBreakdownDisplayValue( + breakdownValue: string, + breakdown: AnalyticsTableBreakdownPreset, + ) { + const key = `${breakdown}:${breakdownValue}` + let displayValue = breakdownDisplayValues.get(key) + if (displayValue === undefined) { + displayValue = formatAnalyticsTableBreakdownDisplayValue( + breakdownValue, + breakdown, + projectNamesById, + getVersionDisplayName, + formatMessage, + ) + breakdownDisplayValues.set(key, displayValue) + } + return displayValue + } + + function getProjectDisplayValueForBreakdownValues(breakdownValues: readonly string[]) { + const versionBreakdownIndex = selectedBreakdowns.indexOf('version_id') + if (versionBreakdownIndex === -1 || selectedBreakdowns.includes('project')) { + return '' + } + + const versionId = breakdownValues[versionBreakdownIndex] + if (!versionId) { + return '' + } + + let displayValue = projectDisplayValues.get(versionId) + if (displayValue === undefined) { + displayValue = getVersionProjectName(versionId) ?? '' + projectDisplayValues.set(versionId, displayValue) + } + return displayValue + } + + function getBreakdownDisplays(breakdownValues: readonly string[]) { + const displays: AnalyticsTableBreakdownDisplayValues = {} + + selectedBreakdowns.forEach((breakdown, index) => { + displays[breakdown] = getBreakdownDisplayValue(breakdownValues[index] ?? '', breakdown) + }) + + return displays + } + + function getCombinedBreakdownDisplay(displays: AnalyticsTableBreakdownDisplayValues) { + return selectedBreakdowns + .map((breakdown) => displays[breakdown]) + .filter((displayValue): displayValue is string => Boolean(displayValue)) + .join(COMBINED_BREAKDOWN_LABEL_SEPARATOR) + } + + function getBucketLabel(sliceIndex: number) { + let bucketLabel = bucketLabelsBySliceIndex.get(sliceIndex) + if (!bucketLabel) { + const bucketRange = getSliceBucketRange(timeRange, sliceCount, sliceIndex) + bucketLabel = { + date: formatBucketEndLabel(bucketRange.end, showTimeInBucketLabel, showYearInBucketLabel), + dateMs: bucketRange.end.getTime(), + } + bucketLabelsBySliceIndex.set(sliceIndex, bucketLabel) + } + return bucketLabel + } + + function createRow( + rowId: string, + breakdownValues: readonly string[], + bucketLabel?: { date: string; dateMs: number }, + ) { + const breakdownKey = + breakdownValues.length === 0 + ? ALL_PROJECTS_BREAKDOWN_VALUE + : getAnalyticsBreakdownKey(breakdownValues) + const breakdownDisplays = getBreakdownDisplays(breakdownValues) + const row: AnalyticsTableRow = { + id: rowId, + date: bucketLabel?.date ?? '', + dateMs: bucketLabel?.dateMs ?? 0, + project: getProjectDisplayValueForBreakdownValues(breakdownValues), + breakdown: breakdownKey, + breakdownValues: Object.fromEntries( + selectedBreakdowns.map((breakdown, index) => [breakdown, breakdownValues[index] ?? '']), + ) as AnalyticsTableBreakdownDisplayValues, + breakdownDisplays, + graphDatasetId: getAnalyticsTableGraphDatasetId(breakdownValues, selectedBreakdowns), + breakdownDisplay: getCombinedBreakdownDisplay(breakdownDisplays), + views: 0, + downloads: 0, + revenue: 0, + playtime: 0, + } + + for (const breakdown of selectedBreakdowns) { + row[getAnalyticsTableBreakdownColumnKey(breakdown)] = breakdownDisplays[breakdown] ?? '' + } + + nextRows.set(rowId, row) + return row + } + + if (!includeDate && selectedBreakdowns.length === 0) { + createRow(ALL_PROJECTS_BREAKDOWN_VALUE, []) + } + + if (!includeDate && selectedBreakdowns.length === 1 && selectedBreakdowns[0] === 'project') { + for (const projectId of selectedProjectIds) { + createRow(projectId, [projectId]) + } + } + + timeSlices.forEach((slice, sliceIndex) => { + const bucketLabel = includeDate ? getBucketLabel(sliceIndex) : undefined + + for (const point of slice) { + if (!isProjectAnalyticsPoint(point)) { + continue + } + + if (!selectedProjectIds.has(point.source_project)) { + continue + } + + const pointStat = getAnalyticsTableStatForMetric(point.metric_kind) + if (!pointStat || !relevantStats.has(pointStat)) { + continue + } + + const breakdownValues = + selectedBreakdowns.length === 0 + ? [] + : getAnalyticsBreakdownValues(point, selectedBreakdowns, formatMessage) + if (breakdownValues.some((breakdownValue) => breakdownValue === ALL_BREAKDOWN_VALUE)) { + continue + } + + const nextBucketLabel = includeDate ? (bucketLabel ?? getBucketLabel(sliceIndex)) : undefined + const breakdownKey = + breakdownValues.length === 0 + ? ALL_PROJECTS_BREAKDOWN_VALUE + : getAnalyticsBreakdownKey(breakdownValues) + const rowId = includeDate ? `${nextBucketLabel?.dateMs ?? 0}::${breakdownKey}` : breakdownKey + const row = nextRows.get(rowId) ?? createRow(rowId, breakdownValues, nextBucketLabel) + addAnalyticsMetricToTableRow(row, point) + } + }) + + return Array.from(nextRows.values()) +} + +function isProjectAnalyticsPoint( + point: Labrinth.Analytics.v3.AnalyticsData, +): point is Labrinth.Analytics.v3.ProjectAnalytics { + return 'source_project' in point +} + +function addAnalyticsMetricToTableRow( + row: AnalyticsTableRow, + point: Labrinth.Analytics.v3.ProjectAnalytics, +) { + switch (point.metric_kind) { + case 'views': + row.views += point.views + break + case 'downloads': + row.downloads += point.downloads + break + case 'playtime': + row.playtime += point.seconds + break + case 'revenue': { + const parsed = Number.parseFloat(point.revenue) + row.revenue += Number.isFinite(parsed) ? parsed : 0 + break + } + } +} + +function getAnalyticsTableStatForMetric( + metricKind: Labrinth.Analytics.v3.ProjectAnalytics['metric_kind'], +): AnalyticsDashboardStat | null { + switch (metricKind) { + case 'views': + case 'downloads': + case 'revenue': + case 'playtime': + return metricKind + default: + return null + } +} + +function getAnalyticsTableGraphDatasetId( + breakdownValues: readonly string[], + selectedBreakdowns: readonly AnalyticsBreakdownPreset[], +): string { + return getAnalyticsBreakdownDatasetId(breakdownValues, selectedBreakdowns) +} + +function formatAnalyticsTableBreakdownDisplayValue( + value: string, + breakdown: AnalyticsTableBreakdownPreset, + projectNamesById: ReadonlyMap, + getVersionDisplayName: (versionId: string) => string, + formatMessage: FormatMessage, +): string { + if (breakdown === 'project') { + return projectNamesById.get(value) ?? value + } + return formatBreakdownLabel(value, breakdown, getVersionDisplayName, formatMessage) +} diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-search-filtering.ts b/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-search-filtering.ts new file mode 100644 index 000000000..406c3c8c3 --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-search-filtering.ts @@ -0,0 +1,47 @@ +import type { TableColumn } from '@modrinth/ui' + +import { isAnalyticsTableBreakdownColumnKey } from './analytics-table-columns' +import type { AnalyticsTableColumnKey, AnalyticsTableRow } from './analytics-table-types' + +const SEARCHABLE_COLUMN_KEYS = new Set(['date', 'project']) + +export function getAnalyticsTableSearchableColumns( + columns: TableColumn[], +): TableColumn[] { + return columns.filter( + (column) => + SEARCHABLE_COLUMN_KEYS.has(column.key) || isAnalyticsTableBreakdownColumnKey(column.key), + ) +} + +export function filterAnalyticsTableRowsBySearch( + rows: AnalyticsTableRow[], + searchableColumns: TableColumn[], + query: string, +): AnalyticsTableRow[] { + if (!query || searchableColumns.length === 0) { + return rows + } + + return rows.filter((row) => + searchableColumns.some((column) => + String(getAnalyticsTableSearchableCellValue(row, column.key)).toLowerCase().includes(query), + ), + ) +} + +function getAnalyticsTableSearchableCellValue( + row: AnalyticsTableRow, + key: AnalyticsTableColumnKey, +): string { + switch (key) { + case 'date': + return row.date + case 'project': + return row.project + case 'breakdown': + return row.breakdownDisplay + default: + return isAnalyticsTableBreakdownColumnKey(key) ? String(row[key] ?? '') : '' + } +} diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-sort-route.ts b/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-sort-route.ts new file mode 100644 index 000000000..6ce0f9f2c --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-sort-route.ts @@ -0,0 +1,109 @@ +import type { TableColumn } from '@modrinth/ui' +import type { LocationQuery } from 'vue-router' + +import { + buildAnalyticsTableSortRouteQuery, + readAnalyticsTableSortState, +} from '~/components/analytics-dashboard/analytics-route-query' +import type { AnalyticsDashboardStat } from '~/providers/analytics/analytics' + +import { isAnalyticsTableBreakdownColumnKey } from './analytics-table-columns' +import { + getAnalyticsTableDefaultSortColumn, + getAnalyticsTableDefaultSortDirection, +} from './analytics-table-sorting' +import type { + AnalyticsTableColumnKey, + AnalyticsTableSortDirectionValue, + AnalyticsTableSortState, +} from './analytics-table-types' + +type GetDefaultAnalyticsTableSortStateOptions = { + columns: TableColumn[] + showGraphDatasetSelection: boolean + activeStat: AnalyticsDashboardStat +} + +export function getRouteAnalyticsTableSortState( + query: LocationQuery, + columns: TableColumn[], + defaultSortOptions: GetDefaultAnalyticsTableSortStateOptions, +): AnalyticsTableSortState { + return getAvailableAnalyticsTableSortState( + readAnalyticsTableSortState(query, getDefaultAnalyticsTableSortState(defaultSortOptions)), + columns, + defaultSortOptions, + ) +} + +export function getAvailableAnalyticsTableSortState( + state: AnalyticsTableSortState, + columns: TableColumn[], + defaultSortOptions: GetDefaultAnalyticsTableSortStateOptions, +): AnalyticsTableSortState { + const availableColumns = new Set(columns.map((column) => column.key)) + if (state.sortColumn && availableColumns.has(state.sortColumn)) { + return state + } + if (state.sortColumn === 'breakdown') { + const firstBreakdownColumn = columns.find((column) => + isAnalyticsTableBreakdownColumnKey(column.key), + ) + if (firstBreakdownColumn) { + return { + sortColumn: firstBreakdownColumn.key, + sortDirection: state.sortDirection, + } + } + } + + return getDefaultAnalyticsTableSortState(defaultSortOptions) +} + +export function getDefaultAnalyticsTableSortState({ + columns, + showGraphDatasetSelection, + activeStat, +}: GetDefaultAnalyticsTableSortStateOptions): AnalyticsTableSortState { + const nextSortColumn = getAnalyticsTableDefaultSortColumn( + columns, + showGraphDatasetSelection, + activeStat, + ) + return { + sortColumn: nextSortColumn, + sortDirection: getAnalyticsTableDefaultSortDirection(nextSortColumn, columns), + } +} + +export function areAnalyticsTableSortStatesEqual( + left: AnalyticsTableSortState, + right: AnalyticsTableSortState, +): boolean { + return left.sortColumn === right.sortColumn && left.sortDirection === right.sortDirection +} + +export function buildSyncedAnalyticsTableSortRouteQuery( + query: LocationQuery, + sortState: AnalyticsTableSortState, + columns: TableColumn[], + defaultSortOptions: GetDefaultAnalyticsTableSortStateOptions, +) { + const nextSortState = getAvailableAnalyticsTableSortState(sortState, columns, defaultSortOptions) + + return buildAnalyticsTableSortRouteQuery( + query, + nextSortState, + getDefaultAnalyticsTableSortState(defaultSortOptions), + ) +} + +export function toAnalyticsTableSortState( + sortColumn: AnalyticsTableColumnKey | undefined, + sortDirection: AnalyticsTableSortDirectionValue, +): AnalyticsTableSortState { + return { + sortColumn, + sortDirection, + } +} diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-sorting.ts b/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-sorting.ts new file mode 100644 index 000000000..d55f8ef26 --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-sorting.ts @@ -0,0 +1,210 @@ +import type { TableColumn } from '@modrinth/ui' + +import type { AnalyticsDashboardStat } from '~/providers/analytics/analytics' + +import { isAnalyticsTableBreakdownColumnKey } from './analytics-table-columns' +import type { + AnalyticsTableColumnKey, + AnalyticsTableRow, + AnalyticsTableSortDirectionValue, +} from './analytics-table-types' + +export function sortAnalyticsTableRows( + rows: AnalyticsTableRow[], + sortColumn: AnalyticsTableColumnKey | undefined, + sortDirection: AnalyticsTableSortDirectionValue, + sortCollator: Intl.Collator, +): AnalyticsTableRow[] { + const nextRows = [...rows] + + if (!sortColumn) { + return nextRows + } + + const directionFactor = sortDirection === 'asc' ? 1 : -1 + nextRows.sort(getAnalyticsTableRowComparator(sortColumn, directionFactor, sortCollator)) + + return nextRows +} + +export function getAnalyticsTableDefaultSortColumn( + nextColumns: TableColumn[], + showGraphDatasetSelection: boolean, + activeStat: AnalyticsDashboardStat, +): AnalyticsTableColumnKey | undefined { + const availableColumns = new Set(nextColumns.map((column) => column.key)) + if (availableColumns.has('date')) { + return 'date' + } + + if (showGraphDatasetSelection && availableColumns.has(activeStat)) { + return activeStat + } + + if (availableColumns.has('downloads')) { + return 'downloads' + } + + return nextColumns[0]?.key +} + +export function getAnalyticsTableDefaultSortDirection( + column: AnalyticsTableColumnKey | undefined, + nextColumns: TableColumn[], +): AnalyticsTableSortDirectionValue { + return nextColumns.find((nextColumn) => nextColumn.key === column)?.defaultSortDirection ?? 'asc' +} + +export function getAnalyticsTableMetricSortedGraphDatasetIds( + rows: AnalyticsTableRow[], + sortColumn: AnalyticsTableColumnKey | undefined, + sortCollator: Intl.Collator, +): string[] { + const metricColumn = getAnalyticsTableMetricSortColumn(sortColumn) + if (!metricColumn) { + return [] + } + + const totalsByGraphDatasetId = new Map() + const labelsByGraphDatasetId = new Map() + for (const row of rows) { + totalsByGraphDatasetId.set( + row.graphDatasetId, + (totalsByGraphDatasetId.get(row.graphDatasetId) ?? 0) + row[metricColumn], + ) + if (!labelsByGraphDatasetId.has(row.graphDatasetId)) { + labelsByGraphDatasetId.set(row.graphDatasetId, row.breakdownDisplay) + } + } + + return Array.from(totalsByGraphDatasetId.keys()).sort((left, right) => { + const totalDifference = + (totalsByGraphDatasetId.get(right) ?? 0) - (totalsByGraphDatasetId.get(left) ?? 0) + return ( + totalDifference || + sortCollator.compare( + labelsByGraphDatasetId.get(left) ?? left, + labelsByGraphDatasetId.get(right) ?? right, + ) || + left.localeCompare(right) + ) + }) +} + +export function getAnalyticsTableMetricSortColumn( + column: AnalyticsTableColumnKey | undefined, +): AnalyticsDashboardStat | null { + switch (column) { + case 'views': + case 'downloads': + case 'revenue': + case 'playtime': + return column + default: + return null + } +} + +function getAnalyticsTableRowComparator( + column: AnalyticsTableColumnKey, + directionFactor: number, + sortCollator: Intl.Collator, +): (left: AnalyticsTableRow, right: AnalyticsTableRow) => number { + switch (column) { + case 'date': + return (left, right) => + compareAnalyticsTableRows( + left, + right, + left.dateMs - right.dateMs, + directionFactor, + sortCollator, + ) + case 'project': + return (left, right) => + compareAnalyticsTableRows( + left, + right, + sortCollator.compare(left.project, right.project), + directionFactor, + sortCollator, + ) + case 'breakdown': + return (left, right) => + compareAnalyticsTableRows( + left, + right, + sortCollator.compare(left.breakdownDisplay, right.breakdownDisplay), + directionFactor, + sortCollator, + ) + case 'views': + return (left, right) => + compareAnalyticsTableRows( + left, + right, + left.views - right.views, + directionFactor, + sortCollator, + ) + case 'downloads': + return (left, right) => + compareAnalyticsTableRows( + left, + right, + left.downloads - right.downloads, + directionFactor, + sortCollator, + ) + case 'revenue': + return (left, right) => + compareAnalyticsTableRows( + left, + right, + left.revenue - right.revenue, + directionFactor, + sortCollator, + ) + case 'playtime': + return (left, right) => + compareAnalyticsTableRows( + left, + right, + left.playtime - right.playtime, + directionFactor, + sortCollator, + ) + default: + if (isAnalyticsTableBreakdownColumnKey(column)) { + return (left, right) => + compareAnalyticsTableRows( + left, + right, + sortCollator.compare(String(left[column] ?? ''), String(right[column] ?? '')), + directionFactor, + sortCollator, + ) + } + + return () => 0 + } +} + +function compareAnalyticsTableRows( + left: AnalyticsTableRow, + right: AnalyticsTableRow, + primaryResult: number, + directionFactor: number, + sortCollator: Intl.Collator, +): number { + if (primaryResult !== 0) { + return primaryResult * directionFactor + } + + const dateResult = left.dateMs - right.dateMs + if (dateResult !== 0) { + return dateResult * directionFactor + } + + return sortCollator.compare(left.breakdown, right.breakdown) * directionFactor +} diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-types.ts b/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-types.ts new file mode 100644 index 000000000..da343e723 --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-types.ts @@ -0,0 +1,43 @@ +import type { + AnalyticsBreakdownPreset, + AnalyticsTableSortColumn, + AnalyticsTableSortDirection, +} from '~/providers/analytics/analytics' + +export type AnalyticsTableMode = 'date_breakdown' | 'breakdown_only' +export type AnalyticsTableBreakdownPreset = Exclude +export type AnalyticsTableBreakdownColumnKey = `breakdown_${AnalyticsTableBreakdownPreset}` +export type AnalyticsTableBreakdownDisplayValues = Partial< + Record +> +export type AnalyticsTableColumnKey = AnalyticsTableSortColumn +export type AnalyticsTableSortState = { + sortColumn: AnalyticsTableColumnKey | undefined + sortDirection: AnalyticsTableSortDirection +} +export type AnalyticsTableSortDirectionValue = AnalyticsTableSortDirection + +export type AnalyticsTableRow = { + [key: string]: string | number | AnalyticsTableBreakdownDisplayValues + id: string + date: string + dateMs: number + project: string + breakdown: string + breakdownValues: AnalyticsTableBreakdownDisplayValues + breakdownDisplays: AnalyticsTableBreakdownDisplayValues + graphDatasetId: string + breakdownDisplay: string + views: number + downloads: number + revenue: number + playtime: number +} + +export type AnalyticsTableDisplayedRowsCache = { + generation: number + mode: AnalyticsTableMode + sortColumn: AnalyticsTableColumnKey | undefined + sortDirection: AnalyticsTableSortDirectionValue + rows: AnalyticsTableRow[] +} diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-table/index.vue b/apps/frontend/src/components/analytics-dashboard/analytics-table/index.vue new file mode 100644 index 000000000..ac0dcdb1f --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-table/index.vue @@ -0,0 +1,655 @@ + + + diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-table/use-analytics-table-graph-selection.ts b/apps/frontend/src/components/analytics-dashboard/analytics-table/use-analytics-table-graph-selection.ts new file mode 100644 index 000000000..bfb609ddf --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-table/use-analytics-table-graph-selection.ts @@ -0,0 +1,191 @@ +import type { ComputedRef, Ref, WritableComputedRef } from 'vue' +import { computed, watch } from 'vue' + +import { areStringArraysEqual } from '~/components/analytics-dashboard/analytics-route-query' +import type { + AnalyticsDashboardStat, + AnalyticsSelectedBreakdowns, +} from '~/providers/analytics/analytics' + +import { getAnalyticsTableMetricSortedGraphDatasetIds } from './analytics-table-sorting' +import type { AnalyticsTableColumnKey, AnalyticsTableRow } from './analytics-table-types' + +type UseAnalyticsTableGraphSelectionOptions = { + sortedRows: ComputedRef + filteredRows: ComputedRef + sortColumn: Ref + showGraphDatasetSelection: ComputedRef + selectedGraphDatasetIds: Ref + hasExplicitGraphDatasetSelection: Ref + isGraphDatasetSelectionActive: Ref + defaultGraphDatasetIds: Ref + topGraphDatasetIds: Ref + queryResetToken: Ref + currentSelectedBreakdowns: Ref + currentSelectedProjectIds: Ref + activeStat: Ref + sortCollator: Intl.Collator + hasTableSortQuery: () => boolean + applyActiveStatSort: () => void + graphDatasetSelectionLimit: number +} + +export function useAnalyticsTableGraphSelection({ + sortedRows, + filteredRows, + sortColumn, + showGraphDatasetSelection, + selectedGraphDatasetIds, + hasExplicitGraphDatasetSelection, + isGraphDatasetSelectionActive, + defaultGraphDatasetIds, + topGraphDatasetIds, + queryResetToken, + currentSelectedBreakdowns, + currentSelectedProjectIds, + activeStat, + sortCollator, + hasTableSortQuery, + applyActiveStatSort, + graphDatasetSelectionLimit, +}: UseAnalyticsTableGraphSelectionOptions): { + filteredSelectableGraphDatasetIds: ComputedRef + tableSelectedGraphDatasetIds: WritableComputedRef +} { + const selectableGraphDatasetIds = computed(() => + getAnalyticsTableSelectableGraphDatasetIds(sortedRows.value), + ) + const filteredSelectableGraphDatasetIds = computed(() => + getAnalyticsTableSelectableGraphDatasetIds(filteredRows.value), + ) + const sortedMetricGraphDatasetIds = computed(() => + getAnalyticsTableMetricSortedGraphDatasetIds(sortedRows.value, sortColumn.value, sortCollator), + ) + const defaultSelectedGraphDatasetIds = computed(() => { + const sortedMetricIds = sortedMetricGraphDatasetIds.value + const defaultIds = + sortedMetricIds.length > 0 ? sortedMetricIds : selectableGraphDatasetIds.value + return defaultIds.slice(0, graphDatasetSelectionLimit) + }) + const tableSelectedGraphDatasetIds = computed({ + get: () => selectedGraphDatasetIds.value, + set: (ids) => { + const nextGraphDatasetIds = ids.filter((id): id is string => typeof id === 'string') + if (showGraphDatasetSelection.value && isDefaultGraphDatasetSelection(nextGraphDatasetIds)) { + setSelectedGraphDatasetIds(defaultSelectedGraphDatasetIds.value, false) + return + } + + selectedGraphDatasetIds.value = nextGraphDatasetIds + hasExplicitGraphDatasetSelection.value = showGraphDatasetSelection.value + }, + }) + + function setSelectedGraphDatasetIds(ids: string[], explicit: boolean) { + selectedGraphDatasetIds.value = ids + hasExplicitGraphDatasetSelection.value = explicit + } + + function resetGraphDatasetSelection() { + setSelectedGraphDatasetIds([], false) + } + + function isDefaultGraphDatasetSelection(ids: string[]) { + const defaultIds = defaultSelectedGraphDatasetIds.value + if (defaultIds.length === 0 || ids.length !== defaultIds.length) { + return false + } + + const selectedIdSet = new Set(ids) + return defaultIds.every((id) => selectedIdSet.has(id)) + } + + watch( + [showGraphDatasetSelection, queryResetToken], + ([nextShowSelection]) => { + isGraphDatasetSelectionActive.value = nextShowSelection + }, + { immediate: true }, + ) + + watch(activeStat, () => { + if (!showGraphDatasetSelection.value) { + return + } + if (hasTableSortQuery()) { + return + } + + applyActiveStatSort() + }) + + watch( + currentSelectedBreakdowns, + (nextBreakdowns, previousBreakdowns) => { + if (areStringArraysEqual([...nextBreakdowns], [...(previousBreakdowns ?? [])])) { + return + } + + resetGraphDatasetSelection() + }, + { deep: true }, + ) + + watch( + currentSelectedProjectIds, + (nextProjectIds, previousProjectIds) => { + if (areStringArraysEqual(nextProjectIds, previousProjectIds ?? [])) { + return + } + + resetGraphDatasetSelection() + }, + { deep: true }, + ) + + watch( + [defaultSelectedGraphDatasetIds, sortedMetricGraphDatasetIds, showGraphDatasetSelection], + ([nextDefaultGraphDatasetIds, nextTopGraphDatasetIds, nextShowGraphDatasetSelection]) => { + defaultGraphDatasetIds.value = nextShowGraphDatasetSelection + ? [...nextDefaultGraphDatasetIds] + : [] + topGraphDatasetIds.value = nextShowGraphDatasetSelection ? [...nextTopGraphDatasetIds] : [] + }, + { immediate: true }, + ) + + watch( + [ + defaultSelectedGraphDatasetIds, + showGraphDatasetSelection, + hasExplicitGraphDatasetSelection, + queryResetToken, + ], + ([nextDefaultGraphDatasetIds, nextShowGraphDatasetSelection, nextHasExplicitSelection]) => { + if (!nextShowGraphDatasetSelection) { + return + } + + if (nextHasExplicitSelection) { + if (isDefaultGraphDatasetSelection(selectedGraphDatasetIds.value)) { + setSelectedGraphDatasetIds(nextDefaultGraphDatasetIds, false) + } + return + } + + if (!areStringArraysEqual(selectedGraphDatasetIds.value, nextDefaultGraphDatasetIds)) { + setSelectedGraphDatasetIds(nextDefaultGraphDatasetIds, false) + } + }, + { immediate: true }, + ) + + function getAnalyticsTableSelectableGraphDatasetIds(rows: AnalyticsTableRow[]): string[] { + return Array.from(new Set(rows.map((row) => row.graphDatasetId))) + } + + return { + filteredSelectableGraphDatasetIds, + tableSelectedGraphDatasetIds, + } +} diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-table/use-analytics-table-pagination.ts b/apps/frontend/src/components/analytics-dashboard/analytics-table/use-analytics-table-pagination.ts new file mode 100644 index 000000000..6760ece33 --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-table/use-analytics-table-pagination.ts @@ -0,0 +1,56 @@ +import type { ComputedRef, Ref } from 'vue' +import { computed, ref, watch } from 'vue' + +import type { AnalyticsTableRow } from './analytics-table-types' + +type UseAnalyticsTablePaginationOptions = { + filteredRows: ComputedRef + pageSize: number +} + +export function useAnalyticsTablePagination({ + filteredRows, + pageSize, +}: UseAnalyticsTablePaginationOptions): { + currentPage: Ref + pageCount: ComputedRef + visibleRowStart: ComputedRef + visibleRowEnd: ComputedRef + paginatedRows: ComputedRef + switchPage: (page: number) => void +} { + const currentPage = ref(1) + const pageCount = computed(() => Math.max(Math.ceil(filteredRows.value.length / pageSize), 1)) + const visibleRowStart = computed(() => + filteredRows.value.length === 0 ? 0 : (currentPage.value - 1) * pageSize + 1, + ) + const visibleRowEnd = computed(() => + Math.min(currentPage.value * pageSize, filteredRows.value.length), + ) + const paginatedRows = computed(() => + filteredRows.value.slice((currentPage.value - 1) * pageSize, currentPage.value * pageSize), + ) + + watch(filteredRows, () => { + currentPage.value = 1 + }) + + watch(pageCount, (nextPageCount) => { + if (currentPage.value > nextPageCount) { + currentPage.value = nextPageCount + } + }) + + function switchPage(page: number) { + currentPage.value = page + } + + return { + currentPage, + pageCount, + visibleRowStart, + visibleRowEnd, + paginatedRows, + switchPage, + } +} diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-table/use-analytics-table-row-cache.ts b/apps/frontend/src/components/analytics-dashboard/analytics-table/use-analytics-table-row-cache.ts new file mode 100644 index 000000000..e947f1338 --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/analytics-table/use-analytics-table-row-cache.ts @@ -0,0 +1,230 @@ +import type { ComputedRef, Ref, ShallowRef } from 'vue' +import { ref, shallowRef } from 'vue' + +import type { + AnalyticsTableColumnKey, + AnalyticsTableDisplayedRowsCache, + AnalyticsTableMode, + AnalyticsTableRow, + AnalyticsTableSortDirectionValue, +} from './analytics-table-types' + +type UseAnalyticsTableRowCacheOptions = { + activeTableMode: ComputedRef + showBreakdownColumn: ComputedRef + analyticsPointCount: ComputedRef + sortColumn: Ref + sortDirection: Ref + buildRows: (mode: AnalyticsTableMode) => AnalyticsTableRow[] + sortRows: (rows: AnalyticsTableRow[]) => AnalyticsTableRow[] + inactiveModeWarmupPointLimit: number +} + +export function useAnalyticsTableRowCache({ + activeTableMode, + showBreakdownColumn, + analyticsPointCount, + sortColumn, + sortDirection, + buildRows, + sortRows, + inactiveModeWarmupPointLimit, +}: UseAnalyticsTableRowCacheOptions): { + displayedTableMode: Ref + displayedSortColumn: Ref + displayedSortDirection: Ref + displayedSortedRows: ShallowRef + invalidateTableCaches: () => void + invalidateSortedCaches: () => void + scheduleRowsForMode: (mode: AnalyticsTableMode) => void + scheduleInactiveModeWarmup: () => void + resortDisplayedRowsForCurrentSort: () => boolean + getSortedRowsForMode: (mode: AnalyticsTableMode) => AnalyticsTableRow[] +} { + const modeBuildRequestIds: Record = { + date_breakdown: 0, + breakdown_only: 0, + } + let tableCacheGeneration = 0 + let displayedSortedRowsGeneration = 0 + const displayedTableMode = ref('breakdown_only') + const displayedSortColumn = ref(sortColumn.value) + const displayedSortDirection = ref(sortDirection.value) + const displayedSortedRows = shallowRef([]) + const displayedRowsCache = shallowRef(null) + + function invalidateTableCaches() { + tableCacheGeneration++ + invalidateSortedCaches() + } + + function invalidateSortedCaches() { + displayedRowsCache.value = null + } + + function hasSortedRowsForMode(mode: AnalyticsTableMode): boolean { + const cached = displayedRowsCache.value + return ( + cached !== null && + cached.generation === tableCacheGeneration && + cached.mode === mode && + cached.sortColumn === sortColumn.value && + cached.sortDirection === sortDirection.value + ) + } + + function setDisplayedRowsForMode( + mode: AnalyticsTableMode, + rows: AnalyticsTableRow[], + generation = tableCacheGeneration, + ) { + displayedRowsCache.value = { + generation, + mode, + sortColumn: sortColumn.value, + sortDirection: sortDirection.value, + rows, + } + + if (mode === activeTableMode.value) { + displayedSortedRowsGeneration = generation + displayedTableMode.value = mode + displayedSortColumn.value = sortColumn.value + displayedSortDirection.value = sortDirection.value + displayedSortedRows.value = rows + } + } + + function scheduleRowsForMode(mode: AnalyticsTableMode) { + if (hasSortedRowsForMode(mode)) { + if (mode === activeTableMode.value) { + displayRowsForMode(mode) + } + return + } + + const requestId = ++modeBuildRequestIds[mode] + const generation = tableCacheGeneration + + void buildRowsForMode(mode, generation, requestId) + } + + function displayRowsForMode(mode: AnalyticsTableMode) { + const cached = displayedRowsCache.value + if (!cached || cached.generation !== tableCacheGeneration || cached.mode !== mode) { + return + } + + displayedSortedRowsGeneration = cached.generation + displayedTableMode.value = mode + displayedSortColumn.value = cached.sortColumn + displayedSortDirection.value = cached.sortDirection + displayedSortedRows.value = cached.rows + } + + async function buildRowsForMode(mode: AnalyticsTableMode, generation: number, requestId: number) { + await waitForDeferredTableWork() + + if (isStaleBuild(mode, generation, requestId)) { + return + } + + const rows = sortRows(buildRows(mode)) + + if (isStaleBuild(mode, generation, requestId)) { + return + } + + setDisplayedRowsForMode(mode, rows, generation) + } + + function isStaleBuild(mode: AnalyticsTableMode, generation: number, requestId: number): boolean { + return tableCacheGeneration !== generation || modeBuildRequestIds[mode] !== requestId + } + + function waitForDeferredTableWork(): Promise { + if (!import.meta.client) { + return Promise.resolve() + } + + return new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()) + }) + }) + } + + function scheduleInactiveModeWarmup() { + if (!showBreakdownColumn.value) { + return + } + if (analyticsPointCount.value > inactiveModeWarmupPointLimit) { + return + } + + const inactiveMode: AnalyticsTableMode = + activeTableMode.value === 'date_breakdown' ? 'breakdown_only' : 'date_breakdown' + + if (hasSortedRowsForMode(inactiveMode)) { + return + } + + if (!import.meta.client) { + scheduleRowsForMode(inactiveMode) + return + } + + const windowWithIdleCallback = window as Window & { + requestIdleCallback?: (callback: () => void, options?: { timeout?: number }) => number + } + + if (windowWithIdleCallback.requestIdleCallback) { + windowWithIdleCallback.requestIdleCallback(() => scheduleRowsForMode(inactiveMode), { + timeout: 2000, + }) + } else { + window.setTimeout(() => scheduleRowsForMode(inactiveMode), 250) + } + } + + function resortDisplayedRowsForCurrentSort(): boolean { + const mode = activeTableMode.value + if ( + displayedTableMode.value !== mode || + displayedSortedRowsGeneration !== tableCacheGeneration + ) { + return false + } + + setDisplayedRowsForMode(mode, sortRows(displayedSortedRows.value)) + return true + } + + function getSortedRowsForMode(mode: AnalyticsTableMode): AnalyticsTableRow[] { + const cached = displayedRowsCache.value + if ( + cached && + cached.generation === tableCacheGeneration && + cached.mode === mode && + cached.sortColumn === sortColumn.value && + cached.sortDirection === sortDirection.value + ) { + return cached.rows + } + + return sortRows(buildRows(mode)) + } + + return { + displayedTableMode, + displayedSortColumn, + displayedSortDirection, + displayedSortedRows, + invalidateTableCaches, + invalidateSortedCaches, + scheduleRowsForMode, + scheduleInactiveModeWarmup, + resortDisplayedRowsForCurrentSort, + getSortedRowsForMode, + } +} diff --git a/apps/frontend/src/components/analytics-dashboard/breakdown.ts b/apps/frontend/src/components/analytics-dashboard/breakdown.ts new file mode 100644 index 000000000..a648671c9 --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/breakdown.ts @@ -0,0 +1,102 @@ +import type { Labrinth } from '@modrinth/api-client' + +import type { AnalyticsBreakdownPreset } from '~/providers/analytics/analytics' + +import { formatAnalyticsDownloadSourceLabel, type FormatMessage } from './analytics-messages' + +export const ALL_BREAKDOWN_VALUE = '__all__' +export const UNKNOWN_BREAKDOWN_VALUE = '__unknown__' +export const COMBINED_BREAKDOWN_LABEL_SEPARATOR = ' + ' +export const COMBINED_BREAKDOWN_DATASET_ID_PREFIX = 'breakdowns:' + +export function getAnalyticsBreakdownValue( + point: Labrinth.Analytics.v3.ProjectAnalytics, + selectedBreakdown: AnalyticsBreakdownPreset, + formatMessage: FormatMessage, +): string { + switch (selectedBreakdown) { + case 'none': + return ALL_BREAKDOWN_VALUE + case 'project': + return normalizeBreakdownValue('source_project' in point ? point.source_project : undefined) + 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 'user_agent': { + const downloadSource = normalizeBreakdownValue( + 'user_agent' in point ? point.user_agent : undefined, + ) + return downloadSource === ALL_BREAKDOWN_VALUE + ? ALL_BREAKDOWN_VALUE + : getDownloadSourceLabel(downloadSource, formatMessage) + } + 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 + } +} + +export function getAnalyticsBreakdownValues( + point: Labrinth.Analytics.v3.ProjectAnalytics, + selectedBreakdowns: readonly AnalyticsBreakdownPreset[], + formatMessage: FormatMessage, +): string[] { + return selectedBreakdowns + .filter((breakdown) => breakdown !== 'none') + .map((breakdown) => getAnalyticsBreakdownValue(point, breakdown, formatMessage)) +} + +export function getAnalyticsBreakdownKey(values: readonly string[]): string { + return values.map((value) => encodeURIComponent(value)).join('+') +} + +export function getAnalyticsBreakdownDatasetId( + values: readonly string[], + selectedBreakdowns: readonly AnalyticsBreakdownPreset[], +): string { + const normalizedBreakdowns = selectedBreakdowns.filter((breakdown) => breakdown !== 'none') + if (normalizedBreakdowns.length === 0) { + return 'all' + } + if (normalizedBreakdowns.length === 1) { + if (normalizedBreakdowns[0] === 'project') { + return values[0] ?? 'all' + } + return `breakdown:${values[0] ?? 'all'}` + } + + return `${COMBINED_BREAKDOWN_DATASET_ID_PREFIX}${getAnalyticsBreakdownKey(values)}` +} + +export function getDownloadSourceLabel(value: string, formatMessage: FormatMessage): string { + return formatAnalyticsDownloadSourceLabel(value, formatMessage) +} + +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-dashboard/index.vue b/apps/frontend/src/components/analytics-dashboard/index.vue new file mode 100644 index 000000000..6dea6eab4 --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/index.vue @@ -0,0 +1,74 @@ + + + diff --git a/apps/frontend/src/components/analytics-dashboard/query-builder/DownloadsThresholdInput.vue b/apps/frontend/src/components/analytics-dashboard/query-builder/DownloadsThresholdInput.vue new file mode 100644 index 000000000..6bc95dc67 --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/query-builder/DownloadsThresholdInput.vue @@ -0,0 +1,150 @@ + + + diff --git a/apps/frontend/src/components/analytics-dashboard/query-builder/QueryFilter.vue b/apps/frontend/src/components/analytics-dashboard/query-builder/QueryFilter.vue new file mode 100644 index 000000000..de635ed3c --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/query-builder/QueryFilter.vue @@ -0,0 +1,944 @@ + + + diff --git a/apps/frontend/src/components/analytics-dashboard/query-builder/TimeframePicker.vue b/apps/frontend/src/components/analytics-dashboard/query-builder/TimeframePicker.vue new file mode 100644 index 000000000..97ae9cacb --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/query-builder/TimeframePicker.vue @@ -0,0 +1,131 @@ + + + diff --git a/apps/frontend/src/components/analytics-dashboard/query-builder/index.vue b/apps/frontend/src/components/analytics-dashboard/query-builder/index.vue new file mode 100644 index 000000000..1a4dd6e45 --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/query-builder/index.vue @@ -0,0 +1,1229 @@ + + + + + diff --git a/apps/frontend/src/components/analytics-dashboard/query-builder/query-filter.ts b/apps/frontend/src/components/analytics-dashboard/query-builder/query-filter.ts new file mode 100644 index 000000000..a73a53a4b --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/query-builder/query-filter.ts @@ -0,0 +1,496 @@ +import type { + AnalyticsBreakdownPreset, + AnalyticsDashboardStat, + AnalyticsQueryFilterCategory, + AnalyticsSelectedFilters, +} from '~/providers/analytics/analytics' + +export type AnalyticsDashboardDimension = + | 'project' + | 'project_status' + | 'version_id' + | 'country' + | 'monetization' + | 'user_agent' + | 'download_reason' + | 'game_version' + | 'loader_type' + +export const ALL_FILTER_VALUE = '__all__' +export const FILTER_VALUE_CATEGORIES: Exclude[] = [ + 'project_status', + 'country', + 'monetization', + 'user_agent', + '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'], + user_agent: ['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', + project: 'project', + country: 'country', + monetization: 'monetization', + user_agent: 'user_agent', + 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', + user_agent: 'user_agent', + 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, + project: null, + country: 'country', + monetization: 'monetization', + user_agent: 'user_agent', + download_reason: 'download_reason', + version_id: 'version_id', + loader: 'loader_type', + game_version: 'game_version', +} + +export type FilterOption = { + value: string + label: string + searchTerms?: string[] +} + +export type ProjectVersionFilterOption = FilterOption + +export type ProjectVersionFilterOptionProjectMetadata = { + name: string + iconUrl?: string +} + +type AnalyticsBreakdownInput = AnalyticsBreakdownPreset | readonly AnalyticsBreakdownPreset[] + +function getOptionalDateTimestamp(date: string | undefined): number | undefined { + if (!date) { + return undefined + } + + const timestamp = new Date(date).getTime() + return Number.isFinite(timestamp) ? timestamp : undefined +} + +export function getProjectVersionFilterOptionsCacheKey( + versionIds: string[], + versionNumbersById: Map, + versionPublishedDatesById: Map, + versionProjectNamesById: Map, +): string { + return versionIds + .map( + (versionId) => + `${versionId}\x1f${versionNumbersById.get(versionId) ?? ''}\x1f${ + versionPublishedDatesById.get(versionId) ?? '' + }\x1f${versionProjectNamesById.get(versionId) ?? ''}`, + ) + .join('\x1e') +} + +export function getProjectVersionFilterOptionProjectMetadataCacheKey( + versionIds: string[], + versionProjectNamesById: Map, + versionProjectIconUrlsById: Map, +): string { + return versionIds + .map( + (versionId) => + `${versionId}\x1f${versionProjectNamesById.get(versionId) ?? ''}\x1f${ + versionProjectIconUrlsById.get(versionId) ?? '' + }`, + ) + .join('\x1e') +} + +export function getProjectVersionFilterOptionMetadataIds( + versionIds: string[], + selectedVersionIds: string[], +): string[] { + const knownVersionIds = new Set(versionIds) + const metadataIds = [...versionIds] + + for (const versionId of selectedVersionIds) { + if (!knownVersionIds.has(versionId)) { + metadataIds.push(versionId) + knownVersionIds.add(versionId) + } + } + + return metadataIds +} + +export function buildProjectVersionFilterOptions( + versionIds: string[], + versionNumbersById: Map, + versionPublishedDatesById: Map, + versionProjectNamesById: Map, +): ProjectVersionFilterOption[] { + return versionIds + .map((versionId) => { + const projectName = versionProjectNamesById.get(versionId) + return { + option: { + value: versionId, + label: versionNumbersById.get(versionId) ?? versionId, + searchTerms: projectName ? [versionId, projectName] : [versionId], + }, + publishedTimestamp: getOptionalDateTimestamp(versionPublishedDatesById.get(versionId)), + } + }) + .sort((left, right) => { + if (left.publishedTimestamp !== undefined && right.publishedTimestamp !== undefined) { + return right.publishedTimestamp - left.publishedTimestamp + } + if (left.publishedTimestamp !== undefined) { + return -1 + } + if (right.publishedTimestamp !== undefined) { + return 1 + } + + return left.option.label.localeCompare(right.option.label) + }) + .map(({ option }) => option) +} + +export function buildProjectVersionFilterOptionProjectMetadataById( + versionIds: string[], + versionProjectNamesById: Map, + versionProjectIconUrlsById: Map, +): Map { + const metadataById = new Map() + + for (const versionId of versionIds) { + const projectName = versionProjectNamesById.get(versionId) + if (!projectName) { + continue + } + + const metadata: ProjectVersionFilterOptionProjectMetadata = { name: projectName } + const iconUrl = versionProjectIconUrlsById.get(versionId) + if (iconUrl) { + metadata.iconUrl = iconUrl + } + + metadataById.set(versionId, [metadata]) + } + + return metadataById +} + +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]) +} + +function normalizeAnalyticsBreakdowns( + breakdowns: AnalyticsBreakdownInput, +): Exclude[] { + const values = Array.isArray(breakdowns) ? breakdowns : [breakdowns] + const normalizedBreakdowns: Exclude[] = [] + + for (const breakdown of values) { + if (breakdown === 'none') { + continue + } + if (!normalizedBreakdowns.includes(breakdown)) { + normalizedBreakdowns.push(breakdown) + } + } + + return normalizedBreakdowns +} + +export function getAnalyticsStatsForBreakdowns( + breakdowns: AnalyticsBreakdownInput, +): readonly AnalyticsDashboardStat[] { + const normalizedBreakdowns = normalizeAnalyticsBreakdowns(breakdowns) + if (normalizedBreakdowns.length === 0) { + return getAnalyticsStatsForBreakdown('none') + } + + let stats = [...getAnalyticsStatsForBreakdown(normalizedBreakdowns[0])] + for (const breakdown of normalizedBreakdowns.slice(1)) { + stats = intersectAnalyticsStats(stats, getAnalyticsStatsForBreakdown(breakdown)) + } + + return stats +} + +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( + breakdowns: AnalyticsBreakdownInput, + filters: AnalyticsSelectedFilters, + ignoredCategory?: AnalyticsQueryFilterCategory, +): readonly AnalyticsDashboardStat[] { + let stats = [...getAnalyticsStatsForBreakdowns(breakdowns)] + + 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( + breakdowns: AnalyticsBreakdownInput, + filters: AnalyticsSelectedFilters, +): readonly AnalyticsDashboardStat[] { + return getAnalyticsStatsForFilterScope(breakdowns, filters) +} + +export function getVisibleAnalyticsFilterCategoriesForState( + breakdowns: AnalyticsBreakdownInput, + filters: AnalyticsSelectedFilters, +): readonly Exclude[] { + return FILTER_VALUE_CATEGORIES.filter((category) => + haveAnalyticsStatOverlap( + getAnalyticsStatsForFilterScope(breakdowns, filters, category), + getAnalyticsStatsForFilterCategory(category), + ), + ) +} + +export function sanitizeAnalyticsSelectedFilters( + breakdowns: AnalyticsBreakdownInput, + filters: AnalyticsSelectedFilters, +): AnalyticsSelectedFilters { + const nextFilters = cloneSelectedFilters(filters) + let availableStats = [...getAnalyticsStatsForBreakdowns(breakdowns)] + + 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], + user_agent: [...filters.user_agent], + 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[] { + if (selectedValues.length === 0) { + return options + } + + const knownValues = new Set(options.map((option) => option.value)) + const missingSelectedOptions = selectedValues + .filter((value) => !knownValues.has(value)) + .map((value) => ({ + value, + label: getMissingSelectedOptionLabel(value), + })) + + return missingSelectedOptions.length === 0 ? options : [...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-dashboard/query-builder/timeframe.ts b/apps/frontend/src/components/analytics-dashboard/query-builder/timeframe.ts new file mode 100644 index 000000000..68d39012e --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/query-builder/timeframe.ts @@ -0,0 +1,299 @@ +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 const MAX_ANALYTICS_TIME_SLICES = 256 + +const GROUP_BY_PRESET_MINUTES: Record = { + '1h': 60, + '6h': 360, + day: 24 * 60, + week: 7 * 24 * 60, + month: 30 * 24 * 60, + year: 365 * 24 * 60, +} + +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 parseDateTimeInputValue(value: string): Date { + const parsedDate = new Date(value) + return Number.isNaN(parsedDate.getTime()) ? getRoundedNow(Date.now()) : 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 getTimeRangeForCustomDateTimeRange( + startDateTime: string, + endDateTime: string, +): AnalyticsTimeRange { + return { + start: parseDateTimeInputValue(startDateTime), + end: parseDateTimeInputValue(endDateTime), + } +} + +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 'custom_datetime_range': + return getTimeRangeForCustomDateTimeRange(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 <= 7) return '6h' + if (days <= 90) return 'day' + if (days <= 365) return 'week' + if (days <= 365 * 3) return 'month' + return 'year' +} + +export function getAnalyticsGroupByPresetMinutes(preset: AnalyticsGroupByPreset): number { + return GROUP_BY_PRESET_MINUTES[preset] +} + +export function isAnalyticsGroupByAvailableForDurationMinutes( + preset: AnalyticsGroupByPreset, + durationMinutes: number, +): boolean { + const groupByMinutes = getAnalyticsGroupByPresetMinutes(preset) + const isTooCoarse = groupByMinutes >= durationMinutes + const isTooFine = durationMinutes / groupByMinutes > MAX_ANALYTICS_TIME_SLICES + + return !isTooCoarse && !isTooFine +} + +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-dashboard/stat-cards/StatCard.vue b/apps/frontend/src/components/analytics-dashboard/stat-cards/StatCard.vue new file mode 100644 index 000000000..3c4107d0c --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/stat-cards/StatCard.vue @@ -0,0 +1,156 @@ + + + diff --git a/apps/frontend/src/components/analytics-dashboard/stat-cards/StatCards.vue b/apps/frontend/src/components/analytics-dashboard/stat-cards/StatCards.vue new file mode 100644 index 000000000..0ccc15364 --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/stat-cards/StatCards.vue @@ -0,0 +1,119 @@ + + + diff --git a/apps/frontend/src/components/analytics-dashboard/use-analytics-route-sync.ts b/apps/frontend/src/components/analytics-dashboard/use-analytics-route-sync.ts new file mode 100644 index 000000000..82aab5497 --- /dev/null +++ b/apps/frontend/src/components/analytics-dashboard/use-analytics-route-sync.ts @@ -0,0 +1,306 @@ +import type { Ref } from 'vue' +import type { LocationQuery } from 'vue-router' + +import { + areSelectedFiltersEqual, + areStringArraysEqual, + buildAnalyticsQueryBuilderRouteQuery, + getAnalyticsBreakdownPresetsForProjectSelection, + hasAnalyticsQueryBuilderRouteChange, + readAnalyticsGraphState, + readAnalyticsQueryBuilderState, +} from '~/components/analytics-dashboard/analytics-route-query' +import type { + AnalyticsBreakdownPreset, + AnalyticsDashboardStat, + AnalyticsGraphViewMode, + AnalyticsGroupByPreset, + AnalyticsLastTimeframeUnit, + AnalyticsSelectedBreakdowns, + AnalyticsSelectedFilters, + AnalyticsTimeframeMode, + AnalyticsTimeframePreset, +} from '~/providers/analytics/analytics-types' + +export type AnalyticsQueryBuilderRouteNavigationMode = 'push' | 'replace' + +export interface AnalyticsQueryBuilderRefs { + selectedProjectIds: Ref + selectedTimeframeMode: Ref + selectedTimeframe: Ref + selectedLastTimeframeAmount: Ref + selectedLastTimeframeUnit: Ref + selectedCustomTimeframeStartDate: Ref + selectedCustomTimeframeEndDate: Ref + selectedGroupBy: Ref + selectedBreakdowns: Ref + selectedFilters: Ref +} + +export interface AnalyticsGraphRefs { + activeStat: Ref + activeGraphViewMode: Ref + isRatioMode: Ref + showChartEvents: Ref + showProjectEvents: Ref + showPreviousPeriod: Ref + hiddenGraphDatasetIds: Ref + hasExplicitGraphDatasetSelection: Ref + selectedGraphDatasetIds: Ref +} + +export interface UseAnalyticsRouteSyncOptions { + queryBuilder: AnalyticsQueryBuilderRefs + graph: AnalyticsGraphRefs + availableProjectIds: Ref + sanitizeSelectedFilters: ( + breakdowns: readonly AnalyticsBreakdownPreset[], + filters: AnalyticsSelectedFilters, + ) => AnalyticsSelectedFilters +} + +export function useAnalyticsRouteSync(options: UseAnalyticsRouteSyncOptions) { + const { queryBuilder, graph, availableProjectIds, sanitizeSelectedFilters } = options + const route = useRoute() + const router = useRouter() + + let nextAnalyticsRouteNavigationMode: AnalyticsQueryBuilderRouteNavigationMode = 'replace' + + function replaceNextAnalyticsRouteNavigation() { + nextAnalyticsRouteNavigationMode = 'replace' + } + + function consumeAnalyticsRouteNavigationMode(): AnalyticsQueryBuilderRouteNavigationMode { + const navigationMode = nextAnalyticsRouteNavigationMode + nextAnalyticsRouteNavigationMode = 'push' + return navigationMode + } + + function getSelectedAnalyticsQueryBuilderState() { + return { + selectedProjectIds: queryBuilder.selectedProjectIds.value, + selectedTimeframeMode: queryBuilder.selectedTimeframeMode.value, + selectedTimeframe: queryBuilder.selectedTimeframe.value, + selectedLastTimeframeAmount: queryBuilder.selectedLastTimeframeAmount.value, + selectedLastTimeframeUnit: queryBuilder.selectedLastTimeframeUnit.value, + selectedCustomTimeframeStartDate: queryBuilder.selectedCustomTimeframeStartDate.value, + selectedCustomTimeframeEndDate: queryBuilder.selectedCustomTimeframeEndDate.value, + selectedGroupBy: queryBuilder.selectedGroupBy.value, + selectedBreakdowns: queryBuilder.selectedBreakdowns.value, + selectedFilters: queryBuilder.selectedFilters.value, + } + } + + function getSelectedAnalyticsGraphState() { + return { + activeStat: graph.activeStat.value, + activeGraphViewMode: graph.activeGraphViewMode.value, + isRatioMode: graph.isRatioMode.value, + showChartEvents: graph.showChartEvents.value, + showProjectEvents: graph.showProjectEvents.value, + showPreviousPeriod: graph.showPreviousPeriod.value, + hiddenGraphDatasetIds: graph.hiddenGraphDatasetIds.value, + selectedGraphDatasetIds: graph.hasExplicitGraphDatasetSelection.value + ? graph.selectedGraphDatasetIds.value + : null, + } + } + + function syncAnalyticsRouteQuery(navigationMode: AnalyticsQueryBuilderRouteNavigationMode) { + if (import.meta.server) { + return + } + + const nextRouteQuery = buildAnalyticsQueryBuilderRouteQuery( + route.query, + getSelectedAnalyticsQueryBuilderState(), + availableProjectIds.value, + getSelectedAnalyticsGraphState(), + ) + + const hasAnalyticsQueryChange = hasAnalyticsQueryBuilderRouteChange(route.query, nextRouteQuery) + + if (!hasAnalyticsQueryChange) return + + if (navigationMode === 'replace') { + router.replace({ + path: route.path, + query: nextRouteQuery, + }) + } else { + router.push({ + path: route.path, + query: nextRouteQuery, + }) + } + } + + function syncQueryBuilderRouteQuery() { + syncAnalyticsRouteQuery(consumeAnalyticsRouteNavigationMode()) + } + + function syncGraphRouteQuery() { + syncAnalyticsRouteQuery('replace') + } + + function applyRouteQueryToState(nextQuery: LocationQuery) { + const nextQueryState = readAnalyticsQueryBuilderState(nextQuery, availableProjectIds.value) + const availableProjectIdSet = new Set(availableProjectIds.value) + const nextSelectedProjectIds = nextQueryState.selectedProjectIds.filter((projectId) => + availableProjectIdSet.has(projectId), + ) + const nextGraphState = readAnalyticsGraphState(nextQuery, nextSelectedProjectIds) + const nextSelectedBreakdowns = getAnalyticsBreakdownPresetsForProjectSelection( + nextQueryState.selectedBreakdowns, + nextSelectedProjectIds, + ) + const nextSelectedFilters = sanitizeSelectedFilters( + nextSelectedBreakdowns, + nextQueryState.selectedFilters, + ) + const shouldUpdateSelectedProjectIds = !areStringArraysEqual( + queryBuilder.selectedProjectIds.value, + nextSelectedProjectIds, + ) + const shouldUpdateSelectedTimeframeMode = + queryBuilder.selectedTimeframeMode.value !== nextQueryState.selectedTimeframeMode + const shouldUpdateSelectedTimeframe = + queryBuilder.selectedTimeframe.value !== nextQueryState.selectedTimeframe + const shouldUpdateSelectedLastTimeframeAmount = + queryBuilder.selectedLastTimeframeAmount.value !== nextQueryState.selectedLastTimeframeAmount + const shouldUpdateSelectedLastTimeframeUnit = + queryBuilder.selectedLastTimeframeUnit.value !== nextQueryState.selectedLastTimeframeUnit + const shouldUpdateSelectedCustomTimeframeStartDate = + queryBuilder.selectedCustomTimeframeStartDate.value !== + nextQueryState.selectedCustomTimeframeStartDate + const shouldUpdateSelectedCustomTimeframeEndDate = + queryBuilder.selectedCustomTimeframeEndDate.value !== + nextQueryState.selectedCustomTimeframeEndDate + const shouldUpdateSelectedGroupBy = + queryBuilder.selectedGroupBy.value !== nextQueryState.selectedGroupBy + const shouldUpdateSelectedBreakdowns = !areStringArraysEqual( + queryBuilder.selectedBreakdowns.value, + nextSelectedBreakdowns, + ) + const shouldUpdateSelectedFilters = !areSelectedFiltersEqual( + queryBuilder.selectedFilters.value, + nextSelectedFilters, + ) + const shouldUpdateActiveStat = graph.activeStat.value !== nextGraphState.activeStat + const shouldUpdateActiveGraphViewMode = + graph.activeGraphViewMode.value !== nextGraphState.activeGraphViewMode + const shouldUpdateIsRatioMode = graph.isRatioMode.value !== nextGraphState.isRatioMode + const shouldUpdateShowChartEvents = + graph.showChartEvents.value !== nextGraphState.showChartEvents + const shouldUpdateShowProjectEvents = + graph.showProjectEvents.value !== nextGraphState.showProjectEvents + const shouldUpdateShowPreviousPeriod = + graph.showPreviousPeriod.value !== nextGraphState.showPreviousPeriod + const shouldUpdateHiddenGraphDatasetIds = !areStringArraysEqual( + graph.hiddenGraphDatasetIds.value, + nextGraphState.hiddenGraphDatasetIds, + ) + const nextHasExplicitGraphDatasetSelection = nextGraphState.selectedGraphDatasetIds !== null + const nextSelectedGraphDatasetIds = nextGraphState.selectedGraphDatasetIds ?? [] + const shouldUpdateHasExplicitGraphDatasetSelection = + graph.hasExplicitGraphDatasetSelection.value !== nextHasExplicitGraphDatasetSelection + const shouldUpdateSelectedGraphDatasetIds = + (nextHasExplicitGraphDatasetSelection || graph.hasExplicitGraphDatasetSelection.value) && + !areStringArraysEqual(graph.selectedGraphDatasetIds.value, nextSelectedGraphDatasetIds) + const hasRouteStateUpdate = + shouldUpdateSelectedProjectIds || + shouldUpdateSelectedTimeframeMode || + shouldUpdateSelectedTimeframe || + shouldUpdateSelectedLastTimeframeAmount || + shouldUpdateSelectedLastTimeframeUnit || + shouldUpdateSelectedCustomTimeframeStartDate || + shouldUpdateSelectedCustomTimeframeEndDate || + shouldUpdateSelectedGroupBy || + shouldUpdateSelectedBreakdowns || + shouldUpdateSelectedFilters || + shouldUpdateActiveStat || + shouldUpdateActiveGraphViewMode || + shouldUpdateIsRatioMode || + shouldUpdateShowChartEvents || + shouldUpdateShowProjectEvents || + shouldUpdateShowPreviousPeriod || + shouldUpdateHiddenGraphDatasetIds || + shouldUpdateHasExplicitGraphDatasetSelection || + shouldUpdateSelectedGraphDatasetIds + + if (hasRouteStateUpdate) { + replaceNextAnalyticsRouteNavigation() + } + + if (shouldUpdateSelectedProjectIds) { + queryBuilder.selectedProjectIds.value = nextSelectedProjectIds + } + if (shouldUpdateSelectedTimeframeMode) { + queryBuilder.selectedTimeframeMode.value = nextQueryState.selectedTimeframeMode + } + if (shouldUpdateSelectedTimeframe) { + queryBuilder.selectedTimeframe.value = nextQueryState.selectedTimeframe + } + if (shouldUpdateSelectedLastTimeframeAmount) { + queryBuilder.selectedLastTimeframeAmount.value = nextQueryState.selectedLastTimeframeAmount + } + if (shouldUpdateSelectedLastTimeframeUnit) { + queryBuilder.selectedLastTimeframeUnit.value = nextQueryState.selectedLastTimeframeUnit + } + if (shouldUpdateSelectedCustomTimeframeStartDate) { + queryBuilder.selectedCustomTimeframeStartDate.value = + nextQueryState.selectedCustomTimeframeStartDate + } + if (shouldUpdateSelectedCustomTimeframeEndDate) { + queryBuilder.selectedCustomTimeframeEndDate.value = + nextQueryState.selectedCustomTimeframeEndDate + } + if (shouldUpdateSelectedGroupBy) { + queryBuilder.selectedGroupBy.value = nextQueryState.selectedGroupBy + } + if (shouldUpdateSelectedBreakdowns) { + queryBuilder.selectedBreakdowns.value = nextSelectedBreakdowns + } + if (shouldUpdateSelectedFilters) { + queryBuilder.selectedFilters.value = nextSelectedFilters + } + if (shouldUpdateActiveStat) { + graph.activeStat.value = nextGraphState.activeStat + } + if (shouldUpdateActiveGraphViewMode) { + graph.activeGraphViewMode.value = nextGraphState.activeGraphViewMode + } + if (shouldUpdateIsRatioMode) { + graph.isRatioMode.value = nextGraphState.isRatioMode + } + if (shouldUpdateShowChartEvents) { + graph.showChartEvents.value = nextGraphState.showChartEvents + } + if (shouldUpdateShowProjectEvents) { + graph.showProjectEvents.value = nextGraphState.showProjectEvents + } + if (shouldUpdateShowPreviousPeriod) { + graph.showPreviousPeriod.value = nextGraphState.showPreviousPeriod + } + if (shouldUpdateHiddenGraphDatasetIds) { + graph.hiddenGraphDatasetIds.value = nextGraphState.hiddenGraphDatasetIds + } + if (shouldUpdateHasExplicitGraphDatasetSelection) { + graph.hasExplicitGraphDatasetSelection.value = nextHasExplicitGraphDatasetSelection + } + if (shouldUpdateSelectedGraphDatasetIds) { + graph.selectedGraphDatasetIds.value = nextSelectedGraphDatasetIds + } + + if (!hasRouteStateUpdate) { + syncAnalyticsRouteQuery('replace') + } + } + + return { + replaceNextAnalyticsRouteNavigation, + syncQueryBuilderRouteQuery, + syncGraphRouteQuery, + applyRouteQueryToState, + } +} 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 16ea42aae..000000000 --- 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 ea6670f93..000000000 --- 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 1a7686419..000000000 --- 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 1e6a84339..84c0c74c1 100644 --- a/apps/frontend/src/layouts/default.vue +++ b/apps/frontend/src/layouts/default.vue @@ -467,7 +467,7 @@