fix: analytics post release bugs (#6291)

* fix: previous period data was included in the table

* fix: revenue displaying stale data when viewing it from different metric and grouped by 6 hour or 1 hour

* fix: remove staletime on analytics query so switching tabs does not refersh query

* feat: add monetization alert

* fix-small: missing space in tooltip

* fix: incorrect y-axis formatting for trailing decimal 0s

* fix: switching tabs resets table series selection due to other refetches

* fix: always show month first in chart tooltip

* fix: change all time start date to be project published date

* fix: increase length on project name column

* fix: unknown download source data points not showing for download source breakdown

* fix: double unknown for loader

* fix: no data on country labeling incorrectly as "Unknown" instead of "Other"

* fix: date picker number inputs showing arrows

* fix: stat card showing enormous percentage for prev period by switching it to absolute value difference after 1000%

* fix: decimal values for playtime being rounded badly, resulting in 0.04 becoming 0.0

* fix: chips having stroke

* refactor: pnpm prepr

* fix: spacing in annoucement link

* fix: legend scroll shadow on top of event tooltip
This commit is contained in:
Truman Gao
2026-06-03 12:27:31 -06:00
committed by GitHub
parent b1cd16f966
commit 8371ff641a
22 changed files with 343 additions and 87 deletions
@@ -10,7 +10,7 @@
>
<div
v-if="showLegendTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-5 bg-gradient-to-b from-surface-3 to-transparent"
class="z-1 pointer-events-none absolute left-0 right-0 top-0 h-5 bg-gradient-to-b from-surface-3 to-transparent"
/>
</Transition>
@@ -99,7 +99,7 @@
>
<div
v-if="showLegendBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-5 bg-gradient-to-t from-surface-3 to-transparent"
class="z-1 pointer-events-none absolute bottom-0 left-0 right-0 h-5 bg-gradient-to-t from-surface-3 to-transparent"
/>
</Transition>
</div>
@@ -132,7 +132,7 @@
:href="event.announcement_url"
target="_blank"
rel="noopener noreferrer"
class="mt-1.5 inline-flex items-center gap-1 text-sm font-medium text-primary underline !transition-all hover:text-contrast"
class="my-0.5 inline-flex items-center gap-1 text-xs font-medium text-primary underline !transition-all hover:text-contrast"
>
{{ formatMessage(analyticsChartMessages.seeAnnouncement) }}
<ExternalIcon class="size-3.5" aria-hidden="true" />
@@ -18,7 +18,7 @@
({{ durationLabel }})
</span>
</span>
<span v-if="previousRangeLabel" class="min-w-0 truncate text-xs text-primary">
<span v-if="previousRangeLabel" class="min-w-0 space-x-1 truncate text-xs text-primary">
<span class="font-medium">{{ previousRangeLabel }}</span>
<span class="font-normal text-secondary">
{{ formatMessage(analyticsChartMessages.previousPeriodShort) }}
@@ -197,6 +197,7 @@ function getEntryAriaLabel(entry: AnalyticsChartTooltipEntry) {
const ONE_DAY_MS = 24 * 60 * 60 * 1000
const ONE_HOUR_MS = 60 * 60 * 1000
const ONE_MINUTE_MS = 60 * 1000
const DATE_LOCALE = 'en-US'
function formatRangeLabel(
start: Date,
@@ -225,13 +226,13 @@ function formatRangeLabel(
}
if (includeTime) {
const startLabel = new Intl.DateTimeFormat(undefined, startOptions).format(start)
const endLabel = new Intl.DateTimeFormat(undefined, timeOptions).format(end)
const startLabel = new Intl.DateTimeFormat(DATE_LOCALE, startOptions).format(start)
const endLabel = new Intl.DateTimeFormat(DATE_LOCALE, timeOptions).format(end)
const range = `${startLabel}${endLabel}`
if (!showTrailingYear) return range
const yearLabel = new Intl.DateTimeFormat(undefined, { year: 'numeric' }).format(end)
const yearLabel = new Intl.DateTimeFormat(DATE_LOCALE, { year: 'numeric' }).format(end)
return `${range}, ${yearLabel}`
}
@@ -244,13 +245,13 @@ function formatRangeLabel(
endOptions = { day: 'numeric' }
}
const startLabel = new Intl.DateTimeFormat(undefined, startOptions).format(start)
const endLabel = new Intl.DateTimeFormat(undefined, endOptions).format(end)
const startLabel = new Intl.DateTimeFormat(DATE_LOCALE, startOptions).format(start)
const endLabel = new Intl.DateTimeFormat(DATE_LOCALE, endOptions).format(end)
const range = `${startLabel}${endLabel}`
if (!showTrailingYear) return range
const yearLabel = new Intl.DateTimeFormat(undefined, { year: 'numeric' }).format(end)
const yearLabel = new Intl.DateTimeFormat(DATE_LOCALE, { year: 'numeric' }).format(end)
return `${range}, ${yearLabel}`
}
@@ -40,7 +40,6 @@ export function useAnalyticsChartEvents(
placeholderData: [],
refetchOnMount: 'always',
retry: false,
staleTime: 0,
})
const localAnalyticsChartEvents = computed(() => analyticsEvents.value ?? [])
@@ -109,7 +109,7 @@ function getRegionDisplayNames(locale: string): Intl.DisplayNames | null {
function formatCountryCode(countryCode: string, formatMessage: FormatMessage): string {
const normalized = countryCode.trim().toUpperCase()
if (normalized === OTHER_COUNTRY_CODE) {
return formatMessage(analyticsMessages.unknown)
return formatMessage(analyticsMessages.other)
}
if (!REGION_CODE_PATTERN.test(normalized)) {
@@ -146,6 +146,9 @@ export function formatBreakdownLabel(
normalizedLowercaseValue === 'other' ||
normalizedLowercaseValue === 'unknown'
) {
if (selectedBreakdown === 'country') {
return formatMessage(analyticsMessages.other)
}
return formatMessage(analyticsMessages.unknown)
}
if (selectedBreakdown === 'country') {
@@ -753,7 +756,7 @@ export function formatMetricValue(
case 'playtime': {
const hours = value / 3600
return formatMessage(analyticsStatCardMessages.playtimeHours, {
hours: hours.toFixed(1),
hours: Math.abs(hours) < 1 ? hours.toFixed(2) : hours.toFixed(1),
})
}
case 'views':
@@ -770,7 +773,11 @@ function formatSmallAxisNumber(value: number): string {
}
const formattedValue = Math.abs(value) < 1 ? value.toFixed(2) : value.toFixed(1)
return formattedValue.replace(/\.?0+$/, '')
return trimTrailingFractionZeros(formattedValue)
}
function trimTrailingFractionZeros(value: string): string {
return value.replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '')
}
const COMPACT_AXIS_UNITS = [
@@ -814,7 +821,7 @@ function formatCompactAxisValue(value: number): string {
return String(truncatedValue)
}
return roundedValue.toFixed(fractionDigitCount).replace(/\.?0+$/, '')
return trimTrailingFractionZeros(roundedValue.toFixed(fractionDigitCount))
}
export function formatAxisValue(
@@ -224,6 +224,19 @@ export const analyticsGraphTitleMessages = defineMessages({
})
export const analyticsStatCardMessages = defineMessages({
monetizationBannerTitle: {
id: 'analytics.stat.monetization-banner.title',
defaultMessage: 'How does monetization work?',
},
monetizationBannerBody: {
id: 'analytics.stat.monetization-banner.body',
defaultMessage:
'Only views and downloads made through Modrinth are eligible for monetization and must pass fraud-prevention filtering. Modrinth App downloads also require the user to be logged in. Because all projects have a similar ratio of monetized downloads, your revenue would not meaningfully change if all downloads were counted.',
},
monetizationBannerLearnMore: {
id: 'analytics.stat.monetization-banner.learn-more',
defaultMessage: 'Learn more',
},
revenueValue: {
id: 'analytics.stat.revenue-value',
defaultMessage: '${value}',
@@ -66,6 +66,7 @@ export function buildAnalyticsTableColumns({
key: getAnalyticsTableBreakdownColumnKey(breakdown),
label: getAnalyticsTableBreakdownColumnLabel(breakdown, formatMessage),
enableSorting: true,
width: breakdown === 'project' ? '25%' : undefined,
})
}
}
@@ -75,6 +76,7 @@ export function buildAnalyticsTableColumns({
key: 'project',
label: formatAnalyticsBreakdownLabel('project', formatMessage),
enableSorting: true,
width: '25%',
})
}
@@ -40,10 +40,12 @@ export function formatAnalyticsTableCompactPlaytime(
formatMessage: FormatMessage,
): string {
const totalSeconds = Math.max(0, Math.round(value))
const hours = totalSeconds / SECONDS_PER_HOUR
const fractionDigits = hours < 1 ? 2 : 1
return formatMessage(analyticsStatCardMessages.playtimeHours, {
hours: (totalSeconds / SECONDS_PER_HOUR).toLocaleString(undefined, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
hours: hours.toLocaleString(undefined, {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
}),
})
}
@@ -12,6 +12,7 @@ import {
getSliceCount,
} from '../analytics-chart/analytics-chart-utils'
import type { FormatMessage } from '../analytics-messages'
import { analyticsMessages } from '../analytics-messages'
import {
ALL_BREAKDOWN_VALUE,
COMBINED_BREAKDOWN_LABEL_SEPARATOR,
@@ -64,6 +65,8 @@ export function buildAnalyticsTableRows({
const timeRange = fetchRequest.time_range
const sliceCount = getSliceCount(timeRange, timeSlices.length)
const currentTimeSlices =
timeSlices.length > sliceCount ? timeSlices.slice(timeSlices.length - sliceCount) : timeSlices
const includeDate = mode === 'date_breakdown'
const breakdownDisplayValues = new Map<string, string>()
const projectDisplayValues = new Map<string, string>()
@@ -119,9 +122,22 @@ export function buildAnalyticsTableRows({
}
function getCombinedBreakdownDisplay(displays: AnalyticsTableBreakdownDisplayValues) {
const unknownBreakdownLabel = formatMessage(analyticsMessages.unknown)
let hasUnknownBreakdownLabel = false
return selectedBreakdowns
.map((breakdown) => displays[breakdown])
.filter((displayValue): displayValue is string => Boolean(displayValue))
.filter((displayValue) => {
if (displayValue !== unknownBreakdownLabel) {
return true
}
if (hasUnknownBreakdownLabel) {
return false
}
hasUnknownBreakdownLabel = true
return true
})
.join(COMBINED_BREAKDOWN_LABEL_SEPARATOR)
}
@@ -184,7 +200,7 @@ export function buildAnalyticsTableRows({
}
}
timeSlices.forEach((slice, sliceIndex) => {
currentTimeSlices.forEach((slice, sliceIndex) => {
const bucketLabel = includeDate ? getBucketLabel(sliceIndex) : undefined
for (const point of slice) {
@@ -30,9 +30,10 @@ export function getAnalyticsBreakdownValue(
case 'user_agent': {
const downloadSource = normalizeBreakdownValue(
'user_agent' in point ? point.user_agent : undefined,
UNKNOWN_BREAKDOWN_VALUE,
)
return downloadSource === ALL_BREAKDOWN_VALUE
? ALL_BREAKDOWN_VALUE
return downloadSource === UNKNOWN_BREAKDOWN_VALUE
? UNKNOWN_BREAKDOWN_VALUE
: getDownloadSourceLabel(downloadSource, formatMessage)
}
case 'download_reason':
@@ -98,5 +99,12 @@ function normalizeBreakdownValue(
fallback = ALL_BREAKDOWN_VALUE,
): string {
const normalized = value?.trim()
const normalizedLowercase = normalized?.toLowerCase()
if (
fallback === UNKNOWN_BREAKDOWN_VALUE &&
(normalizedLowercase === 'unknown' || normalizedLowercase === 'other')
) {
return fallback
}
return normalized && normalized.length > 0 ? normalized : fallback
}
@@ -52,6 +52,7 @@ const {
selectedCustomTimeframeEndDate,
selectedGroupBy,
queryRefreshTimestamp,
analyticsAllTimeStartDate,
refreshAnalyticsQuery,
} = injectAnalyticsDashboardContext()
@@ -107,6 +108,7 @@ function handleTimeframeDraftChange(selection: TimeFramePickerSelection) {
customStartDate: selection.customStartDate,
customEndDate: selection.customEndDate,
nowTimestamp: queryRefreshTimestamp.value,
allTimeStartDate: analyticsAllTimeStartDate.value,
})
const { start, end } = ensureMinimumTimeRange(range.start, range.end)
const durationMinutes = Math.max(1, Math.floor((end.getTime() - start.getTime()) / 60000))
@@ -894,12 +894,6 @@ function isRevenueHourlyGroupBy(groupBy: AnalyticsGroupByPreset): boolean {
return groupBy === '1h' || groupBy === '6h'
}
function getAllTimeYearGroupStart(end: Date): Date {
const start = new Date(end)
start.setFullYear(2021)
return start
}
const groupByOptions = computed<ComboboxOption<AnalyticsGroupByPreset>[]>(() => {
const timeframeMinutes = selectedTimeframeDurationMinutes.value
const options = groupByPresetOptions.map((option) => {
@@ -1159,13 +1153,7 @@ function buildMetricFilters(
const fetchRequest = computed<Labrinth.Analytics.v3.FetchRequest>(() => {
const rawRange = selectedTimeRange.value
const rawStart =
selectedTimeframeMode.value === 'preset' &&
selectedTimeframe.value === 'all_time' &&
selectedGroupBy.value === 'year'
? getAllTimeYearGroupStart(rawRange.end)
: rawRange.start
const { start, end } = ensureMinimumTimeRange(rawStart, rawRange.end)
const { start, end } = ensureMinimumTimeRange(rawRange.start, rawRange.end)
const groupByMs = getAnalyticsGroupByPresetMinutes(selectedGroupBy.value) * 60 * 1000
const desiredSlices = Math.max(1, Math.floor((end.getTime() - start.getTime()) / groupByMs))
@@ -84,6 +84,7 @@ function subtractCalendarMonths(date: Date, months: number): Date {
export function getTimeRangeForPreset(
preset: AnalyticsTimeframePreset,
nowTimestamp: number,
allTimeStartDate: Date = new Date(Date.UTC(2023, 0, 1, 0, 0, 0, 0)),
): AnalyticsTimeRange {
const now = getRoundedNow(nowTimestamp)
const end = new Date(now)
@@ -130,7 +131,7 @@ export function getTimeRangeForPreset(
}
case 'all_time':
return {
start: new Date(Date.UTC(2023, 0, 1, 0, 0, 0, 0)),
start: new Date(allTimeStartDate),
end,
}
default:
@@ -193,6 +194,7 @@ export function getAnalyticsTimeRange({
customStartDate,
customEndDate,
nowTimestamp,
allTimeStartDate,
}: {
mode: AnalyticsTimeframeMode
preset: AnalyticsTimeframePreset
@@ -201,6 +203,7 @@ export function getAnalyticsTimeRange({
customStartDate: string
customEndDate: string
nowTimestamp: number
allTimeStartDate?: Date
}): AnalyticsTimeRange {
switch (mode) {
case 'last':
@@ -211,7 +214,7 @@ export function getAnalyticsTimeRange({
return getTimeRangeForCustomDateTimeRange(customStartDate, customEndDate)
case 'preset':
default:
return getTimeRangeForPreset(preset, nowTimestamp)
return getTimeRangeForPreset(preset, nowTimestamp, allTimeStartDate)
}
}
@@ -269,6 +272,7 @@ export function useSelectedAnalyticsTimeRange() {
selectedCustomTimeframeStartDate,
selectedCustomTimeframeEndDate,
queryRefreshTimestamp,
analyticsAllTimeStartDate,
} = injectAnalyticsDashboardContext()
const selectedTimeRange = computed(() =>
@@ -280,6 +284,7 @@ export function useSelectedAnalyticsTimeRange() {
customStartDate: selectedCustomTimeframeStartDate.value,
customEndDate: selectedCustomTimeframeEndDate.value,
nowTimestamp: queryRefreshTimestamp.value,
allTimeStartDate: analyticsAllTimeStartDate.value,
}),
)
@@ -1,21 +1,45 @@
<template>
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard
v-for="card in statCards"
:key="card.key"
:label="card.label"
:stat-label="card.statLabel"
:vs-prev-period-percent="card.vsPrevPeriodPercent"
:icon="card.icon"
:active="activeStat === card.key"
:disabled="card.disabled"
@click="setActiveStat(card.key)"
/>
<div class="flex w-full flex-col gap-3">
<Admonition
v-if="showMonetizationBanner"
type="info"
:header="formatMessage(analyticsStatCardMessages.monetizationBannerTitle)"
show-actions-underneath
dismissible
@dismiss="dismissMonetizationBanner"
>
<div class="text-primary">
{{ formatMessage(analyticsStatCardMessages.monetizationBannerBody) }}
</div>
<template #actions>
<ButtonStyled color="blue">
<a href="https://modrinth.com/legal/cmp-info" target="_blank" class="w-fit !px-4">
{{ formatMessage(analyticsStatCardMessages.monetizationBannerLearnMore) }}
<RightArrowIcon aria-hidden="true" />
</a>
</ButtonStyled>
</template>
</Admonition>
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard
v-for="card in statCards"
:key="card.key"
:label="card.label"
:stat-label="card.statLabel"
:vs-prev-period-percent="card.vsPrevPeriodPercent"
:icon="card.icon"
:active="activeStat === card.key"
:disabled="card.disabled"
@click="setActiveStat(card.key)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useFormatNumber, useVIntl } from '@modrinth/ui'
import { RightArrowIcon } from '@modrinth/assets'
import { Admonition, ButtonStyled, useFormatNumber, useVIntl } from '@modrinth/ui'
import { useLocalStorage } from '@vueuse/core'
import {
type AnalyticsDashboardStat,
@@ -25,10 +49,13 @@ import {
import { analyticsStatCardMessages, formatAnalyticsStatLabel } from '../analytics-messages.ts'
import StatCard from './StatCard.vue'
const MONETIZATION_BANNER_DISMISSED_KEY = 'analytics-monetization-banner-dismissed'
const {
activeStat,
setActiveStat,
currentTotals,
previousTotals,
percentChanges,
hasPreviousPeriodComparison,
selectedBreakdowns,
@@ -36,6 +63,11 @@ const {
} = injectAnalyticsDashboardContext()
const formatNumber = useFormatNumber()
const { formatMessage } = useVIntl()
const monetizationBannerDismissed = useLocalStorage(MONETIZATION_BANNER_DISMISSED_KEY, false)
const showMonetizationBanner = computed(
() => selectedBreakdowns.value.includes('monetization') && !monetizationBannerDismissed.value,
)
const MAX_PREVIOUS_PERIOD_PERCENT_DISPLAY = 1000
const compactNumberFormatter = computed(
() =>
@@ -57,16 +89,79 @@ function formatStatNumber(value: number): string {
function formatPercent(value: number): string {
const rounded = Math.round(value * 10) / 10
if (rounded === 0) {
return '0%'
}
const signPrefix = rounded > 0 ? '+' : ''
return `${signPrefix}${rounded.toFixed(1)}%`
}
function formatPreviousPeriodPercent(value: number): string | null {
function formatSignedStatNumber(value: number): string {
const signPrefix = value > 0 ? '+' : ''
return `${signPrefix}${formatStatNumber(value)}`
}
function formatSignedRevenue(value: number): string {
const signPrefix = value > 0 ? '+' : value < 0 ? '-' : ''
return `${signPrefix}${formatMessage(analyticsStatCardMessages.revenueValue, {
value: formatStatNumber(Math.abs(value)),
})}`
}
function formatSignedPlaytimeHours(value: number): string {
const rounded = Math.round(value * 10) / 10
if (rounded === 0) {
return '0'
}
if (Math.abs(rounded) >= 1000) {
const signPrefix = rounded > 0 ? '+' : ''
return `${signPrefix}${compactNumberFormatter.value.format(rounded)}`
}
const signPrefix = rounded > 0 ? '+' : ''
return `${signPrefix}${rounded.toFixed(1)}`
}
function formatSignedPlaytime(value: number): string {
return formatMessage(analyticsStatCardMessages.playtimeHours, {
hours: formatSignedPlaytimeHours(value / 3600),
})
}
function formatPreviousPeriodComparison(
stat: AnalyticsDashboardStat,
percentChange: number,
currentValue: number,
previousValue: number,
): string | null {
if (!hasPreviousPeriodComparison.value) {
return null
}
return formatPercent(value)
const delta = currentValue - previousValue
if (previousValue === 0 && currentValue === 0) {
return formatPercent(percentChange)
}
if (previousValue !== 0 && Math.abs(percentChange) <= MAX_PREVIOUS_PERIOD_PERCENT_DISPLAY) {
return formatPercent(percentChange)
}
switch (stat) {
case 'revenue':
return formatSignedRevenue(delta)
case 'playtime':
return formatSignedPlaytime(delta)
case 'views':
case 'downloads':
return formatSignedStatNumber(delta)
}
}
function dismissMonetizationBanner() {
monetizationBannerDismissed.value = true
}
const statCards = computed<
@@ -83,7 +178,12 @@ const statCards = computed<
key: 'views',
label: formatAnalyticsStatLabel('views', formatMessage),
statLabel: formatStatNumber(currentTotals.value.views),
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.views),
vsPrevPeriodPercent: formatPreviousPeriodComparison(
'views',
percentChanges.value.views,
currentTotals.value.views,
previousTotals.value.views,
),
icon: 'eye',
disabled: !isAnalyticsDashboardStatRelevant('views', selectedBreakdowns.value),
},
@@ -91,7 +191,12 @@ const statCards = computed<
key: 'downloads',
label: formatAnalyticsStatLabel('downloads', formatMessage),
statLabel: formatStatNumber(currentTotals.value.downloads),
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.downloads),
vsPrevPeriodPercent: formatPreviousPeriodComparison(
'downloads',
percentChanges.value.downloads,
currentTotals.value.downloads,
previousTotals.value.downloads,
),
icon: 'download',
disabled: !isAnalyticsDashboardStatRelevant('downloads', selectedBreakdowns.value),
},
@@ -101,7 +206,12 @@ const statCards = computed<
statLabel: formatMessage(analyticsStatCardMessages.revenueValue, {
value: formatStatNumber(currentTotals.value.revenue),
}),
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.revenue),
vsPrevPeriodPercent: formatPreviousPeriodComparison(
'revenue',
percentChanges.value.revenue,
currentTotals.value.revenue,
previousTotals.value.revenue,
),
icon: 'dollar',
disabled: !isAnalyticsDashboardStatRelevant('revenue', selectedBreakdowns.value),
},
@@ -111,7 +221,12 @@ const statCards = computed<
statLabel: formatMessage(analyticsStatCardMessages.playtimeHours, {
hours: formatStatNumber(currentTotals.value.playtime / 3600),
}),
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.playtime),
vsPrevPeriodPercent: formatPreviousPeriodComparison(
'playtime',
percentChanges.value.playtime,
currentTotals.value.playtime,
previousTotals.value.playtime,
),
icon: 'clock',
disabled: !isAnalyticsDashboardStatRelevant('playtime', selectedBreakdowns.value),
},
@@ -368,6 +368,15 @@
"analytics.stat.downloads": {
"message": "Downloads"
},
"analytics.stat.monetization-banner.body": {
"message": "Only views and downloads made through Modrinth are eligible for monetization and must pass fraud-prevention filtering. Modrinth App downloads also require the user to be logged in. Because all projects have a similar ratio of monetized downloads, your revenue would not meaningfully change if all downloads were counted."
},
"analytics.stat.monetization-banner.learn-more": {
"message": "Learn more"
},
"analytics.stat.monetization-banner.title": {
"message": "How does monetization work?"
},
"analytics.stat.playtime": {
"message": "Playtime"
},
@@ -17,7 +17,7 @@ import type {
const ANALYTICS_START_TIMESTAMP = '2023-01-01T00:00:00.000Z'
export const ANALYTICS_START_DATE_INPUT_VALUE = ANALYTICS_START_TIMESTAMP.slice(0, 10)
const ANALYTICS_START_TIME = new Date(ANALYTICS_START_TIMESTAMP).getTime()
export const ANALYTICS_START_TIME = new Date(ANALYTICS_START_TIMESTAMP).getTime()
export const REVENUE_MIN_TIMEFRAME_MS = 1 * 24 * 60 * 60 * 1000 // need at least 1 day in timeframe range to show revenue
const ANALYTICS_DAY_MS = 24 * 60 * 60 * 1000
const ANALYTICS_MAX_TIME_SLICES = 256 // controls granularity allowed in "group by" for timeframe ranges
@@ -32,6 +32,7 @@ function isProjectAnalyticsPoint(
export function buildComparisonFetchRequest(
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
minStartTime = ANALYTICS_START_TIME,
): AnalyticsProjectFetchRequest | null {
if (!isAnalyticsFetchRequestReady(fetchRequest)) {
return null
@@ -47,7 +48,7 @@ export function buildComparisonFetchRequest(
const previousStart = new Date(startTimestamp - duration)
if (previousStart.getTime() < ANALYTICS_START_TIME) {
if (previousStart.getTime() < minStartTime) {
return null
}
@@ -93,8 +94,12 @@ function getAnalyticsTimeSliceCount(
export function splitAnalyticsTimeSlices(
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
minStartTime = ANALYTICS_START_TIME,
): AnalyticsTimeSliceSplit {
if (!isAnalyticsFetchRequestReady(fetchRequest) || !buildComparisonFetchRequest(fetchRequest)) {
if (
!isAnalyticsFetchRequestReady(fetchRequest) ||
!buildComparisonFetchRequest(fetchRequest, minStartTime)
) {
return {
currentTimeSlices: timeSlices,
previousTimeSlices: [],
@@ -339,6 +344,7 @@ export function getAnalyticsTimeframeDurationMs({
customStartDate,
customEndDate,
nowTimestamp,
allTimeStartTimestamp = ANALYTICS_START_TIME,
}: {
mode: AnalyticsTimeframeMode
preset: AnalyticsTimeframePreset
@@ -347,6 +353,7 @@ export function getAnalyticsTimeframeDurationMs({
customStartDate: string
customEndDate: string
nowTimestamp: number
allTimeStartTimestamp?: number
}): number {
if (mode === 'preset') {
switch (preset) {
@@ -370,7 +377,7 @@ export function getAnalyticsTimeframeDurationMs({
return now.getTime() - yearStart.getTime()
}
case 'all_time': {
const allTimeDurationMs = nowTimestamp - ANALYTICS_START_TIME
const allTimeDurationMs = nowTimestamp - allTimeStartTimestamp
return Math.max(0, allTimeDurationMs)
}
}
@@ -46,6 +46,7 @@ export function toAnalyticsDashboardProject(
iconUrl: project.icon_url ?? undefined,
downloads: project.downloads ?? 0,
status: getProjectStatusFilterValue(project.status),
publishedAt: project.published ?? undefined,
}
}
@@ -108,6 +108,7 @@ export type AnalyticsDashboardProjectSource = ProjectTypeMetadata & {
icon_url?: string | null
downloads?: number | null
status?: string | null
published?: string | null
}
export type AnalyticsProjectVersionSource = {
@@ -121,6 +122,7 @@ export interface AnalyticsDashboardProject {
iconUrl?: string
downloads: number
status: ProjectStatusFilterValue
publishedAt?: string
}
export interface AnalyticsDashboardProjectGroup {
@@ -36,6 +36,7 @@ import type { OrganizationContext } from '../organization-context'
import {
addVersionIdsFromTimeSlices,
addVersionProjectNamesFromTimeSlices,
ANALYTICS_START_TIME,
areAnalyticsFetchRequestsEqual,
buildAnalyticsCurrentTimeSlicesQueryKey,
buildAnalyticsFacetsRequest,
@@ -68,6 +69,7 @@ import {
sortStringValues,
} from './analytics-filter-utils'
import {
getProjectIdsMatchingStatusFilter,
getProjectOrganizationId,
getSingleQueryValue,
getUniqueAnalyticsDashboardProjects,
@@ -131,6 +133,17 @@ const ANALYTICS_TIME_SLICES_GC_TIME_MS = 30 * 1000
const ANALYTICS_PREFETCH_GC_TIME_MS = 15 * 1000
const ANALYTICS_FILTER_OPTIONS_GC_TIME_MS = 60 * 1000
const ANALYTICS_MOBILE_LAYOUT_QUERY = '(pointer: coarse), (max-width: 800px)'
const ANALYTICS_ALL_TIME_START_OFFSET_MONTHS = 2
function subtractAnalyticsCalendarMonths(date: Date, months: number): Date {
const nextDate = new Date(date)
const day = nextDate.getDate()
nextDate.setDate(1)
nextDate.setMonth(nextDate.getMonth() - months)
const daysInMonth = new Date(nextDate.getFullYear(), nextDate.getMonth() + 1, 0).getDate()
nextDate.setDate(Math.min(day, daysInMonth))
return nextDate
}
function getAnalyticsFetchErrorMessage(error: unknown): string {
if (error && typeof error === 'object') {
@@ -164,6 +177,7 @@ export interface AnalyticsDashboardContextValue {
selectedCustomTimeframeStartDate: Ref<string>
selectedCustomTimeframeEndDate: Ref<string>
selectedGroupBy: Ref<AnalyticsGroupByPreset>
analyticsAllTimeStartDate: ComputedRef<Date>
selectedBreakdowns: Ref<AnalyticsSelectedBreakdowns>
selectedFilters: Ref<AnalyticsSelectedFilters>
queryRefreshTimestamp: Ref<number>
@@ -368,6 +382,7 @@ export function createAnalyticsDashboardContext(
},
enabled: computed(() => shouldFetchEffectiveUser.value && hasCompletedAnalyticsLoading.value),
placeholderData: null,
refetchOnWindowFocus: false,
})
const effectiveUsername = computed(() => {
if (effectiveUserId.value === options.auth.value.user?.id) {
@@ -407,6 +422,7 @@ export function createAnalyticsDashboardContext(
}
},
enabled: shouldFetchDashboardAllProjects,
refetchOnWindowFocus: false,
})
const areProjectsLoaded = computed(() => {
@@ -547,6 +563,63 @@ export function createAnalyticsDashboardContext(
return getAnalyticsVersionIdsFromProjects(projects, sortedSelectedProjectIds.value)
})
const { data: filterOptionProjectVersions, isFetched: hasFetchedFilterOptionProjectVersions } =
useQuery({
queryKey: computed(() => [
'analytics',
'dashboard',
analyticsQueryUserId.value,
'filter-options',
'versions',
filterOptionVersionIds.value,
]),
queryFn: () =>
fetchAnalyticsVersionMetadataByIds(filterOptionVersionIds.value, (ids) =>
client.labrinth.versions_v3.getVersions(ids),
),
enabled: computed(
() =>
filterOptionProjectSources.value !== null && sortedSelectedProjectIds.value.length > 0,
),
placeholderData: [],
gcTime: ANALYTICS_FILTER_OPTIONS_GC_TIME_MS,
refetchOnWindowFocus: false,
})
const projectsById = computed(
() => new Map(projects.value.map((project) => [project.id, project])),
)
const analyticsAllTimeStartDate = computed(() => {
const fallbackStartDate = new Date(ANALYTICS_START_TIME)
const filteredProjectIds = getProjectIdsMatchingStatusFilter(
selectedProjectIds.value.length > 0 ? selectedProjectIds.value : availableProjectIds.value,
projectStatusById.value,
selectedFilters.value,
)
let startTime = Number.POSITIVE_INFINITY
for (const projectId of filteredProjectIds) {
const publishedAt = projectsById.value.get(projectId)?.publishedAt
if (!publishedAt) {
continue
}
const projectStartTime = new Date(publishedAt).getTime()
if (Number.isFinite(projectStartTime)) {
startTime = Math.min(startTime, projectStartTime)
}
}
if (!Number.isFinite(startTime)) {
return fallbackStartDate
}
const offsetStartDate = subtractAnalyticsCalendarMonths(
new Date(startTime),
ANALYTICS_ALL_TIME_START_OFFSET_MONTHS,
)
return new Date(Math.max(offsetStartDate.getTime(), ANALYTICS_START_TIME))
})
const hasExplicitProjectSelectionQuery = computed(() =>
hasAnalyticsProjectSelectionQuery(route.query),
)
@@ -598,6 +671,7 @@ export function createAnalyticsDashboardContext(
customStartDate: selectedCustomTimeframeStartDate.value,
customEndDate: selectedCustomTimeframeEndDate.value,
nowTimestamp: queryRefreshTimestamp.value,
allTimeStartTimestamp: analyticsAllTimeStartDate.value.getTime(),
}) > REVENUE_MIN_TIMEFRAME_MS,
)
@@ -867,7 +941,17 @@ export function createAnalyticsDashboardContext(
{ deep: true },
)
const comparisonFetchRequest = computed(() => buildComparisonFetchRequest(fetchRequest.value))
const analyticsComparisonStartTime = computed(() => {
if (selectedTimeframeMode.value === 'preset' && selectedTimeframe.value === 'all_time') {
const fetchRequestStart = fetchRequest.value?.time_range.start
return new Date(fetchRequestStart ?? analyticsAllTimeStartDate.value).getTime()
}
return ANALYTICS_START_TIME
})
const comparisonFetchRequest = computed(() =>
buildComparisonFetchRequest(fetchRequest.value, analyticsComparisonStartTime.value),
)
const analyticsTimeSlicesFetchRequest = computed(
() => comparisonFetchRequest.value ?? fetchRequest.value,
)
@@ -901,6 +985,7 @@ export function createAnalyticsDashboardContext(
)
},
enabled: computed(() => isAnalyticsFetchRequestReady(analyticsTimeSlicesFetchRequest.value)),
refetchOnWindowFocus: false,
gcTime: ANALYTICS_TIME_SLICES_GC_TIME_MS,
})
watch(currentAnalyticsError, (error) => {
@@ -956,7 +1041,10 @@ export function createAnalyticsDashboardContext(
}
const dailyFetchRequest = buildDailyAnalyticsFetchRequest(fetchRequest.value)
return buildComparisonFetchRequest(dailyFetchRequest) ?? dailyFetchRequest
return (
buildComparisonFetchRequest(dailyFetchRequest, analyticsComparisonStartTime.value) ??
dailyFetchRequest
)
})
watch(
@@ -984,7 +1072,7 @@ export function createAnalyticsDashboardContext(
nextQueryRefreshTimestamp,
),
queryFn: () =>
fetchAnalyticsTimeSlices(nextFetchRequest, (request) =>
fetchAnalyticsData(nextFetchRequest, (request) =>
client.labrinth.analytics_v3.fetch(request),
),
gcTime: ANALYTICS_PREFETCH_GC_TIME_MS,
@@ -1057,6 +1145,7 @@ export function createAnalyticsDashboardContext(
isAnalyticsFetchRequestReady(analyticsFacetsRequest.value),
),
gcTime: ANALYTICS_FILTER_OPTIONS_GC_TIME_MS,
refetchOnWindowFocus: false,
})
const { data: analyticsDownloadCountTimeSlices } = useQuery({
@@ -1086,30 +1175,9 @@ export function createAnalyticsDashboardContext(
),
placeholderData: [],
gcTime: ANALYTICS_FILTER_OPTIONS_GC_TIME_MS,
refetchOnWindowFocus: false,
})
const { data: filterOptionProjectVersions, isFetched: hasFetchedFilterOptionProjectVersions } =
useQuery({
queryKey: computed(() => [
'analytics',
'dashboard',
analyticsQueryUserId.value,
'filter-options',
'versions',
filterOptionVersionIds.value,
]),
queryFn: () =>
fetchAnalyticsVersionMetadataByIds(filterOptionVersionIds.value, (ids) =>
client.labrinth.versions_v3.getVersions(ids),
),
enabled: computed(
() =>
filterOptionProjectSources.value !== null && sortedSelectedProjectIds.value.length > 0,
),
placeholderData: [],
gcTime: ANALYTICS_FILTER_OPTIONS_GC_TIME_MS,
})
const analyticsFacetsFilterOptionSummary = computed(() =>
getAnalyticsFacetsFilterOptionSummary(analyticsFacetsData.value?.facets),
)
@@ -1213,6 +1281,7 @@ export function createAnalyticsDashboardContext(
const splitTimeSlices = splitAnalyticsTimeSlices(
nextAnalyticsData.metrics,
fetchRequest.value,
analyticsComparisonStartTime.value,
)
timeSlices.value = splitTimeSlices.currentTimeSlices
previousTimeSlices.value = splitTimeSlices.previousTimeSlices
@@ -1269,6 +1338,7 @@ export function createAnalyticsDashboardContext(
enabled: computed(() => analyticsVersionIds.value.length > 0),
placeholderData: [],
gcTime: ANALYTICS_FILTER_OPTIONS_GC_TIME_MS,
refetchOnWindowFocus: false,
})
const allVersionMetadata = computed(() => {
@@ -1488,6 +1558,7 @@ export function createAnalyticsDashboardContext(
selectedCustomTimeframeStartDate,
selectedCustomTimeframeEndDate,
selectedGroupBy,
analyticsAllTimeStartDate,
selectedBreakdowns,
selectedFilters,
queryRefreshTimestamp,
@@ -12,7 +12,7 @@
<div class="col-start-2 flex min-w-0 flex-1 flex-col gap-2">
<div
v-if="header || $slots.header || normalizedTimestamp"
class="flex flex-wrap items-center gap-2 text-lg font-bold leading-6"
class="flex flex-wrap items-center gap-2 text-lg font-semibold leading-6"
>
<slot name="header">{{ header }}</slot>
<span
+1 -2
View File
@@ -75,7 +75,7 @@ function toggleItem(item: T) {
flex-wrap: wrap;
.btn {
border: 1px solid var(--surface-5);
border: 1px solid transparent;
&.capitalize {
text-transform: capitalize;
}
@@ -87,7 +87,6 @@ function toggleItem(item: T) {
&:focus-visible {
outline: 0.25rem solid var(--color-focus-ring);
border-radius: 0.25rem;
}
}
@@ -1580,6 +1580,15 @@ defineExpose({
.modrinth-date-picker :deep(.flatpickr-current-month input.cur-year) {
@apply min-w-[76px] px-2 text-center;
}
.modrinth-date-picker :deep(input[type='number']) {
-moz-appearance: textfield;
appearance: textfield;
}
.modrinth-date-picker :deep(input[type='number']::-webkit-inner-spin-button),
.modrinth-date-picker :deep(input[type='number']::-webkit-outer-spin-button) {
margin: 0;
-webkit-appearance: none;
}
.modrinth-date-picker
:deep(.flatpickr-current-month .numInputWrapper:has(input.cur-year:disabled)) {
@apply cursor-not-allowed;