forked from didirus/AstralRinth
11b2b6e6c0
* 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 d6dc8a99f11f94660872427796cdcf6fc93bb21d. * 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>
863 lines
26 KiB
TypeScript
863 lines
26 KiB
TypeScript
import type { Labrinth } from '@modrinth/api-client'
|
|
|
|
import type {
|
|
AnalyticsBreakdownPreset,
|
|
AnalyticsDashboardProject,
|
|
AnalyticsDashboardStat,
|
|
AnalyticsGroupByPreset,
|
|
} from '~/providers/analytics/analytics'
|
|
|
|
import {
|
|
analyticsChartMessages,
|
|
analyticsMessages,
|
|
analyticsStatCardMessages,
|
|
formatAnalyticsDownloadReasonLabel,
|
|
formatAnalyticsLoaderLabel,
|
|
formatAnalyticsMonetizationLabel,
|
|
type FormatMessage,
|
|
} from '../analytics-messages'
|
|
import {
|
|
ALL_BREAKDOWN_VALUE,
|
|
COMBINED_BREAKDOWN_LABEL_SEPARATOR,
|
|
getAnalyticsBreakdownDatasetId,
|
|
getAnalyticsBreakdownKey,
|
|
getAnalyticsBreakdownValues,
|
|
UNKNOWN_BREAKDOWN_VALUE,
|
|
} from '../breakdown'
|
|
import { PREVIOUS_PERIOD_DATASET_ID_PREFIX } from './analytics-chart-constants'
|
|
|
|
export type ChartDataset = {
|
|
projectId: string
|
|
label: string
|
|
projectName?: string
|
|
data: number[]
|
|
borderColor: string
|
|
backgroundColor: string
|
|
borderDash?: number[]
|
|
}
|
|
|
|
export function getChartDatasetTotal(dataset: ChartDataset) {
|
|
return dataset.data.reduce((sum, value) => sum + value, 0)
|
|
}
|
|
|
|
export function getPreviousPeriodDatasetId(datasetId: string) {
|
|
return `${PREVIOUS_PERIOD_DATASET_ID_PREFIX}${datasetId}`
|
|
}
|
|
|
|
export function decodeBreakdownDatasetValue(value: string) {
|
|
try {
|
|
return decodeURIComponent(value)
|
|
} catch {
|
|
return value
|
|
}
|
|
}
|
|
|
|
export function areStringArraysEqual(left: string[], right: string[]) {
|
|
if (left.length !== right.length) return false
|
|
for (let index = 0; index < left.length; index += 1) {
|
|
if (left[index] !== right[index]) return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
const LOADER_CHART_COLORS: Record<string, string> = {
|
|
fabric: 'var(--color-platform-fabric)',
|
|
'legacy-fabric': 'var(--color-platform-fabric)',
|
|
quilt: 'var(--color-platform-quilt)',
|
|
forge: 'var(--color-platform-forge)',
|
|
neoforge: 'var(--color-platform-neoforge)',
|
|
neo_forge: 'var(--color-platform-neoforge)',
|
|
liteloader: 'var(--color-platform-liteloader)',
|
|
bukkit: 'var(--color-platform-bukkit)',
|
|
bungeecord: 'var(--color-platform-bungeecord)',
|
|
folia: 'var(--color-platform-folia)',
|
|
paper: 'var(--color-platform-paper)',
|
|
purpur: 'var(--color-platform-purpur)',
|
|
spigot: 'var(--color-platform-spigot)',
|
|
velocity: 'var(--color-platform-velocity)',
|
|
waterfall: 'var(--color-platform-waterfall)',
|
|
sponge: 'var(--color-platform-sponge)',
|
|
ornithe: 'var(--color-platform-ornithe)',
|
|
'bta-babric': 'var(--color-platform-bta-babric)',
|
|
nilloader: 'var(--color-platform-nilloader)',
|
|
}
|
|
|
|
const REGION_CODE_PATTERN = /^[a-z]{2}$/i
|
|
const OTHER_COUNTRY_CODE = 'XX'
|
|
const ALL_PROJECTS_DATASET_ID = 'all'
|
|
const MONETIZATION_CHART_COLOR_INDEX: Record<string, number> = {
|
|
monetized: 0,
|
|
unmonetized: 1,
|
|
}
|
|
const regionDisplayNamesByLocale = new Map<string, Intl.DisplayNames | null>()
|
|
|
|
function getRegionDisplayNames(locale: string): Intl.DisplayNames | null {
|
|
if (regionDisplayNamesByLocale.has(locale)) {
|
|
return regionDisplayNamesByLocale.get(locale) ?? null
|
|
}
|
|
|
|
try {
|
|
const displayNames = new Intl.DisplayNames(locale, { type: 'region' })
|
|
regionDisplayNamesByLocale.set(locale, displayNames)
|
|
return displayNames
|
|
} catch {
|
|
regionDisplayNamesByLocale.set(locale, null)
|
|
return null
|
|
}
|
|
}
|
|
|
|
function formatCountryCode(countryCode: string, formatMessage: FormatMessage): string {
|
|
const normalized = countryCode.trim().toUpperCase()
|
|
if (normalized === OTHER_COUNTRY_CODE) {
|
|
return formatMessage(analyticsMessages.unknown)
|
|
}
|
|
|
|
if (!REGION_CODE_PATTERN.test(normalized)) {
|
|
return countryCode
|
|
}
|
|
|
|
const locale = new Intl.DateTimeFormat().resolvedOptions().locale || 'en'
|
|
const localizedDisplayNames = getRegionDisplayNames(locale)
|
|
const localizedValue = localizedDisplayNames?.of(normalized)
|
|
if (localizedValue && localizedValue !== normalized) {
|
|
return localizedValue
|
|
}
|
|
|
|
const englishDisplayNames = getRegionDisplayNames('en')
|
|
const englishValue = englishDisplayNames?.of(normalized)
|
|
if (englishValue && englishValue !== normalized) {
|
|
return englishValue
|
|
}
|
|
|
|
return countryCode
|
|
}
|
|
|
|
export function formatBreakdownLabel(
|
|
breakdownValue: string,
|
|
selectedBreakdown: AnalyticsBreakdownPreset,
|
|
getVersionDisplayName: ((versionId: string) => string) | undefined,
|
|
formatMessage: FormatMessage,
|
|
): string {
|
|
const normalizedValue = breakdownValue.trim()
|
|
const normalizedLowercaseValue = normalizedValue.toLowerCase()
|
|
|
|
if (
|
|
normalizedValue === UNKNOWN_BREAKDOWN_VALUE ||
|
|
normalizedLowercaseValue === 'other' ||
|
|
normalizedLowercaseValue === 'unknown'
|
|
) {
|
|
return formatMessage(analyticsMessages.unknown)
|
|
}
|
|
if (selectedBreakdown === 'country') {
|
|
return formatCountryCode(breakdownValue, formatMessage)
|
|
}
|
|
if (selectedBreakdown === 'monetization') {
|
|
return formatAnalyticsMonetizationLabel(normalizedLowercaseValue, formatMessage)
|
|
}
|
|
if (selectedBreakdown === 'download_reason') {
|
|
return formatAnalyticsDownloadReasonLabel(normalizedLowercaseValue, formatMessage)
|
|
}
|
|
if (selectedBreakdown === 'version_id') {
|
|
return getVersionDisplayName?.(breakdownValue) ?? breakdownValue
|
|
}
|
|
if (selectedBreakdown === 'loader') {
|
|
return formatAnalyticsLoaderLabel(normalizedValue, formatMessage)
|
|
}
|
|
|
|
return breakdownValue
|
|
}
|
|
|
|
export function formatBreakdownLabels(
|
|
breakdownValues: readonly string[],
|
|
selectedBreakdowns: readonly AnalyticsBreakdownPreset[],
|
|
getVersionDisplayName: ((versionId: string) => string) | undefined,
|
|
formatMessage: FormatMessage,
|
|
): string {
|
|
return collapseRepeatedUnknownBreakdownLabels(
|
|
selectedBreakdowns
|
|
.filter((breakdown) => breakdown !== 'none')
|
|
.map((breakdown, index) =>
|
|
formatBreakdownLabel(
|
|
breakdownValues[index] ?? '',
|
|
breakdown,
|
|
getVersionDisplayName,
|
|
formatMessage,
|
|
),
|
|
),
|
|
formatMessage,
|
|
).join(COMBINED_BREAKDOWN_LABEL_SEPARATOR)
|
|
}
|
|
|
|
function collapseRepeatedUnknownBreakdownLabels(
|
|
labels: string[],
|
|
formatMessage: FormatMessage,
|
|
): string[] {
|
|
let hasUnknownLabel = false
|
|
const collapsedLabels: string[] = []
|
|
const unknownBreakdownLabel = formatMessage(analyticsMessages.unknown)
|
|
|
|
for (const label of labels) {
|
|
if (label === unknownBreakdownLabel) {
|
|
if (hasUnknownLabel) {
|
|
continue
|
|
}
|
|
hasUnknownLabel = true
|
|
}
|
|
|
|
collapsedLabels.push(label)
|
|
}
|
|
|
|
return collapsedLabels
|
|
}
|
|
|
|
export function shouldCapitalizeBreakdownLabel(
|
|
selectedBreakdown: AnalyticsBreakdownPreset | readonly AnalyticsBreakdownPreset[],
|
|
): boolean {
|
|
const selectedBreakdowns = Array.isArray(selectedBreakdown)
|
|
? selectedBreakdown
|
|
: [selectedBreakdown]
|
|
return (
|
|
selectedBreakdowns.length > 0 &&
|
|
selectedBreakdowns.every(
|
|
(breakdown) =>
|
|
breakdown === 'download_reason' ||
|
|
breakdown === 'monetization' ||
|
|
breakdown === 'loader' ||
|
|
breakdown === 'country',
|
|
)
|
|
)
|
|
}
|
|
|
|
function getBreakdownColor(
|
|
breakdownValue: string,
|
|
selectedBreakdown: AnalyticsBreakdownPreset,
|
|
fallbackColor: string,
|
|
palette: string[],
|
|
): string {
|
|
if (selectedBreakdown === 'monetization') {
|
|
const colorIndex = MONETIZATION_CHART_COLOR_INDEX[breakdownValue]
|
|
if (colorIndex !== undefined) {
|
|
return getPaletteColorForIndex(colorIndex, palette)
|
|
}
|
|
}
|
|
|
|
if (selectedBreakdown !== 'loader') {
|
|
return fallbackColor
|
|
}
|
|
|
|
const normalizedLoader = breakdownValue.trim().toLowerCase()
|
|
return LOADER_CHART_COLORS[normalizedLoader] ?? fallbackColor
|
|
}
|
|
|
|
type PaletteRankEntry = {
|
|
key: string
|
|
label: string
|
|
total: number
|
|
}
|
|
|
|
function getPaletteColorForIndex(index: number, palette: string[]): string {
|
|
if (palette.length === 0) return ''
|
|
|
|
return palette[index % palette.length]
|
|
}
|
|
|
|
function buildPaletteColorsByDownloadRank(
|
|
entries: PaletteRankEntry[],
|
|
palette: string[],
|
|
): Map<string, string> {
|
|
const colorsByKey = new Map<string, string>()
|
|
if (palette.length === 0) return colorsByKey
|
|
|
|
const sortedEntries = [...entries].sort(
|
|
(a, b) => b.total - a.total || a.label.localeCompare(b.label) || a.key.localeCompare(b.key),
|
|
)
|
|
sortedEntries.forEach((entry, index) => {
|
|
colorsByKey.set(entry.key, getPaletteColorForIndex(index, palette))
|
|
})
|
|
|
|
return colorsByKey
|
|
}
|
|
|
|
export function getMetricValue(
|
|
point: Labrinth.Analytics.v3.ProjectAnalytics,
|
|
activeStat: AnalyticsDashboardStat,
|
|
): number {
|
|
switch (activeStat) {
|
|
case 'views':
|
|
return point.metric_kind === 'views' ? point.views : 0
|
|
case 'downloads':
|
|
return point.metric_kind === 'downloads' ? point.downloads : 0
|
|
case 'playtime':
|
|
return point.metric_kind === 'playtime' ? point.seconds : 0
|
|
case 'revenue': {
|
|
if (point.metric_kind !== 'revenue') return 0
|
|
const value = Number.parseFloat(point.revenue)
|
|
return Number.isFinite(value) ? value : 0
|
|
}
|
|
}
|
|
}
|
|
|
|
function isMetricKindForStat(
|
|
point: Labrinth.Analytics.v3.ProjectAnalytics,
|
|
activeStat: AnalyticsDashboardStat,
|
|
): boolean {
|
|
return point.metric_kind === activeStat
|
|
}
|
|
|
|
function isProjectAnalyticsPointInSelectedProjects(
|
|
point: Labrinth.Analytics.v3.AnalyticsData,
|
|
selectedProjectIds: Set<string>,
|
|
): point is Labrinth.Analytics.v3.ProjectAnalytics {
|
|
return 'source_project' in point && selectedProjectIds.has(point.source_project)
|
|
}
|
|
|
|
export function buildChartDatasets(
|
|
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
|
|
selectedProjects: AnalyticsDashboardProject[],
|
|
activeStat: AnalyticsDashboardStat,
|
|
palette: string[],
|
|
selectedBreakdowns: readonly AnalyticsBreakdownPreset[],
|
|
getVersionDisplayName: ((versionId: string) => string) | undefined,
|
|
getVersionProjectName: ((versionId: string) => string | undefined) | undefined,
|
|
formatMessage: FormatMessage,
|
|
sliceCount: number = timeSlices.length,
|
|
): ChartDataset[] {
|
|
const selectedProjectIds = new Set(selectedProjects.map((project) => project.id))
|
|
if (selectedProjectIds.size === 0) {
|
|
return []
|
|
}
|
|
|
|
const dataLength = Math.max(sliceCount, timeSlices.length)
|
|
const normalizedBreakdowns = selectedBreakdowns.filter((breakdown) => breakdown !== 'none')
|
|
const projectNamesById = new Map(selectedProjects.map((project) => [project.id, project.name]))
|
|
|
|
function formatChartBreakdownLabels(breakdownValues: readonly string[]): string {
|
|
return collapseRepeatedUnknownBreakdownLabels(
|
|
normalizedBreakdowns.map((breakdown, index) => {
|
|
const breakdownValue = breakdownValues[index] ?? ''
|
|
if (breakdown === 'project') {
|
|
return projectNamesById.get(breakdownValue) ?? breakdownValue
|
|
}
|
|
|
|
return formatBreakdownLabel(breakdownValue, breakdown, getVersionDisplayName, formatMessage)
|
|
}),
|
|
formatMessage,
|
|
).join(COMBINED_BREAKDOWN_LABEL_SEPARATOR)
|
|
}
|
|
|
|
if (
|
|
normalizedBreakdowns.length > 0 &&
|
|
!(normalizedBreakdowns.length === 1 && normalizedBreakdowns[0] === 'project')
|
|
) {
|
|
const dataByBreakdown = new Map<string, number[]>()
|
|
const breakdownValuesByKey = new Map<string, string[]>()
|
|
const downloadTotalsByBreakdown = new Map<string, number>()
|
|
|
|
timeSlices.forEach((slice, sliceIndex) => {
|
|
for (const point of slice) {
|
|
if (!isProjectAnalyticsPointInSelectedProjects(point, selectedProjectIds)) continue
|
|
|
|
const breakdownValues = getAnalyticsBreakdownValues(
|
|
point,
|
|
normalizedBreakdowns,
|
|
formatMessage,
|
|
)
|
|
if (breakdownValues.some((breakdownValue) => breakdownValue === ALL_BREAKDOWN_VALUE)) {
|
|
continue
|
|
}
|
|
const breakdownKey = getAnalyticsBreakdownKey(breakdownValues)
|
|
|
|
if (!dataByBreakdown.has(breakdownKey)) {
|
|
dataByBreakdown.set(breakdownKey, new Array(dataLength).fill(0))
|
|
breakdownValuesByKey.set(breakdownKey, breakdownValues)
|
|
}
|
|
|
|
if (point.metric_kind === 'downloads') {
|
|
downloadTotalsByBreakdown.set(
|
|
breakdownKey,
|
|
(downloadTotalsByBreakdown.get(breakdownKey) ?? 0) + getMetricValue(point, 'downloads'),
|
|
)
|
|
}
|
|
|
|
if (!isMetricKindForStat(point, activeStat)) continue
|
|
|
|
const breakdownData = dataByBreakdown.get(breakdownKey)
|
|
if (!breakdownData) continue
|
|
breakdownData[sliceIndex] += getMetricValue(point, activeStat)
|
|
}
|
|
})
|
|
|
|
const colorsByBreakdown = buildPaletteColorsByDownloadRank(
|
|
Array.from(dataByBreakdown.keys()).map((breakdownKey) => ({
|
|
key: breakdownKey,
|
|
label: formatChartBreakdownLabels(breakdownValuesByKey.get(breakdownKey) ?? []),
|
|
total: downloadTotalsByBreakdown.get(breakdownKey) ?? 0,
|
|
})),
|
|
palette,
|
|
)
|
|
|
|
return Array.from(dataByBreakdown.entries()).map(([breakdownKey, data]) => {
|
|
const breakdownValues = breakdownValuesByKey.get(breakdownKey) ?? []
|
|
const fallbackColor = colorsByBreakdown.get(breakdownKey) ?? ''
|
|
const color =
|
|
normalizedBreakdowns.length === 1
|
|
? getBreakdownColor(
|
|
breakdownValues[0] ?? '',
|
|
normalizedBreakdowns[0],
|
|
fallbackColor,
|
|
palette,
|
|
)
|
|
: fallbackColor
|
|
return {
|
|
projectId: getAnalyticsBreakdownDatasetId(breakdownValues, normalizedBreakdowns),
|
|
label: formatChartBreakdownLabels(breakdownValues),
|
|
projectName:
|
|
normalizedBreakdowns.length === 1 && normalizedBreakdowns[0] === 'version_id'
|
|
? getVersionProjectName?.(breakdownValues[0] ?? '')
|
|
: undefined,
|
|
data,
|
|
borderColor: color,
|
|
backgroundColor: color,
|
|
}
|
|
})
|
|
}
|
|
|
|
if (normalizedBreakdowns.length === 0) {
|
|
const data = new Array(dataLength).fill(0)
|
|
let downloadTotal = 0
|
|
|
|
timeSlices.forEach((slice, sliceIndex) => {
|
|
for (const point of slice) {
|
|
if (!isProjectAnalyticsPointInSelectedProjects(point, selectedProjectIds)) continue
|
|
|
|
if (point.metric_kind === 'downloads') {
|
|
downloadTotal += getMetricValue(point, 'downloads')
|
|
}
|
|
|
|
if (!isMetricKindForStat(point, activeStat)) continue
|
|
|
|
data[sliceIndex] += getMetricValue(point, activeStat)
|
|
}
|
|
})
|
|
|
|
const color =
|
|
buildPaletteColorsByDownloadRank(
|
|
[
|
|
{
|
|
key: ALL_PROJECTS_DATASET_ID,
|
|
label: formatMessage(analyticsMessages.allProjects),
|
|
total: downloadTotal,
|
|
},
|
|
],
|
|
palette,
|
|
).get(ALL_PROJECTS_DATASET_ID) ?? ''
|
|
const selectedProject = selectedProjects.length === 1 ? selectedProjects[0] : undefined
|
|
|
|
return [
|
|
{
|
|
projectId: ALL_PROJECTS_DATASET_ID,
|
|
label: selectedProject?.name ?? formatMessage(analyticsMessages.allProjects),
|
|
data,
|
|
borderColor: color,
|
|
backgroundColor: color,
|
|
},
|
|
]
|
|
}
|
|
|
|
const dataByProjectBreakdown = new Map<string, number[]>()
|
|
const breakdownValuesByKey = new Map<string, string[]>()
|
|
const downloadTotalsByProjectBreakdown = new Map<string, number>()
|
|
for (const project of selectedProjects) {
|
|
const breakdownValues = [project.id]
|
|
const breakdownKey = getAnalyticsBreakdownKey(breakdownValues)
|
|
dataByProjectBreakdown.set(breakdownKey, new Array(dataLength).fill(0))
|
|
breakdownValuesByKey.set(breakdownKey, breakdownValues)
|
|
downloadTotalsByProjectBreakdown.set(breakdownKey, 0)
|
|
}
|
|
|
|
timeSlices.forEach((slice, sliceIndex) => {
|
|
for (const point of slice) {
|
|
if (!isProjectAnalyticsPointInSelectedProjects(point, selectedProjectIds)) continue
|
|
|
|
const breakdownValues = getAnalyticsBreakdownValues(
|
|
point,
|
|
normalizedBreakdowns,
|
|
formatMessage,
|
|
)
|
|
if (breakdownValues.some((breakdownValue) => breakdownValue === ALL_BREAKDOWN_VALUE)) {
|
|
continue
|
|
}
|
|
const breakdownKey = getAnalyticsBreakdownKey(breakdownValues)
|
|
if (!dataByProjectBreakdown.has(breakdownKey)) {
|
|
dataByProjectBreakdown.set(breakdownKey, new Array(dataLength).fill(0))
|
|
breakdownValuesByKey.set(breakdownKey, breakdownValues)
|
|
downloadTotalsByProjectBreakdown.set(breakdownKey, 0)
|
|
}
|
|
|
|
if (point.metric_kind === 'downloads') {
|
|
downloadTotalsByProjectBreakdown.set(
|
|
breakdownKey,
|
|
(downloadTotalsByProjectBreakdown.get(breakdownKey) ?? 0) +
|
|
getMetricValue(point, 'downloads'),
|
|
)
|
|
}
|
|
|
|
if (!isMetricKindForStat(point, activeStat)) continue
|
|
|
|
const projectData = dataByProjectBreakdown.get(breakdownKey)
|
|
if (!projectData) continue
|
|
|
|
projectData[sliceIndex] += getMetricValue(point, activeStat)
|
|
}
|
|
})
|
|
|
|
const colorsByBreakdown = buildPaletteColorsByDownloadRank(
|
|
Array.from(dataByProjectBreakdown.keys()).map((breakdownKey) => ({
|
|
key: breakdownKey,
|
|
label: formatChartBreakdownLabels(breakdownValuesByKey.get(breakdownKey) ?? []),
|
|
total: downloadTotalsByProjectBreakdown.get(breakdownKey) ?? 0,
|
|
})),
|
|
palette,
|
|
)
|
|
|
|
return Array.from(dataByProjectBreakdown.entries()).map(([breakdownKey, data]) => {
|
|
const breakdownValues = breakdownValuesByKey.get(breakdownKey) ?? []
|
|
const fallbackColor = colorsByBreakdown.get(breakdownKey) ?? ''
|
|
const color =
|
|
normalizedBreakdowns.length === 1
|
|
? getBreakdownColor(
|
|
breakdownValues[0] ?? '',
|
|
normalizedBreakdowns[0],
|
|
fallbackColor,
|
|
palette,
|
|
)
|
|
: fallbackColor
|
|
return {
|
|
projectId: getAnalyticsBreakdownDatasetId(breakdownValues, normalizedBreakdowns),
|
|
label: formatChartBreakdownLabels(breakdownValues),
|
|
projectName:
|
|
normalizedBreakdowns.length === 1 && normalizedBreakdowns[0] === 'version_id'
|
|
? getVersionProjectName?.(breakdownValues[0] ?? '')
|
|
: undefined,
|
|
data,
|
|
borderColor: color,
|
|
backgroundColor: color,
|
|
}
|
|
})
|
|
}
|
|
|
|
export function getSliceCount(
|
|
timeRange: Labrinth.Analytics.v3.TimeRange,
|
|
fallback: number,
|
|
): number {
|
|
if ('slices' in timeRange.resolution) {
|
|
return Math.max(1, timeRange.resolution.slices)
|
|
}
|
|
if ('minutes' in timeRange.resolution) {
|
|
const duration = new Date(timeRange.end).getTime() - new Date(timeRange.start).getTime()
|
|
const bucketMs = timeRange.resolution.minutes * 60 * 1000
|
|
if (bucketMs > 0 && duration > 0) {
|
|
return Math.max(1, Math.ceil(duration / bucketMs))
|
|
}
|
|
}
|
|
return Math.max(1, fallback)
|
|
}
|
|
|
|
export function getSliceBucketRange(
|
|
timeRange: Labrinth.Analytics.v3.TimeRange,
|
|
sliceCount: number,
|
|
index: number,
|
|
): { start: Date; end: Date } {
|
|
const startMs = new Date(timeRange.start).getTime()
|
|
const endMs = new Date(timeRange.end).getTime()
|
|
const bucketMs = sliceCount > 0 ? (endMs - startMs) / sliceCount : 0
|
|
|
|
return {
|
|
start: new Date(startMs + index * bucketMs),
|
|
end: new Date(startMs + (index + 1) * bucketMs),
|
|
}
|
|
}
|
|
|
|
const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
|
const ONE_MINUTE_MS = 60 * 1000
|
|
const YEAR_LABEL_TIME_RANGE_YEARS = 2
|
|
const COMPACT_AXIS_THRESHOLD = 5
|
|
const SHORT_HOURLY_TIME_LABEL_DURATION_MS = 6 * ONE_DAY_MS
|
|
export const DEFAULT_X_AXIS_TICK_LIMIT = 12
|
|
export const SHORT_HOURLY_AXIS_TICK_LIMIT = 8
|
|
|
|
export function buildTimeAxisLabels(
|
|
timeRange: Labrinth.Analytics.v3.TimeRange,
|
|
sliceCount: number,
|
|
groupBy: AnalyticsGroupByPreset,
|
|
): string[] {
|
|
const startMs = new Date(timeRange.start).getTime()
|
|
const endMs = new Date(timeRange.end).getTime()
|
|
const totalMs = endMs - startMs
|
|
const bucketMs = sliceCount > 0 ? totalMs / sliceCount : 0
|
|
const includeTime = shouldShowTimeForHourlyAxis(timeRange, groupBy)
|
|
const includeYear = isYearRelevantForTimeRange(timeRange) || groupBy === 'year'
|
|
|
|
const dates: Date[] = []
|
|
const dateKeys: string[] = []
|
|
for (let i = 0; i < sliceCount; i++) {
|
|
const date = new Date(startMs + (i + 1) * bucketMs)
|
|
dates.push(date)
|
|
dateKeys.push(`${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`)
|
|
}
|
|
|
|
const dateFormatter = new Intl.DateTimeFormat(undefined, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
...(includeYear ? { year: 'numeric' } : {}),
|
|
})
|
|
|
|
if (!includeTime) {
|
|
return dates.map((date) => dateFormatter.format(date))
|
|
}
|
|
|
|
const timeFormatter = new Intl.DateTimeFormat(undefined, { hour: 'numeric' })
|
|
const uniqueDateCount = new Set(dateKeys).size
|
|
|
|
if (uniqueDateCount <= 1 || isSingleFullDayTimeRange(new Date(startMs), new Date(endMs))) {
|
|
return dates.map((date) => timeFormatter.format(date))
|
|
}
|
|
|
|
if (includeTime || sliceCount <= COMPACT_AXIS_THRESHOLD) {
|
|
const dateAndTimeFormatter = new Intl.DateTimeFormat(undefined, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: 'numeric',
|
|
...(includeYear ? { year: 'numeric' } : {}),
|
|
})
|
|
return dates.map((date) => dateAndTimeFormatter.format(date))
|
|
}
|
|
|
|
return dates.map((date) => dateFormatter.format(date))
|
|
}
|
|
|
|
export function isTimeRelevantForGroupBy(groupBy: AnalyticsGroupByPreset): boolean {
|
|
return groupBy === '1h' || groupBy === '6h'
|
|
}
|
|
|
|
export function shouldUseShortHourlyAxis(
|
|
timeRange: Labrinth.Analytics.v3.TimeRange,
|
|
groupBy: AnalyticsGroupByPreset,
|
|
): boolean {
|
|
if (!isTimeRelevantForGroupBy(groupBy)) {
|
|
return false
|
|
}
|
|
|
|
const durationMs = getTimeRangeDurationMs(timeRange)
|
|
|
|
return (
|
|
Number.isFinite(durationMs) &&
|
|
durationMs > 0 &&
|
|
durationMs <= DEFAULT_X_AXIS_TICK_LIMIT * ONE_DAY_MS
|
|
)
|
|
}
|
|
|
|
export function getShortHourlyAxisTickLimit(
|
|
timeRange: Labrinth.Analytics.v3.TimeRange,
|
|
groupBy: AnalyticsGroupByPreset,
|
|
): number | undefined {
|
|
if (!shouldUseShortHourlyAxis(timeRange, groupBy)) {
|
|
return undefined
|
|
}
|
|
|
|
const durationMs = getTimeRangeDurationMs(timeRange)
|
|
if (durationMs > SHORT_HOURLY_TIME_LABEL_DURATION_MS) {
|
|
return Math.min(DEFAULT_X_AXIS_TICK_LIMIT, Math.ceil(durationMs / ONE_DAY_MS))
|
|
}
|
|
|
|
return SHORT_HOURLY_AXIS_TICK_LIMIT
|
|
}
|
|
|
|
function shouldShowTimeForHourlyAxis(
|
|
timeRange: Labrinth.Analytics.v3.TimeRange,
|
|
groupBy: AnalyticsGroupByPreset,
|
|
): boolean {
|
|
const durationMs = getTimeRangeDurationMs(timeRange)
|
|
return (
|
|
isTimeRelevantForGroupBy(groupBy) &&
|
|
Number.isFinite(durationMs) &&
|
|
durationMs > 0 &&
|
|
durationMs <= SHORT_HOURLY_TIME_LABEL_DURATION_MS
|
|
)
|
|
}
|
|
|
|
function getTimeRangeDurationMs(timeRange: Labrinth.Analytics.v3.TimeRange): number {
|
|
return new Date(timeRange.end).getTime() - new Date(timeRange.start).getTime()
|
|
}
|
|
|
|
export function isYearRelevantForTimeRange(timeRange: Labrinth.Analytics.v3.TimeRange): boolean {
|
|
const start = new Date(timeRange.start)
|
|
const end = new Date(timeRange.end)
|
|
const yearLabelThreshold = new Date(start)
|
|
yearLabelThreshold.setFullYear(start.getFullYear() + YEAR_LABEL_TIME_RANGE_YEARS)
|
|
|
|
return (
|
|
Number.isFinite(start.getTime()) &&
|
|
Number.isFinite(end.getTime()) &&
|
|
end.getTime() > yearLabelThreshold.getTime()
|
|
)
|
|
}
|
|
|
|
export function formatBucketEndLabel(end: Date, includeTime: boolean, includeYear = false): string {
|
|
if (includeTime) {
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
...(includeYear ? { year: 'numeric' } : {}),
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
}).format(end)
|
|
}
|
|
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
...(includeYear ? { year: 'numeric' } : {}),
|
|
}).format(end)
|
|
}
|
|
|
|
function isStartOfDay(date: Date): boolean {
|
|
return (
|
|
date.getHours() === 0 &&
|
|
date.getMinutes() === 0 &&
|
|
date.getSeconds() === 0 &&
|
|
date.getMilliseconds() === 0
|
|
)
|
|
}
|
|
|
|
function isSingleFullDayTimeRange(start: Date, end: Date): boolean {
|
|
const durationMs = end.getTime() - start.getTime()
|
|
return (
|
|
Math.abs(durationMs - ONE_DAY_MS) < ONE_MINUTE_MS && isStartOfDay(start) && isStartOfDay(end)
|
|
)
|
|
}
|
|
|
|
export function formatMetricValue(
|
|
value: number,
|
|
activeStat: AnalyticsDashboardStat,
|
|
formatNumber: (value: number) => string,
|
|
formatMessage: FormatMessage,
|
|
): string {
|
|
switch (activeStat) {
|
|
case 'revenue': {
|
|
const amount = Math.round(value * 100) / 100
|
|
return formatMessage(analyticsStatCardMessages.revenueValue, {
|
|
value: formatNumber(amount),
|
|
})
|
|
}
|
|
case 'playtime': {
|
|
const hours = value / 3600
|
|
return formatMessage(analyticsStatCardMessages.playtimeHours, {
|
|
hours: hours.toFixed(1),
|
|
})
|
|
}
|
|
case 'views':
|
|
case 'downloads':
|
|
default:
|
|
return formatNumber(Math.round(value))
|
|
}
|
|
}
|
|
|
|
function formatSmallAxisNumber(value: number): string {
|
|
const rounded = Math.round(value)
|
|
if (Math.abs(value - rounded) < 0.0000001) {
|
|
return String(rounded)
|
|
}
|
|
|
|
const formattedValue = Math.abs(value) < 1 ? value.toFixed(2) : value.toFixed(1)
|
|
return formattedValue.replace(/\.?0+$/, '')
|
|
}
|
|
|
|
const COMPACT_AXIS_UNITS = [
|
|
{ threshold: 1_000_000, divisor: 1_000_000, suffix: 'M' },
|
|
{ threshold: 1_000, divisor: 1_000, suffix: 'K' },
|
|
] as const
|
|
const MAX_COMPACT_AXIS_DIGITS = 3
|
|
|
|
function getCompactAxisUnit(values: readonly number[]) {
|
|
let maxAbsoluteValue = 0
|
|
for (const value of values) {
|
|
if (Number.isFinite(value)) {
|
|
maxAbsoluteValue = Math.max(maxAbsoluteValue, Math.abs(value))
|
|
}
|
|
}
|
|
|
|
return COMPACT_AXIS_UNITS.find((unit) => maxAbsoluteValue >= unit.threshold) ?? null
|
|
}
|
|
|
|
function formatCompactAxisNumber(value: number, axisValues: readonly number[]): string | null {
|
|
if (Math.abs(value) === 0) return '0'
|
|
|
|
const unit = getCompactAxisUnit(axisValues)
|
|
if (!unit) return null
|
|
|
|
return `${formatCompactAxisValue(value / unit.divisor)}${unit.suffix}`
|
|
}
|
|
|
|
function formatCompactAxisValue(value: number): string {
|
|
const absoluteValue = Math.abs(value)
|
|
if (absoluteValue === 0) return '0'
|
|
|
|
const integerDigitCount = absoluteValue < 1 ? 1 : Math.floor(absoluteValue).toString().length
|
|
const fractionDigitCount = Math.max(0, MAX_COMPACT_AXIS_DIGITS - integerDigitCount)
|
|
const roundedValue = Number(value.toFixed(fractionDigitCount))
|
|
const roundedIntegerDigitCount =
|
|
Math.abs(roundedValue) < 1 ? 1 : Math.floor(Math.abs(roundedValue)).toString().length
|
|
|
|
if (roundedIntegerDigitCount > MAX_COMPACT_AXIS_DIGITS) {
|
|
const truncatedValue = Math.sign(value) * (10 ** MAX_COMPACT_AXIS_DIGITS - 1)
|
|
return String(truncatedValue)
|
|
}
|
|
|
|
return roundedValue.toFixed(fractionDigitCount).replace(/\.?0+$/, '')
|
|
}
|
|
|
|
export function formatAxisValue(
|
|
value: number,
|
|
activeStat: AnalyticsDashboardStat,
|
|
formatCompact: (value: number) => string,
|
|
formatMessage: FormatMessage,
|
|
axisValues: readonly number[] = [value],
|
|
): string {
|
|
switch (activeStat) {
|
|
case 'revenue': {
|
|
const amount = Math.round(value * 100) / 100
|
|
const axisAmounts = axisValues.map((axisValue) => Math.round(axisValue * 100) / 100)
|
|
return formatMessage(analyticsStatCardMessages.revenueValue, {
|
|
value: formatCompactAxisNumber(amount, axisAmounts) ?? formatCompact(amount),
|
|
})
|
|
}
|
|
case 'playtime': {
|
|
const formattedHours = formatCompactAxisNumber(value, axisValues)
|
|
if (formattedHours) {
|
|
return formatMessage(analyticsChartMessages.playtimeAxisHours, { hours: formattedHours })
|
|
}
|
|
if (Math.abs(value) < 10) {
|
|
return formatMessage(analyticsChartMessages.playtimeAxisHours, {
|
|
hours: formatSmallAxisNumber(value),
|
|
})
|
|
}
|
|
return formatMessage(analyticsChartMessages.playtimeAxisHours, {
|
|
hours: formatCompact(Math.round(value)),
|
|
})
|
|
}
|
|
case 'views':
|
|
case 'downloads':
|
|
default: {
|
|
const roundedValue = Math.round(value)
|
|
const roundedAxisValues = axisValues.map((axisValue) => Math.round(axisValue))
|
|
const formattedValue = formatCompactAxisNumber(roundedValue, roundedAxisValues)
|
|
if (formattedValue) return formattedValue
|
|
if (Math.abs(value) < 10) {
|
|
return formatSmallAxisNumber(value)
|
|
}
|
|
return formatCompact(roundedValue)
|
|
}
|
|
}
|
|
}
|