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
+184 -109
View File
@@ -6,114 +6,123 @@
>
<slot name="header" />
</div>
<table class="w-full table-fixed border-separate border-spacing-0 border-surface-5">
<colgroup>
<col v-if="showSelection" class="w-10" />
<col
v-for="column in columns"
:key="column.key"
:style="column.width ? { width: column.width } : undefined"
/>
</colgroup>
<thead class="">
<tr class="bg-surface-3">
<th v-if="showSelection" class="w-10 pl-4">
<Checkbox
:model-value="allSelected"
:indeterminate="someSelected"
class="shrink-0 py-4"
@update:model-value="toggleSelectAll"
/>
</th>
<th
<div class="overflow-x-auto overflow-y-hidden">
<table
class="w-full table-fixed border-separate border-spacing-0 border-surface-5"
:style="tableMinWidth ? { minWidth: tableMinWidth } : undefined"
>
<colgroup>
<col v-if="showSelection" class="w-12" />
<col
v-for="column in columns"
:key="column.key"
class="h-14 first:pl-4 last:pr-4"
:class="[
`text-${column.align ?? 'left'}`,
column.enableSorting ? 'cursor-pointer select-none' : '',
]"
@click="column.enableSorting ? handleSort(column.key) : undefined"
>
<slot :name="`header-${column.key}`" :column="column">
<span
v-if="column.label || column.enableSorting"
class="inline-flex min-w-0 max-w-full items-center gap-1 font-semibold"
:class="`${sortColumn === column.key ? 'text-contrast' : ''}`"
>
<span class="min-w-0 truncate">{{ column.label ?? '' }}</span>
<template v-if="column.enableSorting">
<ChevronUpIcon
v-if="sortColumn === column.key && sortDirection === 'asc'"
class="size-4 shrink-0"
/>
<ChevronDownIcon
v-else-if="sortColumn === column.key && sortDirection === 'desc'"
class="size-4 shrink-0"
/>
</template>
</span>
</slot>
</th>
</tr>
</thead>
<tbody :ref="setListContainer">
<tr v-if="data.length === 0" class="bg-surface-2">
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-5 p-0">
<slot name="empty-state">
<div class="text-secondary flex h-64 items-center justify-center">
No data available.
</div>
</slot>
</td>
</tr>
<template v-else>
<tr v-if="virtualized && topSpacerHeight > 0" aria-hidden="true">
<td
:colspan="columnSpan"
class="border-0 p-0"
:style="{ height: `${topSpacerHeight}px` }"
></td>
</tr>
<tr
v-for="(row, rowIndex) in renderedRows"
:key="getRowRenderKey(row, getAbsoluteRowIndex(rowIndex))"
:class="getAbsoluteRowIndex(rowIndex) % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'"
>
<td v-if="showSelection" class="w-10 border-solid border-0 border-t border-surface-5">
:style="column.width ? { width: column.width } : undefined"
/>
</colgroup>
<thead class="">
<tr class="bg-surface-3">
<th v-if="showSelection" class="w-12">
<Checkbox
:model-value="isSelected(row)"
class="shrink-0 p-4"
@update:model-value="toggleSelection(row)"
:model-value="allSelected"
:indeterminate="someSelected"
class="shrink-0 p-4 focus-visible:!outline-none"
@update:model-value="toggleSelectAll"
/>
</td>
<td
</th>
<th
v-for="column in columns"
:key="column.key"
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-5"
:class="`text-${column.align ?? 'left'}`"
class="h-14 first:pl-4 last:pr-4"
:class="[
`text-${column.align ?? 'left'}`,
column.enableSorting ? 'cursor-pointer select-none' : '',
]"
:style="column.width ? { width: column.width } : undefined"
@click="column.enableSorting ? handleSort(column.key) : undefined"
>
<slot
:name="`cell-${column.key}`"
:row="row"
:value="row[column.key]"
:column="column"
:index="getAbsoluteRowIndex(rowIndex)"
>
{{ row[column.key] ?? '' }}
<slot :name="`header-${column.key}`" :column="column">
<span
v-if="column.label || column.enableSorting"
class="inline-flex min-w-0 max-w-full items-center gap-1 font-semibold"
:class="`${sortColumn === column.key ? 'text-contrast' : ''}`"
>
<span class="min-w-0 truncate">{{ column.label ?? '' }}</span>
<template v-if="column.enableSorting">
<ChevronUpIcon
v-if="sortColumn === column.key && sortDirection === 'asc'"
class="size-4 shrink-0"
/>
<ChevronDownIcon
v-else-if="sortColumn === column.key && sortDirection === 'desc'"
class="size-4 shrink-0"
/>
</template>
</span>
</slot>
</th>
</tr>
</thead>
<tbody :ref="setListContainer">
<tr v-if="data.length === 0" class="bg-surface-2">
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-5 p-0">
<slot name="empty-state">
<div class="text-secondary flex h-64 items-center justify-center">
No data available.
</div>
</slot>
</td>
</tr>
<tr v-if="virtualized && bottomSpacerHeight > 0" aria-hidden="true">
<td
:colspan="columnSpan"
class="border-0 p-0"
:style="{ height: `${bottomSpacerHeight}px` }"
></td>
</tr>
</template>
</tbody>
</table>
<template v-else>
<tr v-if="virtualized && topSpacerHeight > 0" aria-hidden="true">
<td
:colspan="columnSpan"
class="border-0 p-0"
:style="{ height: `${topSpacerHeight}px` }"
></td>
</tr>
<tr
v-for="(row, rowIndex) in renderedRows"
:key="getRowRenderKey(row, getAbsoluteRowIndex(rowIndex))"
:class="getAbsoluteRowIndex(rowIndex) % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'"
>
<td
v-if="showSelection"
class="w-12 border-solid border-0 border-t border-surface-5 focus:outline-none"
>
<Checkbox
:model-value="isSelected(row)"
class="shrink-0 p-4 -outline-offset-[14px] outline rounded-2xl"
@update:model-value="(selectRow, event) => toggleSelection(row, selectRow, event)"
/>
</td>
<td
v-for="column in columns"
:key="column.key"
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-5"
:class="`text-${column.align ?? 'left'}`"
>
<slot
:name="`cell-${column.key}`"
:row="row"
:value="row[column.key]"
:column="column"
:index="getAbsoluteRowIndex(rowIndex)"
>
{{ row[column.key] ?? '' }}
</slot>
</td>
</tr>
<tr v-if="virtualized && bottomSpacerHeight > 0" aria-hidden="true">
<td
:colspan="columnSpan"
class="border-0 p-0"
:style="{ height: `${bottomSpacerHeight}px` }"
></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
@@ -123,7 +132,7 @@
generic="K extends string = string, T extends Record<string, unknown> = Record<K, unknown>"
>
import { ChevronDownIcon, ChevronUpIcon } from '@modrinth/assets'
import { computed, toRef, useSlots } from 'vue'
import { computed, ref, toRef, useSlots } from 'vue'
import { useVirtualScroll } from '../../composables/virtual-scroll'
import Checkbox from './Checkbox.vue'
@@ -140,6 +149,7 @@ export interface TableColumn<K extends string = string> {
label?: string
align?: TableColumnAlign
enableSorting?: boolean
defaultSortDirection?: SortDirection
/**
* CSS width value for the column.
* Accepts any valid CSS width (e.g., '200px', '20%', '10rem', 'auto', 'fit-content').
@@ -153,9 +163,16 @@ const props = withDefaults(
data: T[] /* Row data for table */
showSelection?: boolean
rowKey?: keyof T /* The key used to uniquely identify each row */
selectionKey?: keyof T /* The key used to identify selectable rows */
selectionData?: T[] /* The complete selectable data set when data is paginated */
selectionIds?: unknown[] /* Complete selectable IDs when callers do not want to retain row objects */
virtualized?: boolean
virtualRowHeight?: number
virtualBufferSize?: number /* The number of extra rows rendered above and below the visible viewport */
/**
* Sets a minimum width for the table content, allowing horizontal overflow below that width.
*/
tableMinWidth?: string
}>(),
{
showSelection: false,
@@ -170,6 +187,7 @@ const selectedIds = defineModel<unknown[]>('selectedIds', { default: () => [] })
const sortColumn = defineModel<string | undefined>('sortColumn')
const sortDirection = defineModel<SortDirection>('sortDirection', { default: 'asc' })
const slots = useSlots()
const selectionAnchorId = ref<unknown>()
const hasHeaderSlot = computed(() => Boolean(slots.header))
const columnSpan = computed(() => Math.max(props.columns.length + (props.showSelection ? 1 : 0), 1))
@@ -201,17 +219,39 @@ const emit = defineEmits<{
sort: [column: string, direction: SortDirection]
}>()
const selectableRows = computed(() => props.selectionData ?? props.data)
const selectableRowIds = computed(
() => props.selectionIds ?? selectableRows.value.map((row) => getSelectionId(row)),
)
const selectedIdSet = computed(() => new Set(selectedIds.value))
const selectedSelectableIdCount = computed(() => {
let count = 0
for (const id of selectableRowIds.value) {
if (selectedIdSet.value.has(id)) {
count++
}
}
return count
})
const allSelected = computed(
() => props.data.length > 0 && selectedIds.value.length === props.data.length,
() =>
selectableRowIds.value.length > 0 &&
selectedSelectableIdCount.value === selectableRowIds.value.length,
)
const someSelected = computed(
() => selectedIds.value.length > 0 && selectedIds.value.length < props.data.length,
() =>
selectedSelectableIdCount.value > 0 &&
selectedSelectableIdCount.value < selectableRowIds.value.length,
)
function getRowId(row: T): unknown {
return row[props.rowKey as keyof T]
}
function getSelectionId(row: T): unknown {
return row[(props.selectionKey ?? props.rowKey) as keyof T]
}
function setListContainer(element: unknown) {
listContainer.value = props.virtualized ? (element as HTMLElement | null) : null
}
@@ -230,31 +270,66 @@ function getRowRenderKey(row: T, rowIndex: number): PropertyKey {
}
function isSelected(row: T): boolean {
return selectedIds.value.includes(getRowId(row))
return selectedIdSet.value.has(getSelectionId(row))
}
function toggleSelection(row: T) {
const id = getRowId(row)
if (isSelected(row)) {
selectedIds.value = selectedIds.value.filter((selectedId) => selectedId !== id)
function toggleSelection(row: T, selectRow: boolean, event?: MouseEvent) {
const id = getSelectionId(row)
const rowIndex = selectableRowIds.value.findIndex((selectableId) => selectableId === id)
const anchorIndex = selectableRowIds.value.findIndex(
(selectableId) => selectableId === selectionAnchorId.value,
)
if (event?.shiftKey && rowIndex !== -1 && anchorIndex !== -1) {
const startIndex = Math.min(rowIndex, anchorIndex)
const endIndex = Math.max(rowIndex, anchorIndex)
const rangeIds = selectableRowIds.value.slice(startIndex, endIndex + 1)
if (selectRow) {
const nextSelectedIds = [...selectedIds.value]
const nextSelectedIdSet = new Set(nextSelectedIds)
for (const rangeId of rangeIds) {
if (!nextSelectedIdSet.has(rangeId)) {
nextSelectedIds.push(rangeId)
nextSelectedIdSet.add(rangeId)
}
}
selectedIds.value = nextSelectedIds
} else {
const rangeIdSet = new Set(rangeIds)
selectedIds.value = selectedIds.value.filter((selectedId) => !rangeIdSet.has(selectedId))
}
} else {
selectedIds.value = [...selectedIds.value, id]
selectedIds.value = selectRow
? [...selectedIds.value, id]
: selectedIds.value.filter((selectedId) => selectedId !== id)
}
selectionAnchorId.value = id
}
function toggleSelectAll(selectAll: boolean) {
selectionAnchorId.value = undefined
if (selectAll) {
selectedIds.value = props.data.map((row) => getRowId(row))
selectedIds.value = [...selectableRowIds.value]
} else {
selectedIds.value = []
}
}
function handleSort(columnKey: string) {
const column = props.columns.find((column) => column.key === columnKey)
const defaultDirection = column?.defaultSortDirection ?? 'asc'
const newDirection: SortDirection =
sortColumn.value === columnKey && sortDirection.value === 'asc' ? 'desc' : 'asc'
sortColumn.value === columnKey && sortDirection.value === defaultDirection
? getOppositeSortDirection(defaultDirection)
: defaultDirection
sortColumn.value = columnKey
sortDirection.value = newDirection
emit('sort', columnKey, newDirection)
}
function getOppositeSortDirection(direction: SortDirection): SortDirection {
return direction === 'asc' ? 'desc' : 'asc'
}
</script>