feat: improve analytics dashboard (#5897)

* feat: implement cancel/apply for custom timeframe range picker

* feat: implement dot for showing todays date

* feat: add max date to be today and show todays date

* feat: if ratio mode, dont show total

* feat: implement show more batching excess lines into "Other" bucket

* refactor: pnpm prepr

* feat: add pick and plop for date range start/end dates

* feat: implement reset query button

* feat: clear button to clear breakdown

* feat: more aggressively trim allowed minimum group by option

* fix: dont show project status filter when from project settings/analytics

* fix: clear selected X above number when appropriate

* feat: graph style updates and dont show year in x axis unless more than 2 year timeframe

* fix: loading state to include legend in blur

* feat: add project icon to project select

* feat: filter out draft projects from analytics

* feat: implement multiselect sections headers, project select org sections, and project options icons

* feat: implement click and drag to select date range

* feat: implement windows history for query builder

* revert: no longer switch breakdown/filter option if same category

* feat: implement showing project for project version breakdown/filter when there are multiple projects

* feat: implement modrinth sided events

* fix: border radius

* feat: implement analytics range highlight

* fix: loading state showing empty state text

* refactor: pnpm prepr

* feat: improve dropdown filter bar and multiselect performance

* fix: multiselect keyboard use

* fix: graph overflow issues

* fix: loading state text on table

* feat: implement tooltip scroll

* fix: adjust charts event tooltip

* feat: shorten time to not repeat am/pm

* feat: implement query params for graph component settings

* fix: qa

* feat: add reset timeframe button

* fix: legend colors moving between metric by determining color based on only downloads metric index

* feat: implement auto switching temporarily to group by day for renvenue metric and disable revenue metric for time range < 2 days

* fix: change to > 1 day

* fix: custom timeframe picker

* feat: implement big performance improvement for table

* feat: implement hover on legend to highlight graph

* fix: defer commit in query builder/filter and style fixes

* feat: more performance optimization to analytics dashboard state, chart, and table

* feat: add tooltip for other item

* feat: improve custom time frame range select

* feat: implement analytics events admin page

* fix: switch column order

* pnpm prepr

* feat: implement mock analytics events

* feat: improve analytics events admin page

* feat: focus title input on analytics create event modal

* fix: remove labels annoying

* feat: hook up analytics events backend

* fix: type error

* feat: reduce combobox padding

* feat: reduce padding on multiselect

* feat: add overlay scrollbar for combobox

* feat: a bunch of style fixes to combobox, multiselect and dropdown filter bar

* feat: MORE PADDING fixes

* feat: use user_agent for download source

* Revert "feat: use user_agent for download source"

This reverts commit d6dc8a99f1.

* fix: query filter project version lag and borked virtualization

* feat: rename breakdown "none" to "project"

* feat: implement right side checkmark for multiselect

* feat: keep crossed out legend items still shown in tooltip but also crossed out

* fix: focus styles

* fix: focus styles pt2

* feat: implement filter by top 8

* fix: preview is incorrect when selecting same date in range date picker

* feat (playtest): cross out legend items in tooltip and allow hide/show in tooltip

* feat (playtest): table component controls what graph shows

* feat: change download source to use user_agent

* feat: fix click to cross out in legend

* feat: add hover legend item to highlight line in tooltip

* fix: export csv to always be dropdown

* feat: implement breakdown = none

* performance: frontend memory reduction

* performance: reduce memory usage from project versions query by keeping only whats necessary

* fix: table checked items not in graph if 0

* feat: add shift click to select a range in table

* performance: add caching for metric types so switching between them is snappy

* performance: batch analytics requests by 15 project ids, with 150 ms delay between, so backend is happy

* feat: add analytics table search

* refactor: pnpm prepr

* fix: query filter options not coming in from analytics fetch

* feat: remove breakdown = none when there are multiple projects

* feat: improve table sorting

* feat: sort projects in project dropdown

* fix: getting project name for project versions

* fix: add loading state for filter and parallel fetch

* performance: use precomputed map for project version options to remove first hover lag

* feat: dropdown filter always open on one side and improve styles

* fix: custom time range picker being weird

* refactor: pnpm prepr

* fix: add back in batch with 300ms interval for projects to prevent backend rate limiting

* performance: only do queries to populate graph first before other analytics queries

* fix: QA polish issues around style and copy

* feat: dont show select all when its just one item in section

* fix: bugs with ratio mode and hiding chart lines

* fix: adjust padding in combobox and multiselect and fix not unfocusing when deselect

* fix: small styles

* fix: polish admin analytic events

* fix: keep scroll position with selection action row appearing when selecting one

* feat: add subheading in graph for showing N items from table

* feat: add unmonetized explaination tooltip

* performance: implement limit on how many lines can be shown in graph

* feat: mobile pass

* refactor: pnpm prepr

* add clear button

* feat: add time in analytics event and normalize date/time so its correct to timezones

* fix: padding

* feat: implement show prev period toggle

* feat: extract TimeFramePicker to packages/ui

* fix: adjust style

* feat: keep table selected persisted in query parameter

* fix: style on prev item value in legend

* fix: when breakdown switches, reset selected series

* fix: tooltip styles

* feat: change project selection to reset to show top 8 only if reconciled down to 0 items

* feat: implement show top 8 button in graph subheading

* fix: rename download type to download reason

* fix: formatting label for table

* feat: persist table sort by and sort direction

* fix: show top 8 button in graph not defaulting to top 8 for other metrics

* feat: implement prev period analytics fetch into the same current period fetch by shifting start date

* refactor: pnpm prepr

* fix: remove number if its just top 1

* fix: brief select items empty state when switch breakdown

* feat: implement format table playtime column

* feat: update export csv filename

* feat: change playtime column to display in hours

* refactor: pnpm prepr

* fix: still download type in filter

* feat: update analytics tooltip

* fix: wrong all projects icon

* feat: force legend order and graph colour for monetization

* refactor: pnpm prepr

* fix: multiselect and combobox sizes

* fix: chart icon add hover delay

* feat: (to playtest) implement multiple breakdowns

* fix: couple UX things for multiple breakdown

* fix: cannot unpin on page click

* fix: multiple breakdown legend and tooltip labels

* feat: add right side checkmark for dropdown filtr bar

* feat: enabling prev period will cross out prev for current ones already crossed out

* feat-mobile: remove drag to select time frame in graph

* feat-mobile: dropdown filter to replace dropdown for submenus on small screen

* feat-mobile: time frame picker to use different start and end date pickers for mobile

* fix-mobile: fix multiselect scroll on mobile

* feat: consolidate is mobile ref into context

* fix-mobile: combobox and multiselect scroll bug when mobile search bar open, fix timeframe picker mobile pick date, and dropdown filter bar click outside to close

* fix-mobile: smaller metric card font

* fix: dropdown filter bar scroll while search

* feat: implement project side events

* feat: implement better mobile view design for query builder

* feat: handle events overflow

* small: add select none

* feat: remove clear project and breakdown

* fix: event icon hover color

* feat: default hide project events if there are multiple projects, and default show if only 1 project

* feat: implement analytics performance updates, including facets, and v3 user projects

* feat: grey out dimmed lined on legend item hover

* feat-mobile: style fixes

* add close on select prop

* feat: add close on select for time frame picker mobile

* feat: date picker default read only

* refactor: pnpm prepr

* feat: default to projects breakdown instead of no breakdown with multiple projects

* fix-mobile: improve graph touch interactions

* small: 2 sig figs on playtime

* feat: deduplicate version uploads that have same version number and are uploaded on same day

* fix: analytics events grouping causing overflow

* feat: improve performance on analytics events grouping

* fix: tooltip expanding page width briefly

* fix: prevent double tap to zoom on inputs

* feat: add click to show chart event for mobile

* fix: toggle not having touch manipulation

* fix: chart tooltip scroll in mobile

* fix: remove project breakdownoption as it is default breakdown when none are selected

* fix: dropdown filter bar briefly empty when switching pages in mobile

* feat: keep tooltip open after drag in mobile

* fix: using plural instead of single for project breakdown

* fix: date picker scrolling page after picking date in mobile by suppressing focus

* fix: callback to Organization instead of org id

* feat: improve chart tooltip date range label formatter to be much more consistent

* feat: tap to toggle event tooltip

* fix: add user select none on graph and fix zoom into download threshold input

* fix: frontend still filtering after backend already filters

* feat: fix emptys state height content shift

* fix: qa issues

* fix: a number of qa issues

- Hide project events based on visible project legend/table selection
- Filter project status events by end status and add explicit copy for approved, private, and unlisted
- Style Modrinth analytics events with blue icon, marker, guide, and range borders
- Add scroll fade shadows to analytics chart and event tooltips
- Show previous-period date range in the chart tooltip
- Make project breakdown conditional on multiple selected projects and allow no breakdown when none are selected
- Add breakdown selection actions and fix “Group by day” copy

* feat: implement graph controls dropdown

* fix date picker typing into time input

* fix: styles in events table

* small: style

* feat: implement using new backend facets route

* feat: implement user get all projects

* performance: deter non-critical fetches to after analytics is in

* fix: refreshing causes multiple projects to do breakdown=none

* performance: cache project version options to fix lag on open sub menu

* refactor: remove chart event height being controlled by parent

* feat: update controls dropdown to have fainter border

* fix: loading bar not fading away

* fix: cannot click in graph

* feat: dont conditionally show multiselect selection actions

* fix: z-index and padding issues

* fix: project events incorrectly toggling on for first page load

* feat: remove show more and show less in legend, always show all

* fix: playtime y axis labels

* feat: improve y axis formatting for playtime and others

* feat: use tabs for game version select, and remove prev period when change breakdown or project selection

* refactor: pnpm prepr

* feat: change hidden legend items to not contribute to ratio percentages

* feat: event icon consume scroll for tooltip panel

* feat: remove gap inside chart tooltip

* feat: add gap for date picker 2 calendar view

* feat: improve analytics events grouping logic for modrinth events to be close to target

* pnpm prepr

* fix: cant click in gap in toggle

* fix: bugs around selected series from table not persisting with timeframe or filter changes

* refactor: kabab case

* refactor: split up large analytics chart and table component files into smaller components and ts modules

* fix: legend is stale after resetting query

* refactor: split up giant analytics provider with utils

* i18n pass

* revert: format number composable change

* fix: playtime was choosing y axis ticks in seconds instead of hours

* refactor: rename folder that with components to match main component name

* refactor: same rename for analytics table for consistency

* refactor: name main components to index.vue and keep folder name as component name

* refactor: pnpm prepr

* refactor: rename types

* refactor: move query builder types into types file and move components into components/analytics-dashboard

* refactor: colocate query builder url with analytics-dashboard component

* refactor: pnpm prepr:frontend

* fix: download threshold not width fit

* fix: no option to see release/all game versions in selected filter dropdown

* fix: game version dropdown width

---------

Signed-off-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
This commit is contained in:
Truman Gao
2026-05-29 19:39:55 +00:00
committed by GitHub
co-authored by Calum H.
parent f49951084e
commit 11b2b6e6c0
100 changed files with 23707 additions and 2981 deletions
@@ -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<AnalyticsTableColumnKey>[] {
const nextColumns: TableColumn<AnalyticsTableColumnKey>[] = []
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<AnalyticsTableColumnKey> | 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_')
}
@@ -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<AnalyticsTableColumnKey>[],
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<AnalyticsTableColumnKey>,
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()
}
@@ -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(', ')
}
@@ -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<string>
relevantStats: ReadonlySet<AnalyticsDashboardStat>
projectNamesById: ReadonlyMap<string, string>
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<string, string>()
const projectDisplayValues = new Map<string, string>()
const nextRows = new Map<string, AnalyticsTableRow>()
const bucketLabelsBySliceIndex = new Map<number, { date: string; dateMs: number }>()
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<string, string>,
getVersionDisplayName: (versionId: string) => string,
formatMessage: FormatMessage,
): string {
if (breakdown === 'project') {
return projectNamesById.get(value) ?? value
}
return formatBreakdownLabel(value, breakdown, getVersionDisplayName, formatMessage)
}
@@ -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<AnalyticsTableColumnKey>(['date', 'project'])
export function getAnalyticsTableSearchableColumns(
columns: TableColumn<AnalyticsTableColumnKey>[],
): TableColumn<AnalyticsTableColumnKey>[] {
return columns.filter(
(column) =>
SEARCHABLE_COLUMN_KEYS.has(column.key) || isAnalyticsTableBreakdownColumnKey(column.key),
)
}
export function filterAnalyticsTableRowsBySearch(
rows: AnalyticsTableRow[],
searchableColumns: TableColumn<AnalyticsTableColumnKey>[],
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] ?? '') : ''
}
}
@@ -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<AnalyticsTableColumnKey>[]
showGraphDatasetSelection: boolean
activeStat: AnalyticsDashboardStat
}
export function getRouteAnalyticsTableSortState(
query: LocationQuery,
columns: TableColumn<AnalyticsTableColumnKey>[],
defaultSortOptions: GetDefaultAnalyticsTableSortStateOptions,
): AnalyticsTableSortState {
return getAvailableAnalyticsTableSortState(
readAnalyticsTableSortState(query, getDefaultAnalyticsTableSortState(defaultSortOptions)),
columns,
defaultSortOptions,
)
}
export function getAvailableAnalyticsTableSortState(
state: AnalyticsTableSortState,
columns: TableColumn<AnalyticsTableColumnKey>[],
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<AnalyticsTableColumnKey>[],
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,
}
}
@@ -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<AnalyticsTableColumnKey>[],
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<AnalyticsTableColumnKey>[],
): 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<string, number>()
const labelsByGraphDatasetId = new Map<string, string>()
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
}
@@ -0,0 +1,43 @@
import type {
AnalyticsBreakdownPreset,
AnalyticsTableSortColumn,
AnalyticsTableSortDirection,
} from '~/providers/analytics/analytics'
export type AnalyticsTableMode = 'date_breakdown' | 'breakdown_only'
export type AnalyticsTableBreakdownPreset = Exclude<AnalyticsBreakdownPreset, 'none'>
export type AnalyticsTableBreakdownColumnKey = `breakdown_${AnalyticsTableBreakdownPreset}`
export type AnalyticsTableBreakdownDisplayValues = Partial<
Record<AnalyticsTableBreakdownPreset, string>
>
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[]
}
@@ -0,0 +1,655 @@
<template>
<div class="relative overflow-hidden rounded-2xl">
<AnalyticsLoadingBar :loading="isDataLoading" />
<Table
v-model:selected-ids="tableSelectedGraphDatasetIds"
:sort-column="displayedSortColumn"
:sort-direction="displayedSortDirection"
:columns="columns"
:data="paginatedRows"
row-key="id"
selection-key="graphDatasetId"
:selection-ids="filteredSelectableGraphDatasetIds"
:show-selection="showGraphDatasetSelection"
table-min-width="44rem"
virtualized
:virtual-row-height="56"
@sort="applyRequestedSort"
>
<template #header>
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div class="text-xl font-semibold text-contrast">
{{ formatMessage(analyticsBreakdownMessages.breakdown) }}
</div>
<div class="flex w-full flex-wrap items-center gap-2 md:w-auto">
<StyledInput
v-model="searchQuery"
:icon="SearchIcon"
:placeholder="formatMessage(analyticsTableMessages.searchPlaceholder)"
clearable
wrapper-class="w-full sm:w-64"
@focusin="selectSearchInputText"
/>
<ButtonStyled>
<OverflowMenu
class="!shadow-none"
:options="csvExportOptions"
:disabled="isDataLoading || filteredRows.length === 0"
>
<DownloadIcon />
{{ formatMessage(analyticsTableMessages.exportCsvButton) }}
<DropdownIcon />
<template #cumulative-csv>
{{ formatMessage(analyticsTableMessages.cumulativeCsv) }}
</template>
<template #grouped-csv>
{{ formatMessage(analyticsTableMessages.groupedCsv, { groupBy: groupByLabel }) }}
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
</template>
<template #cell-date="{ value }">
<span class="text-primary">{{ value }}</span>
</template>
<template #cell-breakdown_project="{ value }">
<span class="text-primary">{{ value }}</span>
</template>
<template #cell-breakdown_country="{ value }">
<span class="text-primary">{{ value }}</span>
</template>
<template #cell-breakdown_monetization="{ value }">
<span class="text-primary">{{ value }}</span>
</template>
<template #cell-breakdown_user_agent="{ value }">
<span class="text-primary">{{ value }}</span>
</template>
<template #cell-breakdown_download_reason="{ value }">
<span class="text-primary">{{ value }}</span>
</template>
<template #cell-breakdown_version_id="{ value }">
<span class="text-primary">{{ value }}</span>
</template>
<template #cell-breakdown_loader="{ value }">
<span class="text-primary">{{ value }}</span>
</template>
<template #cell-breakdown_game_version="{ value }">
<span class="text-primary">{{ value }}</span>
</template>
<template #cell-project="{ value }">
<span class="text-primary">{{ value }}</span>
</template>
<template #cell-views="{ row }">
<span>{{ formatInteger(row.views) }}</span>
</template>
<template #cell-downloads="{ row }">
<span>{{ formatInteger(row.downloads) }}</span>
</template>
<template #cell-revenue="{ row }">
<span>{{ formatRevenue(row.revenue) }}</span>
</template>
<template #cell-playtime="{ row }">
<span v-tooltip="formatFullPlaytime(row.playtime)">
{{ formatCompactPlaytime(row.playtime) }}
</span>
</template>
<template #empty-state>
<div class="flex h-64 items-center justify-center text-secondary">
{{ !isDataLoading ? emptyTableMessage : '' }}
</div>
</template>
</Table>
<div
v-if="filteredRows.length > PAGE_SIZE"
class="mt-3 flex flex-wrap items-center justify-between gap-3 px-1 text-sm text-secondary"
>
<span>
{{
formatMessage(analyticsTableMessages.paginationSummary, {
start: visibleRowStart,
end: visibleRowEnd,
total: filteredRows.length,
})
}}
</span>
<Pagination :page="currentPage" :count="pageCount" @switch-page="switchPage" />
</div>
<div v-if="isDataLoading" class="absolute inset-0 z-10 overflow-hidden rounded-xl">
<div class="absolute inset-0 bg-surface-3 opacity-50" />
<div class="absolute inset-0 backdrop-blur-[4px]" />
<div class="absolute inset-0 flex h-full max-h-[500px] items-center justify-center pt-10">
<div class="inline-flex items-center gap-2 text-lg font-semibold text-primary opacity-100">
<span>{{ formatMessage(analyticsMessages.fetchingResults) }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { DownloadIcon, DropdownIcon, SearchIcon } from '@modrinth/assets'
import {
ButtonStyled,
OverflowMenu,
type OverflowMenuOption,
Pagination,
StyledInput,
Table,
useFormatNumber,
useVIntl,
} from '@modrinth/ui'
import type { LocationQuery } from 'vue-router'
import {
hasAnalyticsTableSortQuery,
hasAnalyticsTableSortRouteChange,
readAnalyticsTableSortState,
} from '~/components/analytics-dashboard/analytics-route-query'
import {
doesProjectStatusMatchFilters,
injectAnalyticsDashboardContext,
} from '~/providers/analytics/analytics'
import {
isTimeRelevantForGroupBy,
isYearRelevantForTimeRange,
} from '../analytics-chart/analytics-chart-utils.ts'
import {
analyticsBreakdownMessages,
analyticsMessages,
analyticsTableMessages,
} from '../analytics-messages.ts'
import AnalyticsLoadingBar from '../AnalyticsLoadingBar.vue'
import {
buildAnalyticsTableColumns,
getAnalyticsTableBreakdownColumnLabel,
} from './analytics-table-columns.ts'
import {
buildAnalyticsTableCsvContent,
downloadAnalyticsTableCsv,
getAnalyticsTableCsvFilename,
} from './analytics-table-csv-export.ts'
import {
formatAnalyticsTableCompactPlaytime,
formatAnalyticsTableFullPlaytime,
formatAnalyticsTableInteger,
formatAnalyticsTableRevenue,
getAnalyticsTableGroupByLabel,
} from './analytics-table-formatting.ts'
import { buildAnalyticsTableRows } from './analytics-table-row-builder.ts'
import {
filterAnalyticsTableRowsBySearch,
getAnalyticsTableSearchableColumns,
} from './analytics-table-search-filtering.ts'
import {
areAnalyticsTableSortStatesEqual,
buildSyncedAnalyticsTableSortRouteQuery,
getRouteAnalyticsTableSortState,
toAnalyticsTableSortState,
} from './analytics-table-sort-route.ts'
import { sortAnalyticsTableRows } from './analytics-table-sorting.ts'
import type {
AnalyticsTableColumnKey,
AnalyticsTableMode,
AnalyticsTableSortDirectionValue,
} from './analytics-table-types.ts'
import { useAnalyticsTableGraphSelection } from './use-analytics-table-graph-selection.ts'
import { useAnalyticsTablePagination } from './use-analytics-table-pagination.ts'
import { useAnalyticsTableRowCache } from './use-analytics-table-row-cache.ts'
const {
hasProjectContext,
projects,
selectedProjectIds: currentSelectedProjectIds,
selectedBreakdowns: currentSelectedBreakdowns,
displayedSelectedProjectIds: selectedProjectIds,
displayedSelectedGroupBy: selectedGroupBy,
displayedSelectedBreakdowns: selectedBreakdowns,
displayedSelectedFilters: selectedFilters,
displayedFetchRequest: fetchRequest,
displayedTimeSlices: timeSlices,
activeStat,
hasExplicitGraphDatasetSelection,
isGraphDatasetSelectionActive,
selectedGraphDatasetIds,
defaultGraphDatasetIds,
topGraphDatasetIds,
queryResetToken,
getRelevantAnalyticsDashboardStats,
isLoading,
versionNumbersById,
versionProjectNamesById,
getVersionDisplayName,
getVersionProjectName,
} = injectAnalyticsDashboardContext()
const formatNumber = useFormatNumber()
const { formatMessage } = useVIntl()
const isDataLoading = computed(() => isLoading.value)
const route = useRoute()
const router = useRouter()
const initialTableSortState = readAnalyticsTableSortState(route.query, {
sortColumn: 'date',
sortDirection: 'desc',
})
const tableMode = ref<AnalyticsTableMode>('breakdown_only')
const sortColumn = ref<AnalyticsTableColumnKey | undefined>(initialTableSortState.sortColumn)
const sortDirection = ref<AnalyticsTableSortDirectionValue>(initialTableSortState.sortDirection)
const PAGE_SIZE = 500
const GRAPH_DATASET_SELECTION_LIMIT = 8
const INACTIVE_MODE_WARMUP_POINT_LIMIT = 12000
const searchQuery = ref('')
const sortCollator = new Intl.Collator(undefined, { sensitivity: 'base' })
const selectedProjectIdSet = computed(
() =>
new Set(
projects.value
.filter(
(project) =>
selectedProjectIds.value.includes(project.id) &&
doesProjectStatusMatchFilters(project.status, selectedFilters.value),
)
.map((project) => project.id),
),
)
const selectedBreakdownSet = computed(() => new Set(selectedBreakdowns.value))
const showBreakdownColumn = computed(() => selectedBreakdowns.value.length > 0)
const showGraphDatasetSelection = computed(() =>
selectedBreakdowns.value.length === 1 && selectedBreakdowns.value[0] === 'project'
? selectedProjectIdSet.value.size > 1
: selectedBreakdowns.value.length > 0,
)
const showProjectVersionProjectColumn = computed(
() =>
selectedBreakdownSet.value.has('version_id') &&
!selectedBreakdownSet.value.has('project') &&
selectedProjectIdSet.value.size > 1,
)
const includeDateColumn = computed(
() =>
selectedBreakdowns.value.length === 0 ||
(!showGraphDatasetSelection.value && tableMode.value === 'date_breakdown'),
)
const activeTableMode = computed<AnalyticsTableMode>(() =>
selectedBreakdowns.value.length === 0
? 'date_breakdown'
: showGraphDatasetSelection.value
? 'breakdown_only'
: tableMode.value,
)
const displayedIncludeDateColumn = computed(() =>
selectedBreakdowns.value.length === 0
? true
: showGraphDatasetSelection.value
? false
: displayedTableMode.value === 'date_breakdown',
)
const groupByLabel = computed(() =>
getAnalyticsTableGroupByLabel(selectedGroupBy.value, formatMessage),
)
const csvExportOptions = computed<OverflowMenuOption[]>(() => {
if (showGraphDatasetSelection.value) {
return [
{
id: 'cumulative-csv',
action: () => downloadCsv('breakdown_only'),
},
{
id: 'grouped-csv',
action: () => downloadCsv('date_breakdown'),
},
]
}
const mode = displayedTableMode.value
return [
{
id: mode === 'date_breakdown' ? 'grouped-csv' : 'cumulative-csv',
action: () => downloadCsv(mode),
},
]
})
const projectNamesById = computed(
() => new Map(projects.value.map((project) => [project.id, project.name])),
)
const hasAvailableProjects = computed(() => projects.value.length > 0)
const analyticsPointCount = computed(() =>
timeSlices.value.reduce((sum, slice) => sum + slice.length, 0),
)
const emptyTableMessage = computed(() => {
if (trimmedSearchQuery.value && sortedRows.value.length > 0) {
return formatMessage(analyticsTableMessages.noMatchingRows)
}
if (hasProjectContext.value) {
return formatMessage(analyticsMessages.noDataAvailableForAnalytics)
}
return hasAvailableProjects.value
? formatMessage(analyticsMessages.noDataAvailable)
: formatMessage(analyticsMessages.noProjectsAvailableForAnalytics)
})
const breakdownColumnLabel = computed(() =>
selectedBreakdowns.value.length === 1
? getAnalyticsTableBreakdownColumnLabel(selectedBreakdowns.value[0], formatMessage)
: formatMessage(analyticsBreakdownMessages.breakdown),
)
const relevantStats = computed(
() =>
new Set(getRelevantAnalyticsDashboardStats(selectedBreakdowns.value, selectedFilters.value)),
)
const showTimeInBucketLabel = computed(() => isTimeRelevantForGroupBy(selectedGroupBy.value))
const showYearInBucketLabel = computed(() => {
const nextFetchRequest = fetchRequest.value
return nextFetchRequest
? isYearRelevantForTimeRange(nextFetchRequest.time_range) || selectedGroupBy.value === 'year'
: false
})
function buildTableRows(mode: AnalyticsTableMode) {
return buildAnalyticsTableRows({
mode,
fetchRequest: fetchRequest.value,
timeSlices: timeSlices.value,
selectedBreakdowns: selectedBreakdowns.value,
selectedProjectIds: selectedProjectIdSet.value,
relevantStats: relevantStats.value,
projectNamesById: projectNamesById.value,
getVersionDisplayName,
getVersionProjectName,
showTimeInBucketLabel: showTimeInBucketLabel.value,
showYearInBucketLabel: showYearInBucketLabel.value,
formatMessage,
})
}
const columns = computed(() => buildColumns(displayedIncludeDateColumn.value))
const activeColumns = computed(() => buildColumns(includeDateColumn.value))
function buildColumns(includeDate: boolean) {
return buildAnalyticsTableColumns({
includeDate,
selectedBreakdowns: selectedBreakdowns.value,
selectedFilters: selectedFilters.value,
showBreakdownColumn: showBreakdownColumn.value,
showProjectVersionProjectColumn: showProjectVersionProjectColumn.value,
formatMessage,
getRelevantAnalyticsDashboardStats,
})
}
watch(
activeColumns,
(nextColumns) => {
applyRouteOrDefaultSort(nextColumns)
},
{ immediate: true },
)
function sortTableRows(rows: ReturnType<typeof buildTableRows>) {
return sortAnalyticsTableRows(rows, sortColumn.value, sortDirection.value, sortCollator)
}
const {
displayedTableMode,
displayedSortColumn,
displayedSortDirection,
displayedSortedRows,
invalidateTableCaches,
invalidateSortedCaches,
scheduleRowsForMode,
scheduleInactiveModeWarmup,
resortDisplayedRowsForCurrentSort,
getSortedRowsForMode,
} = useAnalyticsTableRowCache({
activeTableMode,
showBreakdownColumn,
analyticsPointCount,
sortColumn,
sortDirection,
buildRows: buildTableRows,
sortRows: sortTableRows,
inactiveModeWarmupPointLimit: INACTIVE_MODE_WARMUP_POINT_LIMIT,
})
const sortedRows = computed(() => {
return displayedSortedRows.value
})
const trimmedSearchQuery = computed(() => searchQuery.value.trim().toLowerCase())
const searchableColumns = computed(() => getAnalyticsTableSearchableColumns(columns.value))
const filteredRows = computed(() => {
if (!trimmedSearchQuery.value) {
return sortedRows.value
}
return filterAnalyticsTableRowsBySearch(
sortedRows.value,
searchableColumns.value,
trimmedSearchQuery.value,
)
})
watch(
[
fetchRequest,
timeSlices,
selectedProjectIds,
selectedGroupBy,
selectedBreakdowns,
selectedFilters,
projects,
versionNumbersById,
versionProjectNamesById,
],
() => {
invalidateTableCaches()
scheduleRowsForMode(activeTableMode.value)
scheduleInactiveModeWarmup()
},
{ immediate: true, flush: 'post' },
)
watch(activeTableMode, () => {
scheduleRowsForMode(activeTableMode.value)
scheduleInactiveModeWarmup()
})
watch(
() => route.query,
(nextQuery) => {
const nextSortState = getRouteTableSortState(nextQuery, activeColumns.value)
if (!areAnalyticsTableSortStatesEqual(getCurrentSortState(), nextSortState)) {
applyTableSortState(nextSortState)
return
}
syncTableSortRouteQuery()
},
)
watch([sortColumn, sortDirection], () => {
syncTableSortRouteQuery()
if (resortDisplayedRowsForCurrentSort()) {
scheduleInactiveModeWarmup()
return
}
invalidateSortedCaches()
scheduleRowsForMode(activeTableMode.value)
scheduleInactiveModeWarmup()
})
const { filteredSelectableGraphDatasetIds, tableSelectedGraphDatasetIds } =
useAnalyticsTableGraphSelection({
sortedRows,
filteredRows,
sortColumn,
showGraphDatasetSelection,
selectedGraphDatasetIds,
hasExplicitGraphDatasetSelection,
isGraphDatasetSelectionActive,
defaultGraphDatasetIds,
topGraphDatasetIds,
queryResetToken,
currentSelectedBreakdowns,
currentSelectedProjectIds,
activeStat,
sortCollator,
hasTableSortQuery: () => hasAnalyticsTableSortQuery(route.query),
applyActiveStatSort,
graphDatasetSelectionLimit: GRAPH_DATASET_SELECTION_LIMIT,
})
const { currentPage, pageCount, visibleRowStart, visibleRowEnd, paginatedRows, switchPage } =
useAnalyticsTablePagination({
filteredRows,
pageSize: PAGE_SIZE,
})
const revenueFormatter = computed(
() =>
new Intl.NumberFormat(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
)
function formatInteger(value: number): string {
return formatAnalyticsTableInteger(formatNumber, value)
}
function formatRevenue(value: number): string {
return formatAnalyticsTableRevenue(revenueFormatter.value, value, formatMessage)
}
function formatCompactPlaytime(value: number): string {
return formatAnalyticsTableCompactPlaytime(value, formatMessage)
}
function formatFullPlaytime(value: number): string {
return formatAnalyticsTableFullPlaytime(value, formatMessage)
}
function applyRouteOrDefaultSort(nextColumns = activeColumns.value) {
const nextSortState = getRouteTableSortState(route.query, nextColumns)
if (!areAnalyticsTableSortStatesEqual(getCurrentSortState(), nextSortState)) {
applyTableSortState(nextSortState)
}
syncTableSortRouteQuery()
}
function applyTableSortState(state: {
sortColumn: AnalyticsTableColumnKey | undefined
sortDirection: AnalyticsTableSortDirectionValue
}) {
sortColumn.value = state.sortColumn
sortDirection.value = state.sortDirection
}
function getRouteTableSortState(
query: LocationQuery,
nextColumns = activeColumns.value,
): {
sortColumn: AnalyticsTableColumnKey | undefined
sortDirection: AnalyticsTableSortDirectionValue
} {
return getRouteAnalyticsTableSortState(query, nextColumns, getDefaultSortOptions(nextColumns))
}
function getCurrentSortState() {
return toAnalyticsTableSortState(sortColumn.value, sortDirection.value)
}
function getDefaultSortOptions(nextColumns = activeColumns.value) {
return {
columns: nextColumns,
showGraphDatasetSelection: showGraphDatasetSelection.value,
activeStat: activeStat.value,
}
}
function syncTableSortRouteQuery() {
if (import.meta.server) {
return
}
const nextRouteQuery = buildSyncedAnalyticsTableSortRouteQuery(
route.query,
getCurrentSortState(),
activeColumns.value,
getDefaultSortOptions(),
)
if (!hasAnalyticsTableSortRouteChange(route.query, nextRouteQuery)) {
return
}
router.replace({
path: route.path,
query: nextRouteQuery,
})
}
function applyActiveStatSort() {
const availableColumns = new Set(activeColumns.value.map((column) => column.key))
if (!availableColumns.has(activeStat.value)) {
return
}
sortColumn.value = activeStat.value
sortDirection.value = 'desc'
}
function applyRequestedSort(column: string, direction: AnalyticsTableSortDirectionValue) {
sortColumn.value = column as AnalyticsTableColumnKey
sortDirection.value = direction
}
function selectSearchInputText(event: FocusEvent) {
const target = event.target
if (target instanceof HTMLInputElement) {
target.select()
}
}
function getCsvRows(mode: AnalyticsTableMode) {
const visibleColumns = getCsvColumns(mode)
return filterAnalyticsTableRowsBySearch(
getSortedRowsForMode(mode),
visibleColumns,
trimmedSearchQuery.value,
)
}
function getCsvColumns(mode: AnalyticsTableMode) {
return buildColumns(mode === 'date_breakdown' || !showBreakdownColumn.value)
}
function getCsvFilename(): string {
return getAnalyticsTableCsvFilename(breakdownColumnLabel.value, fetchRequest.value, formatMessage)
}
function downloadCsv(mode: AnalyticsTableMode) {
if (!import.meta.client) {
return
}
const csvRows = getCsvRows(mode)
if (csvRows.length === 0) {
return
}
const visibleColumns = getCsvColumns(mode)
const csvContent = buildAnalyticsTableCsvContent(csvRows, visibleColumns, formatMessage)
downloadAnalyticsTableCsv(getCsvFilename(), csvContent)
}
</script>
@@ -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<AnalyticsTableRow[]>
filteredRows: ComputedRef<AnalyticsTableRow[]>
sortColumn: Ref<AnalyticsTableColumnKey | undefined>
showGraphDatasetSelection: ComputedRef<boolean>
selectedGraphDatasetIds: Ref<string[]>
hasExplicitGraphDatasetSelection: Ref<boolean>
isGraphDatasetSelectionActive: Ref<boolean>
defaultGraphDatasetIds: Ref<string[]>
topGraphDatasetIds: Ref<string[]>
queryResetToken: Ref<number>
currentSelectedBreakdowns: Ref<AnalyticsSelectedBreakdowns>
currentSelectedProjectIds: Ref<string[]>
activeStat: Ref<AnalyticsDashboardStat>
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<string[]>
tableSelectedGraphDatasetIds: WritableComputedRef<unknown[]>
} {
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<unknown[]>({
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,
}
}
@@ -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<AnalyticsTableRow[]>
pageSize: number
}
export function useAnalyticsTablePagination({
filteredRows,
pageSize,
}: UseAnalyticsTablePaginationOptions): {
currentPage: Ref<number>
pageCount: ComputedRef<number>
visibleRowStart: ComputedRef<number>
visibleRowEnd: ComputedRef<number>
paginatedRows: ComputedRef<AnalyticsTableRow[]>
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<AnalyticsTableRow[]>(() =>
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,
}
}
@@ -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<AnalyticsTableMode>
showBreakdownColumn: ComputedRef<boolean>
analyticsPointCount: ComputedRef<number>
sortColumn: Ref<AnalyticsTableColumnKey | undefined>
sortDirection: Ref<AnalyticsTableSortDirectionValue>
buildRows: (mode: AnalyticsTableMode) => AnalyticsTableRow[]
sortRows: (rows: AnalyticsTableRow[]) => AnalyticsTableRow[]
inactiveModeWarmupPointLimit: number
}
export function useAnalyticsTableRowCache({
activeTableMode,
showBreakdownColumn,
analyticsPointCount,
sortColumn,
sortDirection,
buildRows,
sortRows,
inactiveModeWarmupPointLimit,
}: UseAnalyticsTableRowCacheOptions): {
displayedTableMode: Ref<AnalyticsTableMode>
displayedSortColumn: Ref<AnalyticsTableColumnKey | undefined>
displayedSortDirection: Ref<AnalyticsTableSortDirectionValue>
displayedSortedRows: ShallowRef<AnalyticsTableRow[]>
invalidateTableCaches: () => void
invalidateSortedCaches: () => void
scheduleRowsForMode: (mode: AnalyticsTableMode) => void
scheduleInactiveModeWarmup: () => void
resortDisplayedRowsForCurrentSort: () => boolean
getSortedRowsForMode: (mode: AnalyticsTableMode) => AnalyticsTableRow[]
} {
const modeBuildRequestIds: Record<AnalyticsTableMode, number> = {
date_breakdown: 0,
breakdown_only: 0,
}
let tableCacheGeneration = 0
let displayedSortedRowsGeneration = 0
const displayedTableMode = ref<AnalyticsTableMode>('breakdown_only')
const displayedSortColumn = ref<AnalyticsTableColumnKey | undefined>(sortColumn.value)
const displayedSortDirection = ref<AnalyticsTableSortDirectionValue>(sortDirection.value)
const displayedSortedRows = shallowRef<AnalyticsTableRow[]>([])
const displayedRowsCache = shallowRef<AnalyticsTableDisplayedRowsCache | null>(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<void> {
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,
}
}