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
@@ -1,30 +1,7 @@
<template>
<div>
<div class="universal-card">
<h2>Analytics</h2>
<p>
This page shows you the analytics for your project,
<strong>{{ project.title }}</strong
>. You can see the number of downloads, page views and revenue earned for your project, as
well as the total downloads and page views for {{ project.title }} by country.
</p>
</div>
<ChartDisplay :projects="[project]" />
</div>
<AnalyticsDashboard />
</template>
<script setup>
import { injectProjectPageContext } from '@modrinth/ui'
import ChartDisplay from '~/components/ui/charts/ChartDisplay.vue'
const { projectV2: project } = injectProjectPageContext()
<script setup lang="ts">
import AnalyticsDashboard from '~/components/analytics-dashboard/index.vue'
</script>
<style scoped lang="scss">
.markdown-body {
margin-bottom: var(--gap-md);
}
</style>
@@ -0,0 +1,842 @@
<template>
<ConfirmModal
ref="deleteEventModal"
title="Delete analytics event?"
:description="deleteEventDescription"
proceed-label="Delete event"
@proceed="confirmDeleteEvent"
/>
<NewModal
ref="eventModal"
:header="modalMode === 'create' ? 'New event' : 'Edit event'"
width="480px"
max-width="calc(100vw - 2rem)"
:on-hide="resetForm"
:close-on-click-outside="false"
>
<div class="flex flex-col gap-5" @submit.prevent="saveEvent">
<div class="flex flex-col gap-2">
<span class="label__title font-semibold">Title</span>
<StyledInput
id="analytics-event-title"
ref="titleInput"
v-model="form.title"
type="text"
autocomplete="off"
placeholder="Event title..."
:maxlength="120"
/>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="label__title font-semibold">Announcement link (optional)</span>
<ButtonStyled v-if="committedNormalizedAnnouncementUrl" type="transparent" size="small">
<a
:href="committedNormalizedAnnouncementUrl"
target="_blank"
rel="noopener noreferrer"
aria-label="Check announcement link"
title="Check announcement link"
class="text-sm"
>
<ExternalIcon aria-hidden="true" />
Open link
</a>
</ButtonStyled>
</div>
<div class="flex items-center gap-2">
<StyledInput
id="analytics-event-link"
v-model="form.announcementUrl"
type="url"
autocomplete="off"
placeholder="Announcement link..."
wrapper-class="w-full"
@change="commitAnnouncementUrl"
/>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-1">
<span class="label__title font-semibold">Start date ({{ EVENT_TIME_ZONE_LABEL }})</span>
</div>
<DatePicker
id="analytics-event-starts"
v-model="form.startsAt"
enable-time
date-format="Y-m-d H:i"
alt-format="F j, Y at h:i K"
placeholder="Select start..."
input-class="w-full"
wrapper-class="w-full"
show-today
/>
</div>
<div class="flex flex-col gap-2">
<span class="label__title font-semibold"
>End date ({{ EVENT_TIME_ZONE_LABEL }}, optional)</span
>
<DatePicker
id="analytics-event-ends"
v-model="form.endsAt"
enable-time
date-format="Y-m-d H:i"
alt-format="F j, Y at h:i K"
placeholder="Select end..."
input-class="w-full"
wrapper-class="w-full"
show-today
/>
</div>
<div class="flex flex-col gap-2">
<span class="label__title font-semibold">Metric</span>
<MultiSelect
v-model="form.metricKinds"
:options="metricKindOptions"
:clearable="false"
:max-tag-rows="2"
placeholder="Select metrics this applies to"
include-select-all-option
/>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="transparent">
<button @click="eventModal?.hide()">Cancel</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!canSaveEvent || isSaving" @click="saveEvent">
<SaveIcon aria-hidden="true" />
{{ modalMode === 'create' ? 'Create event' : 'Save' }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
<div class="normal-page no-sidebar">
<div class="normal-page__content flex flex-col gap-4">
<div class="mt-8 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<h1 class="m-0 text-2xl font-extrabold text-contrast">Analytics Events</h1>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<StyledInput
v-model="searchQuery"
:icon="SearchIcon"
type="search"
placeholder="Search..."
clearable
wrapper-class="w-full sm:w-72"
/>
<ButtonStyled color="brand">
<button :disabled="isSaving" @click="openCreateModal">
<PlusIcon aria-hidden="true" />
New event
</button>
</ButtonStyled>
</div>
</div>
<Table
v-model:sort-column="sortColumn"
v-model:sort-direction="sortDirection"
:columns="columns"
:data="sortedEvents"
row-key="id"
>
<template #cell-title="{ row }">
<span class="line-clamp-2 font-medium text-primary">{{ row.title }}</span>
</template>
<template #cell-announcement="{ row }">
<a
v-if="row.announcement_url"
:href="row.announcement_url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 font-medium text-primary hover:text-contrast"
>
Open link
<ExternalIcon class="size-4" aria-hidden="true" />
</a>
<span v-else class="text-xs font-medium text-primary"></span>
</template>
<template #cell-date="{ row }">
<div
v-if="isEventDateRange(row)"
class="flex flex-col gap-0.5 text-sm font-medium leading-5 text-primary"
>
<span>{{ formatEventDateRangeStart(row) }} -</span>
<span>{{ formatEventDateRangeEnd(row) }}</span>
</div>
<span v-else class="font-medium text-primary">{{ formatEventDateRange(row) }}</span>
</template>
<template #cell-metrics="{ row }">
<div class="flex flex-wrap gap-1">
<span
v-for="metric in getMetricKindOptions(row.for_metric_kind)"
:key="metric.value"
class="inline-flex items-center rounded-full border border-solid border-surface-5 px-2 py-0.5 text-xs font-medium text-secondary"
>
{{ metric.label }}
</span>
</div>
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end gap-2">
<ButtonStyled circular type="outlined" color="red">
<button
:aria-label="`Delete ${row.title}`"
:disabled="isDeletingEvent(row.id)"
@click="openDeleteEventModal(row)"
>
<TrashIcon aria-hidden="true" />
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button :disabled="isSaving || isDeletingEvent(row.id)" @click="openEditModal(row)">
Edit
<EditIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
</template>
<template #empty-state>
<div class="flex h-64 items-center justify-center text-secondary">
{{ isLoadingEvents ? 'Loading analytics events...' : 'No results.' }}
</div>
</template>
</Table>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { EditIcon, ExternalIcon, PlusIcon, SaveIcon, SearchIcon, TrashIcon } from '@modrinth/assets'
import {
ButtonStyled,
ConfirmModal,
DatePicker,
injectModrinthClient,
injectNotificationManager,
MultiSelect,
type MultiSelectOption,
NewModal,
type SortDirection,
StyledInput,
Table,
type TableColumn,
} from '@modrinth/ui'
import { isAdmin } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
definePageMeta({
middleware: [
'auth',
async () => {
const auth = await useAuth()
if (!auth.value.user || !isAdmin(auth.value.user)) {
throw createError({
fatal: true,
statusCode: 401,
statusMessage: 'Unauthorized',
})
}
},
],
})
type EventColumnKey = 'title' | 'announcement' | 'date' | 'metrics' | 'actions'
type AnalyticsEventMetricKind = Labrinth.Analytics.v3.AnalyticsEventMetricKind
type AnalyticsEventRow = Labrinth.Analytics.v3.AnalyticsEvent & {
announcement: string
date: string
metrics: string
actions: string
}
type EventForm = {
title: string
announcementUrl: string
startsAt: DatePickerValue
endsAt: DatePickerValue
metricKinds: AnalyticsEventMetricKind[]
}
type DatePickerValue = string | Date | null | undefined
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const analyticsEventsQueryKey = ['analytics-events'] as const
const EVENT_TIME_ZONE = 'America/Los_Angeles'
const EVENT_TIME_ZONE_LABEL = 'PST'
const columns: TableColumn<EventColumnKey>[] = [
{ key: 'date', label: 'Date (PST)', width: '18%', enableSorting: true },
{ key: 'title', label: 'Title' },
{ key: 'announcement', label: 'Announcement link', width: '18%' },
{ key: 'metrics', label: 'Metric', width: '18%' },
{ key: 'actions', label: 'Actions', width: '15%', align: 'right' },
]
const metricKindOptions: MultiSelectOption<AnalyticsEventMetricKind>[] = [
{ value: 'views', label: 'Views' },
{ value: 'downloads', label: 'Downloads' },
{ value: 'revenue', label: 'Revenue' },
{ value: 'playtime', label: 'Playtime' },
]
const allMetricKinds = metricKindOptions.map((option) => option.value)
const deleteEventModal = ref<InstanceType<typeof ConfirmModal> | null>(null)
const eventModal = ref<InstanceType<typeof NewModal> | null>(null)
const titleInput = ref<InstanceType<typeof StyledInput> | null>(null)
const searchQuery = ref('')
const sortColumn = ref<EventColumnKey | undefined>('date')
const sortDirection = ref<SortDirection>('desc')
const modalMode = ref<'create' | 'edit'>('create')
const editingEventId = ref<Labrinth.Analytics.v3.AnalyticsEventId | null>(null)
const pendingDeleteEvent = ref<Labrinth.Analytics.v3.AnalyticsEvent | null>(null)
const form = ref<EventForm>(getEmptyForm())
const isSaving = ref(false)
const deletingEventIds = ref(new Set<Labrinth.Analytics.v3.AnalyticsEventId>())
const notifiedEventsErrorMessage = ref<string | null>(null)
const committedAnnouncementUrl = ref('')
let resetFormTimeout: ReturnType<typeof setTimeout> | null = null
const {
data: analyticsEvents,
error: eventsError,
isLoading: isLoadingEvents,
} = useQuery({
queryKey: analyticsEventsQueryKey,
queryFn: () => client.labrinth.analytics_v3.getEvents(),
placeholderData: [],
refetchOnMount: 'always',
retry: false,
staleTime: 0,
})
watch(eventsError, (error) => {
if (!error) {
notifiedEventsErrorMessage.value = null
return
}
const message = error.message
if (notifiedEventsErrorMessage.value === message) {
return
}
notifiedEventsErrorMessage.value = message
addNotification({
title: 'Failed to load analytics events',
text: message,
type: 'error',
})
})
onBeforeUnmount(() => {
clearResetFormTimeout()
})
const trimmedSearchQuery = computed(() => searchQuery.value.trim().toLowerCase())
const normalizedAnnouncementUrl = computed(() => normalizeUrl(form.value.announcementUrl))
const committedNormalizedAnnouncementUrl = computed(() =>
normalizeUrl(committedAnnouncementUrl.value),
)
const canSaveEvent = computed(
() =>
form.value.title.trim().length > 0 &&
Boolean(getEventFormDateRange()) &&
form.value.metricKinds.length > 0,
)
const deleteEventDescription = computed(() => {
if (!pendingDeleteEvent.value) {
return 'This analytics event will be deleted. This cannot be undone.'
}
return `This will delete "${pendingDeleteEvent.value.title}" from analytics events. This cannot be undone.`
})
const eventRows = computed<AnalyticsEventRow[]>(() =>
(analyticsEvents.value ?? []).map((event) => ({
...event,
announcement: '',
date: '',
metrics: '',
actions: '',
})),
)
const filteredEvents = computed(() => {
if (!trimmedSearchQuery.value) {
return eventRows.value
}
return eventRows.value.filter((event) => {
const dateRange = formatEventDateRange(event).toLowerCase()
return [event.title, event.announcement_url ?? '', dateRange].some((value) =>
value.toLowerCase().includes(trimmedSearchQuery.value),
)
})
})
const sortedEvents = computed(() => {
const sorted = [...filteredEvents.value]
if (sortColumn.value === 'date') {
sorted.sort((left, right) => {
const direction = sortDirection.value === 'asc' ? 1 : -1
return (getDateTime(left.starts) - getDateTime(right.starts)) * direction
})
}
return sorted
})
function getEmptyForm(): EventForm {
return {
title: '',
announcementUrl: '',
startsAt: '',
endsAt: '',
metricKinds: [],
}
}
function openCreateModal() {
modalMode.value = 'create'
editingEventId.value = null
form.value = {
...getEmptyForm(),
}
committedAnnouncementUrl.value = ''
clearResetFormTimeout()
eventModal.value?.show()
void focusTitleInput()
}
function openEditModal(event: Labrinth.Analytics.v3.AnalyticsEvent) {
modalMode.value = 'edit'
editingEventId.value = event.id
form.value = {
title: event.title,
announcementUrl: event.announcement_url ?? '',
startsAt: getDateTimeInputValue(event.starts),
endsAt: getDateTimeInputValue(event.ends),
metricKinds: event.for_metric_kind?.length ? [...event.for_metric_kind] : [...allMetricKinds],
}
committedAnnouncementUrl.value = event.announcement_url ?? ''
clearResetFormTimeout()
eventModal.value?.show()
void focusTitleInput()
}
function openDeleteEventModal(event: Labrinth.Analytics.v3.AnalyticsEvent) {
pendingDeleteEvent.value = event
deleteEventModal.value?.show()
}
async function focusTitleInput() {
await nextTick()
setTimeout(() => {
titleInput.value?.focus()
}, 75)
}
async function saveEvent() {
if (!canSaveEvent.value || isSaving.value) {
return
}
isSaving.value = true
try {
const payload = buildEventPayload()
if (modalMode.value === 'edit' && editingEventId.value !== null) {
await client.labrinth.analytics_v3.editEvent(editingEventId.value, payload)
} else {
await client.labrinth.analytics_v3.createEvent(payload)
}
await queryClient.invalidateQueries({ queryKey: analyticsEventsQueryKey })
eventModal.value?.hide()
addNotification({
title: modalMode.value === 'edit' ? 'Analytics event updated' : 'Analytics event created',
type: 'success',
})
} catch (error) {
addNotification({
title:
modalMode.value === 'edit'
? 'Failed to update analytics event'
: 'Failed to create analytics event',
text: getErrorMessage(error),
type: 'error',
})
} finally {
isSaving.value = false
}
}
async function confirmDeleteEvent() {
if (!pendingDeleteEvent.value) {
return
}
const eventId = pendingDeleteEvent.value.id
pendingDeleteEvent.value = null
await deleteEvent(eventId)
}
async function deleteEvent(eventId: Labrinth.Analytics.v3.AnalyticsEventId) {
if (isDeletingEvent(eventId)) {
return
}
setDeletingEvent(eventId, true)
try {
await client.labrinth.analytics_v3.deleteEvent(eventId)
await queryClient.invalidateQueries({ queryKey: analyticsEventsQueryKey })
addNotification({
title: 'Analytics event deleted',
type: 'success',
})
} catch (error) {
addNotification({
title: 'Failed to delete analytics event',
text: getErrorMessage(error),
type: 'error',
})
} finally {
setDeletingEvent(eventId, false)
}
}
function resetForm() {
clearResetFormTimeout()
resetFormTimeout = setTimeout(() => {
form.value = getEmptyForm()
editingEventId.value = null
committedAnnouncementUrl.value = ''
resetFormTimeout = null
}, 500)
}
function clearResetFormTimeout() {
if (!resetFormTimeout) {
return
}
clearTimeout(resetFormTimeout)
resetFormTimeout = null
}
function normalizeUrl(value: string): string | undefined {
const trimmed = value.trim()
if (!trimmed) {
return undefined
}
if (/^https?:\/\//i.test(trimmed)) {
return trimmed
}
return `https://${trimmed}`
}
function commitAnnouncementUrl() {
committedAnnouncementUrl.value = form.value.announcementUrl
}
function buildEventPayload(): Labrinth.Analytics.v3.AnalyticsEventUpsert {
const selectedRange = getEventFormDateRange()
if (!selectedRange) {
throw new Error('Select a valid start and end date')
}
const starts = parseDateTimeInputValue(selectedRange[0]).toISOString()
const ends = parseDateTimeInputValue(selectedRange[1]).toISOString()
return {
announcement_url: normalizedAnnouncementUrl.value ?? null,
for_metric_kind: [...form.value.metricKinds],
title: form.value.title.trim(),
ends,
starts,
}
}
function getMetricKindOptions(
metricKinds: AnalyticsEventMetricKind[] | null,
): MultiSelectOption<AnalyticsEventMetricKind>[] {
const visibleKinds = metricKinds?.length ? metricKinds : allMetricKinds
return metricKindOptions.filter((option) => visibleKinds.includes(option.value))
}
function formatEventDateRange(event: Labrinth.Analytics.v3.AnalyticsEvent): string {
const startDate = new Date(event.starts)
const endDate = new Date(event.ends)
const startDateValue = getDateInputValueInTimeZone(startDate, EVENT_TIME_ZONE)
const endDateValue = getDateInputValueInTimeZone(endDate, EVENT_TIME_ZONE)
if (startDate.getTime() === endDate.getTime()) {
return formatDateTime(startDate)
}
const sameYear = startDateValue.slice(0, 4) === endDateValue.slice(0, 4)
const sameMonth = sameYear && startDateValue.slice(5, 7) === endDateValue.slice(5, 7)
const sameDay = startDateValue === endDateValue
if (sameDay) {
return `${formatLongDate(startDate)}, ${formatTime(startDate)} - ${formatTime(endDate)}`
}
if (sameMonth) {
return `${formatMonthDayTime(startDate)} - ${formatMonthDayTime(endDate)}, ${endDateValue.slice(0, 4)}`
}
if (sameYear) {
return `${formatMonthDayTime(startDate)} - ${formatLongDateTime(endDate)}`
}
return `${formatLongDateTime(startDate)} - ${formatLongDateTime(endDate)}`
}
function isEventDateRange(event: Labrinth.Analytics.v3.AnalyticsEvent): boolean {
return new Date(event.starts).getTime() !== new Date(event.ends).getTime()
}
function formatEventDateRangeStart(event: Labrinth.Analytics.v3.AnalyticsEvent): string {
return formatLongDateTime(new Date(event.starts))
}
function formatEventDateRangeEnd(event: Labrinth.Analytics.v3.AnalyticsEvent): string {
return formatLongDateTime(new Date(event.ends))
}
function getEventFormDateRange(): [string, string] | null {
const startValue = getDatePickerValueString(form.value.startsAt)
const endValue = isEmptyDatePickerValue(form.value.endsAt)
? startValue
: getDatePickerValueString(form.value.endsAt)
if (!startValue || !endValue) {
return null
}
const startDate = parseDateTimeInputValue(startValue)
const endDate = parseDateTimeInputValue(endValue)
if (startDate.getTime() > endDate.getTime()) {
return null
}
return [startValue, endValue]
}
function isEmptyDatePickerValue(value: DatePickerValue): boolean {
return value === '' || value === null || value === undefined
}
function getDatePickerValueString(value: DatePickerValue): string | null {
if (typeof value === 'string') {
return isValidDateTimeInputValue(value) ? value : null
}
if (value instanceof Date && !Number.isNaN(value.getTime())) {
return formatDateTimeInputValue(value)
}
return null
}
function isValidDateTimeInputValue(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(value)) return false
const parsedDate = parseDateTimeInputValue(value)
return (
!Number.isNaN(parsedDate.getTime()) &&
formatDateTimeInputValueInTimeZone(parsedDate, EVENT_TIME_ZONE) === value
)
}
function getDateTimeInputValue(value: string): string {
const date = new Date(value)
return Number.isNaN(date.getTime())
? ''
: formatDateTimeInputValueInTimeZone(date, EVENT_TIME_ZONE)
}
function formatDateTimeInputValue(date: Date): string {
return formatDateTimeInputValueInTimeZone(date, EVENT_TIME_ZONE)
}
function formatDateTimeInputValueInTimeZone(date: Date, timeZone: string): string {
const parts = getTimeZoneDateParts(date, timeZone)
if (!parts) return ''
const year = `${parts.year}`.padStart(4, '0')
const month = `${parts.month}`.padStart(2, '0')
const day = `${parts.day}`.padStart(2, '0')
const hours = `${parts.hour}`.padStart(2, '0')
const minutes = `${parts.minute}`.padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
function getDateInputValueInTimeZone(date: Date, timeZone: string): string {
const parts = getTimeZoneDateParts(date, timeZone)
if (!parts) return ''
const year = `${parts.year}`.padStart(4, '0')
const month = `${parts.month}`.padStart(2, '0')
const day = `${parts.day}`.padStart(2, '0')
return `${year}-${month}-${day}`
}
function parseDateTimeInputValue(value: string): Date {
return getDateTimeInTimeZone(value, EVENT_TIME_ZONE)
}
function getDateTimeInTimeZone(value: string, timeZone: string): Date {
const match = value.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/)
if (!match) return new Date(Number.NaN)
const [, yearValue, monthValue, dayValue, hourValue, minuteValue] = match
const year = Number(yearValue)
const month = Number(monthValue)
const day = Number(dayValue)
const hour = Number(hourValue)
const minute = Number(minuteValue)
const utcGuess = Date.UTC(year, month - 1, day, hour, minute)
let offset = getTimeZoneOffsetMs(new Date(utcGuess), timeZone)
offset = getTimeZoneOffsetMs(new Date(utcGuess - offset), timeZone)
return new Date(utcGuess - offset)
}
function getTimeZoneOffsetMs(date: Date, timeZone: string): number {
const parts = getTimeZoneDateParts(date, timeZone)
if (!parts) return 0
const zonedDateAsUtc = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second,
)
return zonedDateAsUtc - date.getTime()
}
function getTimeZoneDateParts(date: Date, timeZone: string) {
if (Number.isNaN(date.getTime())) return null
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
}).formatToParts(date)
const valueByType = Object.fromEntries(parts.map((part) => [part.type, part.value]))
return {
year: Number(valueByType.year),
month: Number(valueByType.month),
day: Number(valueByType.day),
hour: Number(valueByType.hour),
minute: Number(valueByType.minute),
second: Number(valueByType.second),
}
}
function formatDateTime(date: Date): string {
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZone: EVENT_TIME_ZONE,
}).format(date)
}
function formatLongDateTime(date: Date): string {
return `${formatLongDate(date)}, ${formatTime(date)}`
}
function formatMonthDayTime(date: Date): string {
return `${formatMonthDay(date)}, ${formatTime(date)}`
}
function formatLongDate(date: Date): string {
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: EVENT_TIME_ZONE,
}).format(date)
}
function formatMonthDay(date: Date): string {
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
timeZone: EVENT_TIME_ZONE,
}).format(date)
}
function formatTime(date: Date): string {
return new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: '2-digit',
timeZone: EVENT_TIME_ZONE,
}).format(date)
}
function getDateTime(value: string): number {
return Date.parse(value)
}
function isDeletingEvent(eventId: Labrinth.Analytics.v3.AnalyticsEventId): boolean {
return deletingEventIds.value.has(eventId)
}
function setDeletingEvent(eventId: Labrinth.Analytics.v3.AnalyticsEventId, deleting: boolean) {
const nextIds = new Set(deletingEventIds.value)
if (deleting) {
nextIds.add(eventId)
} else {
nextIds.delete(eventId)
}
deletingEventIds.value = nextIds
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
</script>
@@ -1,30 +1,14 @@
<template>
<div>
<Suspense>
<ChartDisplay :projects="projects" :personal="true" />
<template #fallback>
<div class="universal-card">
<h2><span class="label__title">Loading analytics...</span></h2>
</div>
</template>
</Suspense>
</div>
<AnalyticsDashboard />
</template>
<script setup>
import {
commonProjectSettingsMessages,
injectModrinthClient,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import { commonProjectSettingsMessages, useVIntl } from '@modrinth/ui'
import ChartDisplay from '~/components/ui/charts/ChartDisplay.vue'
import AnalyticsDashboard from '~/components/analytics-dashboard/index.vue'
const { formatMessage } = useVIntl()
const debug = useDebugLogger('analytics.vue')
definePageMeta({
middleware: 'auth',
})
@@ -32,14 +16,4 @@ definePageMeta({
useHead({
title: () => `${formatMessage(commonProjectSettingsMessages.analytics)} - Modrinth`,
})
const auth = await useAuth()
const client = injectModrinthClient()
const id = auth.value?.user?.id
debug('auth resolved', { id })
const projects = await client.labrinth.users_v2.getProjects(id)
debug('projects resolved', { count: projects?.length })
</script>
@@ -5,7 +5,7 @@
:preloaded-payment-data="preloadedPaymentMethods"
@refresh-data="refreshData"
/>
<div class="mb-20 flex flex-col gap-6 lg:pl-8">
<div class="mb-20 flex flex-col gap-6 lg:pl-4 lg:pt-1.5">
<div class="flex flex-col gap-4 md:gap-5">
<div class="flex flex-col gap-1">
<span class="text-xl font-semibold text-contrast md:text-2xl">{{
@@ -1,28 +1,9 @@
<template>
<div class="normal-page__content">
<div class="universal-card">
<h2>Analytics</h2>
<p>
This page shows you the analytics for your organization's projects. You can see the number
of downloads, page views and revenue earned for all of your projects, as well as the total
downloads and page views for each project by country.
</p>
</div>
<ChartDisplay :projects="projects.map((x) => ({ title: x.name, ...x }))" />
<AnalyticsDashboard />
</div>
</template>
<script setup>
import ChartDisplay from '~/components/ui/charts/ChartDisplay.vue'
import { injectOrganizationContext } from '~/providers/organization-context.ts'
const { projects } = injectOrganizationContext()
<script setup lang="ts">
import AnalyticsDashboard from '~/components/analytics-dashboard/index.vue'
</script>
<style scoped lang="scss">
.markdown-body {
margin-bottom: var(--gap-md);
}
</style>