Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
0301f4a
feat: implement analytics route in api client
tdgao Apr 21, 2026
1db663b
remove: delete current analytics implementation
tdgao Apr 22, 2026
e09845b
feat: wire up shared analytics dashboard page
tdgao Apr 22, 2026
1c368d8
feat: initial implementation of analytics DI, query builder component…
tdgao Apr 22, 2026
661765d
feat: style consistency updates
tdgao Apr 22, 2026
5234270
feat: implement analytics chart
tdgao Apr 22, 2026
31f13a2
feat: improve query builder styles
tdgao Apr 22, 2026
e52d642
feat: add query to url params for query builder
tdgao Apr 22, 2026
97fde95
feat: implement analytics table and breakdown
tdgao Apr 23, 2026
5297b17
fix: date display to show time conditionally
tdgao Apr 23, 2026
fbc996b
fix: query builder disable group-by options if not relavant
tdgao Apr 23, 2026
6ce8be3
feat: style improvements
tdgao Apr 23, 2026
270e73f
remove: events toggle button for now since it does nothing
tdgao Apr 23, 2026
fd07f8e
fix: type error
tdgao Apr 23, 2026
8840a13
refactor: pnpm prepr
tdgao Apr 23, 2026
3daa527
feat: improve query builder styles and timeframes
tdgao Apr 23, 2026
b126bc4
feat: add table empty state
tdgao Apr 23, 2026
d5239bd
feat: implement disabled statcard for non-applicable ones
tdgao Apr 23, 2026
bfdb386
refactor: object destructure for context
tdgao Apr 23, 2026
f17b978
feat: filter server projects
tdgao Apr 24, 2026
ec723c7
feat: style improvements to project select
tdgao Apr 27, 2026
4231250
feat: separate out query filter component
tdgao Apr 27, 2026
34b392b
fix: type
tdgao Apr 27, 2026
43abf27
fix: triangle safe area for query filter sub menus
tdgao Apr 28, 2026
ff0968b
implement header slot for table
tdgao Apr 28, 2026
5cf6941
feat: implement multiselect input content slow
tdgao Apr 28, 2026
329cc3b
feat: use mutliselect for active filtered by options and use table he…
tdgao Apr 28, 2026
40d8007
Merge branch 'main' into truman/analytics
tdgao Apr 28, 2026
8694971
refactor: pnpm prepr
tdgao Apr 28, 2026
8c7e485
fix: broken lock file
tdgao Apr 28, 2026
34b736f
feat: implement adding project id analytics
tdgao Apr 28, 2026
df8aff2
feat: hide/show specific series in graph, formatted legend labels, lo…
tdgao Apr 28, 2026
849edf0
fix: queries not caching properly
tdgao Apr 28, 2026
53a04cf
feat: update columns widths
tdgao Apr 28, 2026
628712e
refactor: pnpm prepr
tdgao Apr 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"@vueuse/core": "^11.1.0",
"ace-builds": "^1.36.2",
"ansi-to-html": "^0.7.2",
"chart.js": "^4.5.1",
"dayjs": "^1.11.7",
"dompurify": "^3.1.7",
"floating-vue": "^5.2.2",
Expand Down
35 changes: 35 additions & 0 deletions apps/frontend/src/components/analytics/AnalyticsDashboard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<template>
<div class="flex flex-col gap-4 pb-20">
<QueryBuilder />
<StatCards />
<AnalyticsGraph />
<AnalyticsTable />
</div>
</template>

<script setup lang="ts">
import { injectProjectPageContext } from '@modrinth/ui'
import {
createAnalyticsDashboardContext,
provideAnalyticsDashboardContext,
} from '~/providers/analytics/analytics'
import { injectOrganizationContext } from '~/providers/organization-context'
import AnalyticsGraph from './graph/AnalyticsGraph.vue'
import QueryBuilder from './query-builder/QueryBuilder.vue'
import StatCards from './stat-cards/StatCards.vue'
import AnalyticsTable from './table/AnalyticsTable.vue'
const auth = await useAuth()
const projectPageContext = injectProjectPageContext(null)
const organizationContext = injectOrganizationContext(null)
const analyticsDashboardContext = createAnalyticsDashboardContext({
auth,
projectPageContext,
organizationContext,
})
provideAnalyticsDashboardContext(analyticsDashboardContext)
</script>
38 changes: 38 additions & 0 deletions apps/frontend/src/components/analytics/breakdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { Labrinth } from '@modrinth/api-client'

import type { AnalyticsBreakdownPreset } from '~/providers/analytics/analytics'

export const ALL_BREAKDOWN_VALUE = 'All'

export function getAnalyticsBreakdownValue(
point: Labrinth.Analytics.v3.ProjectAnalytics,
selectedBreakdown: AnalyticsBreakdownPreset,
): string {
switch (selectedBreakdown) {
case 'none':
return ALL_BREAKDOWN_VALUE
case 'country':
return normalizeBreakdownValue('country' in point ? point.country : undefined)
case 'monetization': {
if ('monetized' in point && typeof point.monetized === 'boolean') {
return point.monetized ? 'monetized' : 'unmonetized'
}
return ALL_BREAKDOWN_VALUE
}
case 'download_source':
return normalizeBreakdownValue('domain' in point ? point.domain : undefined)
case 'download_type':
return normalizeBreakdownValue('version_id' in point ? point.version_id : undefined)
case 'loader':
return normalizeBreakdownValue('loader' in point ? point.loader : undefined)
case 'game_version':
return normalizeBreakdownValue('game_version' in point ? point.game_version : undefined)
default:
return ALL_BREAKDOWN_VALUE
}
}

function normalizeBreakdownValue(value: string | undefined): string {
const normalized = value?.trim()
return normalized && normalized.length > 0 ? normalized : ALL_BREAKDOWN_VALUE
}
267 changes: 267 additions & 0 deletions apps/frontend/src/components/analytics/graph/AnalyticsChart.client.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
<template>
<canvas ref="canvasRef" />
</template>

<script setup lang="ts">
import { useCompactNumber } from '@modrinth/ui'
import {
BarController,
BarElement,
CategoryScale,
Chart,
type ChartConfiguration,
Filler,
LinearScale,
LineController,
LineElement,
PointElement,
Tooltip,
} from 'chart.js'

import type { AnalyticsDashboardStat } from '~/providers/analytics/analytics'

import { type ChartDataset, formatAxisValue } from './utils'

Chart.register(
LineController,
BarController,
LineElement,
BarElement,
PointElement,
CategoryScale,
LinearScale,
Filler,
Tooltip,
)

export type AnalyticsChartHoverPayload = {
visible: boolean
x: number
y: number
sliceIndex: number | null
}

const props = defineProps<{
type: 'line' | 'bar'
fill: boolean
stacked: boolean
datasets: ChartDataset[]
labels: string[]
activeStat: AnalyticsDashboardStat
pinnedSliceIndex: number | null
}>()

const emit = defineEmits<{
(event: 'hover', payload: AnalyticsChartHoverPayload): void
}>()

const canvasRef = ref<HTMLCanvasElement | null>(null)
let chartInstance: Chart | null = null

const { formatCompactNumber } = useCompactNumber()

type ExternalTooltipHandler = NonNullable<
NonNullable<NonNullable<ChartConfiguration['options']>['plugins']>['tooltip']
>['external']
type ExternalTooltipContext = Parameters<Exclude<ExternalTooltipHandler, undefined>>[0]

function withAlpha(color: string, alpha: number): string {
const match = /^#([0-9a-f]{6})$/i.exec(color)
if (!match) return color
const r = Number.parseInt(match[1].slice(0, 2), 16)
const g = Number.parseInt(match[1].slice(2, 4), 16)
const b = Number.parseInt(match[1].slice(4, 6), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}

function buildDatasets() {
return props.datasets.map((dataset, index) => {
const common = {
label: dataset.label,
data: dataset.data,
borderColor: dataset.borderColor,
borderWidth: 2,
}

if (props.type === 'bar') {
return {
...common,
backgroundColor: withAlpha(dataset.backgroundColor, 0.85),
borderWidth: 0,
stack: props.stacked ? 'analytics' : undefined,
}
}

const lineFill: 'origin' | '-1' | false = props.fill ? (index === 0 ? 'origin' : '-1') : false

return {
...common,
backgroundColor: props.fill
? withAlpha(dataset.backgroundColor, 0.3)
: dataset.backgroundColor,
fill: lineFill,
tension: 0.35,
pointRadius: 0,
pointHoverRadius: 4,
pointHitRadius: 16,
stack: props.stacked ? 'analytics' : undefined,
}
})
}

function buildConfig(): ChartConfiguration {
return {
type: props.type,
data: {
labels: props.labels,
datasets: buildDatasets() as ChartConfiguration['data']['datasets'],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: { display: false },
tooltip: {
enabled: false,
external: handleExternalTooltip,
},
},
scales: {
x: {
stacked: props.stacked && props.type === 'bar',
grid: { display: false },
ticks: {
maxTicksLimit: 8,
autoSkip: true,
color: 'rgba(148, 163, 184, 0.9)',
},
border: { color: 'rgba(148, 163, 184, 0.35)' },
},
y: {
stacked: props.stacked,
beginAtZero: true,
grid: {
color: 'rgba(148, 163, 184, 0.15)',
},
border: { display: false },
ticks: {
color: 'rgba(148, 163, 184, 0.9)',
callback: (tickValue) => {
const numeric =
typeof tickValue === 'number' ? tickValue : Number.parseFloat(String(tickValue))
if (!Number.isFinite(numeric)) return String(tickValue)
return formatAxisValue(numeric, props.activeStat, formatCompactNumber)
},
},
},
},
},
}
}

function handleExternalTooltip(context: ExternalTooltipContext) {
const tooltip = context.tooltip
if (!tooltip || tooltip.opacity === 0) {
emit('hover', { visible: false, x: 0, y: 0, sliceIndex: null })
return
}
const sliceIndex = tooltip.dataPoints?.[0]?.dataIndex ?? null
emit('hover', {
visible: true,
x: tooltip.caretX,
y: tooltip.caretY,
sliceIndex,
})
}

function createChart() {
if (!canvasRef.value) return
chartInstance = new Chart(canvasRef.value, buildConfig())
}

function refreshChart() {
if (!chartInstance) return
const config = buildConfig()
chartInstance.data = config.data
chartInstance.options = config.options ?? {}
chartInstance.update('none')
applyPinnedSliceState()
}

function applyPinnedSliceState() {
if (!chartInstance) return

if (props.pinnedSliceIndex === null) {
chartInstance.setActiveElements([])
chartInstance.update('none')
return
}

const activeElements: { datasetIndex: number; index: number }[] = []
for (let datasetIndex = 0; datasetIndex < chartInstance.data.datasets.length; datasetIndex++) {
const dataset = chartInstance.data.datasets[datasetIndex]
if (!dataset) continue

const dataLength = Array.isArray(dataset.data) ? dataset.data.length : 0
if (props.pinnedSliceIndex >= dataLength) continue

activeElements.push({
datasetIndex,
index: props.pinnedSliceIndex,
})
}

chartInstance.setActiveElements(activeElements)
chartInstance.update('none')
}

function handleCanvasLeave() {
emit('hover', { visible: false, x: 0, y: 0, sliceIndex: null })
if (props.pinnedSliceIndex !== null) {
requestAnimationFrame(() => applyPinnedSliceState())
}
}

onMounted(() => {
createChart()
canvasRef.value?.addEventListener('mouseleave', handleCanvasLeave)
})

onBeforeUnmount(() => {
canvasRef.value?.removeEventListener('mouseleave', handleCanvasLeave)
chartInstance?.destroy()
chartInstance = null
})

watch(
() => [props.type, props.fill, props.stacked],
() => {
chartInstance?.destroy()
chartInstance = null
nextTick(() => {
createChart()
applyPinnedSliceState()
})
},
)

watch(
() => [props.datasets, props.labels, props.activeStat],
() => {
refreshChart()
},
{ deep: true },
)

watch(
() => props.pinnedSliceIndex,
() => {
applyPinnedSliceState()
},
)
</script>
Loading
Loading