Files
modrinth/apps/frontend/src/components/analytics-dashboard/analytics-chart/use-analytics-chart-datasets.ts
T

442 lines
14 KiB
TypeScript

import type { Labrinth } from '@modrinth/api-client'
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 {
AnalyticsBreakdownPreset,
AnalyticsDashboardContextValue,
AnalyticsDashboardProject,
AnalyticsDashboardStat,
AnalyticsSelectedFilters,
} 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,
getSliceBucketRange,
getSliceCount,
shouldCapitalizeBreakdownLabel,
} from './analytics-chart-utils'
export function useAnalyticsChartDatasets(
context: Pick<
AnalyticsDashboardContextValue,
| 'activeStat'
| 'activeGraphViewMode'
| 'isRatioMode'
| 'showPreviousPeriod'
| 'hasPreviousPeriodComparison'
| 'hasProjectContext'
| 'displayedFetchRequest'
| 'displayedTimeSlices'
| 'displayedPreviousTimeSlices'
| 'displayedSelectedGroupBy'
| 'displayedSelectedBreakdowns'
| 'displayedSelectedFilters'
| 'hiddenGraphDatasetIds'
| 'hasExplicitGraphDatasetSelection'
| 'isGraphDatasetSelectionActive'
| 'selectedGraphDatasetIds'
| 'defaultGraphDatasetIds'
| 'topGraphDatasetIds'
| 'projectNamesById'
| 'userNamesById'
| 'dependentProjectTypesById'
| 'getVersionDisplayName'
| 'getVersionProjectName'
>,
selectedProjects: ComputedRef<AnalyticsDashboardProject[]>,
hasAvailableProjects: ComputedRef<boolean>,
) {
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.noDataAvailable)
: 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<Record<AnalyticsDashboardStat, ChartDataset[]>>(() => {
const datasets = buildDatasetsByStat(
context.displayedTimeSlices.value,
selectedProjects.value,
legendPalette.value,
context.displayedSelectedBreakdowns.value,
context.displayedSelectedFilters.value,
context.dependentProjectTypesById.value,
context.projectNamesById.value,
context.userNamesById.value,
context.getVersionDisplayName,
showProjectVersionNames.value ? context.getVersionProjectName : undefined,
formatMessage,
sliceCount.value,
)
const nextFetchRequest = context.displayedFetchRequest.value
if (
nextFetchRequest &&
context.displayedSelectedGroupBy.value === 'day' &&
isLastBucketCurrentDay(nextFetchRequest.time_range, sliceCount.value)
) {
datasets.revenue = datasets.revenue.map((dataset) => ({
...dataset,
lastDataPointUnavailable: true,
}))
}
return datasets
})
const previousChartDatasetsByStat = computed<Record<AnalyticsDashboardStat, ChartDataset[]>>(() =>
buildDatasetsByStat(
context.displayedPreviousTimeSlices.value,
selectedProjects.value,
legendPalette.value,
context.displayedSelectedBreakdowns.value,
context.displayedSelectedFilters.value,
context.dependentProjectTypesById.value,
context.projectNamesById.value,
context.userNamesById.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: Labrinth.Analytics.v3.TimeSlice[],
selectedProjects: AnalyticsDashboardProject[],
palette: string[],
selectedBreakdowns: readonly AnalyticsBreakdownPreset[],
selectedFilters: AnalyticsSelectedFilters,
dependentProjectTypesById: ReadonlyMap<string, readonly string[]>,
projectNamesById: ReadonlyMap<string, string>,
userNamesById: ReadonlyMap<string, string>,
getVersionDisplayName: (versionId: string) => string,
getVersionProjectName: ((versionId: string) => string | undefined) | undefined,
formatMessage: FormatMessage,
sliceCount: number,
) {
const datasetsByStat = {} as Record<AnalyticsDashboardStat, ChartDataset[]>
for (const stat of ANALYTICS_DASHBOARD_STATS) {
datasetsByStat[stat] = buildChartDatasets(
timeSlices,
selectedProjects,
stat,
palette,
selectedBreakdowns,
selectedFilters,
dependentProjectTypesById,
projectNamesById,
userNamesById,
getVersionDisplayName,
getVersionProjectName,
formatMessage,
sliceCount,
)
}
return datasetsByStat
}
function isLastBucketCurrentDay(timeRange: Labrinth.Analytics.v3.TimeRange, sliceCount: number) {
const lastBucket = getSliceBucketRange(timeRange, sliceCount, sliceCount - 1)
const todayStart = new Date()
todayStart.setHours(0, 0, 0, 0)
const tomorrowStart = new Date(todayStart)
tomorrowStart.setDate(tomorrowStart.getDate() + 1)
return lastBucket.start < tomorrowStart && lastBucket.end > todayStart
}
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)
}