You've already forked AstralRinth
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 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>
This commit is contained in:
@@ -50,6 +50,7 @@
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
margin-top: 1.5rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.normal-page__sidebar {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div class="analytics-loading-bar" :style="{ opacity: isVisible ? 1 : 0 }" aria-hidden="true">
|
||||
<div
|
||||
class="analytics-loading-bar__track"
|
||||
:style="{
|
||||
width: `${progress}%`,
|
||||
transition: !isTransitioning
|
||||
? 'none'
|
||||
: isFinishing
|
||||
? 'width 0.1s ease-in-out'
|
||||
: isCreeping
|
||||
? 'width 2s linear'
|
||||
: 'width 0.9s ease-in-out',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const progress = ref(0)
|
||||
const isVisible = ref(false)
|
||||
const isFinishing = ref(false)
|
||||
const isCreeping = ref(false)
|
||||
const isTransitioning = ref(false)
|
||||
|
||||
let startFrame: number | null = null
|
||||
let showFrame: number | null = null
|
||||
let creepTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let resetTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearTimers() {
|
||||
if (showFrame !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(showFrame)
|
||||
}
|
||||
if (startFrame !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(startFrame)
|
||||
}
|
||||
if (creepTimeout) clearTimeout(creepTimeout)
|
||||
if (hideTimeout) clearTimeout(hideTimeout)
|
||||
if (resetTimeout) clearTimeout(resetTimeout)
|
||||
showFrame = null
|
||||
startFrame = null
|
||||
creepTimeout = null
|
||||
hideTimeout = null
|
||||
resetTimeout = null
|
||||
}
|
||||
|
||||
function start() {
|
||||
clearTimers()
|
||||
isVisible.value = false
|
||||
progress.value = 0
|
||||
isFinishing.value = false
|
||||
isCreeping.value = false
|
||||
isTransitioning.value = false
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
progress.value = 98
|
||||
return
|
||||
}
|
||||
|
||||
showFrame = window.requestAnimationFrame(() => {
|
||||
isVisible.value = true
|
||||
showFrame = null
|
||||
startFrame = window.requestAnimationFrame(() => {
|
||||
isTransitioning.value = true
|
||||
progress.value = 85
|
||||
startFrame = null
|
||||
})
|
||||
})
|
||||
creepTimeout = setTimeout(() => {
|
||||
isCreeping.value = true
|
||||
progress.value = 98
|
||||
creepTimeout = null
|
||||
}, 900)
|
||||
}
|
||||
|
||||
function finish() {
|
||||
clearTimers()
|
||||
isVisible.value = true
|
||||
isFinishing.value = true
|
||||
isCreeping.value = false
|
||||
isTransitioning.value = true
|
||||
progress.value = 100
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
isVisible.value = false
|
||||
progress.value = 0
|
||||
isFinishing.value = false
|
||||
isCreeping.value = false
|
||||
isTransitioning.value = false
|
||||
return
|
||||
}
|
||||
|
||||
hideTimeout = setTimeout(() => {
|
||||
isVisible.value = false
|
||||
resetTimeout = setTimeout(() => {
|
||||
isTransitioning.value = false
|
||||
progress.value = 0
|
||||
isFinishing.value = false
|
||||
isCreeping.value = false
|
||||
}, 400)
|
||||
}, 350)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
(loading) => {
|
||||
if (loading) {
|
||||
start()
|
||||
} else if (
|
||||
isVisible.value ||
|
||||
progress.value > 0 ||
|
||||
showFrame !== null ||
|
||||
startFrame !== null ||
|
||||
creepTimeout !== null
|
||||
) {
|
||||
finish()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(clearTimers)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.analytics-loading-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
height: 2px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--color-brand) 18%, transparent);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.4s;
|
||||
}
|
||||
|
||||
.analytics-loading-bar__track {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--loading-bar-gradient);
|
||||
}
|
||||
</style>
|
||||
+1024
File diff suppressed because it is too large
Load Diff
+78
@@ -0,0 +1,78 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import type { AnalyticsDashboardStat } from '~/providers/analytics/analytics'
|
||||
|
||||
export const ANALYTICS_DASHBOARD_STATS: readonly AnalyticsDashboardStat[] = [
|
||||
'views',
|
||||
'downloads',
|
||||
'revenue',
|
||||
'playtime',
|
||||
]
|
||||
|
||||
export const TOP_GRAPH_DATASET_LIMIT = 8
|
||||
export const GRAPH_RENDER_DATASET_LIMIT = 250
|
||||
export const PREVIOUS_PERIOD_DATASET_ID_PREFIX = 'previous-period:'
|
||||
export const PREVIOUS_PERIOD_BORDER_DASH = [6, 4]
|
||||
export const PROJECT_VERSION_UPLOAD_DEDUPE_WINDOW_MS = 24 * 60 * 60 * 1000
|
||||
export const ALL_PROJECTS_DATASET_ID = 'all'
|
||||
|
||||
export const PROJECT_EVENT_DATE_FORMATTER = new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
export const MONETIZATION_LEGEND_ENTRY_ORDER = new Map([
|
||||
['breakdown:monetized', 0],
|
||||
['breakdown:unmonetized', 1],
|
||||
])
|
||||
|
||||
export const VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUSES = [
|
||||
'approved',
|
||||
'unlisted',
|
||||
'private',
|
||||
] as const satisfies readonly Labrinth.Projects.v2.ProjectStatus[]
|
||||
|
||||
export type VisibleProjectStatusChangeEventStatus =
|
||||
(typeof VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUSES)[number]
|
||||
|
||||
export const VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUS_SET =
|
||||
new Set<Labrinth.Projects.v2.ProjectStatus>(VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUSES)
|
||||
|
||||
export const LIGHT_LEGEND_PALETTE = [
|
||||
'hsl(152, 100%, 34%)',
|
||||
'hsl(26, 100%, 42%)',
|
||||
'hsl(202, 100%, 35%)',
|
||||
'hsl(327, 45%, 64%)',
|
||||
'hsl(41, 100%, 45%)',
|
||||
'hsl(250, 60%, 33%)',
|
||||
'hsl(170, 43%, 47%)',
|
||||
'hsl(330, 60%, 33%)',
|
||||
'hsl(46, 100%, 36%)',
|
||||
'hsl(167, 100%, 30%)',
|
||||
'hsl(343, 38%, 45%)',
|
||||
'hsl(222, 100%, 28%)',
|
||||
'hsl(270, 62%, 60%)',
|
||||
'hsl(32, 100%, 37%)',
|
||||
'hsl(349, 57%, 51%)',
|
||||
'hsl(191, 43%, 37%)',
|
||||
]
|
||||
|
||||
export const DARK_LEGEND_PALETTE = [
|
||||
'hsl(145, 78%, 48%)',
|
||||
'hsl(41, 100%, 50%)',
|
||||
'hsl(202, 77%, 63%)',
|
||||
'hsl(323, 66%, 72%)',
|
||||
'hsl(56, 85%, 60%)',
|
||||
'hsl(255, 92%, 80%)',
|
||||
'hsl(12, 100%, 67%)',
|
||||
'hsl(176, 58%, 56%)',
|
||||
'hsl(60, 100%, 41%)',
|
||||
'hsl(165, 80%, 38%)',
|
||||
'hsl(341, 36%, 56%)',
|
||||
'hsl(226, 60%, 49%)',
|
||||
'hsl(252, 53%, 62%)',
|
||||
'hsl(75, 59%, 50%)',
|
||||
'hsl(195, 56%, 42%)',
|
||||
'hsl(30, 59%, 56%)',
|
||||
]
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<Menu
|
||||
theme="analytics-controls-menu"
|
||||
placement="bottom-end"
|
||||
:shown="isControlsMenuOpen"
|
||||
:triggers="[]"
|
||||
:popper-triggers="[]"
|
||||
:aria-id="controlsMenuId"
|
||||
no-auto-focus
|
||||
@update:shown="isControlsMenuOpen = $event"
|
||||
>
|
||||
<button
|
||||
ref="controlsMenuTrigger"
|
||||
type="button"
|
||||
:aria-expanded="isControlsMenuOpen"
|
||||
:aria-controls="controlsMenuId"
|
||||
:aria-label="
|
||||
formatMessage(analyticsChartMessages.controlsAria, {
|
||||
activeCount: activeControlCountLabel,
|
||||
})
|
||||
"
|
||||
class="btn-dropdown-animation inline-flex min-h-5 cursor-pointer items-center justify-between gap-2 rounded-xl border-0 bg-surface-4 px-3 py-2 text-left text-sm font-semibold text-button-text shadow-none transition-all duration-200 hover:brightness-[115%] focus-visible:brightness-[115%] active:brightness-[115%]"
|
||||
@click="toggleControlsMenu"
|
||||
>
|
||||
<Settings2Icon class="size-4 text-secondary" aria-hidden="true" />
|
||||
<span class="leading-tight text-primary">
|
||||
{{ formatMessage(analyticsChartMessages.controlsButton) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="activeControlCount > 0"
|
||||
class="inline-flex min-w-5 items-center justify-center rounded-full bg-highlight-green px-1.5 text-xs font-semibold leading-5 text-green"
|
||||
>
|
||||
{{ activeControlCount }}
|
||||
</span>
|
||||
<DropdownIcon class="size-4 text-secondary" aria-hidden="true" />
|
||||
</button>
|
||||
<template #popper>
|
||||
<div
|
||||
ref="controlsMenuPanel"
|
||||
role="dialog"
|
||||
:aria-label="formatMessage(analyticsChartMessages.controlsDialogAria)"
|
||||
class="mt-1 flex w-[228px] max-w-[calc(100vw_-_2rem)] flex-col overflow-hidden rounded-[14px] border border-solid border-surface-4 bg-surface-3 text-sm shadow-2xl"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 px-3 py-2.5 text-xs font-medium">
|
||||
<span class="font-semibold text-primary">{{ activeControlCountLabel }}</span>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="isResetDisabled"
|
||||
class="border-0 bg-transparent p-0 text-xs font-semibold text-primary transition-all disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:class="isResetDisabled ? '' : 'hover:text-contrast focus-visible:text-contrast'"
|
||||
@click="resetControls"
|
||||
>
|
||||
{{ formatMessage(analyticsMessages.resetButton) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasDisplayControls"
|
||||
class="flex flex-col gap-1 border-0 border-t border-solid border-surface-4 px-3 py-2.5"
|
||||
>
|
||||
<div class="mb-0.5 text-xs font-semibold text-secondary">
|
||||
{{ formatMessage(analyticsChartMessages.displayControls) }}
|
||||
</div>
|
||||
<div v-if="canShowPreviousPeriod" class="flex min-h-7 items-center justify-between">
|
||||
<label
|
||||
:for="previousPeriodToggleId"
|
||||
class="flex min-h-7 min-w-0 grow cursor-pointer items-center gap-1.5 pr-3 font-semibold leading-tight text-primary"
|
||||
>
|
||||
<HistoryIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
|
||||
<span class="min-w-0 truncate">
|
||||
{{ formatMessage(analyticsChartMessages.previousPeriod) }}
|
||||
</span>
|
||||
</label>
|
||||
<Toggle
|
||||
:id="previousPeriodToggleId"
|
||||
v-model="showPreviousPeriodModel"
|
||||
:small="smallToggles"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="canUseRatioMode" class="flex min-h-7 items-center justify-between">
|
||||
<label
|
||||
:for="ratioModeToggleId"
|
||||
class="flex min-h-7 min-w-0 grow cursor-pointer items-center gap-1.5 pr-3 font-semibold leading-tight text-primary"
|
||||
>
|
||||
<span
|
||||
class="inline-flex size-4 shrink-0 items-center justify-center text-sm font-semibold leading-none text-secondary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
%
|
||||
</span>
|
||||
<span class="min-w-0 truncate">
|
||||
{{ formatMessage(analyticsChartMessages.ratio) }}
|
||||
</span>
|
||||
</label>
|
||||
<Toggle :id="ratioModeToggleId" v-model="ratioModeModel" :small="smallToggles" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-1 border-0 border-t border-solid border-surface-4 px-3 py-2.5"
|
||||
>
|
||||
<div class="mb-0.5 text-xs font-semibold text-secondary">
|
||||
{{ formatMessage(analyticsChartMessages.annotations) }}
|
||||
</div>
|
||||
<div
|
||||
v-tooltip="projectEventsDisabledTooltip"
|
||||
class="justify3 flex min-h-7 items-center"
|
||||
:aria-disabled="!hasProjectEvents"
|
||||
>
|
||||
<label
|
||||
:for="projectEventsToggleId"
|
||||
class="flex min-h-7 min-w-0 grow items-center gap-1.5 pr-3 font-semibold leading-tight text-primary"
|
||||
:class="hasProjectEvents ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
|
||||
>
|
||||
<TagCategoryFlagIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
|
||||
<span class="min-w-0 truncate">
|
||||
{{ formatMessage(analyticsChartMessages.projectEvents) }}
|
||||
</span>
|
||||
</label>
|
||||
<Toggle
|
||||
:id="projectEventsToggleId"
|
||||
v-model="showProjectEventsControlModel"
|
||||
:small="smallToggles"
|
||||
:disabled="!hasProjectEvents"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-tooltip="modrinthEventsDisabledTooltip"
|
||||
class="justify3 flex min-h-7 items-center"
|
||||
:aria-disabled="!hasChartEvents"
|
||||
>
|
||||
<label
|
||||
:for="modrinthEventsToggleId"
|
||||
class="flex min-h-7 min-w-0 grow items-center gap-1.5 pr-3 font-semibold leading-tight text-primary"
|
||||
:class="hasChartEvents ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
|
||||
>
|
||||
<InfoIcon class="size-4 shrink-0 text-blue" aria-hidden="true" />
|
||||
<span class="min-w-0 truncate">
|
||||
{{ formatMessage(analyticsChartMessages.modrinthEvents) }}
|
||||
</span>
|
||||
</label>
|
||||
<Toggle
|
||||
:id="modrinthEventsToggleId"
|
||||
v-model="showChartEventsControlModel"
|
||||
:small="smallToggles"
|
||||
:disabled="!hasChartEvents"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Menu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DropdownIcon,
|
||||
HistoryIcon,
|
||||
InfoIcon,
|
||||
Settings2Icon,
|
||||
TagCategoryFlagIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Toggle, useVIntl } from '@modrinth/ui'
|
||||
import { Menu } from 'floating-vue'
|
||||
|
||||
import { analyticsChartMessages, analyticsMessages } from '../../analytics-messages'
|
||||
|
||||
const props = defineProps<{
|
||||
ratioMode: boolean
|
||||
showChartEvents: boolean
|
||||
showProjectEvents: boolean
|
||||
showPreviousPeriod: boolean
|
||||
canUseRatioMode: boolean
|
||||
canShowPreviousPeriod: boolean
|
||||
hasChartEvents: boolean
|
||||
hasProjectEvents: boolean
|
||||
smallToggles: boolean
|
||||
defaultRatioMode: boolean
|
||||
defaultShowChartEvents: boolean
|
||||
defaultShowProjectEvents: boolean
|
||||
defaultShowPreviousPeriod: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e:
|
||||
| 'update:ratioMode'
|
||||
| 'update:showChartEvents'
|
||||
| 'update:showProjectEvents'
|
||||
| 'update:showPreviousPeriod',
|
||||
value: boolean,
|
||||
): void
|
||||
}>()
|
||||
|
||||
const isControlsMenuOpen = ref(false)
|
||||
const controlsMenuTrigger = ref<HTMLElement | null>(null)
|
||||
const controlsMenuPanel = ref<HTMLElement | null>(null)
|
||||
const controlsMenuId = useId()
|
||||
const ratioModeToggleId = useId()
|
||||
const previousPeriodToggleId = useId()
|
||||
const modrinthEventsToggleId = useId()
|
||||
const projectEventsToggleId = useId()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const ratioModeModel = computed({
|
||||
get: () => props.ratioMode,
|
||||
set: (value: boolean) => emit('update:ratioMode', value),
|
||||
})
|
||||
const showChartEventsModel = computed({
|
||||
get: () => props.showChartEvents,
|
||||
set: (value: boolean) => emit('update:showChartEvents', value),
|
||||
})
|
||||
const showChartEventsControlModel = computed({
|
||||
get: () => props.hasChartEvents && props.showChartEvents,
|
||||
set: (value: boolean) => emit('update:showChartEvents', value),
|
||||
})
|
||||
const showProjectEventsModel = computed({
|
||||
get: () => props.showProjectEvents,
|
||||
set: (value: boolean) => emit('update:showProjectEvents', value),
|
||||
})
|
||||
const showProjectEventsControlModel = computed({
|
||||
get: () => props.hasProjectEvents && props.showProjectEvents,
|
||||
set: (value: boolean) => emit('update:showProjectEvents', value),
|
||||
})
|
||||
const showPreviousPeriodModel = computed({
|
||||
get: () => props.showPreviousPeriod,
|
||||
set: (value: boolean) => emit('update:showPreviousPeriod', value),
|
||||
})
|
||||
|
||||
const hasDisplayControls = computed(() => props.canShowPreviousPeriod || props.canUseRatioMode)
|
||||
const projectEventsDisabledTooltip = computed(() =>
|
||||
props.hasProjectEvents ? undefined : formatMessage(analyticsChartMessages.noProjectEvents),
|
||||
)
|
||||
const modrinthEventsDisabledTooltip = computed(() =>
|
||||
props.hasChartEvents ? undefined : formatMessage(analyticsChartMessages.noModrinthEvents),
|
||||
)
|
||||
const activeControlCount = computed(() => {
|
||||
let count = 0
|
||||
if (props.canShowPreviousPeriod && props.showPreviousPeriod) count += 1
|
||||
if (props.canUseRatioMode && props.ratioMode) count += 1
|
||||
if (props.hasProjectEvents && props.showProjectEvents) count += 1
|
||||
if (props.hasChartEvents && props.showChartEvents) count += 1
|
||||
return count
|
||||
})
|
||||
const activeControlCountLabel = computed(() =>
|
||||
formatMessage(analyticsChartMessages.activeControlCount, { count: activeControlCount.value }),
|
||||
)
|
||||
const isResetDisabled = computed(
|
||||
() =>
|
||||
props.showPreviousPeriod === props.defaultShowPreviousPeriod &&
|
||||
props.ratioMode === props.defaultRatioMode &&
|
||||
props.showProjectEvents === props.defaultShowProjectEvents &&
|
||||
props.showChartEvents === props.defaultShowChartEvents,
|
||||
)
|
||||
|
||||
function toggleControlsMenu() {
|
||||
isControlsMenuOpen.value = !isControlsMenuOpen.value
|
||||
}
|
||||
|
||||
function resetControls() {
|
||||
if (isResetDisabled.value) return
|
||||
|
||||
showPreviousPeriodModel.value = props.defaultShowPreviousPeriod
|
||||
ratioModeModel.value = props.defaultRatioMode
|
||||
showProjectEventsModel.value = props.defaultShowProjectEvents
|
||||
showChartEventsModel.value = props.defaultShowChartEvents
|
||||
}
|
||||
|
||||
function onDocumentPointerDown(event: PointerEvent) {
|
||||
if (!isControlsMenuOpen.value || !(event.target instanceof Node)) return
|
||||
if (controlsMenuTrigger.value?.contains(event.target)) return
|
||||
if (controlsMenuPanel.value?.contains(event.target)) return
|
||||
isControlsMenuOpen.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown, true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.v-popper--theme-analytics-controls-menu .v-popper__inner {
|
||||
overflow: visible !important;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.v-popper--theme-analytics-controls-menu .v-popper__arrow-container {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-5"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-5"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<div
|
||||
ref="legendContainer"
|
||||
class="flex max-h-[130px] flex-wrap items-center gap-y-1 overflow-y-auto px-3"
|
||||
@scroll="checkLegendScrollState"
|
||||
>
|
||||
<div
|
||||
v-for="legendEntry in legendEntries"
|
||||
:key="legendEntry.id"
|
||||
class="inline-flex items-center"
|
||||
>
|
||||
<button
|
||||
v-tooltip="legendEntry.projectName ?? ''"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 px-2 py-0.5 text-sm !outline-0 transition-all focus-within:!outline-0 focus:!outline-0 focus-visible:!outline-0"
|
||||
:class="[
|
||||
legendEntry.hidden ? 'text-secondary opacity-70' : 'text-primary',
|
||||
isLegendEntryToggleDisabled(legendEntry) && !isShiftKeyPressed
|
||||
? 'cursor-default'
|
||||
: 'cursor-pointer hover:brightness-125',
|
||||
]"
|
||||
:aria-pressed="!legendEntry.hidden"
|
||||
@mouseenter="emit('entry-hover', legendEntry.id)"
|
||||
@mouseleave="emit('entry-hover-clear', legendEntry.id)"
|
||||
@focus="emit('entry-hover', legendEntry.id)"
|
||||
@blur="emit('entry-hover-clear', legendEntry.id)"
|
||||
@click="emit('entry-click', $event, legendEntry.id)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
legendEntry.isPreviousPeriod
|
||||
? 'h-0 w-2 rounded-none border-0 border-t-2 border-dashed bg-transparent'
|
||||
: 'size-2 rounded-full'
|
||||
"
|
||||
:style="
|
||||
legendEntry.isPreviousPeriod
|
||||
? { borderColor: legendEntry.color }
|
||||
: { backgroundColor: legendEntry.color }
|
||||
"
|
||||
/>
|
||||
<span
|
||||
:class="{
|
||||
'line-through': legendEntry.hidden,
|
||||
capitalize: shouldCapitalizeDatasetLabels,
|
||||
}"
|
||||
>
|
||||
{{ legendEntry.name }}
|
||||
</span>
|
||||
</button>
|
||||
<Dropdown
|
||||
v-if="showUnmonetizedInfo && legendEntry.id === 'breakdown:unmonetized'"
|
||||
theme="analytics-monetization-popover"
|
||||
:triggers="['hover', 'focus']"
|
||||
:popper-triggers="['hover', 'focus']"
|
||||
:delay="{ show: 0, hide: 250 }"
|
||||
placement="top"
|
||||
:aria-id="monetizationPopoverId"
|
||||
no-auto-focus
|
||||
>
|
||||
<InfoIcon
|
||||
class="-ml-1 mt-px inline-flex size-4 items-center justify-center rounded-full border-0 bg-transparent p-0 text-secondary transition-all hover:text-contrast focus-visible:text-contrast"
|
||||
:aria-label="formatMessage(analyticsChartMessages.viewMonetizedAnalyticsDetails)"
|
||||
/>
|
||||
<template #popper>
|
||||
<div
|
||||
role="dialog"
|
||||
:aria-label="formatMessage(analyticsChartMessages.monetizedAnalyticsDetails)"
|
||||
class="font-base w-[292px] rounded-xl border border-solid border-surface-5 bg-surface-3 p-3 text-sm leading-snug shadow-2xl"
|
||||
>
|
||||
{{ formatMessage(analyticsChartMessages.monetizedAnalyticsDetailsDescription) }}
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-5"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-5"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { InfoIcon } from '@modrinth/assets'
|
||||
import { useScrollIndicator, useVIntl } from '@modrinth/ui'
|
||||
import { Dropdown } from 'floating-vue'
|
||||
|
||||
import { analyticsChartMessages } from '../../analytics-messages'
|
||||
import type { AnalyticsChartLegendEntry } from '../analytics-chart-types'
|
||||
|
||||
const props = defineProps<{
|
||||
legendEntries: AnalyticsChartLegendEntry[]
|
||||
shouldCapitalizeDatasetLabels: boolean
|
||||
showUnmonetizedInfo: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'entry-hover': [datasetId: string]
|
||||
'entry-hover-clear': [datasetId: string]
|
||||
'entry-click': [event: MouseEvent, datasetId: string]
|
||||
}>()
|
||||
|
||||
const monetizationPopoverId = useId()
|
||||
const legendContainer = ref<HTMLElement | null>(null)
|
||||
const isShiftKeyPressed = ref(false)
|
||||
const { formatMessage } = useVIntl()
|
||||
const {
|
||||
showTopFade: showLegendTopFade,
|
||||
showBottomFade: showLegendBottomFade,
|
||||
checkScrollState: checkLegendScrollState,
|
||||
forceCheck: forceCheckLegendScrollState,
|
||||
} = useScrollIndicator(legendContainer)
|
||||
|
||||
function updateShiftKeyState(event: KeyboardEvent) {
|
||||
isShiftKeyPressed.value = event.shiftKey
|
||||
}
|
||||
|
||||
function clearShiftKeyState() {
|
||||
isShiftKeyPressed.value = false
|
||||
}
|
||||
|
||||
function isLegendEntryToggleDisabled(legendEntry: AnalyticsChartLegendEntry) {
|
||||
if (legendEntry.hidden) return false
|
||||
const visibleCount = props.legendEntries.filter((entry) => !entry.hidden).length
|
||||
return visibleCount <= 1
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.legendEntries,
|
||||
() => {
|
||||
nextTick(() => {
|
||||
forceCheckLegendScrollState()
|
||||
})
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', updateShiftKeyState)
|
||||
window.addEventListener('keyup', updateShiftKeyState)
|
||||
window.addEventListener('blur', clearShiftKeyState)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', updateShiftKeyState)
|
||||
window.removeEventListener('keyup', updateShiftKeyState)
|
||||
window.removeEventListener('blur', clearShiftKeyState)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.v-popper--theme-analytics-monetization-popover .v-popper__inner {
|
||||
overflow: visible !important;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.v-popper--theme-analytics-monetization-popover .v-popper__arrow-container {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(analyticsChartMessages.renderLimitHeader, { count: tableProjectCount })"
|
||||
fade="warning"
|
||||
width="500px"
|
||||
max-width="calc(100vw - 2rem)"
|
||||
>
|
||||
<p class="m-0 max-w-[32rem] text-primary">
|
||||
{{ formatMessage(analyticsChartMessages.renderLimitDescription) }}
|
||||
</p>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="transparent">
|
||||
<button @click="modal?.hide()">
|
||||
{{ formatMessage(analyticsChartMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button class="!shadow-none" @click="confirm">
|
||||
{{ formatMessage(analyticsChartMessages.showAll) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, NewModal, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import { analyticsChartMessages } from '../../analytics-messages'
|
||||
|
||||
defineProps<{
|
||||
tableProjectCount: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const modal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
|
||||
function show(event: MouseEvent) {
|
||||
modal.value?.show(event)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
emit('confirm')
|
||||
hide()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
</script>
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div
|
||||
class="flex min-h-[84px] w-full flex-col items-start justify-between gap-3 rounded-t-2xl border-0 border-b border-solid border-surface-5 bg-surface-3 p-4 sm:flex-row sm:items-center"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="w-max text-xl font-semibold text-contrast">
|
||||
{{ graphTitle }}
|
||||
</div>
|
||||
<div
|
||||
v-if="showTableSelectionSubheading"
|
||||
class="m-0 flex w-max flex-wrap items-center gap-2 text-sm text-secondary"
|
||||
>
|
||||
<span>{{ tableSelectionSubheading }}</span>
|
||||
|
||||
<button
|
||||
v-if="showGraphRenderLimitButton"
|
||||
type="button"
|
||||
class="font-base border-0 bg-transparent p-0 text-sm underline transition-all hover:brightness-125"
|
||||
@click="emit('toggle-graph-render-limit', $event)"
|
||||
>
|
||||
{{ graphRenderLimitButtonLabel }}
|
||||
</button>
|
||||
<button
|
||||
v-if="showTopGraphDatasetsButton"
|
||||
type="button"
|
||||
class="font-base border-0 bg-transparent p-0 text-sm underline transition-all hover:brightness-125"
|
||||
@click="emit('show-top-graph-datasets')"
|
||||
>
|
||||
{{ formatMessage(analyticsChartMessages.showTopEight) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex grow select-none flex-wrap-reverse items-center justify-end gap-2 gap-y-2">
|
||||
<AnalyticsChartControls
|
||||
v-model:ratio-mode="ratioMode"
|
||||
v-model:show-chart-events="showChartEvents"
|
||||
v-model:show-project-events="showProjectEvents"
|
||||
v-model:show-previous-period="showPreviousPeriod"
|
||||
:can-use-ratio-mode="canUseRatioMode"
|
||||
:can-show-previous-period="canShowPreviousPeriod"
|
||||
:has-chart-events="hasChartEvents"
|
||||
:has-project-events="hasProjectEvents"
|
||||
:small-toggles="smallToggles"
|
||||
:default-ratio-mode="DEFAULT_ANALYTICS_GRAPH_RATIO_MODE"
|
||||
:default-show-chart-events="DEFAULT_ANALYTICS_GRAPH_EVENTS_VISIBILITY"
|
||||
:default-show-project-events="defaultShowProjectEvents"
|
||||
:default-show-previous-period="DEFAULT_ANALYTICS_GRAPH_PREVIOUS_PERIOD_VISIBILITY"
|
||||
/>
|
||||
<Tabs
|
||||
:value="activeGraphViewMode"
|
||||
:tabs="viewModeTabs"
|
||||
@update:value="activeGraphViewMode = $event as AnalyticsGraphViewMode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChartAreaIcon, ChartColumnBigIcon, ChartSplineIcon } from '@modrinth/assets'
|
||||
import { Tabs, type TabsTab, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import {
|
||||
DEFAULT_ANALYTICS_GRAPH_EVENTS_VISIBILITY,
|
||||
DEFAULT_ANALYTICS_GRAPH_PREVIOUS_PERIOD_VISIBILITY,
|
||||
DEFAULT_ANALYTICS_GRAPH_RATIO_MODE,
|
||||
} from '~/components/analytics-dashboard/analytics-route-query'
|
||||
import type { AnalyticsGraphViewMode } from '~/providers/analytics/analytics'
|
||||
|
||||
import { analyticsChartMessages } from '../../analytics-messages.ts'
|
||||
import AnalyticsChartControls from './AnalyticsChartControls.vue'
|
||||
|
||||
const activeGraphViewMode = defineModel<AnalyticsGraphViewMode>('activeGraphViewMode', {
|
||||
required: true,
|
||||
})
|
||||
const ratioMode = defineModel<boolean>('ratioMode', { required: true })
|
||||
const showChartEvents = defineModel<boolean>('showChartEvents', { required: true })
|
||||
const showProjectEvents = defineModel<boolean>('showProjectEvents', { required: true })
|
||||
const showPreviousPeriod = defineModel<boolean>('showPreviousPeriod', { required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
graphTitle: string
|
||||
showTableSelectionSubheading: boolean
|
||||
tableSelectionSubheading: string
|
||||
showGraphRenderLimitButton: boolean
|
||||
graphRenderLimitButtonLabel: string
|
||||
showTopGraphDatasetsButton: boolean
|
||||
canUseRatioMode: boolean
|
||||
canShowPreviousPeriod: boolean
|
||||
hasChartEvents: boolean
|
||||
hasProjectEvents: boolean
|
||||
smallToggles: boolean
|
||||
defaultShowProjectEvents: boolean
|
||||
isMobileLayout: boolean
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const emit = defineEmits<{
|
||||
'toggle-graph-render-limit': [event: MouseEvent]
|
||||
'show-top-graph-datasets': []
|
||||
}>()
|
||||
|
||||
const viewModeTabs = computed<TabsTab[]>(() => [
|
||||
{
|
||||
value: 'line',
|
||||
label: props.isMobileLayout ? '' : formatMessage(analyticsChartMessages.lineView),
|
||||
icon: ChartSplineIcon,
|
||||
},
|
||||
{
|
||||
value: 'area',
|
||||
label: props.isMobileLayout ? '' : formatMessage(analyticsChartMessages.areaView),
|
||||
icon: ChartAreaIcon,
|
||||
},
|
||||
{
|
||||
value: 'bar',
|
||||
label: props.isMobileLayout ? '' : formatMessage(analyticsChartMessages.barView),
|
||||
icon: ChartColumnBigIcon,
|
||||
},
|
||||
])
|
||||
</script>
|
||||
+450
@@ -0,0 +1,450 @@
|
||||
import { useVIntl } from '@modrinth/ui'
|
||||
import { computed, type ComputedRef, type Ref, ref, watch } from 'vue'
|
||||
|
||||
import type {
|
||||
AnalyticsBreakdownPreset,
|
||||
AnalyticsDashboardProject,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import { analyticsChartMessages } from '../../analytics-messages.ts'
|
||||
import { COMBINED_BREAKDOWN_DATASET_ID_PREFIX } from '../../breakdown.ts'
|
||||
import {
|
||||
ALL_PROJECTS_DATASET_ID,
|
||||
MONETIZATION_LEGEND_ENTRY_ORDER,
|
||||
PREVIOUS_PERIOD_BORDER_DASH,
|
||||
} from '../analytics-chart-constants.ts'
|
||||
import type { AnalyticsChartEvent } from '../analytics-chart-plot/AnalyticsChartEvents.vue'
|
||||
import type { AnalyticsChartLegendEntry } from '../analytics-chart-types.ts'
|
||||
import {
|
||||
areStringArraysEqual,
|
||||
type ChartDataset,
|
||||
decodeBreakdownDatasetValue,
|
||||
getChartDatasetTotal,
|
||||
getPreviousPeriodDatasetId,
|
||||
} from '../analytics-chart-utils.ts'
|
||||
|
||||
export function useAnalyticsChartLegend({
|
||||
selectableChartDatasets,
|
||||
allChartDatasets,
|
||||
previousChartDatasets,
|
||||
shouldShowPreviousPeriod,
|
||||
isRatioMode,
|
||||
hiddenGraphDatasetIds,
|
||||
selectedBreakdowns,
|
||||
isGraphDatasetSelectionActive,
|
||||
selectedProjects,
|
||||
selectedProjectIdSet,
|
||||
selectedProjectEventIdSet,
|
||||
}: {
|
||||
selectableChartDatasets: ComputedRef<ChartDataset[]>
|
||||
allChartDatasets: ComputedRef<ChartDataset[]>
|
||||
previousChartDatasets: ComputedRef<ChartDataset[]>
|
||||
shouldShowPreviousPeriod: ComputedRef<boolean>
|
||||
isRatioMode: Ref<boolean>
|
||||
hiddenGraphDatasetIds: Ref<string[]>
|
||||
selectedBreakdowns: Ref<readonly AnalyticsBreakdownPreset[]>
|
||||
isGraphDatasetSelectionActive: Ref<boolean>
|
||||
selectedProjects: ComputedRef<AnalyticsDashboardProject[]>
|
||||
selectedProjectIdSet: ComputedRef<Set<string>>
|
||||
selectedProjectEventIdSet: ComputedRef<Set<string>>
|
||||
}) {
|
||||
const { formatMessage } = useVIntl()
|
||||
const hoveredLegendEntryId = ref<string | null>(null)
|
||||
const hiddenDatasetIds = computed(() => new Set(hiddenGraphDatasetIds.value))
|
||||
const previousChartDatasetByOriginalId = computed(() => {
|
||||
const datasets = new Map<string, ChartDataset>()
|
||||
for (const dataset of previousChartDatasets.value) {
|
||||
datasets.set(dataset.projectId, dataset)
|
||||
}
|
||||
return datasets
|
||||
})
|
||||
const currentLegendEntries = computed<AnalyticsChartLegendEntry[]>(() =>
|
||||
selectableChartDatasets.value
|
||||
.map((dataset) => ({
|
||||
id: dataset.projectId,
|
||||
name: dataset.label,
|
||||
projectName: dataset.projectName,
|
||||
color: dataset.borderColor,
|
||||
totalValue: getChartDatasetTotal(dataset),
|
||||
hidden: hiddenDatasetIds.value.has(dataset.projectId),
|
||||
}))
|
||||
.sort(compareLegendEntries),
|
||||
)
|
||||
const visibleProjectEventIdSet = computed(() => {
|
||||
if (!selectedBreakdowns.value.includes('project')) {
|
||||
return selectedProjectEventIdSet.value
|
||||
}
|
||||
|
||||
const visibleProjectIds = new Set<string>()
|
||||
const projectIdsWithLegendEntries = new Set<string>()
|
||||
|
||||
for (const legendEntry of currentLegendEntries.value) {
|
||||
const projectId = getLegendEntryProjectId(legendEntry)
|
||||
if (!projectId) {
|
||||
continue
|
||||
}
|
||||
|
||||
projectIdsWithLegendEntries.add(projectId)
|
||||
if (!legendEntry.hidden) {
|
||||
visibleProjectIds.add(projectId)
|
||||
}
|
||||
}
|
||||
|
||||
if (isGraphDatasetSelectionActive.value) {
|
||||
return visibleProjectIds
|
||||
}
|
||||
|
||||
if (projectIdsWithLegendEntries.size === 0) {
|
||||
return selectedProjectEventIdSet.value
|
||||
}
|
||||
|
||||
const eventProjectIds = new Set<string>()
|
||||
for (const projectId of selectedProjectEventIdSet.value) {
|
||||
if (!projectIdsWithLegendEntries.has(projectId) || visibleProjectIds.has(projectId)) {
|
||||
eventProjectIds.add(projectId)
|
||||
}
|
||||
}
|
||||
|
||||
return eventProjectIds
|
||||
})
|
||||
const legendEntries = computed<AnalyticsChartLegendEntry[]>(() => {
|
||||
if (!shouldShowPreviousPeriod.value) {
|
||||
return currentLegendEntries.value
|
||||
}
|
||||
|
||||
return currentLegendEntries.value.flatMap((entry) => {
|
||||
const previousDataset = previousChartDatasetByOriginalId.value.get(entry.id)
|
||||
const previousEntry: AnalyticsChartLegendEntry = {
|
||||
id: getPreviousPeriodDatasetId(entry.id),
|
||||
name: formatMessage(analyticsChartMessages.previousPeriodSuffix, { name: entry.name }),
|
||||
projectName: entry.projectName,
|
||||
color: entry.color,
|
||||
totalValue: previousDataset ? getChartDatasetTotal(previousDataset) : 0,
|
||||
hidden: hiddenDatasetIds.value.has(getPreviousPeriodDatasetId(entry.id)),
|
||||
isPreviousPeriod: true,
|
||||
}
|
||||
|
||||
return [entry, previousEntry]
|
||||
})
|
||||
})
|
||||
const hiddenCurrentLegendEntryIds = computed(() =>
|
||||
currentLegendEntries.value.filter((entry) => entry.hidden).map((entry) => entry.id),
|
||||
)
|
||||
const hiddenCurrentLegendEntryIdsKey = computed(() =>
|
||||
hiddenCurrentLegendEntryIds.value.join('\u0000'),
|
||||
)
|
||||
const chartDatasetById = computed(() => {
|
||||
const datasets = new Map<string, ChartDataset>()
|
||||
for (const dataset of selectableChartDatasets.value) {
|
||||
datasets.set(dataset.projectId, dataset)
|
||||
|
||||
if (!shouldShowPreviousPeriod.value) {
|
||||
continue
|
||||
}
|
||||
|
||||
const previousDataset = previousChartDatasetByOriginalId.value.get(dataset.projectId)
|
||||
const previousData = Array.from(
|
||||
{ length: dataset.data.length },
|
||||
(_, index) => previousDataset?.data[index] ?? 0,
|
||||
)
|
||||
datasets.set(getPreviousPeriodDatasetId(dataset.projectId), {
|
||||
projectId: getPreviousPeriodDatasetId(dataset.projectId),
|
||||
label: formatMessage(analyticsChartMessages.previousPeriodSuffix, {
|
||||
name: dataset.label,
|
||||
}),
|
||||
projectName: dataset.projectName,
|
||||
data: previousData,
|
||||
borderColor: dataset.borderColor,
|
||||
backgroundColor: dataset.backgroundColor,
|
||||
borderDash: PREVIOUS_PERIOD_BORDER_DASH,
|
||||
})
|
||||
}
|
||||
return datasets
|
||||
})
|
||||
const hoverRatioSliceTotals = computed(() => {
|
||||
const sliceLength = selectableChartDatasets.value.reduce(
|
||||
(maxLength, dataset) => Math.max(maxLength, dataset.data.length),
|
||||
0,
|
||||
)
|
||||
const totals = new Array<number>(sliceLength).fill(0)
|
||||
|
||||
for (const legendEntry of legendEntries.value) {
|
||||
if (legendEntry.hidden) continue
|
||||
|
||||
const dataset = chartDatasetById.value.get(legendEntry.id)
|
||||
if (!dataset) continue
|
||||
|
||||
for (let i = 0; i < sliceLength; i++) {
|
||||
totals[i] += dataset.data[i] ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
return totals
|
||||
})
|
||||
const baseVisibleChartDatasets = computed(() =>
|
||||
legendEntries.value
|
||||
.filter((legendEntry) => !legendEntry.hidden)
|
||||
.map((legendEntry) => {
|
||||
const dataset = chartDatasetById.value.get(legendEntry.id)
|
||||
if (!dataset) return null
|
||||
|
||||
return {
|
||||
...dataset,
|
||||
borderColor: legendEntry.color,
|
||||
backgroundColor: legendEntry.color,
|
||||
}
|
||||
})
|
||||
.filter((dataset): dataset is ChartDataset => Boolean(dataset)),
|
||||
)
|
||||
const visibleChartDatasets = computed<ChartDataset[]>(() => {
|
||||
const datasets = baseVisibleChartDatasets.value
|
||||
if (!isRatioMode.value || datasets.length === 0) return datasets
|
||||
|
||||
const sliceLength = datasets.reduce(
|
||||
(maxLength, dataset) => Math.max(maxLength, dataset.data.length),
|
||||
0,
|
||||
)
|
||||
const totals = new Array<number>(sliceLength).fill(0)
|
||||
for (const dataset of datasets) {
|
||||
for (let i = 0; i < sliceLength; i++) {
|
||||
totals[i] += dataset.data[i] ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
return datasets.map((dataset) => ({
|
||||
...dataset,
|
||||
data: dataset.data.map((value, i) => (totals[i] === 0 ? 0 : (value / totals[i]) * 100)),
|
||||
}))
|
||||
})
|
||||
const visibleChartDatasetById = computed(() => {
|
||||
const datasets = new Map<string, ChartDataset>()
|
||||
for (const dataset of visibleChartDatasets.value) {
|
||||
datasets.set(dataset.projectId, dataset)
|
||||
}
|
||||
return datasets
|
||||
})
|
||||
const highlightedChartDatasetId = computed(() => {
|
||||
const datasetId = hoveredLegendEntryId.value
|
||||
if (!datasetId || !visibleChartDatasetById.value.has(datasetId)) return null
|
||||
return datasetId
|
||||
})
|
||||
|
||||
function compareLegendEntries(a: AnalyticsChartLegendEntry, b: AnalyticsChartLegendEntry) {
|
||||
if (selectedBreakdowns.value.length === 1 && selectedBreakdowns.value[0] === 'monetization') {
|
||||
const aOrder = MONETIZATION_LEGEND_ENTRY_ORDER.get(a.id)
|
||||
const bOrder = MONETIZATION_LEGEND_ENTRY_ORDER.get(b.id)
|
||||
|
||||
if (aOrder !== undefined || bOrder !== undefined) {
|
||||
return (aOrder ?? Number.MAX_SAFE_INTEGER) - (bOrder ?? Number.MAX_SAFE_INTEGER)
|
||||
}
|
||||
}
|
||||
|
||||
return b.totalValue - a.totalValue || a.name.localeCompare(b.name)
|
||||
}
|
||||
|
||||
function isProjectChartEventVisibleForLegend(event: AnalyticsChartEvent) {
|
||||
return !event.projectId || visibleProjectEventIdSet.value.has(event.projectId)
|
||||
}
|
||||
|
||||
function getLegendEntryProjectId(legendEntry: AnalyticsChartLegendEntry) {
|
||||
const projectBreakdownIndex = selectedBreakdowns.value.findIndex(
|
||||
(breakdown) => breakdown === 'project',
|
||||
)
|
||||
|
||||
if (projectBreakdownIndex === -1) {
|
||||
if (selectedProjects.value.length === 1 && legendEntry.id === ALL_PROJECTS_DATASET_ID) {
|
||||
return selectedProjects.value[0]?.id ?? null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (selectedBreakdowns.value.length === 1) {
|
||||
return selectedProjectIdSet.value.has(legendEntry.id) ? legendEntry.id : null
|
||||
}
|
||||
|
||||
if (!legendEntry.id.startsWith(COMBINED_BREAKDOWN_DATASET_ID_PREFIX)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const values = legendEntry.id
|
||||
.slice(COMBINED_BREAKDOWN_DATASET_ID_PREFIX.length)
|
||||
.split('+')
|
||||
.map(decodeBreakdownDatasetValue)
|
||||
const projectId = values[projectBreakdownIndex]
|
||||
return projectId && selectedProjectIdSet.value.has(projectId) ? projectId : null
|
||||
}
|
||||
|
||||
function hidePreviousPeriodEntriesForHiddenCurrentEntries() {
|
||||
if (hiddenCurrentLegendEntryIds.value.length === 0) return
|
||||
|
||||
const nextHiddenDatasetIds = new Set(hiddenGraphDatasetIds.value)
|
||||
for (const datasetId of hiddenCurrentLegendEntryIds.value) {
|
||||
nextHiddenDatasetIds.add(getPreviousPeriodDatasetId(datasetId))
|
||||
}
|
||||
|
||||
const nextHiddenDatasetIdList = Array.from(nextHiddenDatasetIds)
|
||||
if (!areStringArraysEqual(hiddenGraphDatasetIds.value, nextHiddenDatasetIdList)) {
|
||||
hiddenGraphDatasetIds.value = nextHiddenDatasetIdList
|
||||
}
|
||||
}
|
||||
|
||||
function isLegendEntryToggleDisabled(legendEntry: AnalyticsChartLegendEntry) {
|
||||
if (legendEntry.hidden) return false
|
||||
const visibleCount = legendEntries.value.filter((entry) => !entry.hidden).length
|
||||
return visibleCount <= 1
|
||||
}
|
||||
|
||||
function getLegendEntryTooltip(legendEntry: AnalyticsChartLegendEntry) {
|
||||
return legendEntry.projectName ?? ''
|
||||
}
|
||||
|
||||
function isUnmonetizedLegendEntry(legendEntry: AnalyticsChartLegendEntry) {
|
||||
return (
|
||||
selectedBreakdowns.value.length === 1 &&
|
||||
selectedBreakdowns.value[0] === 'monetization' &&
|
||||
legendEntry.id === 'breakdown:unmonetized'
|
||||
)
|
||||
}
|
||||
|
||||
function setHoveredLegendEntryId(datasetId: string) {
|
||||
hoveredLegendEntryId.value = datasetId
|
||||
}
|
||||
|
||||
function clearHoveredLegendEntryId(datasetId: string) {
|
||||
if (hoveredLegendEntryId.value === datasetId) {
|
||||
hoveredLegendEntryId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearLegendHoverState() {
|
||||
hoveredLegendEntryId.value = null
|
||||
}
|
||||
|
||||
function toggleLegendEntryVisibility(datasetId: string) {
|
||||
const nextHiddenDatasetIds = new Set(hiddenDatasetIds.value)
|
||||
if (nextHiddenDatasetIds.has(datasetId)) {
|
||||
nextHiddenDatasetIds.delete(datasetId)
|
||||
} else {
|
||||
const visibleCount = legendEntries.value.filter((entry) => !entry.hidden).length
|
||||
if (visibleCount <= 1) return
|
||||
nextHiddenDatasetIds.add(datasetId)
|
||||
}
|
||||
hiddenGraphDatasetIds.value = Array.from(nextHiddenDatasetIds)
|
||||
}
|
||||
|
||||
function soloLegendEntry(datasetId: string) {
|
||||
const currentLegendEntryIds = new Set(legendEntries.value.map((entry) => entry.id))
|
||||
const otherIds = legendEntries.value.map((entry) => entry.id).filter((id) => id !== datasetId)
|
||||
const isAlreadySolo =
|
||||
!hiddenDatasetIds.value.has(datasetId) &&
|
||||
otherIds.every((id) => hiddenDatasetIds.value.has(id))
|
||||
|
||||
if (isAlreadySolo) {
|
||||
hiddenGraphDatasetIds.value = hiddenGraphDatasetIds.value.filter(
|
||||
(hiddenDatasetId) => !currentLegendEntryIds.has(hiddenDatasetId),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const nextHiddenDatasetIds = new Set(hiddenDatasetIds.value)
|
||||
for (const legendEntry of legendEntries.value) {
|
||||
if (legendEntry.id === datasetId) {
|
||||
nextHiddenDatasetIds.delete(legendEntry.id)
|
||||
} else {
|
||||
nextHiddenDatasetIds.add(legendEntry.id)
|
||||
}
|
||||
}
|
||||
hiddenGraphDatasetIds.value = Array.from(nextHiddenDatasetIds)
|
||||
}
|
||||
|
||||
function onLegendEntryClick(event: MouseEvent, datasetId: string) {
|
||||
if (event.shiftKey) {
|
||||
soloLegendEntry(datasetId)
|
||||
clearLegendHoverState()
|
||||
return
|
||||
}
|
||||
toggleLegendEntryVisibility(datasetId)
|
||||
clearLegendHoverState()
|
||||
}
|
||||
|
||||
function onTooltipEntryClick(datasetId: string, shiftKey: boolean) {
|
||||
if (!chartDatasetById.value.has(datasetId)) return
|
||||
|
||||
if (shiftKey) {
|
||||
soloLegendEntry(datasetId)
|
||||
clearLegendHoverState()
|
||||
return
|
||||
}
|
||||
toggleLegendEntryVisibility(datasetId)
|
||||
clearLegendHoverState()
|
||||
}
|
||||
|
||||
watch(
|
||||
[shouldShowPreviousPeriod, hiddenCurrentLegendEntryIdsKey],
|
||||
([showPreviousPeriod]) => {
|
||||
if (!showPreviousPeriod) return
|
||||
hidePreviousPeriodEntriesForHiddenCurrentEntries()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[allChartDatasets, legendEntries],
|
||||
([datasets]) => {
|
||||
if (datasets.length === 0) return
|
||||
|
||||
const availableDatasetIds = new Set(legendEntries.value.map((entry) => entry.id))
|
||||
const nextHiddenDatasetIds = hiddenGraphDatasetIds.value.filter((datasetId) =>
|
||||
availableDatasetIds.has(datasetId),
|
||||
)
|
||||
if (
|
||||
legendEntries.value.length > 0 &&
|
||||
legendEntries.value.every((entry) => nextHiddenDatasetIds.includes(entry.id))
|
||||
) {
|
||||
const firstLegendEntry = legendEntries.value[0]
|
||||
if (firstLegendEntry) {
|
||||
const firstLegendEntryIndex = nextHiddenDatasetIds.indexOf(firstLegendEntry.id)
|
||||
if (firstLegendEntryIndex !== -1) {
|
||||
nextHiddenDatasetIds.splice(firstLegendEntryIndex, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!areStringArraysEqual(hiddenGraphDatasetIds.value, nextHiddenDatasetIds)) {
|
||||
hiddenGraphDatasetIds.value = nextHiddenDatasetIds
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
return {
|
||||
hoveredLegendEntryId,
|
||||
hiddenDatasetIds,
|
||||
previousChartDatasetByOriginalId,
|
||||
currentLegendEntries,
|
||||
visibleProjectEventIdSet,
|
||||
legendEntries,
|
||||
hiddenCurrentLegendEntryIds,
|
||||
hiddenCurrentLegendEntryIdsKey,
|
||||
chartDatasetById,
|
||||
hoverRatioSliceTotals,
|
||||
baseVisibleChartDatasets,
|
||||
visibleChartDatasets,
|
||||
visibleChartDatasetById,
|
||||
highlightedChartDatasetId,
|
||||
isProjectChartEventVisibleForLegend,
|
||||
getLegendEntryProjectId,
|
||||
hidePreviousPeriodEntriesForHiddenCurrentEntries,
|
||||
isLegendEntryToggleDisabled,
|
||||
getLegendEntryTooltip,
|
||||
isUnmonetizedLegendEntry,
|
||||
setHoveredLegendEntryId,
|
||||
clearHoveredLegendEntryId,
|
||||
clearLegendHoverState,
|
||||
toggleLegendEntryVisibility,
|
||||
soloLegendEntry,
|
||||
onLegendEntryClick,
|
||||
onTooltipEntryClick,
|
||||
}
|
||||
}
|
||||
+1154
File diff suppressed because it is too large
Load Diff
+487
@@ -0,0 +1,487 @@
|
||||
<template>
|
||||
<div
|
||||
v-show="visible"
|
||||
ref="tooltipElement"
|
||||
class="analytics-chart-tooltip absolute left-0 top-0 z-10 flex max-h-[356px] flex-col overflow-hidden rounded-lg border border-solid border-surface-5 bg-surface-3 py-2 text-sm shadow-lg"
|
||||
:class="pinned ? '' : 'pointer-events-none'"
|
||||
:style="positionStyle"
|
||||
@wheel.stop
|
||||
@click.stop
|
||||
>
|
||||
<div
|
||||
class="mb-1.5 flex shrink-0 items-start justify-between gap-2 border-0 border-b border-solid border-surface-5 px-3 pb-1.5 font-medium text-contrast"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<span class="min-w-0 truncate">
|
||||
{{ rangeLabel }}
|
||||
<span v-if="durationLabel" class="text-xs font-normal text-secondary">
|
||||
({{ durationLabel }})
|
||||
</span>
|
||||
</span>
|
||||
<span v-if="previousRangeLabel" class="min-w-0 truncate text-xs text-primary">
|
||||
<span class="font-medium">{{ previousRangeLabel }}</span>
|
||||
<span class="font-normal text-secondary">
|
||||
{{ formatMessage(analyticsChartMessages.previousPeriodShort) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<PinIcon
|
||||
v-if="pinned"
|
||||
v-tooltip="formatMessage(analyticsChartMessages.tooltipPinned)"
|
||||
class="pointer-events-none size-4 shrink-0 font-normal text-contrast"
|
||||
:aria-label="formatMessage(analyticsChartMessages.pinned)"
|
||||
/>
|
||||
</div>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-6"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-6"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showEntriesTopFade"
|
||||
class="analytics-chart-tooltip-entries-fade-top pointer-events-none absolute left-0 right-0 z-10 -mt-1 h-6 bg-gradient-to-b from-surface-3 to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<div
|
||||
ref="entriesElement"
|
||||
class="analytics-chart-tooltip-entries flex min-h-0 flex-col overflow-y-auto overscroll-contain px-3"
|
||||
@scroll="checkEntriesScrollState"
|
||||
@touchstart="onEntriesTouchStart"
|
||||
@touchmove="onEntriesTouchMove"
|
||||
@touchend="clearEntriesTouchScroll"
|
||||
@touchcancel="clearEntriesTouchScroll"
|
||||
>
|
||||
<div v-if="!ratioMode" class="flex shrink-0 items-center justify-between gap-4">
|
||||
<span class="font-medium text-primary">
|
||||
{{ formatMessage(analyticsChartMessages.total) }}
|
||||
</span>
|
||||
<span class="font-semibold text-contrast">{{ formattedTotal }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="entry in entries"
|
||||
:key="entry.projectId"
|
||||
class="flex w-full min-w-0 items-center justify-between gap-4 text-primary"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-w-0 items-center gap-1.5 border-0 bg-transparent p-0 py-0.5 text-left focus-visible:!outline-none"
|
||||
:class="
|
||||
entry.toggleDisabled && !shiftKeyPressed
|
||||
? 'cursor-default'
|
||||
: entry.hidden
|
||||
? 'cursor-pointer text-secondary opacity-70'
|
||||
: 'cursor-pointer text-primary transition-all hover:brightness-125'
|
||||
"
|
||||
:aria-label="getEntryAriaLabel(entry)"
|
||||
@mouseenter="emit('entry-hover', entry.projectId)"
|
||||
@mouseleave="emit('entry-hover-clear', entry.projectId)"
|
||||
@focus="emit('entry-hover', entry.projectId)"
|
||||
@blur="emit('entry-hover-clear', entry.projectId)"
|
||||
@click="onEntryClick($event, entry)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
entry.isPreviousPeriod
|
||||
? 'h-0 w-2 rounded-none border-0 border-t-2 border-dashed bg-transparent'
|
||||
: 'size-2 rounded-full'
|
||||
"
|
||||
class="shrink-0"
|
||||
:style="
|
||||
entry.isPreviousPeriod
|
||||
? { borderColor: entry.color }
|
||||
: { backgroundColor: entry.color }
|
||||
"
|
||||
/>
|
||||
<span
|
||||
v-tooltip="entry.projectName ?? ''"
|
||||
class="min-w-0 truncate"
|
||||
:class="{
|
||||
'line-through': entry.hidden,
|
||||
capitalize: capitalizeLabels,
|
||||
}"
|
||||
>
|
||||
{{ entry.name }}
|
||||
</span>
|
||||
</button>
|
||||
<span
|
||||
:class="[
|
||||
'shrink-0',
|
||||
entry.isPreviousPeriod ? 'font-medium text-secondary' : 'font-semibold',
|
||||
entry.hidden ? 'text-primary line-through opacity-70' : 'text-contrast',
|
||||
]"
|
||||
>
|
||||
{{ entry.formattedValue }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-6"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-6"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showEntriesBottomFade"
|
||||
class="analytics-chart-tooltip-entries-fade-bottom pointer-events-none absolute left-0 right-0 z-10 -mb-1 h-6 bg-gradient-to-t from-surface-3 to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PinIcon } from '@modrinth/assets'
|
||||
import { useScrollIndicator, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import { analyticsChartMessages } from '../../analytics-messages'
|
||||
|
||||
export type AnalyticsChartTooltipEntry = {
|
||||
projectId: string
|
||||
name: string
|
||||
projectName?: string
|
||||
color: string
|
||||
formattedValue: string
|
||||
hidden: boolean
|
||||
toggleDisabled: boolean
|
||||
isPreviousPeriod?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
x: number
|
||||
y: number
|
||||
start: Date | null
|
||||
end: Date | null
|
||||
previousStart: Date | null
|
||||
previousEnd: Date | null
|
||||
chartStart: Date | null
|
||||
chartEnd: Date | null
|
||||
formattedTotal: string
|
||||
entries: AnalyticsChartTooltipEntry[]
|
||||
containerWidth: number
|
||||
containerHeight: number
|
||||
pinned: boolean
|
||||
ratioMode: boolean
|
||||
capitalizeLabels: boolean
|
||||
shiftKeyPressed: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'entry-click': [projectId: string, shiftKey: boolean]
|
||||
'entry-hover': [projectId: string]
|
||||
'entry-hover-clear': [projectId: string]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
function onEntryClick(event: MouseEvent, entry: AnalyticsChartTooltipEntry) {
|
||||
if (entry.toggleDisabled && !event.shiftKey) return
|
||||
emit('entry-click', entry.projectId, event.shiftKey)
|
||||
}
|
||||
|
||||
function getEntryAriaLabel(entry: AnalyticsChartTooltipEntry) {
|
||||
return formatMessage(
|
||||
entry.hidden
|
||||
? analyticsChartMessages.showEntryInGraph
|
||||
: analyticsChartMessages.hideEntryInGraph,
|
||||
{ name: entry.name },
|
||||
)
|
||||
}
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000
|
||||
const ONE_MINUTE_MS = 60 * 1000
|
||||
|
||||
function formatRangeLabel(
|
||||
start: Date,
|
||||
end: Date,
|
||||
chartStart: Date | null,
|
||||
chartEnd: Date | null,
|
||||
): string {
|
||||
const includeTime = end.getTime() - start.getTime() < ONE_DAY_MS
|
||||
const yearsDiffer = start.getFullYear() !== end.getFullYear()
|
||||
const chartYearsDiffer =
|
||||
chartStart !== null && chartEnd !== null && chartStart.getFullYear() !== chartEnd.getFullYear()
|
||||
const rangeYearDiffersFromChart =
|
||||
chartStart !== null && start.getFullYear() !== chartStart.getFullYear()
|
||||
const showTrailingYear = !yearsDiffer && (chartYearsDiffer || rangeYearDiffersFromChart)
|
||||
const monthsDiffer = yearsDiffer || start.getMonth() !== end.getMonth()
|
||||
|
||||
const timeOptions: Intl.DateTimeFormatOptions = includeTime
|
||||
? { hour: 'numeric', minute: '2-digit', hour12: true }
|
||||
: {}
|
||||
|
||||
const startOptions: Intl.DateTimeFormatOptions = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
...(yearsDiffer ? { year: 'numeric' } : {}),
|
||||
...timeOptions,
|
||||
}
|
||||
|
||||
if (includeTime) {
|
||||
const startLabel = new Intl.DateTimeFormat(undefined, startOptions).format(start)
|
||||
const endLabel = new Intl.DateTimeFormat(undefined, timeOptions).format(end)
|
||||
const range = `${startLabel}–${endLabel}`
|
||||
|
||||
if (!showTrailingYear) return range
|
||||
|
||||
const yearLabel = new Intl.DateTimeFormat(undefined, { year: 'numeric' }).format(end)
|
||||
return `${range}, ${yearLabel}`
|
||||
}
|
||||
|
||||
let endOptions: Intl.DateTimeFormatOptions
|
||||
if (yearsDiffer) {
|
||||
endOptions = { month: 'short', day: 'numeric', year: 'numeric' }
|
||||
} else if (monthsDiffer) {
|
||||
endOptions = { month: 'short', day: 'numeric' }
|
||||
} else {
|
||||
endOptions = { day: 'numeric' }
|
||||
}
|
||||
|
||||
const startLabel = new Intl.DateTimeFormat(undefined, startOptions).format(start)
|
||||
const endLabel = new Intl.DateTimeFormat(undefined, endOptions).format(end)
|
||||
const range = `${startLabel}–${endLabel}`
|
||||
|
||||
if (!showTrailingYear) return range
|
||||
|
||||
const yearLabel = new Intl.DateTimeFormat(undefined, { year: 'numeric' }).format(end)
|
||||
return `${range}, ${yearLabel}`
|
||||
}
|
||||
|
||||
function formatDurationLabel(start: Date, end: Date): string {
|
||||
const durationMs = end.getTime() - start.getTime()
|
||||
if (!Number.isFinite(durationMs) || durationMs <= 0) return ''
|
||||
|
||||
if (durationMs >= ONE_DAY_MS) {
|
||||
const days = Math.round(durationMs / ONE_DAY_MS)
|
||||
return formatMessage(analyticsChartMessages.durationDays, { count: days })
|
||||
}
|
||||
if (durationMs >= ONE_HOUR_MS) {
|
||||
const hours = Math.round(durationMs / ONE_HOUR_MS)
|
||||
return formatMessage(analyticsChartMessages.durationHours, { count: hours })
|
||||
}
|
||||
const minutes = Math.max(1, Math.round(durationMs / ONE_MINUTE_MS))
|
||||
return formatMessage(analyticsChartMessages.durationMinutes, { count: minutes })
|
||||
}
|
||||
|
||||
const rangeLabel = computed(() =>
|
||||
props.start && props.end
|
||||
? formatRangeLabel(props.start, props.end, props.chartStart, props.chartEnd)
|
||||
: '',
|
||||
)
|
||||
|
||||
const durationLabel = computed(() =>
|
||||
props.start && props.end ? formatDurationLabel(props.start, props.end) : '',
|
||||
)
|
||||
const previousRangeLabel = computed(() =>
|
||||
props.previousStart && props.previousEnd
|
||||
? formatRangeLabel(props.previousStart, props.previousEnd, props.chartStart, props.chartEnd)
|
||||
: '',
|
||||
)
|
||||
|
||||
const tooltipElement = ref<HTMLDivElement | null>(null)
|
||||
const entriesElement = ref<HTMLDivElement | null>(null)
|
||||
const {
|
||||
showTopFade: showEntriesTopFade,
|
||||
showBottomFade: showEntriesBottomFade,
|
||||
checkScrollState: checkEntriesScrollState,
|
||||
forceCheck: forceCheckEntriesScrollState,
|
||||
} = useScrollIndicator(entriesElement)
|
||||
const tooltipWidth = ref(0)
|
||||
const tooltipHeight = ref(0)
|
||||
const entriesTopOffset = ref(0)
|
||||
const entriesBottomOffset = ref(0)
|
||||
const tooltipOffsetParentLeft = ref(0)
|
||||
const viewportWidth = ref(0)
|
||||
let entriesTouchStartY = 0
|
||||
let entriesTouchStartScrollTop = 0
|
||||
|
||||
const CURSOR_OFFSET = 12
|
||||
const EDGE_PADDING = 8
|
||||
const TOOLTIP_MAX_WIDTH = 26 * 16
|
||||
const WHEEL_DELTA_LINE = 1
|
||||
const WHEEL_DELTA_PAGE = 2
|
||||
const WHEEL_LINE_HEIGHT = 16
|
||||
|
||||
function getTooltipFallbackWidth() {
|
||||
const availableWidth = (viewportWidth.value || props.containerWidth) - EDGE_PADDING * 2
|
||||
if (availableWidth <= 0) return TOOLTIP_MAX_WIDTH
|
||||
return Math.min(TOOLTIP_MAX_WIDTH, availableWidth)
|
||||
}
|
||||
|
||||
function updateTooltipMeasurements() {
|
||||
nextTick(() => {
|
||||
const element = tooltipElement.value
|
||||
if (!element) return
|
||||
|
||||
tooltipWidth.value = element.offsetWidth
|
||||
tooltipHeight.value = element.offsetHeight
|
||||
|
||||
const entries = entriesElement.value
|
||||
if (entries) {
|
||||
entriesTopOffset.value = entries.offsetTop
|
||||
entriesBottomOffset.value = Math.max(
|
||||
0,
|
||||
element.offsetHeight - entries.offsetTop - entries.offsetHeight,
|
||||
)
|
||||
}
|
||||
|
||||
const offsetParent =
|
||||
element.offsetParent instanceof HTMLElement ? element.offsetParent : element.parentElement
|
||||
tooltipOffsetParentLeft.value = offsetParent?.getBoundingClientRect().left ?? 0
|
||||
viewportWidth.value =
|
||||
document.documentElement.clientWidth || window.innerWidth || props.containerWidth
|
||||
forceCheckEntriesScrollState()
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.visible,
|
||||
props.entries,
|
||||
rangeLabel.value,
|
||||
durationLabel.value,
|
||||
previousRangeLabel.value,
|
||||
props.pinned,
|
||||
props.containerWidth,
|
||||
props.containerHeight,
|
||||
],
|
||||
updateTooltipMeasurements,
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
updateTooltipMeasurements()
|
||||
window.addEventListener('resize', updateTooltipMeasurements)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateTooltipMeasurements)
|
||||
})
|
||||
|
||||
function getNormalizedWheelDeltaY(event: WheelEvent, element: HTMLElement) {
|
||||
if (event.deltaMode === WHEEL_DELTA_PAGE) return event.deltaY * element.clientHeight
|
||||
if (event.deltaMode === WHEEL_DELTA_LINE) return event.deltaY * WHEEL_LINE_HEIGHT
|
||||
return event.deltaY
|
||||
}
|
||||
|
||||
function getMaxScrollTop(element: HTMLElement) {
|
||||
return Math.max(0, element.scrollHeight - element.clientHeight)
|
||||
}
|
||||
|
||||
function consumeWheel(event: WheelEvent): boolean {
|
||||
const element = entriesElement.value
|
||||
if (!props.visible || !element) return false
|
||||
|
||||
const maxScrollTop = getMaxScrollTop(element)
|
||||
if (maxScrollTop <= 0) return false
|
||||
|
||||
const deltaY = getNormalizedWheelDeltaY(event, element)
|
||||
if (deltaY === 0) return false
|
||||
|
||||
const scrollTop = element.scrollTop
|
||||
element.scrollTop = Math.min(maxScrollTop, Math.max(0, scrollTop + deltaY))
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
function onEntriesTouchStart(event: TouchEvent) {
|
||||
const element = entriesElement.value
|
||||
const touch = event.touches[0]
|
||||
if (!element || !touch) return
|
||||
|
||||
entriesTouchStartY = touch.clientY
|
||||
entriesTouchStartScrollTop = element.scrollTop
|
||||
}
|
||||
|
||||
function onEntriesTouchMove(event: TouchEvent) {
|
||||
const element = entriesElement.value
|
||||
const touch = event.touches[0]
|
||||
if (!props.visible || !element || !touch) return
|
||||
|
||||
const maxScrollTop = getMaxScrollTop(element)
|
||||
if (maxScrollTop <= 0) return
|
||||
|
||||
const nextScrollTop = Math.min(
|
||||
maxScrollTop,
|
||||
Math.max(0, entriesTouchStartScrollTop + entriesTouchStartY - touch.clientY),
|
||||
)
|
||||
element.scrollTop = nextScrollTop
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
function clearEntriesTouchScroll() {
|
||||
entriesTouchStartY = 0
|
||||
entriesTouchStartScrollTop = 0
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
consumeWheel,
|
||||
})
|
||||
|
||||
const positionStyle = computed(() => {
|
||||
const tooltipMaxWidth = getTooltipFallbackWidth()
|
||||
const tooltipWidthForPosition = tooltipWidth.value || tooltipMaxWidth
|
||||
const desiredLeft = props.x + CURSOR_OFFSET
|
||||
const viewportRight = viewportWidth.value || tooltipOffsetParentLeft.value + props.containerWidth
|
||||
const desiredViewportRight = tooltipOffsetParentLeft.value + desiredLeft + tooltipWidthForPosition
|
||||
const shouldPlaceLeft =
|
||||
props.x <= props.containerWidth / 4 || desiredViewportRight > viewportRight - EDGE_PADDING
|
||||
const candidateLeft = shouldPlaceLeft
|
||||
? props.x - tooltipWidthForPosition - CURSOR_OFFSET
|
||||
: desiredLeft
|
||||
const minLeft = EDGE_PADDING - tooltipOffsetParentLeft.value
|
||||
const maxLeft = Math.max(
|
||||
minLeft,
|
||||
viewportRight - tooltipOffsetParentLeft.value - tooltipWidthForPosition - EDGE_PADDING,
|
||||
)
|
||||
const clampedLeft = Math.min(maxLeft, Math.max(minLeft, candidateLeft))
|
||||
|
||||
const desiredTop = props.y - tooltipHeight.value / 2
|
||||
const maxTop = Math.max(EDGE_PADDING, props.containerHeight - tooltipHeight.value - EDGE_PADDING)
|
||||
const clampedTop = Math.min(maxTop, Math.max(EDGE_PADDING, desiredTop))
|
||||
|
||||
return {
|
||||
'--analytics-chart-tooltip-max-width': `${tooltipMaxWidth}px`,
|
||||
'--analytics-chart-tooltip-entries-top': `${entriesTopOffset.value}px`,
|
||||
'--analytics-chart-tooltip-entries-bottom': `${entriesBottomOffset.value}px`,
|
||||
transform: `translate3d(${clampedLeft}px, ${clampedTop}px, 0)`,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.analytics-chart-tooltip {
|
||||
min-width: min(14rem, var(--analytics-chart-tooltip-max-width, calc(100vw - 1rem)));
|
||||
max-width: var(--analytics-chart-tooltip-max-width, min(26rem, calc(100vw - 1rem)));
|
||||
transition: transform 750ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.analytics-chart-tooltip-entries {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.analytics-chart-tooltip-entries-fade-top {
|
||||
top: var(--analytics-chart-tooltip-entries-top, 0rem);
|
||||
}
|
||||
|
||||
.analytics-chart-tooltip-entries-fade-bottom {
|
||||
bottom: var(--analytics-chart-tooltip-entries-bottom, 0rem);
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.analytics-chart-tooltip {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div
|
||||
ref="chartContainer"
|
||||
class="relative -ml-4 h-[460px] select-none"
|
||||
@click="onChartClick"
|
||||
@wheel.capture="onChartWheel"
|
||||
>
|
||||
<div :class="['h-full']">
|
||||
<div v-if="showEmptyChartState" class="flex h-full items-center justify-center rounded-xl">
|
||||
<div v-if="!isDataLoading" class="relative bottom-6 text-base font-normal text-secondary">
|
||||
{{ emptyChartMessage }}
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<ClientOnly>
|
||||
<AnalyticsChartClient
|
||||
:type="chartType"
|
||||
:fill="isArea"
|
||||
:stacked="isStacked"
|
||||
:ratio-mode="isRatioMode"
|
||||
:datasets="visibleChartDatasets"
|
||||
:labels="chartLabels"
|
||||
:x-axis-tick-limit="xAxisTickLimit"
|
||||
:active-stat="activeStat"
|
||||
:pinned-slice-index="pinnedSliceIndex"
|
||||
:highlighted-dataset-id="highlightedChartDatasetId"
|
||||
@hover="onChartHover"
|
||||
@geometry="onChartGeometry"
|
||||
@pinned-drag="onPinnedDrag"
|
||||
@range-select="onRangeSelect"
|
||||
@touch-drag="onTouchDragEnd"
|
||||
/>
|
||||
</ClientOnly>
|
||||
<AnalyticsChartEvents
|
||||
v-if="hasVisibleTimelineEvents"
|
||||
:events="visibleTimelineEvents"
|
||||
:active-stat="activeStat"
|
||||
:group-by="selectedGroupBy"
|
||||
:chart-start="chartRangeBounds?.start ?? null"
|
||||
:chart-end="chartRangeBounds?.end ?? null"
|
||||
:geometry="chartGeometry"
|
||||
/>
|
||||
<div
|
||||
v-if="showHoverGuide"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute bottom-0 left-0 top-0 z-10 mb-[1.8rem] mt-2.5 border-0 border-l border-solid border-contrast opacity-25"
|
||||
:style="{ transform: `translate(${hoverState.x}px, 0)` }"
|
||||
/>
|
||||
<div
|
||||
v-if="showPinnedGuide"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute bottom-0 left-0 top-0 z-10 mb-[1.8rem] mt-2.5 border-0 border-l border-dashed border-green opacity-75"
|
||||
:style="{ transform: `translate(${hoverState.x}px, 0)` }"
|
||||
/>
|
||||
<AnalyticsChartTooltip
|
||||
ref="chartTooltip"
|
||||
:visible="hoverState.visible"
|
||||
:x="hoverState.x"
|
||||
:y="hoverState.y"
|
||||
:start="hoverBucketRange?.start ?? null"
|
||||
:end="hoverBucketRange?.end ?? null"
|
||||
:previous-start="previousHoverBucketRange?.start ?? null"
|
||||
:previous-end="previousHoverBucketRange?.end ?? null"
|
||||
:chart-start="chartRangeBounds?.start ?? null"
|
||||
:chart-end="chartRangeBounds?.end ?? null"
|
||||
:formatted-total="hoverFormattedTotal"
|
||||
:entries="hoverEntries"
|
||||
:container-width="containerSize.width"
|
||||
:container-height="containerSize.height"
|
||||
:pinned="isHoverPinned"
|
||||
:ratio-mode="isRatioMode"
|
||||
:capitalize-labels="shouldCapitalizeDatasetLabels"
|
||||
:shift-key-pressed="isShiftKeyPressed"
|
||||
@entry-click="(datasetId, shiftKey) => emit('entry-click', datasetId, shiftKey)"
|
||||
@entry-hover="emit('entry-hover', $event)"
|
||||
@entry-hover-clear="emit('entry-hover-clear', $event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { useFormatNumber, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import type {
|
||||
AnalyticsDashboardStat,
|
||||
AnalyticsGroupByPreset,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import type {
|
||||
AnalyticsChartLegendEntry,
|
||||
AnalyticsChartRangeBounds,
|
||||
} from '../analytics-chart-types.ts'
|
||||
import type { ChartDataset } from '../analytics-chart-utils.ts'
|
||||
import { formatMetricValue } from '../analytics-chart-utils.ts'
|
||||
import AnalyticsChartClient from '../AnalyticsChart.client.vue'
|
||||
import AnalyticsChartEvents, { type AnalyticsChartEvent } from './AnalyticsChartEvents.vue'
|
||||
import AnalyticsChartTooltip from './AnalyticsChartTooltip.vue'
|
||||
import { useAnalyticsChartInteractions } from './use-analytics-chart-interactions.ts'
|
||||
|
||||
const props = defineProps<{
|
||||
chartType: 'line' | 'bar'
|
||||
isArea: boolean
|
||||
isStacked: boolean
|
||||
isRatioMode: boolean
|
||||
isDataLoading: boolean
|
||||
showEmptyChartState: boolean
|
||||
emptyChartMessage: string
|
||||
visibleChartDatasets: ChartDataset[]
|
||||
chartLabels: string[]
|
||||
xAxisTickLimit?: number
|
||||
activeStat: AnalyticsDashboardStat
|
||||
highlightedChartDatasetId: string | null
|
||||
hasVisibleTimelineEvents: boolean
|
||||
visibleTimelineEvents: AnalyticsChartEvent[]
|
||||
selectedGroupBy: AnalyticsGroupByPreset
|
||||
chartRangeBounds: AnalyticsChartRangeBounds | null
|
||||
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null
|
||||
sliceCount: number
|
||||
shouldShowPreviousPeriod: boolean
|
||||
allChartDatasets: ChartDataset[]
|
||||
currentLegendEntries: AnalyticsChartLegendEntry[]
|
||||
legendEntries: AnalyticsChartLegendEntry[]
|
||||
chartDatasetById: Map<string, ChartDataset>
|
||||
hoverRatioSliceTotals: number[]
|
||||
shouldCapitalizeDatasetLabels: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'range-select': [start: Date, end: Date, groupBy: AnalyticsGroupByPreset]
|
||||
'entry-click': [datasetId: string, shiftKey: boolean]
|
||||
'entry-hover': [datasetId: string]
|
||||
'entry-hover-clear': [datasetId: string]
|
||||
}>()
|
||||
|
||||
const formatNumber = useFormatNumber()
|
||||
const { formatMessage } = useVIntl()
|
||||
const {
|
||||
chartContainer,
|
||||
chartTooltip,
|
||||
chartGeometry,
|
||||
containerSize,
|
||||
hoverState,
|
||||
isHoverPinned,
|
||||
isShiftKeyPressed,
|
||||
onChartHover,
|
||||
onPinnedDrag,
|
||||
onTouchDragEnd,
|
||||
onChartGeometry,
|
||||
onRangeSelect,
|
||||
onChartClick,
|
||||
onChartWheel,
|
||||
pinnedSliceIndex,
|
||||
showHoverGuide,
|
||||
showPinnedGuide,
|
||||
hoverBucketRange,
|
||||
previousHoverBucketRange,
|
||||
} = useAnalyticsChartInteractions({
|
||||
isDataLoading: computed(() => props.isDataLoading),
|
||||
fetchRequest: computed(() => props.fetchRequest),
|
||||
sliceCount: computed(() => props.sliceCount),
|
||||
chartLabels: computed(() => props.chartLabels),
|
||||
allChartDatasets: computed(() => props.allChartDatasets),
|
||||
chartRangeBounds: computed(() => props.chartRangeBounds),
|
||||
shouldShowPreviousPeriod: computed(() => props.shouldShowPreviousPeriod),
|
||||
onRangeSelected: (start, end, groupBy) => emit('range-select', start, end, groupBy),
|
||||
})
|
||||
|
||||
const hoverTotalValue = computed(() => {
|
||||
if (hoverState.sliceIndex === null) return 0
|
||||
const sliceIndex = hoverState.sliceIndex
|
||||
if (props.isRatioMode) return props.hoverRatioSliceTotals[sliceIndex] ?? 0
|
||||
|
||||
return props.currentLegendEntries.reduce((sum, legendEntry) => {
|
||||
if (legendEntry.hidden) return sum
|
||||
const dataset = props.chartDatasetById.get(legendEntry.id)
|
||||
return sum + (dataset?.data[sliceIndex] ?? 0)
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const hoverFormattedTotal = computed(() => {
|
||||
if (props.isRatioMode) {
|
||||
return hoverTotalValue.value > 0 ? '100%' : '0%'
|
||||
}
|
||||
return formatMetricValue(hoverTotalValue.value, props.activeStat, formatNumber, formatMessage)
|
||||
})
|
||||
|
||||
const hoverEntries = computed(() => {
|
||||
if (hoverState.sliceIndex === null) return []
|
||||
const sliceIndex = hoverState.sliceIndex
|
||||
const totalValue = hoverTotalValue.value
|
||||
|
||||
return props.legendEntries.map((legendEntry) => {
|
||||
const dataset = props.chartDatasetById.get(legendEntry.id)
|
||||
const value = dataset?.data[sliceIndex] ?? 0
|
||||
const ratioValue = legendEntry.hidden || totalValue === 0 ? 0 : (value / totalValue) * 100
|
||||
return {
|
||||
projectId: legendEntry.id,
|
||||
name: legendEntry.name,
|
||||
projectName: legendEntry.projectName,
|
||||
color: legendEntry.color,
|
||||
formattedValue: props.isRatioMode
|
||||
? `${ratioValue.toFixed(1)}%`
|
||||
: formatMetricValue(value, props.activeStat, formatNumber, formatMessage),
|
||||
hidden: legendEntry.hidden,
|
||||
toggleDisabled: !legendEntry.hidden && isLegendEntryToggleDisabled(legendEntry),
|
||||
isPreviousPeriod: legendEntry.isPreviousPeriod,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function isLegendEntryToggleDisabled(legendEntry: AnalyticsChartLegendEntry) {
|
||||
if (legendEntry.hidden) return false
|
||||
const visibleCount = props.legendEntries.filter((entry) => !entry.hidden).length
|
||||
return visibleCount <= 1
|
||||
}
|
||||
</script>
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { injectModrinthClient, useVIntl } from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, type ComputedRef } from 'vue'
|
||||
|
||||
import type { AnalyticsDashboardContextValue } from '~/providers/analytics/analytics'
|
||||
|
||||
import { analyticsProjectEventMessages, type FormatMessage } from '../../analytics-messages.ts'
|
||||
import {
|
||||
PROJECT_EVENT_DATE_FORMATTER,
|
||||
PROJECT_VERSION_UPLOAD_DEDUPE_WINDOW_MS,
|
||||
VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUS_SET,
|
||||
type VisibleProjectStatusChangeEventStatus,
|
||||
} from '../analytics-chart-constants.ts'
|
||||
import type { AnalyticsChartRangeBounds } from '../analytics-chart-types.ts'
|
||||
import type { AnalyticsChartEvent } from './AnalyticsChartEvents.vue'
|
||||
|
||||
const analyticsEventsQueryKey = ['analytics-events'] as const
|
||||
|
||||
export function useAnalyticsChartEvents(
|
||||
context: Pick<
|
||||
AnalyticsDashboardContextValue,
|
||||
| 'activeStat'
|
||||
| 'showChartEvents'
|
||||
| 'showProjectEvents'
|
||||
| 'displayedProjectEvents'
|
||||
| 'hasCompletedAnalyticsLoading'
|
||||
>,
|
||||
chartRangeBounds: ComputedRef<AnalyticsChartRangeBounds | null>,
|
||||
selectedProjectNameById: ComputedRef<Map<string, string>>,
|
||||
selectedProjectEventIdSet: ComputedRef<Set<string>>,
|
||||
visibleProjectEventIdSet: ComputedRef<Set<string>>,
|
||||
) {
|
||||
const client = injectModrinthClient()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { data: analyticsEvents } = useQuery({
|
||||
queryKey: analyticsEventsQueryKey,
|
||||
queryFn: () => client.labrinth.analytics_v3.getEvents(),
|
||||
enabled: computed(() => context.hasCompletedAnalyticsLoading.value),
|
||||
placeholderData: [],
|
||||
refetchOnMount: 'always',
|
||||
retry: false,
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const localAnalyticsChartEvents = computed(() => analyticsEvents.value ?? [])
|
||||
const hasChartEvents = computed(() =>
|
||||
localAnalyticsChartEvents.value.some(isTimelineEventVisibleInCurrentGraph),
|
||||
)
|
||||
const visibleModrinthChartEvents = computed<AnalyticsChartEvent[]>(() =>
|
||||
context.showChartEvents.value
|
||||
? localAnalyticsChartEvents.value.map((event) => ({
|
||||
...event,
|
||||
markerIcon: 'info' as const,
|
||||
groupKey: 'modrinth',
|
||||
}))
|
||||
: [],
|
||||
)
|
||||
const localProjectChartEvents = computed<AnalyticsChartEvent[]>(() =>
|
||||
dedupeProjectVersionUploadEvents(
|
||||
context.displayedProjectEvents.value.filter(
|
||||
(event) =>
|
||||
selectedProjectEventIdSet.value.has(event.project_id) && shouldShowProjectEvent(event),
|
||||
),
|
||||
).map((event) => ({
|
||||
title: getProjectEventTitle(event, formatMessage),
|
||||
starts: event.timestamp,
|
||||
ends: event.timestamp,
|
||||
projectId: event.project_id,
|
||||
projectName: selectedProjectNameById.value.get(event.project_id),
|
||||
subtitle: formatProjectEventDate(event.timestamp),
|
||||
markerIcon: 'flag' as const,
|
||||
groupKey: 'project',
|
||||
})),
|
||||
)
|
||||
const hasProjectEvents = computed(() =>
|
||||
localProjectChartEvents.value.some(
|
||||
(event) =>
|
||||
isProjectChartEventVisibleForLegend(event) && isTimelineEventVisibleInCurrentGraph(event),
|
||||
),
|
||||
)
|
||||
const visibleProjectChartEvents = computed(() =>
|
||||
context.showProjectEvents.value
|
||||
? localProjectChartEvents.value.filter(isProjectChartEventVisibleForLegend)
|
||||
: [],
|
||||
)
|
||||
const visibleTimelineEvents = computed(() => [
|
||||
...visibleModrinthChartEvents.value,
|
||||
...visibleProjectChartEvents.value,
|
||||
])
|
||||
const hasVisibleTimelineEvents = computed(
|
||||
() => visibleModrinthChartEvents.value.length > 0 || visibleProjectChartEvents.value.length > 0,
|
||||
)
|
||||
|
||||
function isTimelineEventVisibleInCurrentGraph(event: AnalyticsChartEvent) {
|
||||
const rangeBounds = chartRangeBounds.value
|
||||
if (!rangeBounds) return false
|
||||
if (!doesTimelineEventMatchActiveStat(event)) return false
|
||||
|
||||
const eventStartMs = new Date(event.starts).getTime()
|
||||
const eventEndMs = new Date(event.ends).getTime()
|
||||
if (!Number.isFinite(eventStartMs) || !Number.isFinite(eventEndMs)) return false
|
||||
if (eventEndMs < eventStartMs) return false
|
||||
|
||||
return eventEndMs >= rangeBounds.start.getTime() && eventStartMs <= rangeBounds.end.getTime()
|
||||
}
|
||||
|
||||
function doesTimelineEventMatchActiveStat(event: AnalyticsChartEvent) {
|
||||
if (!event.for_metric_kind?.length) return true
|
||||
return event.for_metric_kind.some((metricKind) => metricKind === context.activeStat.value)
|
||||
}
|
||||
|
||||
function isProjectChartEventVisibleForLegend(event: AnalyticsChartEvent) {
|
||||
return !event.projectId || visibleProjectEventIdSet.value.has(event.projectId)
|
||||
}
|
||||
|
||||
return {
|
||||
localAnalyticsChartEvents,
|
||||
hasChartEvents,
|
||||
visibleModrinthChartEvents,
|
||||
localProjectChartEvents,
|
||||
hasProjectEvents,
|
||||
visibleProjectChartEvents,
|
||||
visibleTimelineEvents,
|
||||
hasVisibleTimelineEvents,
|
||||
isTimelineEventVisibleInCurrentGraph,
|
||||
isProjectChartEventVisibleForLegend,
|
||||
}
|
||||
}
|
||||
|
||||
function getProjectEventTitle(
|
||||
event: Labrinth.Analytics.v3.ProjectAnalyticsEvent,
|
||||
formatMessage: FormatMessage,
|
||||
) {
|
||||
if (event.kind === 'version_uploaded') {
|
||||
const versionNumber = event.version_number.trim()
|
||||
return versionNumber
|
||||
? formatMessage(analyticsProjectEventMessages.versionReleased, { version: versionNumber })
|
||||
: formatMessage(analyticsProjectEventMessages.versionUploaded)
|
||||
}
|
||||
|
||||
if (isVisibleProjectStatusChangeEventStatus(event.status_to)) {
|
||||
return getProjectStatusEventTitle(event.status_to, formatMessage)
|
||||
}
|
||||
|
||||
return formatMessage(analyticsProjectEventMessages.projectStatusChanged)
|
||||
}
|
||||
|
||||
function getProjectStatusEventTitle(
|
||||
status: VisibleProjectStatusChangeEventStatus,
|
||||
formatMessage: FormatMessage,
|
||||
) {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return formatMessage(analyticsProjectEventMessages.projectApproved)
|
||||
case 'unlisted':
|
||||
return formatMessage(analyticsProjectEventMessages.projectUnlisted)
|
||||
case 'private':
|
||||
return formatMessage(analyticsProjectEventMessages.projectPrivate)
|
||||
}
|
||||
}
|
||||
|
||||
function shouldShowProjectEvent(event: Labrinth.Analytics.v3.ProjectAnalyticsEvent) {
|
||||
if (event.kind !== 'status_changed') {
|
||||
return true
|
||||
}
|
||||
|
||||
return isVisibleProjectStatusChangeEventStatus(event.status_to)
|
||||
}
|
||||
|
||||
function isVisibleProjectStatusChangeEventStatus(
|
||||
status: Labrinth.Projects.v2.ProjectStatus,
|
||||
): status is VisibleProjectStatusChangeEventStatus {
|
||||
return VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUS_SET.has(status)
|
||||
}
|
||||
|
||||
function dedupeProjectVersionUploadEvents(events: Labrinth.Analytics.v3.ProjectAnalyticsEvent[]) {
|
||||
const keptEvents: Labrinth.Analytics.v3.ProjectAnalyticsEvent[] = []
|
||||
const keptVersionUploadEventsByKey = new Map<
|
||||
string,
|
||||
Labrinth.Analytics.v3.ProjectAnalyticsEvent[]
|
||||
>()
|
||||
|
||||
for (const event of events) {
|
||||
const key = getProjectVersionUploadDedupeKey(event)
|
||||
if (!key) {
|
||||
keptEvents.push(event)
|
||||
continue
|
||||
}
|
||||
|
||||
const matchingEvents = keptVersionUploadEventsByKey.get(key) ?? []
|
||||
if (
|
||||
matchingEvents.some((matchingEvent) =>
|
||||
areProjectEventsWithinDedupeWindow(event, matchingEvent),
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
keptEvents.push(event)
|
||||
matchingEvents.push(event)
|
||||
keptVersionUploadEventsByKey.set(key, matchingEvents)
|
||||
}
|
||||
|
||||
return keptEvents
|
||||
}
|
||||
|
||||
function getProjectVersionUploadDedupeKey(event: Labrinth.Analytics.v3.ProjectAnalyticsEvent) {
|
||||
if (event.kind !== 'version_uploaded') return null
|
||||
|
||||
const versionNumber = event.version_number.trim()
|
||||
if (versionNumber.length === 0) return null
|
||||
|
||||
return `${event.project_id}:${versionNumber}`
|
||||
}
|
||||
|
||||
function areProjectEventsWithinDedupeWindow(
|
||||
left: Labrinth.Analytics.v3.ProjectAnalyticsEvent,
|
||||
right: Labrinth.Analytics.v3.ProjectAnalyticsEvent,
|
||||
) {
|
||||
const leftTimestamp = new Date(left.timestamp).getTime()
|
||||
const rightTimestamp = new Date(right.timestamp).getTime()
|
||||
if (!Number.isFinite(leftTimestamp) || !Number.isFinite(rightTimestamp)) return false
|
||||
|
||||
return Math.abs(leftTimestamp - rightTimestamp) <= PROJECT_VERSION_UPLOAD_DEDUPE_WINDOW_MS
|
||||
}
|
||||
|
||||
function formatProjectEventDate(timestamp: string) {
|
||||
const date = new Date(timestamp)
|
||||
if (Number.isNaN(date.getTime())) return timestamp
|
||||
return PROJECT_EVENT_DATE_FORMATTER.format(date)
|
||||
}
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { computed, type ComputedRef, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import type { AnalyticsGroupByPreset } from '~/providers/analytics/analytics'
|
||||
|
||||
import {
|
||||
ensureMinimumTimeRange,
|
||||
getDefaultAnalyticsGroupByForDurationMinutes,
|
||||
} from '../../query-builder/timeframe.ts'
|
||||
import type {
|
||||
AnalyticsChartHoverState,
|
||||
AnalyticsChartRangeBounds,
|
||||
} from '../analytics-chart-types.ts'
|
||||
import type { ChartDataset } from '../analytics-chart-utils.ts'
|
||||
import { getSliceBucketRange } from '../analytics-chart-utils.ts'
|
||||
import type {
|
||||
AnalyticsChartGeometryPayload,
|
||||
AnalyticsChartRangeSelectPayload,
|
||||
} from '../AnalyticsChart.client.vue'
|
||||
import type AnalyticsChartTooltip from './AnalyticsChartTooltip.vue'
|
||||
|
||||
export function useAnalyticsChartInteractions({
|
||||
isDataLoading,
|
||||
fetchRequest,
|
||||
sliceCount,
|
||||
chartLabels,
|
||||
allChartDatasets,
|
||||
chartRangeBounds,
|
||||
shouldShowPreviousPeriod,
|
||||
onRangeSelected,
|
||||
}: {
|
||||
isDataLoading: ComputedRef<boolean>
|
||||
fetchRequest: ComputedRef<Labrinth.Analytics.v3.FetchRequest | null>
|
||||
sliceCount: ComputedRef<number>
|
||||
chartLabels: ComputedRef<string[]>
|
||||
allChartDatasets: ComputedRef<ChartDataset[]>
|
||||
chartRangeBounds: ComputedRef<AnalyticsChartRangeBounds | null>
|
||||
shouldShowPreviousPeriod: ComputedRef<boolean>
|
||||
onRangeSelected: (start: Date, end: Date, groupBy: AnalyticsGroupByPreset) => void
|
||||
}) {
|
||||
const chartContainer = ref<HTMLElement | null>(null)
|
||||
const chartTooltip = ref<InstanceType<typeof AnalyticsChartTooltip> | null>(null)
|
||||
const chartGeometry = ref<AnalyticsChartGeometryPayload | null>(null)
|
||||
const containerSize = reactive({ width: 0, height: 0 })
|
||||
const hoverState = reactive<AnalyticsChartHoverState>({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
sliceIndex: null,
|
||||
})
|
||||
const isHoverPinned = ref(false)
|
||||
const ignoreNextChartClick = ref(false)
|
||||
const isShiftKeyPressed = ref(false)
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let clearIgnoredChartClickTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function setHoverState(payload: AnalyticsChartHoverState) {
|
||||
hoverState.visible = payload.visible
|
||||
hoverState.x = payload.x
|
||||
hoverState.y = payload.y
|
||||
hoverState.sliceIndex = payload.sliceIndex
|
||||
}
|
||||
|
||||
function clearHoverState() {
|
||||
hoverState.visible = false
|
||||
hoverState.sliceIndex = null
|
||||
}
|
||||
|
||||
function unpinHoverState() {
|
||||
isHoverPinned.value = false
|
||||
clearHoverState()
|
||||
}
|
||||
|
||||
function updateShiftKeyState(event: KeyboardEvent) {
|
||||
isShiftKeyPressed.value = event.shiftKey
|
||||
}
|
||||
|
||||
function clearShiftKeyState() {
|
||||
isShiftKeyPressed.value = false
|
||||
}
|
||||
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
if (!isHoverPinned.value) return
|
||||
if (event.target instanceof Node && chartContainer.value?.contains(event.target)) return
|
||||
unpinHoverState()
|
||||
}
|
||||
|
||||
function onChartHover(payload: AnalyticsChartHoverState) {
|
||||
if (isDataLoading.value) return
|
||||
if (isHoverPinned.value) return
|
||||
setHoverState(payload)
|
||||
}
|
||||
|
||||
function ignoreUpcomingChartClick() {
|
||||
ignoreNextChartClick.value = true
|
||||
if (clearIgnoredChartClickTimeout) {
|
||||
clearTimeout(clearIgnoredChartClickTimeout)
|
||||
}
|
||||
clearIgnoredChartClickTimeout = setTimeout(() => {
|
||||
ignoreNextChartClick.value = false
|
||||
clearIgnoredChartClickTimeout = null
|
||||
}, 350)
|
||||
}
|
||||
|
||||
function onPinnedDrag(payload: AnalyticsChartHoverState) {
|
||||
if (isDataLoading.value || !isHoverPinned.value) return
|
||||
ignoreUpcomingChartClick()
|
||||
setHoverState(payload)
|
||||
}
|
||||
|
||||
function onTouchDragEnd() {
|
||||
ignoreUpcomingChartClick()
|
||||
}
|
||||
|
||||
function onChartGeometry(payload: AnalyticsChartGeometryPayload) {
|
||||
chartGeometry.value = payload
|
||||
}
|
||||
|
||||
function getDefaultGroupByForRange(start: Date, end: Date) {
|
||||
const ensuredRange = ensureMinimumTimeRange(start, end)
|
||||
const durationMinutes = Math.max(
|
||||
1,
|
||||
Math.floor((ensuredRange.end.getTime() - ensuredRange.start.getTime()) / 60000),
|
||||
)
|
||||
|
||||
return getDefaultAnalyticsGroupByForDurationMinutes(durationMinutes)
|
||||
}
|
||||
|
||||
function onRangeSelect(payload: AnalyticsChartRangeSelectPayload) {
|
||||
if (isDataLoading.value) return
|
||||
|
||||
const nextFetchRequest = fetchRequest.value
|
||||
if (!nextFetchRequest) return
|
||||
|
||||
if (payload.startSliceIndex === payload.endSliceIndex) {
|
||||
ignoreUpcomingChartClick()
|
||||
return
|
||||
}
|
||||
|
||||
const startSliceIndex = Math.min(payload.startSliceIndex, payload.endSliceIndex)
|
||||
const endSliceIndex = Math.max(payload.startSliceIndex, payload.endSliceIndex)
|
||||
const startBucketRange = getSliceBucketRange(
|
||||
nextFetchRequest.time_range,
|
||||
sliceCount.value,
|
||||
startSliceIndex,
|
||||
)
|
||||
const endBucketRange = getSliceBucketRange(
|
||||
nextFetchRequest.time_range,
|
||||
sliceCount.value,
|
||||
endSliceIndex,
|
||||
)
|
||||
const start = startBucketRange.start
|
||||
const end = endBucketRange.end
|
||||
|
||||
if (
|
||||
!Number.isFinite(start.getTime()) ||
|
||||
!Number.isFinite(end.getTime()) ||
|
||||
end.getTime() <= start.getTime()
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
ignoreUpcomingChartClick()
|
||||
unpinHoverState()
|
||||
onRangeSelected(start, end, getDefaultGroupByForRange(start, end))
|
||||
}
|
||||
|
||||
function onChartClick() {
|
||||
if (isDataLoading.value) return
|
||||
if (ignoreNextChartClick.value) {
|
||||
ignoreNextChartClick.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!hoverState.visible || hoverState.sliceIndex === null) {
|
||||
if (isHoverPinned.value) {
|
||||
unpinHoverState()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isHoverPinned.value) {
|
||||
unpinHoverState()
|
||||
return
|
||||
}
|
||||
|
||||
isHoverPinned.value = true
|
||||
}
|
||||
|
||||
function onChartWheel(event: WheelEvent) {
|
||||
if (isAnalyticsEventTooltipTrigger(event.target)) return
|
||||
if (!hoverState.visible) return
|
||||
chartTooltip.value?.consumeWheel(event)
|
||||
}
|
||||
|
||||
function isAnalyticsEventTooltipTrigger(target: EventTarget | null) {
|
||||
return (
|
||||
target instanceof Element && target.closest('[data-analytics-event-tooltip-trigger]') !== null
|
||||
)
|
||||
}
|
||||
|
||||
const pinnedSliceIndex = computed(() => (isHoverPinned.value ? hoverState.sliceIndex : null))
|
||||
const showHoverGuide = computed(
|
||||
() =>
|
||||
!isDataLoading.value &&
|
||||
!isHoverPinned.value &&
|
||||
hoverState.visible &&
|
||||
hoverState.sliceIndex !== null,
|
||||
)
|
||||
const showPinnedGuide = computed(
|
||||
() =>
|
||||
!isDataLoading.value &&
|
||||
isHoverPinned.value &&
|
||||
hoverState.visible &&
|
||||
hoverState.sliceIndex !== null,
|
||||
)
|
||||
const hoverBucketRange = computed(() => {
|
||||
const nextFetchRequest = fetchRequest.value
|
||||
if (!nextFetchRequest || hoverState.sliceIndex === null) return null
|
||||
return getSliceBucketRange(nextFetchRequest.time_range, sliceCount.value, hoverState.sliceIndex)
|
||||
})
|
||||
const previousHoverBucketRange = computed(() => {
|
||||
if (!shouldShowPreviousPeriod.value) return null
|
||||
|
||||
const bucketRange = hoverBucketRange.value
|
||||
const rangeBounds = chartRangeBounds.value
|
||||
if (!bucketRange || !rangeBounds) return null
|
||||
|
||||
const periodMs = rangeBounds.end.getTime() - rangeBounds.start.getTime()
|
||||
if (!Number.isFinite(periodMs) || periodMs <= 0) return null
|
||||
|
||||
return {
|
||||
start: new Date(bucketRange.start.getTime() - periodMs),
|
||||
end: new Date(bucketRange.end.getTime() - periodMs),
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (chartContainer.value && typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
if (!entry) return
|
||||
containerSize.width = entry.contentRect.width
|
||||
containerSize.height = entry.contentRect.height
|
||||
})
|
||||
resizeObserver.observe(chartContainer.value)
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', updateShiftKeyState)
|
||||
window.addEventListener('keyup', updateShiftKeyState)
|
||||
window.addEventListener('blur', clearShiftKeyState)
|
||||
document.addEventListener('click', onDocumentClick, true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
window.removeEventListener('keydown', updateShiftKeyState)
|
||||
window.removeEventListener('keyup', updateShiftKeyState)
|
||||
window.removeEventListener('blur', clearShiftKeyState)
|
||||
document.removeEventListener('click', onDocumentClick, true)
|
||||
if (clearIgnoredChartClickTimeout) {
|
||||
clearTimeout(clearIgnoredChartClickTimeout)
|
||||
clearIgnoredChartClickTimeout = null
|
||||
}
|
||||
})
|
||||
|
||||
watch([chartLabels, allChartDatasets], () => {
|
||||
isHoverPinned.value = false
|
||||
clearHoverState()
|
||||
})
|
||||
|
||||
watch(isDataLoading, (loading) => {
|
||||
if (!loading) return
|
||||
isHoverPinned.value = false
|
||||
clearHoverState()
|
||||
})
|
||||
|
||||
return {
|
||||
chartContainer,
|
||||
chartTooltip,
|
||||
chartGeometry,
|
||||
containerSize,
|
||||
hoverState,
|
||||
isHoverPinned,
|
||||
isShiftKeyPressed,
|
||||
setHoverState,
|
||||
clearHoverState,
|
||||
unpinHoverState,
|
||||
onChartHover,
|
||||
onPinnedDrag,
|
||||
onTouchDragEnd,
|
||||
onChartGeometry,
|
||||
onRangeSelect,
|
||||
onChartClick,
|
||||
onChartWheel,
|
||||
pinnedSliceIndex,
|
||||
showHoverGuide,
|
||||
showPinnedGuide,
|
||||
hoverBucketRange,
|
||||
previousHoverBucketRange,
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { computed, type ComputedRef, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
export function useAnalyticsChartLayout(showEmptyChartState: ComputedRef<boolean>) {
|
||||
const graphSection = ref<HTMLElement | null>(null)
|
||||
const rememberedGraphSectionHeight = ref(0)
|
||||
const graphSectionStyle = computed(() =>
|
||||
showEmptyChartState.value && rememberedGraphSectionHeight.value > 0
|
||||
? { height: `${rememberedGraphSectionHeight.value}px` }
|
||||
: undefined,
|
||||
)
|
||||
let graphSectionResizeObserver: ResizeObserver | null = null
|
||||
|
||||
function rememberGraphSectionHeight() {
|
||||
if (!graphSection.value) return
|
||||
|
||||
const height = graphSection.value.getBoundingClientRect().height
|
||||
if (height > 0) {
|
||||
rememberedGraphSectionHeight.value = height
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (graphSection.value && typeof ResizeObserver !== 'undefined') {
|
||||
graphSectionResizeObserver = new ResizeObserver(() => {
|
||||
if (showEmptyChartState.value) return
|
||||
rememberGraphSectionHeight()
|
||||
})
|
||||
graphSectionResizeObserver.observe(graphSection.value)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
graphSectionResizeObserver?.disconnect()
|
||||
graphSectionResizeObserver = null
|
||||
})
|
||||
|
||||
watch(showEmptyChartState, (showEmpty) => {
|
||||
if (showEmpty) {
|
||||
rememberGraphSectionHeight()
|
||||
} else {
|
||||
nextTick(rememberGraphSectionHeight)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
graphSection,
|
||||
graphSectionStyle,
|
||||
rememberGraphSectionHeight,
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
export type AnalyticsChartRangeBounds = {
|
||||
start: Date
|
||||
end: Date
|
||||
}
|
||||
|
||||
export type AnalyticsChartHoverState = {
|
||||
visible: boolean
|
||||
x: number
|
||||
y: number
|
||||
sliceIndex: number | null
|
||||
}
|
||||
|
||||
export type AnalyticsChartLegendEntry = {
|
||||
id: string
|
||||
name: string
|
||||
projectName?: string
|
||||
color: string
|
||||
totalValue: number
|
||||
hidden: boolean
|
||||
isPreviousPeriod?: boolean
|
||||
}
|
||||
+862
@@ -0,0 +1,862 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<AnalyticsChartRenderLimitModal
|
||||
ref="showAllSelectedGraphDatasetsModal"
|
||||
:table-project-count="tableProjectCount"
|
||||
@confirm="confirmShowAllSelectedGraphDatasets"
|
||||
/>
|
||||
|
||||
<section
|
||||
ref="graphSection"
|
||||
class="relative flex flex-col rounded-2xl border border-solid border-surface-5 bg-surface-3"
|
||||
:style="graphSectionStyle"
|
||||
>
|
||||
<AnalyticsChartHeader
|
||||
v-model:active-graph-view-mode="activeGraphViewMode"
|
||||
v-model:ratio-mode="isRatioMode"
|
||||
v-model:show-chart-events="showChartEvents"
|
||||
v-model:show-project-events="showProjectEvents"
|
||||
v-model:show-previous-period="showPreviousPeriod"
|
||||
:graph-title="graphTitle"
|
||||
:show-table-selection-subheading="showTableSelectionSubheading"
|
||||
:table-selection-subheading="tableSelectionSubheading"
|
||||
:show-graph-render-limit-button="showGraphRenderLimitButton"
|
||||
:graph-render-limit-button-label="graphRenderLimitButtonLabel"
|
||||
:show-top-graph-datasets-button="showTopGraphDatasetsButton"
|
||||
:can-use-ratio-mode="canUseRatioMode"
|
||||
:can-show-previous-period="canShowPreviousPeriodToggle"
|
||||
:has-chart-events="hasChartEvents"
|
||||
:has-project-events="hasProjectEvents"
|
||||
:small-toggles="!isMobileLayout"
|
||||
:default-show-project-events="defaultShowProjectEvents"
|
||||
:is-mobile-layout="isMobileLayout"
|
||||
@toggle-graph-render-limit="toggleGraphRenderLimit"
|
||||
@show-top-graph-datasets="showTopGraphDatasets"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-6 px-4 pb-6 pt-5"
|
||||
:class="['transition-opacity', isDataLoading ? 'pointer-events-none opacity-75' : '']"
|
||||
>
|
||||
<AnalyticsChartLegend
|
||||
:legend-entries="legendEntries"
|
||||
:should-capitalize-dataset-labels="shouldCapitalizeDatasetLabels"
|
||||
:show-unmonetized-info="showUnmonetizedInfo"
|
||||
@entry-hover="setHoveredLegendEntryId"
|
||||
@entry-hover-clear="clearHoveredLegendEntryId"
|
||||
@entry-click="onLegendEntryClick"
|
||||
/>
|
||||
|
||||
<AnalyticsChartPlot
|
||||
:chart-type="chartType"
|
||||
:is-area="isArea"
|
||||
:is-stacked="isStacked"
|
||||
:is-ratio-mode="isRatioMode"
|
||||
:is-data-loading="isDataLoading"
|
||||
:show-empty-chart-state="showEmptyChartState"
|
||||
:empty-chart-message="emptyChartMessage"
|
||||
:visible-chart-datasets="visibleChartDatasets"
|
||||
:chart-labels="chartLabels"
|
||||
:x-axis-tick-limit="xAxisTickLimit"
|
||||
:active-stat="activeStat"
|
||||
:highlighted-chart-dataset-id="highlightedChartDatasetId"
|
||||
:has-visible-timeline-events="hasVisibleTimelineEvents"
|
||||
:visible-timeline-events="visibleTimelineEvents"
|
||||
:selected-group-by="selectedGroupBy"
|
||||
:chart-range-bounds="chartRangeBounds"
|
||||
:fetch-request="fetchRequest"
|
||||
:slice-count="sliceCount"
|
||||
:should-show-previous-period="shouldShowPreviousPeriod"
|
||||
:all-chart-datasets="allChartDatasets"
|
||||
:current-legend-entries="currentLegendEntries"
|
||||
:legend-entries="legendEntries"
|
||||
:chart-dataset-by-id="chartDatasetById"
|
||||
:hover-ratio-slice-totals="hoverRatioSliceTotals"
|
||||
:should-capitalize-dataset-labels="shouldCapitalizeDatasetLabels"
|
||||
@range-select="onRangeSelect"
|
||||
@entry-click="onTooltipEntryClick"
|
||||
@entry-hover="setHoveredLegendEntryId"
|
||||
@entry-hover-clear="clearHoveredLegendEntryId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pointer-events-none absolute inset-0 z-[20] overflow-hidden rounded-xl">
|
||||
<AnalyticsLoadingBar :loading="isDataLoading" />
|
||||
</div>
|
||||
|
||||
<div v-if="isDataLoading" class="absolute inset-0 z-[19] overflow-hidden rounded-xl">
|
||||
<div class="absolute inset-0 bg-surface-3 opacity-50" />
|
||||
<div class="absolute inset-0 backdrop-blur-[3px]" />
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
class="relative bottom-6 inline-flex items-center gap-2 text-lg font-semibold text-primary"
|
||||
>
|
||||
<span>{{ formatMessage(analyticsMessages.fetchingResults) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVIntl } from '@modrinth/ui'
|
||||
|
||||
import { getDefaultAnalyticsGraphProjectEventsVisibility } from '~/components/analytics-dashboard/analytics-route-query'
|
||||
import type { AnalyticsGroupByPreset } from '~/providers/analytics/analytics'
|
||||
import { injectAnalyticsDashboardContext } from '~/providers/analytics/analytics'
|
||||
|
||||
import { analyticsMessages } from '../analytics-messages.ts'
|
||||
import AnalyticsLoadingBar from '../AnalyticsLoadingBar.vue'
|
||||
import AnalyticsChartLegend from './analytics-chart-header/AnalyticsChartLegend.vue'
|
||||
import AnalyticsChartRenderLimitModal from './analytics-chart-header/AnalyticsChartRenderLimitModal.vue'
|
||||
import AnalyticsChartHeader from './analytics-chart-header/index.vue'
|
||||
import { useAnalyticsChartLegend } from './analytics-chart-header/use-analytics-chart-legend.ts'
|
||||
import AnalyticsChartPlot from './analytics-chart-plot/index.vue'
|
||||
import { useAnalyticsChartEvents } from './analytics-chart-plot/use-analytics-chart-events.ts'
|
||||
import { useAnalyticsChartLayout } from './analytics-chart-plot/use-analytics-chart-layout.ts'
|
||||
import { useAnalyticsChartDatasets } from './use-analytics-chart-datasets.ts'
|
||||
import { useAnalyticsChartProjects } from './use-analytics-chart-projects.ts'
|
||||
|
||||
const dashboardContext = injectAnalyticsDashboardContext()
|
||||
const { formatMessage } = useVIntl()
|
||||
const {
|
||||
activeStat,
|
||||
activeGraphViewMode,
|
||||
isRatioMode,
|
||||
showChartEvents,
|
||||
showProjectEvents,
|
||||
showPreviousPeriod,
|
||||
isMobileLayout,
|
||||
hiddenGraphDatasetIds,
|
||||
isGraphDatasetSelectionActive,
|
||||
selectedProjectIds: currentSelectedProjectIds,
|
||||
selectedTimeframeMode,
|
||||
selectedCustomTimeframeStartDate,
|
||||
selectedCustomTimeframeEndDate,
|
||||
selectedGroupBy: selectedDashboardGroupBy,
|
||||
displayedFetchRequest: fetchRequest,
|
||||
displayedSelectedGroupBy: selectedGroupBy,
|
||||
displayedSelectedBreakdowns: selectedBreakdowns,
|
||||
isLoading,
|
||||
} = dashboardContext
|
||||
|
||||
const isDataLoading = computed(() => isLoading.value)
|
||||
const defaultShowProjectEvents = computed(() =>
|
||||
getDefaultAnalyticsGraphProjectEventsVisibility(currentSelectedProjectIds.value),
|
||||
)
|
||||
|
||||
const {
|
||||
selectedProjectIdSet,
|
||||
hasAvailableProjects,
|
||||
selectedProjects,
|
||||
selectedProjectNameById,
|
||||
selectedProjectEventIdSet,
|
||||
} = useAnalyticsChartProjects(dashboardContext)
|
||||
const {
|
||||
showAllSelectedGraphDatasets,
|
||||
chartRangeBounds,
|
||||
tableProjectCount,
|
||||
showEmptyChartState,
|
||||
emptyChartMessage,
|
||||
graphTitle,
|
||||
showTableSelectionSubheading,
|
||||
shouldCapitalizeDatasetLabels,
|
||||
chartType,
|
||||
canShowPreviousPeriodToggle,
|
||||
shouldShowPreviousPeriod,
|
||||
isArea,
|
||||
isStacked,
|
||||
sliceCount,
|
||||
chartLabels,
|
||||
xAxisTickLimit,
|
||||
allChartDatasets,
|
||||
previousChartDatasets,
|
||||
tableSelectionSubheading,
|
||||
showGraphRenderLimitButton,
|
||||
graphRenderLimitButtonLabel,
|
||||
showTopGraphDatasetsButton,
|
||||
selectableChartDatasets,
|
||||
showTopGraphDatasets,
|
||||
} = useAnalyticsChartDatasets(dashboardContext, selectedProjects, hasAvailableProjects)
|
||||
const {
|
||||
currentLegendEntries,
|
||||
visibleProjectEventIdSet,
|
||||
legendEntries,
|
||||
chartDatasetById,
|
||||
hoverRatioSliceTotals,
|
||||
visibleChartDatasets,
|
||||
highlightedChartDatasetId,
|
||||
setHoveredLegendEntryId,
|
||||
clearHoveredLegendEntryId,
|
||||
onLegendEntryClick,
|
||||
onTooltipEntryClick,
|
||||
} = useAnalyticsChartLegend({
|
||||
selectableChartDatasets,
|
||||
allChartDatasets,
|
||||
previousChartDatasets,
|
||||
shouldShowPreviousPeriod,
|
||||
isRatioMode,
|
||||
hiddenGraphDatasetIds,
|
||||
selectedBreakdowns,
|
||||
isGraphDatasetSelectionActive,
|
||||
selectedProjects,
|
||||
selectedProjectIdSet,
|
||||
selectedProjectEventIdSet,
|
||||
})
|
||||
const { hasChartEvents, hasProjectEvents, visibleTimelineEvents, hasVisibleTimelineEvents } =
|
||||
useAnalyticsChartEvents(
|
||||
dashboardContext,
|
||||
chartRangeBounds,
|
||||
selectedProjectNameById,
|
||||
selectedProjectEventIdSet,
|
||||
visibleProjectEventIdSet,
|
||||
)
|
||||
const { graphSection, graphSectionStyle } = useAnalyticsChartLayout(showEmptyChartState)
|
||||
|
||||
const showAllSelectedGraphDatasetsModal = ref<InstanceType<
|
||||
typeof AnalyticsChartRenderLimitModal
|
||||
> | null>(null)
|
||||
const canUseRatioMode = computed(
|
||||
() =>
|
||||
(activeGraphViewMode.value === 'area' || activeGraphViewMode.value === 'bar') &&
|
||||
legendEntries.value.length > 1,
|
||||
)
|
||||
const showUnmonetizedInfo = computed(
|
||||
() => selectedBreakdowns.value.length === 1 && selectedBreakdowns.value[0] === 'monetization',
|
||||
)
|
||||
|
||||
function toggleGraphRenderLimit(event: MouseEvent) {
|
||||
if (showAllSelectedGraphDatasets.value) {
|
||||
showAllSelectedGraphDatasets.value = false
|
||||
return
|
||||
}
|
||||
|
||||
showAllSelectedGraphDatasetsModal.value?.show(event)
|
||||
}
|
||||
|
||||
function confirmShowAllSelectedGraphDatasets() {
|
||||
showAllSelectedGraphDatasets.value = true
|
||||
}
|
||||
|
||||
function onRangeSelect(start: Date, end: Date, groupBy: AnalyticsGroupByPreset) {
|
||||
selectedTimeframeMode.value = 'custom_datetime_range'
|
||||
selectedCustomTimeframeStartDate.value = start.toISOString()
|
||||
selectedCustomTimeframeEndDate.value = end.toISOString()
|
||||
selectedDashboardGroupBy.value = groupBy
|
||||
}
|
||||
|
||||
watch(canUseRatioMode, (canUse) => {
|
||||
if (!canUse) {
|
||||
isRatioMode.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
+395
@@ -0,0 +1,395 @@
|
||||
import { useVIntl } from '@modrinth/ui'
|
||||
import { computed, type ComputedRef, ref, watch } from 'vue'
|
||||
|
||||
import { useTheme } from '~/composables/nuxt-accessors'
|
||||
import { isDarkTheme } from '~/plugins/theme/index.ts'
|
||||
import type {
|
||||
AnalyticsDashboardContextValue,
|
||||
AnalyticsDashboardProject,
|
||||
AnalyticsDashboardStat,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import {
|
||||
analyticsChartMessages,
|
||||
analyticsMessages,
|
||||
formatAnalyticsGraphTitle,
|
||||
type FormatMessage,
|
||||
getAnalyticsBreakdownItemType,
|
||||
} from '../analytics-messages'
|
||||
import {
|
||||
ANALYTICS_DASHBOARD_STATS,
|
||||
DARK_LEGEND_PALETTE,
|
||||
GRAPH_RENDER_DATASET_LIMIT,
|
||||
LIGHT_LEGEND_PALETTE,
|
||||
TOP_GRAPH_DATASET_LIMIT,
|
||||
} from './analytics-chart-constants'
|
||||
import {
|
||||
buildChartDatasets,
|
||||
buildTimeAxisLabels,
|
||||
type ChartDataset,
|
||||
getChartDatasetTotal,
|
||||
getShortHourlyAxisTickLimit,
|
||||
getSliceCount,
|
||||
shouldCapitalizeBreakdownLabel,
|
||||
} from './analytics-chart-utils'
|
||||
|
||||
export function useAnalyticsChartDatasets(
|
||||
context: Pick<
|
||||
AnalyticsDashboardContextValue,
|
||||
| 'activeStat'
|
||||
| 'activeGraphViewMode'
|
||||
| 'isRatioMode'
|
||||
| 'showPreviousPeriod'
|
||||
| 'hasPreviousPeriodComparison'
|
||||
| 'hasProjectContext'
|
||||
| 'displayedFetchRequest'
|
||||
| 'displayedTimeSlices'
|
||||
| 'displayedPreviousTimeSlices'
|
||||
| 'displayedSelectedGroupBy'
|
||||
| 'displayedSelectedBreakdowns'
|
||||
| 'hiddenGraphDatasetIds'
|
||||
| 'hasExplicitGraphDatasetSelection'
|
||||
| 'isGraphDatasetSelectionActive'
|
||||
| 'selectedGraphDatasetIds'
|
||||
| 'defaultGraphDatasetIds'
|
||||
| 'topGraphDatasetIds'
|
||||
| 'getVersionDisplayName'
|
||||
| 'getVersionProjectName'
|
||||
>,
|
||||
selectedProjects: ComputedRef<AnalyticsDashboardProject[]>,
|
||||
hasAvailableProjects: ComputedRef<boolean>,
|
||||
) {
|
||||
const theme = useTheme()
|
||||
const { formatMessage } = useVIntl()
|
||||
const showAllSelectedGraphDatasets = ref(false)
|
||||
|
||||
const chartRangeBounds = computed(() => {
|
||||
const nextFetchRequest = context.displayedFetchRequest.value
|
||||
if (!nextFetchRequest) return null
|
||||
return {
|
||||
start: new Date(nextFetchRequest.time_range.start),
|
||||
end: new Date(nextFetchRequest.time_range.end),
|
||||
}
|
||||
})
|
||||
const showProjectVersionNames = computed(
|
||||
() =>
|
||||
context.displayedSelectedBreakdowns.value.includes('version_id') &&
|
||||
selectedProjects.value.length > 1,
|
||||
)
|
||||
const tableProjectCount = computed(() => context.selectedGraphDatasetIds.value.length)
|
||||
const isTableGraphSelectionEmpty = computed(
|
||||
() =>
|
||||
context.isGraphDatasetSelectionActive.value &&
|
||||
context.hasExplicitGraphDatasetSelection.value &&
|
||||
tableProjectCount.value === 0,
|
||||
)
|
||||
const showEmptyChartState = computed(
|
||||
() => selectedProjects.value.length === 0 || isTableGraphSelectionEmpty.value,
|
||||
)
|
||||
const emptyChartMessage = computed(() => {
|
||||
if (isTableGraphSelectionEmpty.value) {
|
||||
return formatMessage(analyticsChartMessages.selectTableItemsEmpty)
|
||||
}
|
||||
|
||||
if (context.hasProjectContext.value) {
|
||||
return formatMessage(analyticsMessages.noDataAvailableForAnalytics)
|
||||
}
|
||||
|
||||
return hasAvailableProjects.value
|
||||
? formatMessage(analyticsMessages.selectAtLeastOneProject)
|
||||
: formatMessage(analyticsMessages.noProjectsAvailableForAnalytics)
|
||||
})
|
||||
const legendPalette = computed(() =>
|
||||
isDarkTheme(theme.active) ? DARK_LEGEND_PALETTE : LIGHT_LEGEND_PALETTE,
|
||||
)
|
||||
const graphTitle = computed(() =>
|
||||
formatAnalyticsGraphTitle(context.activeStat.value, formatMessage),
|
||||
)
|
||||
const showTableSelectionSubheading = computed(
|
||||
() => context.isGraphDatasetSelectionActive.value && tableProjectCount.value > 0,
|
||||
)
|
||||
const tableBreakdownItemType = computed(() =>
|
||||
getAnalyticsBreakdownItemType(context.displayedSelectedBreakdowns.value),
|
||||
)
|
||||
const shouldCapitalizeDatasetLabels = computed(() =>
|
||||
shouldCapitalizeBreakdownLabel(context.displayedSelectedBreakdowns.value),
|
||||
)
|
||||
const chartType = computed<'line' | 'bar'>(() =>
|
||||
context.activeGraphViewMode.value === 'bar' ? 'bar' : 'line',
|
||||
)
|
||||
const canShowPreviousPeriodToggle = computed(
|
||||
() => context.activeGraphViewMode.value === 'line' && context.hasPreviousPeriodComparison.value,
|
||||
)
|
||||
const shouldShowPreviousPeriod = computed(
|
||||
() => canShowPreviousPeriodToggle.value && context.showPreviousPeriod.value,
|
||||
)
|
||||
const isArea = computed(() => context.activeGraphViewMode.value === 'area')
|
||||
const isStacked = computed(
|
||||
() =>
|
||||
context.isRatioMode.value ||
|
||||
context.activeGraphViewMode.value === 'area' ||
|
||||
context.activeGraphViewMode.value === 'bar',
|
||||
)
|
||||
const sliceCount = computed(() => {
|
||||
const nextFetchRequest = context.displayedFetchRequest.value
|
||||
const fallback = context.displayedTimeSlices.value.length
|
||||
if (!nextFetchRequest) return Math.max(1, fallback)
|
||||
return getSliceCount(nextFetchRequest.time_range, fallback)
|
||||
})
|
||||
const chartLabels = computed(() => {
|
||||
const nextFetchRequest = context.displayedFetchRequest.value
|
||||
if (!nextFetchRequest) return []
|
||||
return buildTimeAxisLabels(
|
||||
nextFetchRequest.time_range,
|
||||
sliceCount.value,
|
||||
context.displayedSelectedGroupBy.value,
|
||||
)
|
||||
})
|
||||
const xAxisTickLimit = computed(() => {
|
||||
const nextFetchRequest = context.displayedFetchRequest.value
|
||||
return nextFetchRequest
|
||||
? getShortHourlyAxisTickLimit(
|
||||
nextFetchRequest.time_range,
|
||||
context.displayedSelectedGroupBy.value,
|
||||
)
|
||||
: undefined
|
||||
})
|
||||
const chartDatasetsByStat = computed<Record<AnalyticsDashboardStat, ChartDataset[]>>(() =>
|
||||
buildDatasetsByStat(
|
||||
context.displayedTimeSlices.value,
|
||||
selectedProjects.value,
|
||||
legendPalette.value,
|
||||
context.displayedSelectedBreakdowns.value,
|
||||
context.getVersionDisplayName,
|
||||
showProjectVersionNames.value ? context.getVersionProjectName : undefined,
|
||||
formatMessage,
|
||||
sliceCount.value,
|
||||
),
|
||||
)
|
||||
const previousChartDatasetsByStat = computed<Record<AnalyticsDashboardStat, ChartDataset[]>>(() =>
|
||||
buildDatasetsByStat(
|
||||
context.displayedPreviousTimeSlices.value,
|
||||
selectedProjects.value,
|
||||
legendPalette.value,
|
||||
context.displayedSelectedBreakdowns.value,
|
||||
context.getVersionDisplayName,
|
||||
showProjectVersionNames.value ? context.getVersionProjectName : undefined,
|
||||
formatMessage,
|
||||
sliceCount.value,
|
||||
),
|
||||
)
|
||||
const allChartDatasets = computed(() => chartDatasetsByStat.value[context.activeStat.value])
|
||||
const previousChartDatasets = computed(
|
||||
() => previousChartDatasetsByStat.value[context.activeStat.value],
|
||||
)
|
||||
const sortedChartDatasetIds = computed(() => sortDatasetsByTotal(allChartDatasets.value))
|
||||
const chartTopGraphDatasetIds = computed(() =>
|
||||
sortedChartDatasetIds.value.slice(0, TOP_GRAPH_DATASET_LIMIT),
|
||||
)
|
||||
const fallbackDefaultGraphDatasetIds = computed(() =>
|
||||
context.defaultGraphDatasetIds.value.length > 0
|
||||
? context.defaultGraphDatasetIds.value
|
||||
: chartTopGraphDatasetIds.value,
|
||||
)
|
||||
const isShowingAllTableItems = computed(() => {
|
||||
if (context.selectedGraphDatasetIds.value.length !== sortedChartDatasetIds.value.length) {
|
||||
return false
|
||||
}
|
||||
const selectedDatasetIds = new Set(context.selectedGraphDatasetIds.value)
|
||||
return sortedChartDatasetIds.value.every((datasetId) => selectedDatasetIds.has(datasetId))
|
||||
})
|
||||
const isShowingTopGraphDatasets = computed(() => {
|
||||
if (
|
||||
context.selectedGraphDatasetIds.value.length !== fallbackDefaultGraphDatasetIds.value.length
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const selectedDatasetIds = new Set(context.selectedGraphDatasetIds.value)
|
||||
return fallbackDefaultGraphDatasetIds.value.every((datasetId) =>
|
||||
selectedDatasetIds.has(datasetId),
|
||||
)
|
||||
})
|
||||
const isShowingTopTableItems = computed(() => {
|
||||
const topDatasetIds = new Set(
|
||||
context.topGraphDatasetIds.value.slice(0, context.selectedGraphDatasetIds.value.length),
|
||||
)
|
||||
return context.selectedGraphDatasetIds.value.every((datasetId) => topDatasetIds.has(datasetId))
|
||||
})
|
||||
const isGraphRenderDatasetOverLimit = computed(
|
||||
() =>
|
||||
context.isGraphDatasetSelectionActive.value &&
|
||||
selectedChartDatasets.value.length > GRAPH_RENDER_DATASET_LIMIT,
|
||||
)
|
||||
const isGraphRenderDatasetLimitActive = computed(
|
||||
() => isGraphRenderDatasetOverLimit.value && !showAllSelectedGraphDatasets.value,
|
||||
)
|
||||
const tableSelectionSubheading = computed(() => {
|
||||
if (isGraphRenderDatasetLimitActive.value) {
|
||||
return formatMessage(analyticsChartMessages.tableSelectionLimited, {
|
||||
limit: GRAPH_RENDER_DATASET_LIMIT,
|
||||
itemType: tableBreakdownItemType.value,
|
||||
})
|
||||
}
|
||||
|
||||
if (isShowingAllTableItems.value) {
|
||||
return formatMessage(analyticsChartMessages.tableSelectionAll, {
|
||||
count: tableProjectCount.value,
|
||||
itemType: tableBreakdownItemType.value,
|
||||
})
|
||||
}
|
||||
|
||||
if (isShowingTopTableItems.value) {
|
||||
return formatMessage(analyticsChartMessages.tableSelectionTop, {
|
||||
count: tableProjectCount.value,
|
||||
itemType: tableBreakdownItemType.value,
|
||||
})
|
||||
}
|
||||
|
||||
return formatMessage(analyticsChartMessages.tableSelectionCount, {
|
||||
count: tableProjectCount.value,
|
||||
itemType: tableBreakdownItemType.value,
|
||||
})
|
||||
})
|
||||
const shouldUseDefaultGraphDatasetSelection = computed(
|
||||
() =>
|
||||
context.isGraphDatasetSelectionActive.value &&
|
||||
!context.hasExplicitGraphDatasetSelection.value &&
|
||||
context.selectedGraphDatasetIds.value.length === 0,
|
||||
)
|
||||
const selectedGraphDatasetIdSet = computed(() => {
|
||||
if (shouldUseDefaultGraphDatasetSelection.value) {
|
||||
return new Set(fallbackDefaultGraphDatasetIds.value)
|
||||
}
|
||||
|
||||
return new Set(context.selectedGraphDatasetIds.value)
|
||||
})
|
||||
const selectedChartDatasets = computed(() => {
|
||||
if (!context.isGraphDatasetSelectionActive.value) {
|
||||
return allChartDatasets.value
|
||||
}
|
||||
|
||||
return allChartDatasets.value.filter((dataset) =>
|
||||
selectedGraphDatasetIdSet.value.has(dataset.projectId),
|
||||
)
|
||||
})
|
||||
const sortedSelectedChartDatasetIds = computed(() =>
|
||||
sortDatasetsByTotal(selectedChartDatasets.value),
|
||||
)
|
||||
const showGraphRenderLimitButton = computed(() => isGraphRenderDatasetOverLimit.value)
|
||||
const graphRenderLimitButtonLabel = computed(() =>
|
||||
showAllSelectedGraphDatasets.value
|
||||
? formatMessage(analyticsChartMessages.showLimited)
|
||||
: formatMessage(analyticsChartMessages.showAll),
|
||||
)
|
||||
const showTopGraphDatasetsButton = computed(
|
||||
() =>
|
||||
context.isGraphDatasetSelectionActive.value &&
|
||||
context.topGraphDatasetIds.value.length > 0 &&
|
||||
!isShowingTopGraphDatasets.value,
|
||||
)
|
||||
const limitedGraphDatasetIds = computed(
|
||||
() => new Set(sortedSelectedChartDatasetIds.value.slice(0, GRAPH_RENDER_DATASET_LIMIT)),
|
||||
)
|
||||
const selectableChartDatasets = computed(() => {
|
||||
if (!isGraphRenderDatasetLimitActive.value) {
|
||||
return selectedChartDatasets.value
|
||||
}
|
||||
|
||||
return selectedChartDatasets.value.filter((dataset) =>
|
||||
limitedGraphDatasetIds.value.has(dataset.projectId),
|
||||
)
|
||||
})
|
||||
|
||||
function showTopGraphDatasets() {
|
||||
context.selectedGraphDatasetIds.value = []
|
||||
context.hasExplicitGraphDatasetSelection.value = false
|
||||
showAllSelectedGraphDatasets.value = false
|
||||
}
|
||||
|
||||
watch([() => context.selectedGraphDatasetIds.value.join('\u0000'), allChartDatasets], () => {
|
||||
showAllSelectedGraphDatasets.value = false
|
||||
})
|
||||
|
||||
return {
|
||||
showAllSelectedGraphDatasets,
|
||||
chartRangeBounds,
|
||||
showProjectVersionNames,
|
||||
tableProjectCount,
|
||||
isTableGraphSelectionEmpty,
|
||||
showEmptyChartState,
|
||||
emptyChartMessage,
|
||||
legendPalette,
|
||||
graphTitle,
|
||||
showTableSelectionSubheading,
|
||||
shouldCapitalizeDatasetLabels,
|
||||
chartType,
|
||||
canShowPreviousPeriodToggle,
|
||||
shouldShowPreviousPeriod,
|
||||
isArea,
|
||||
isStacked,
|
||||
sliceCount,
|
||||
chartLabels,
|
||||
xAxisTickLimit,
|
||||
chartDatasetsByStat,
|
||||
previousChartDatasetsByStat,
|
||||
allChartDatasets,
|
||||
previousChartDatasets,
|
||||
sortedChartDatasetIds,
|
||||
chartTopGraphDatasetIds,
|
||||
fallbackDefaultGraphDatasetIds,
|
||||
isShowingAllTableItems,
|
||||
isShowingTopGraphDatasets,
|
||||
isShowingTopTableItems,
|
||||
tableSelectionSubheading,
|
||||
shouldUseDefaultGraphDatasetSelection,
|
||||
selectedGraphDatasetIdSet,
|
||||
selectedChartDatasets,
|
||||
sortedSelectedChartDatasetIds,
|
||||
isGraphRenderDatasetOverLimit,
|
||||
showGraphRenderLimitButton,
|
||||
graphRenderLimitButtonLabel,
|
||||
showTopGraphDatasetsButton,
|
||||
isGraphRenderDatasetLimitActive,
|
||||
limitedGraphDatasetIds,
|
||||
selectableChartDatasets,
|
||||
showTopGraphDatasets,
|
||||
}
|
||||
}
|
||||
|
||||
function buildDatasetsByStat(
|
||||
timeSlices: Parameters<typeof buildChartDatasets>[0],
|
||||
selectedProjects: AnalyticsDashboardProject[],
|
||||
palette: string[],
|
||||
selectedBreakdowns: Parameters<typeof buildChartDatasets>[4],
|
||||
getVersionDisplayName: Parameters<typeof buildChartDatasets>[5],
|
||||
getVersionProjectName: Parameters<typeof buildChartDatasets>[6],
|
||||
formatMessage: FormatMessage,
|
||||
sliceCount: number,
|
||||
) {
|
||||
const datasetsByStat = {} as Record<AnalyticsDashboardStat, ChartDataset[]>
|
||||
for (const stat of ANALYTICS_DASHBOARD_STATS) {
|
||||
datasetsByStat[stat] = buildChartDatasets(
|
||||
timeSlices,
|
||||
selectedProjects,
|
||||
stat,
|
||||
palette,
|
||||
selectedBreakdowns,
|
||||
getVersionDisplayName,
|
||||
getVersionProjectName,
|
||||
formatMessage,
|
||||
sliceCount,
|
||||
)
|
||||
}
|
||||
return datasetsByStat
|
||||
}
|
||||
|
||||
function sortDatasetsByTotal(datasets: ChartDataset[]) {
|
||||
return [...datasets]
|
||||
.sort((a, b) => {
|
||||
const totalDifference = getChartDatasetTotal(b) - getChartDatasetTotal(a)
|
||||
return (
|
||||
totalDifference || a.label.localeCompare(b.label) || a.projectId.localeCompare(b.projectId)
|
||||
)
|
||||
})
|
||||
.map((dataset) => dataset.projectId)
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
type AnalyticsDashboardContextValue,
|
||||
doesProjectStatusMatchFilters,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
export function useAnalyticsChartProjects(
|
||||
context: Pick<
|
||||
AnalyticsDashboardContextValue,
|
||||
'displayedSelectedProjectIds' | 'projects' | 'displayedSelectedFilters'
|
||||
>,
|
||||
) {
|
||||
const selectedProjectIdSet = computed(() => new Set(context.displayedSelectedProjectIds.value))
|
||||
const hasAvailableProjects = computed(() => context.projects.value.length > 0)
|
||||
|
||||
const selectedProjects = computed(() =>
|
||||
context.projects.value.filter(
|
||||
(project) =>
|
||||
selectedProjectIdSet.value.has(project.id) &&
|
||||
doesProjectStatusMatchFilters(project.status, context.displayedSelectedFilters.value),
|
||||
),
|
||||
)
|
||||
const selectedProjectNameById = computed(
|
||||
() => new Map(selectedProjects.value.map((project) => [project.id, project.name])),
|
||||
)
|
||||
const selectedProjectEventIdSet = computed(
|
||||
() => new Set(selectedProjects.value.map((project) => project.id)),
|
||||
)
|
||||
|
||||
return {
|
||||
selectedProjectIdSet,
|
||||
hasAvailableProjects,
|
||||
selectedProjects,
|
||||
selectedProjectNameById,
|
||||
selectedProjectEventIdSet,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,897 @@
|
||||
import { defineMessages, getLoaderMessage, type VIntlFormatters } from '@modrinth/ui'
|
||||
|
||||
import type {
|
||||
AnalyticsBreakdownPreset,
|
||||
AnalyticsDashboardStat,
|
||||
AnalyticsGroupByPreset,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
export type FormatMessage = VIntlFormatters['formatMessage']
|
||||
export type AnalyticsBreakdownItemType =
|
||||
| 'country'
|
||||
| 'downloadReason'
|
||||
| 'downloadSource'
|
||||
| 'gameVersion'
|
||||
| 'loader'
|
||||
| 'monetization'
|
||||
| 'project'
|
||||
| 'projectVersion'
|
||||
| 'other'
|
||||
|
||||
export const analyticsMessages = defineMessages({
|
||||
title: {
|
||||
id: 'analytics.title',
|
||||
defaultMessage: 'Analytics',
|
||||
},
|
||||
resetButton: {
|
||||
id: 'analytics.action.reset',
|
||||
defaultMessage: 'Reset',
|
||||
},
|
||||
refreshButton: {
|
||||
id: 'analytics.action.refresh',
|
||||
defaultMessage: 'Refresh',
|
||||
},
|
||||
fetchingResults: {
|
||||
id: 'analytics.loading.fetching-results',
|
||||
defaultMessage: 'Fetching results...',
|
||||
},
|
||||
allProjects: {
|
||||
id: 'analytics.project.all',
|
||||
defaultMessage: 'All projects',
|
||||
},
|
||||
selectProjects: {
|
||||
id: 'analytics.project.select',
|
||||
defaultMessage: 'Select projects',
|
||||
},
|
||||
projectCount: {
|
||||
id: 'analytics.project.count',
|
||||
defaultMessage: '{count, plural, one {# project} other {# projects}}',
|
||||
},
|
||||
projectIconAlt: {
|
||||
id: 'analytics.project.icon-alt',
|
||||
defaultMessage: '{name} Icon',
|
||||
},
|
||||
noDataAvailable: {
|
||||
id: 'analytics.empty.no-data',
|
||||
defaultMessage: 'No data available',
|
||||
},
|
||||
noDataAvailableForAnalytics: {
|
||||
id: 'analytics.empty.no-data-for-analytics',
|
||||
defaultMessage: 'No data available for analytics',
|
||||
},
|
||||
noProjectsAvailable: {
|
||||
id: 'analytics.empty.no-projects',
|
||||
defaultMessage: 'No projects available',
|
||||
},
|
||||
noProjectsAvailableForAnalytics: {
|
||||
id: 'analytics.empty.no-projects-for-analytics',
|
||||
defaultMessage: 'No projects available for analytics',
|
||||
},
|
||||
selectAtLeastOneProject: {
|
||||
id: 'analytics.empty.select-project',
|
||||
defaultMessage: 'Select at least one project to view data',
|
||||
},
|
||||
unknown: {
|
||||
id: 'analytics.value.unknown',
|
||||
defaultMessage: 'Unknown',
|
||||
},
|
||||
other: {
|
||||
id: 'analytics.value.other',
|
||||
defaultMessage: 'Other',
|
||||
},
|
||||
none: {
|
||||
id: 'analytics.value.none',
|
||||
defaultMessage: 'None',
|
||||
},
|
||||
noBreakdown: {
|
||||
id: 'analytics.breakdown.none.selected',
|
||||
defaultMessage: 'No breakdown',
|
||||
},
|
||||
breakdownBy: {
|
||||
id: 'analytics.breakdown.selected',
|
||||
defaultMessage: 'Breakdown by {breakdown}',
|
||||
},
|
||||
projectLabel: {
|
||||
id: 'analytics.query.label.project',
|
||||
defaultMessage: 'Project:',
|
||||
},
|
||||
timeframeLabel: {
|
||||
id: 'analytics.query.label.timeframe',
|
||||
defaultMessage: 'Timeframe:',
|
||||
},
|
||||
groupedByLabel: {
|
||||
id: 'analytics.query.label.grouped-by',
|
||||
defaultMessage: 'Grouped by',
|
||||
},
|
||||
breakdownLabel: {
|
||||
id: 'analytics.query.label.breakdown',
|
||||
defaultMessage: 'Breakdown:',
|
||||
},
|
||||
addFilterButton: {
|
||||
id: 'analytics.query.filter.add',
|
||||
defaultMessage: 'Add filter',
|
||||
},
|
||||
addButton: {
|
||||
id: 'analytics.action.add',
|
||||
defaultMessage: 'Add',
|
||||
},
|
||||
downloadsSuffix: {
|
||||
id: 'analytics.downloads.suffix',
|
||||
defaultMessage: 'downloads',
|
||||
},
|
||||
projectsAbove: {
|
||||
id: 'analytics.threshold.projects-above',
|
||||
defaultMessage: 'Projects above',
|
||||
},
|
||||
countriesAbove: {
|
||||
id: 'analytics.threshold.countries-above',
|
||||
defaultMessage: 'Countries above',
|
||||
},
|
||||
projectVersionsAbove: {
|
||||
id: 'analytics.threshold.project-versions-above',
|
||||
defaultMessage: 'Project versions above',
|
||||
},
|
||||
gameVersionsAbove: {
|
||||
id: 'analytics.threshold.game-versions-above',
|
||||
defaultMessage: 'Game versions above',
|
||||
},
|
||||
projectDownloadsThresholdAria: {
|
||||
id: 'analytics.threshold.project-downloads-aria',
|
||||
defaultMessage: 'Project downloads threshold',
|
||||
},
|
||||
countryDownloadsThresholdAria: {
|
||||
id: 'analytics.threshold.country-downloads-aria',
|
||||
defaultMessage: 'Country downloads threshold',
|
||||
},
|
||||
projectVersionDownloadsThresholdAria: {
|
||||
id: 'analytics.threshold.project-version-downloads-aria',
|
||||
defaultMessage: 'Project version downloads threshold',
|
||||
},
|
||||
gameVersionDownloadsThresholdAria: {
|
||||
id: 'analytics.threshold.game-version-downloads-aria',
|
||||
defaultMessage: 'Game version downloads threshold',
|
||||
},
|
||||
loadingOptions: {
|
||||
id: 'analytics.options.loading',
|
||||
defaultMessage: 'Loading...',
|
||||
},
|
||||
searchCountriesPlaceholder: {
|
||||
id: 'analytics.filter.search.countries',
|
||||
defaultMessage: 'Search countries...',
|
||||
},
|
||||
searchDownloadSourcesPlaceholder: {
|
||||
id: 'analytics.filter.search.download-sources',
|
||||
defaultMessage: 'Search download sources...',
|
||||
},
|
||||
searchProjectVersionsPlaceholder: {
|
||||
id: 'analytics.filter.search.project-versions',
|
||||
defaultMessage: 'Search project versions...',
|
||||
},
|
||||
searchVersionsPlaceholder: {
|
||||
id: 'analytics.filter.search.versions',
|
||||
defaultMessage: 'Search versions...',
|
||||
},
|
||||
gameVersionTypeAria: {
|
||||
id: 'analytics.filter.game-version-type',
|
||||
defaultMessage: 'Game version type',
|
||||
},
|
||||
releaseTab: {
|
||||
id: 'analytics.filter.game-version-type.release',
|
||||
defaultMessage: 'Release',
|
||||
},
|
||||
allTab: {
|
||||
id: 'analytics.filter.game-version-type.all',
|
||||
defaultMessage: 'All',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsStatMessages = defineMessages({
|
||||
views: {
|
||||
id: 'analytics.stat.views',
|
||||
defaultMessage: 'Views',
|
||||
},
|
||||
downloads: {
|
||||
id: 'analytics.stat.downloads',
|
||||
defaultMessage: 'Downloads',
|
||||
},
|
||||
revenue: {
|
||||
id: 'analytics.stat.revenue',
|
||||
defaultMessage: 'Revenue',
|
||||
},
|
||||
playtime: {
|
||||
id: 'analytics.stat.playtime',
|
||||
defaultMessage: 'Playtime',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsGraphTitleMessages = defineMessages({
|
||||
views: {
|
||||
id: 'analytics.graph.title.views',
|
||||
defaultMessage: 'Views Over Time',
|
||||
},
|
||||
downloads: {
|
||||
id: 'analytics.graph.title.downloads',
|
||||
defaultMessage: 'Downloads Over Time',
|
||||
},
|
||||
revenue: {
|
||||
id: 'analytics.graph.title.revenue',
|
||||
defaultMessage: 'Revenue Over Time',
|
||||
},
|
||||
playtime: {
|
||||
id: 'analytics.graph.title.playtime',
|
||||
defaultMessage: 'Playtime Over Time',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsStatCardMessages = defineMessages({
|
||||
revenueValue: {
|
||||
id: 'analytics.stat.revenue-value',
|
||||
defaultMessage: '${value}',
|
||||
},
|
||||
playtimeHours: {
|
||||
id: 'analytics.stat.playtime-hours',
|
||||
defaultMessage: '{hours} hrs',
|
||||
},
|
||||
unavailableTooltip: {
|
||||
id: 'analytics.stat.unavailable-tooltip',
|
||||
defaultMessage: 'Stat unavailable for current query',
|
||||
},
|
||||
unavailableLabel: {
|
||||
id: 'analytics.stat.unavailable',
|
||||
defaultMessage: 'N/A',
|
||||
},
|
||||
previousPeriodComparison: {
|
||||
id: 'analytics.stat.previous-period-comparison',
|
||||
defaultMessage: 'vs prev. period',
|
||||
},
|
||||
previousPeriodComparisonShort: {
|
||||
id: 'analytics.stat.previous-period-comparison-short',
|
||||
defaultMessage: 'vs prev.',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsGroupByMessages = defineMessages({
|
||||
oneHour: {
|
||||
id: 'analytics.group-by.1h',
|
||||
defaultMessage: '1h',
|
||||
},
|
||||
sixHours: {
|
||||
id: 'analytics.group-by.6h',
|
||||
defaultMessage: '6h',
|
||||
},
|
||||
day: {
|
||||
id: 'analytics.group-by.day',
|
||||
defaultMessage: 'Day',
|
||||
},
|
||||
week: {
|
||||
id: 'analytics.group-by.week',
|
||||
defaultMessage: 'Week',
|
||||
},
|
||||
month: {
|
||||
id: 'analytics.group-by.month',
|
||||
defaultMessage: 'Month',
|
||||
},
|
||||
year: {
|
||||
id: 'analytics.group-by.year',
|
||||
defaultMessage: 'Year',
|
||||
},
|
||||
date: {
|
||||
id: 'analytics.group-by.date',
|
||||
defaultMessage: 'Date',
|
||||
},
|
||||
groupByHour: {
|
||||
id: 'analytics.group-by.selected.hour',
|
||||
defaultMessage: 'Group by hour',
|
||||
},
|
||||
groupBySixHours: {
|
||||
id: 'analytics.group-by.selected.six-hours',
|
||||
defaultMessage: 'Group by 6 hours',
|
||||
},
|
||||
groupByDay: {
|
||||
id: 'analytics.group-by.selected.day',
|
||||
defaultMessage: 'Group by day',
|
||||
},
|
||||
groupByWeek: {
|
||||
id: 'analytics.group-by.selected.week',
|
||||
defaultMessage: 'Group by week',
|
||||
},
|
||||
groupByMonth: {
|
||||
id: 'analytics.group-by.selected.month',
|
||||
defaultMessage: 'Group by month',
|
||||
},
|
||||
groupByYear: {
|
||||
id: 'analytics.group-by.selected.year',
|
||||
defaultMessage: 'Group by year',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsBreakdownMessages = defineMessages({
|
||||
breakdown: {
|
||||
id: 'analytics.breakdown.generic',
|
||||
defaultMessage: 'Breakdown',
|
||||
},
|
||||
project: {
|
||||
id: 'analytics.breakdown.project',
|
||||
defaultMessage: 'Project',
|
||||
},
|
||||
country: {
|
||||
id: 'analytics.breakdown.country',
|
||||
defaultMessage: 'Country',
|
||||
},
|
||||
monetization: {
|
||||
id: 'analytics.breakdown.monetization',
|
||||
defaultMessage: 'Monetization',
|
||||
},
|
||||
userAgent: {
|
||||
id: 'analytics.breakdown.download-source',
|
||||
defaultMessage: 'Download source',
|
||||
},
|
||||
downloadReason: {
|
||||
id: 'analytics.breakdown.download-reason',
|
||||
defaultMessage: 'Download reason',
|
||||
},
|
||||
versionId: {
|
||||
id: 'analytics.breakdown.project-version',
|
||||
defaultMessage: 'Project version',
|
||||
},
|
||||
loader: {
|
||||
id: 'analytics.breakdown.loader',
|
||||
defaultMessage: 'Loader',
|
||||
},
|
||||
gameVersion: {
|
||||
id: 'analytics.breakdown.game-version',
|
||||
defaultMessage: 'Game version',
|
||||
},
|
||||
projectStatus: {
|
||||
id: 'analytics.breakdown.project-status',
|
||||
defaultMessage: 'Project status',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsMonetizationMessages = defineMessages({
|
||||
monetized: {
|
||||
id: 'analytics.value.monetized',
|
||||
defaultMessage: 'Monetized',
|
||||
},
|
||||
unmonetized: {
|
||||
id: 'analytics.value.unmonetized',
|
||||
defaultMessage: 'Unmonetized',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsDownloadReasonMessages = defineMessages({
|
||||
standalone: {
|
||||
id: 'analytics.download-reason.standalone',
|
||||
defaultMessage: 'Standalone',
|
||||
},
|
||||
dependency: {
|
||||
id: 'analytics.download-reason.dependency',
|
||||
defaultMessage: 'Dependency',
|
||||
},
|
||||
modpack: {
|
||||
id: 'analytics.download-reason.modpack',
|
||||
defaultMessage: 'Modpack',
|
||||
},
|
||||
update: {
|
||||
id: 'analytics.download-reason.update',
|
||||
defaultMessage: 'Update',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsDownloadSourceMessages = defineMessages({
|
||||
website: {
|
||||
id: 'analytics.download-source.website',
|
||||
defaultMessage: 'Modrinth Website',
|
||||
},
|
||||
app: {
|
||||
id: 'analytics.download-source.app',
|
||||
defaultMessage: 'Modrinth App',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsProjectStatusMessages = defineMessages({
|
||||
approved: {
|
||||
id: 'analytics.project-status.approved',
|
||||
defaultMessage: 'Approved',
|
||||
},
|
||||
archived: {
|
||||
id: 'analytics.project-status.archived',
|
||||
defaultMessage: 'Archived',
|
||||
},
|
||||
rejected: {
|
||||
id: 'analytics.project-status.rejected',
|
||||
defaultMessage: 'Rejected',
|
||||
},
|
||||
draft: {
|
||||
id: 'analytics.project-status.draft',
|
||||
defaultMessage: 'Draft',
|
||||
},
|
||||
unlisted: {
|
||||
id: 'analytics.project-status.unlisted',
|
||||
defaultMessage: 'Unlisted',
|
||||
},
|
||||
withheld: {
|
||||
id: 'analytics.project-status.withheld',
|
||||
defaultMessage: 'Withheld',
|
||||
},
|
||||
private: {
|
||||
id: 'analytics.project-status.private',
|
||||
defaultMessage: 'Private',
|
||||
},
|
||||
other: {
|
||||
id: 'analytics.project-status.other',
|
||||
defaultMessage: 'Other',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsTableMessages = defineMessages({
|
||||
searchPlaceholder: {
|
||||
id: 'analytics.table.search.placeholder',
|
||||
defaultMessage: 'Search...',
|
||||
},
|
||||
exportCsvButton: {
|
||||
id: 'analytics.table.export-csv',
|
||||
defaultMessage: 'Export CSV',
|
||||
},
|
||||
cumulativeCsv: {
|
||||
id: 'analytics.table.export.cumulative',
|
||||
defaultMessage: 'Cumulative',
|
||||
},
|
||||
groupedCsv: {
|
||||
id: 'analytics.table.export.grouped',
|
||||
defaultMessage: 'Grouped by {groupBy}',
|
||||
},
|
||||
noMatchingRows: {
|
||||
id: 'analytics.table.empty.no-matching-rows',
|
||||
defaultMessage: 'No matching analytics rows',
|
||||
},
|
||||
paginationSummary: {
|
||||
id: 'analytics.table.pagination.summary',
|
||||
defaultMessage: 'Showing {start} to {end} of {total}',
|
||||
},
|
||||
playtimeSecondsHeader: {
|
||||
id: 'analytics.table.csv.header.playtime-seconds',
|
||||
defaultMessage: 'Playtime (seconds)',
|
||||
},
|
||||
csvSelectedRange: {
|
||||
id: 'analytics.table.csv.selected-range',
|
||||
defaultMessage: 'Selected Range',
|
||||
},
|
||||
csvDateRange: {
|
||||
id: 'analytics.table.csv.date-range',
|
||||
defaultMessage: '{start} to {end}',
|
||||
},
|
||||
csvFilename: {
|
||||
id: 'analytics.table.csv.filename',
|
||||
defaultMessage: 'Modrinth Analytics {breakdown} Breakdown - {dateRange}',
|
||||
},
|
||||
durationDays: {
|
||||
id: 'analytics.table.duration.days',
|
||||
defaultMessage: '{count, plural, one {# day} other {# days}}',
|
||||
},
|
||||
durationHours: {
|
||||
id: 'analytics.table.duration.hours',
|
||||
defaultMessage: '{count, plural, one {# hour} other {# hours}}',
|
||||
},
|
||||
durationMinutes: {
|
||||
id: 'analytics.table.duration.minutes',
|
||||
defaultMessage: '{count, plural, one {# minute} other {# minutes}}',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsChartMessages = defineMessages({
|
||||
selectTableItemsEmpty: {
|
||||
id: 'analytics.chart.empty.select-table-items',
|
||||
defaultMessage: 'Select items from table below to visualize your data.',
|
||||
},
|
||||
showLimited: {
|
||||
id: 'analytics.chart.action.show-limited',
|
||||
defaultMessage: 'Show limited',
|
||||
},
|
||||
showAll: {
|
||||
id: 'analytics.chart.action.show-all',
|
||||
defaultMessage: 'Show all',
|
||||
},
|
||||
showTopEight: {
|
||||
id: 'analytics.chart.action.show-top-eight',
|
||||
defaultMessage: 'Show top 8',
|
||||
},
|
||||
tableSelectionLimited: {
|
||||
id: 'analytics.chart.table-selection.limited',
|
||||
defaultMessage:
|
||||
'Showing {limit} {itemType, select, project {{limit, plural, one {project} other {projects}}} country {{limit, plural, one {country} other {countries}}} monetization {{limit, plural, one {monetization value} other {monetization values}}} downloadSource {{limit, plural, one {download source} other {download sources}}} downloadReason {{limit, plural, one {download reason} other {download reasons}}} projectVersion {{limit, plural, one {project version} other {project versions}}} loader {{limit, plural, one {loader} other {loaders}}} gameVersion {{limit, plural, one {game version} other {game versions}}} other {{limit, plural, one {item} other {items}}}} from table',
|
||||
},
|
||||
tableSelectionAll: {
|
||||
id: 'analytics.chart.table-selection.all',
|
||||
defaultMessage:
|
||||
'Showing all {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table',
|
||||
},
|
||||
tableSelectionTop: {
|
||||
id: 'analytics.chart.table-selection.top',
|
||||
defaultMessage:
|
||||
'Showing top {count} {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table',
|
||||
},
|
||||
tableSelectionCount: {
|
||||
id: 'analytics.chart.table-selection.count',
|
||||
defaultMessage:
|
||||
'Showing {count} {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table',
|
||||
},
|
||||
lineView: {
|
||||
id: 'analytics.chart.view.line',
|
||||
defaultMessage: 'Line',
|
||||
},
|
||||
areaView: {
|
||||
id: 'analytics.chart.view.area',
|
||||
defaultMessage: 'Area',
|
||||
},
|
||||
barView: {
|
||||
id: 'analytics.chart.view.bar',
|
||||
defaultMessage: 'Bar',
|
||||
},
|
||||
controlsButton: {
|
||||
id: 'analytics.chart.controls.button',
|
||||
defaultMessage: 'Controls',
|
||||
},
|
||||
controlsAria: {
|
||||
id: 'analytics.chart.controls.aria',
|
||||
defaultMessage: 'Analytics graph controls, {activeCount}',
|
||||
},
|
||||
controlsDialogAria: {
|
||||
id: 'analytics.chart.controls.dialog-aria',
|
||||
defaultMessage: 'Analytics graph controls',
|
||||
},
|
||||
activeControlCount: {
|
||||
id: 'analytics.chart.controls.active-count',
|
||||
defaultMessage: '{count} active',
|
||||
},
|
||||
displayControls: {
|
||||
id: 'analytics.chart.controls.display',
|
||||
defaultMessage: 'Display',
|
||||
},
|
||||
previousPeriod: {
|
||||
id: 'analytics.chart.controls.previous-period',
|
||||
defaultMessage: 'Previous period',
|
||||
},
|
||||
ratio: {
|
||||
id: 'analytics.chart.controls.ratio',
|
||||
defaultMessage: 'Ratio',
|
||||
},
|
||||
annotations: {
|
||||
id: 'analytics.chart.controls.annotations',
|
||||
defaultMessage: 'Annotations',
|
||||
},
|
||||
projectEvents: {
|
||||
id: 'analytics.chart.controls.project-events',
|
||||
defaultMessage: 'Project events',
|
||||
},
|
||||
modrinthEvents: {
|
||||
id: 'analytics.chart.controls.modrinth-events',
|
||||
defaultMessage: 'Modrinth events',
|
||||
},
|
||||
noProjectEvents: {
|
||||
id: 'analytics.chart.controls.no-project-events',
|
||||
defaultMessage: 'No project events in graph.',
|
||||
},
|
||||
noModrinthEvents: {
|
||||
id: 'analytics.chart.controls.no-modrinth-events',
|
||||
defaultMessage: 'No Modrinth events in graph.',
|
||||
},
|
||||
viewMonetizedAnalyticsDetails: {
|
||||
id: 'analytics.chart.legend.monetization-details.aria',
|
||||
defaultMessage: 'View monetized analytics details',
|
||||
},
|
||||
monetizedAnalyticsDetails: {
|
||||
id: 'analytics.chart.legend.monetization-details.title',
|
||||
defaultMessage: 'Monetized analytics details',
|
||||
},
|
||||
monetizedAnalyticsDetailsDescription: {
|
||||
id: 'analytics.chart.legend.monetization-details.description',
|
||||
defaultMessage:
|
||||
'Only views and downloads made through Modrinth count toward monetization, and downloads require users to be logged in.',
|
||||
},
|
||||
previousPeriodSuffix: {
|
||||
id: 'analytics.chart.legend.previous-period-suffix',
|
||||
defaultMessage: '{name} (Prev.)',
|
||||
},
|
||||
previousPeriodShort: {
|
||||
id: 'analytics.chart.tooltip.previous-period-short',
|
||||
defaultMessage: '(prev.)',
|
||||
},
|
||||
tooltipPinned: {
|
||||
id: 'analytics.chart.tooltip.pinned',
|
||||
defaultMessage: 'Chart tooltip pinned',
|
||||
},
|
||||
pinned: {
|
||||
id: 'analytics.chart.tooltip.pinned-aria',
|
||||
defaultMessage: 'Pinned',
|
||||
},
|
||||
total: {
|
||||
id: 'analytics.chart.tooltip.total',
|
||||
defaultMessage: 'Total',
|
||||
},
|
||||
showEntryInGraph: {
|
||||
id: 'analytics.chart.tooltip.show-entry',
|
||||
defaultMessage: 'Show {name} in graph',
|
||||
},
|
||||
hideEntryInGraph: {
|
||||
id: 'analytics.chart.tooltip.hide-entry',
|
||||
defaultMessage: 'Hide {name} in graph',
|
||||
},
|
||||
durationDays: {
|
||||
id: 'analytics.chart.tooltip.duration.days',
|
||||
defaultMessage: '{count, plural, one {# day} other {# days}}',
|
||||
},
|
||||
durationHours: {
|
||||
id: 'analytics.chart.tooltip.duration.hours',
|
||||
defaultMessage: '{count, plural, one {# hour} other {# hours}}',
|
||||
},
|
||||
durationMinutes: {
|
||||
id: 'analytics.chart.tooltip.duration.minutes',
|
||||
defaultMessage: '{count, plural, one {# minute} other {# minutes}}',
|
||||
},
|
||||
playtimeAxisHours: {
|
||||
id: 'analytics.chart.axis.playtime-hours',
|
||||
defaultMessage: '{hours} h',
|
||||
},
|
||||
renderLimitHeader: {
|
||||
id: 'analytics.chart.render-limit.header',
|
||||
defaultMessage: 'Show all {count} lines in graph?',
|
||||
},
|
||||
renderLimitDescription: {
|
||||
id: 'analytics.chart.render-limit.description',
|
||||
defaultMessage: 'Showing all selected lines from table may degrade page performance.',
|
||||
},
|
||||
cancelButton: {
|
||||
id: 'analytics.action.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
analyticsEventsCount: {
|
||||
id: 'analytics.chart.events.count-aria',
|
||||
defaultMessage: '{count, plural, one {# analytics event} other {# analytics events}}',
|
||||
},
|
||||
seeAnnouncement: {
|
||||
id: 'analytics.chart.events.see-announcement',
|
||||
defaultMessage: 'See announcement',
|
||||
},
|
||||
projectEventTitle: {
|
||||
id: 'analytics.chart.events.project-title',
|
||||
defaultMessage: '<project>{projectName}</project>: {title}',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsProjectEventMessages = defineMessages({
|
||||
versionReleased: {
|
||||
id: 'analytics.project-event.version-released',
|
||||
defaultMessage: '{version} released',
|
||||
},
|
||||
versionUploaded: {
|
||||
id: 'analytics.project-event.version-uploaded',
|
||||
defaultMessage: 'Version uploaded',
|
||||
},
|
||||
projectApproved: {
|
||||
id: 'analytics.project-event.project-approved',
|
||||
defaultMessage: 'Project approved',
|
||||
},
|
||||
projectUnlisted: {
|
||||
id: 'analytics.project-event.project-unlisted',
|
||||
defaultMessage: 'Project unlisted',
|
||||
},
|
||||
projectPrivate: {
|
||||
id: 'analytics.project-event.project-private',
|
||||
defaultMessage: 'Project set to private',
|
||||
},
|
||||
projectStatusChanged: {
|
||||
id: 'analytics.project-event.project-status-changed',
|
||||
defaultMessage: 'Project status changed',
|
||||
},
|
||||
})
|
||||
|
||||
export function formatAnalyticsStatLabel(
|
||||
stat: AnalyticsDashboardStat,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
return formatMessage(analyticsStatMessages[stat])
|
||||
}
|
||||
|
||||
export function formatAnalyticsGraphTitle(
|
||||
stat: AnalyticsDashboardStat,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
return formatMessage(analyticsGraphTitleMessages[stat])
|
||||
}
|
||||
|
||||
export function formatAnalyticsGroupByLabel(
|
||||
groupBy: AnalyticsGroupByPreset,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
switch (groupBy) {
|
||||
case '1h':
|
||||
return formatMessage(analyticsGroupByMessages.oneHour)
|
||||
case '6h':
|
||||
return formatMessage(analyticsGroupByMessages.sixHours)
|
||||
case 'day':
|
||||
return formatMessage(analyticsGroupByMessages.day)
|
||||
case 'week':
|
||||
return formatMessage(analyticsGroupByMessages.week)
|
||||
case 'month':
|
||||
return formatMessage(analyticsGroupByMessages.month)
|
||||
case 'year':
|
||||
return formatMessage(analyticsGroupByMessages.year)
|
||||
default:
|
||||
return formatMessage(analyticsGroupByMessages.date)
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAnalyticsGroupBySelectedLabel(
|
||||
groupBy: AnalyticsGroupByPreset,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
switch (groupBy) {
|
||||
case '1h':
|
||||
return formatMessage(analyticsGroupByMessages.groupByHour)
|
||||
case '6h':
|
||||
return formatMessage(analyticsGroupByMessages.groupBySixHours)
|
||||
case 'day':
|
||||
return formatMessage(analyticsGroupByMessages.groupByDay)
|
||||
case 'week':
|
||||
return formatMessage(analyticsGroupByMessages.groupByWeek)
|
||||
case 'month':
|
||||
return formatMessage(analyticsGroupByMessages.groupByMonth)
|
||||
case 'year':
|
||||
return formatMessage(analyticsGroupByMessages.groupByYear)
|
||||
default:
|
||||
return formatMessage(analyticsGroupByMessages.groupByDay)
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAnalyticsBreakdownLabel(
|
||||
breakdown: AnalyticsBreakdownPreset,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
switch (breakdown) {
|
||||
case 'none':
|
||||
case 'project':
|
||||
return formatMessage(analyticsBreakdownMessages.project)
|
||||
case 'country':
|
||||
return formatMessage(analyticsBreakdownMessages.country)
|
||||
case 'monetization':
|
||||
return formatMessage(analyticsBreakdownMessages.monetization)
|
||||
case 'user_agent':
|
||||
return formatMessage(analyticsBreakdownMessages.userAgent)
|
||||
case 'download_reason':
|
||||
return formatMessage(analyticsBreakdownMessages.downloadReason)
|
||||
case 'version_id':
|
||||
return formatMessage(analyticsBreakdownMessages.versionId)
|
||||
case 'loader':
|
||||
return formatMessage(analyticsBreakdownMessages.loader)
|
||||
case 'game_version':
|
||||
return formatMessage(analyticsBreakdownMessages.gameVersion)
|
||||
default:
|
||||
return formatMessage(analyticsBreakdownMessages.breakdown)
|
||||
}
|
||||
}
|
||||
|
||||
export function getAnalyticsBreakdownItemType(
|
||||
breakdowns: readonly AnalyticsBreakdownPreset[],
|
||||
): AnalyticsBreakdownItemType {
|
||||
if (breakdowns.length !== 1) {
|
||||
return 'other'
|
||||
}
|
||||
|
||||
switch (breakdowns[0]) {
|
||||
case 'project':
|
||||
return 'project'
|
||||
case 'country':
|
||||
return 'country'
|
||||
case 'monetization':
|
||||
return 'monetization'
|
||||
case 'user_agent':
|
||||
return 'downloadSource'
|
||||
case 'download_reason':
|
||||
return 'downloadReason'
|
||||
case 'version_id':
|
||||
return 'projectVersion'
|
||||
case 'loader':
|
||||
return 'loader'
|
||||
case 'game_version':
|
||||
return 'gameVersion'
|
||||
default:
|
||||
return 'other'
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAnalyticsMonetizationLabel(
|
||||
value: string,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
switch (value.trim().toLowerCase()) {
|
||||
case 'monetized':
|
||||
return formatMessage(analyticsMonetizationMessages.monetized)
|
||||
case 'unmonetized':
|
||||
return formatMessage(analyticsMonetizationMessages.unmonetized)
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAnalyticsDownloadReasonLabel(
|
||||
reason: string,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
switch (reason.trim().toLowerCase()) {
|
||||
case 'standalone':
|
||||
return formatMessage(analyticsDownloadReasonMessages.standalone)
|
||||
case 'dependency':
|
||||
return formatMessage(analyticsDownloadReasonMessages.dependency)
|
||||
case 'modpack':
|
||||
return formatMessage(analyticsDownloadReasonMessages.modpack)
|
||||
case 'update':
|
||||
return formatMessage(analyticsDownloadReasonMessages.update)
|
||||
default:
|
||||
return reason
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAnalyticsDownloadSourceLabel(
|
||||
source: string,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
const normalized = source.trim()
|
||||
const normalizedLowercase = normalized.toLowerCase()
|
||||
if (normalizedLowercase === 'website') {
|
||||
return formatMessage(analyticsDownloadSourceMessages.website)
|
||||
}
|
||||
if (normalizedLowercase === 'modrinth_app') {
|
||||
return formatMessage(analyticsDownloadSourceMessages.app)
|
||||
}
|
||||
if (!normalized.includes('_')) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return normalizedLowercase
|
||||
.split('_')
|
||||
.filter((part) => part.length > 0)
|
||||
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
export function formatAnalyticsProjectStatusLabel(
|
||||
status: string,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
switch (status.trim().toLowerCase()) {
|
||||
case 'approved':
|
||||
return formatMessage(analyticsProjectStatusMessages.approved)
|
||||
case 'archived':
|
||||
return formatMessage(analyticsProjectStatusMessages.archived)
|
||||
case 'rejected':
|
||||
return formatMessage(analyticsProjectStatusMessages.rejected)
|
||||
case 'draft':
|
||||
return formatMessage(analyticsProjectStatusMessages.draft)
|
||||
case 'unlisted':
|
||||
return formatMessage(analyticsProjectStatusMessages.unlisted)
|
||||
case 'withheld':
|
||||
return formatMessage(analyticsProjectStatusMessages.withheld)
|
||||
case 'private':
|
||||
return formatMessage(analyticsProjectStatusMessages.private)
|
||||
case 'other':
|
||||
return formatMessage(analyticsProjectStatusMessages.other)
|
||||
default:
|
||||
return capitalizeAnalyticsValue(status)
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAnalyticsLoaderLabel(loader: string, formatMessage: FormatMessage): string {
|
||||
const normalizedLoader = loader.trim()
|
||||
const loaderMessage = getLoaderMessage(normalizedLoader)
|
||||
return loaderMessage ? formatMessage(loaderMessage) : capitalizeAnalyticsValue(normalizedLoader)
|
||||
}
|
||||
|
||||
function capitalizeAnalyticsValue(value: string): string {
|
||||
const normalizedValue = value.trim()
|
||||
if (normalizedValue.length === 0) {
|
||||
return value
|
||||
}
|
||||
|
||||
return `${normalizedValue.charAt(0).toUpperCase()}${normalizedValue.slice(1)}`
|
||||
}
|
||||
@@ -0,0 +1,908 @@
|
||||
import type { LocationQuery, LocationQueryValue, LocationQueryValueRaw } from 'vue-router'
|
||||
|
||||
import type {
|
||||
AnalyticsBreakdownPreset,
|
||||
AnalyticsDashboardStat,
|
||||
AnalyticsGraphState,
|
||||
AnalyticsGraphViewMode,
|
||||
AnalyticsGroupByPreset,
|
||||
AnalyticsLastTimeframeUnit,
|
||||
AnalyticsQueryBuilderState,
|
||||
AnalyticsQueryFilterCategory,
|
||||
AnalyticsSelectedBreakdowns,
|
||||
AnalyticsSelectedFilters,
|
||||
AnalyticsTableSortColumn,
|
||||
AnalyticsTableSortDirection,
|
||||
AnalyticsTableSortState,
|
||||
AnalyticsTimeframeMode,
|
||||
AnalyticsTimeframePreset,
|
||||
MutableRouteQuery,
|
||||
} from '~/providers/analytics/analytics-types'
|
||||
|
||||
export const DEFAULT_TIMEFRAME_PRESET: AnalyticsTimeframePreset = 'last_30_days'
|
||||
export const DEFAULT_TIMEFRAME_MODE: AnalyticsTimeframeMode = 'preset'
|
||||
export const DEFAULT_LAST_TIMEFRAME_AMOUNT = 1
|
||||
export const DEFAULT_LAST_TIMEFRAME_UNIT: AnalyticsLastTimeframeUnit = 'days'
|
||||
export const DEFAULT_GROUP_BY_PRESET: AnalyticsGroupByPreset = 'day'
|
||||
export const DEFAULT_BREAKDOWN_PRESET: AnalyticsBreakdownPreset = 'none'
|
||||
export const DEFAULT_ANALYTICS_DASHBOARD_STAT: AnalyticsDashboardStat = 'views'
|
||||
export const DEFAULT_ANALYTICS_GRAPH_VIEW_MODE: AnalyticsGraphViewMode = 'line'
|
||||
export const DEFAULT_ANALYTICS_GRAPH_RATIO_MODE = false
|
||||
export const DEFAULT_ANALYTICS_GRAPH_EVENTS_VISIBILITY = true
|
||||
export const DEFAULT_ANALYTICS_GRAPH_PREVIOUS_PERIOD_VISIBILITY = false
|
||||
export const MAX_ANALYTICS_BREAKDOWN_PRESETS = 2
|
||||
|
||||
const TIMEFRAME_PRESET_VALUES: AnalyticsTimeframePreset[] = [
|
||||
'today',
|
||||
'yesterday',
|
||||
'last_7_days',
|
||||
'last_14_days',
|
||||
'last_30_days',
|
||||
'last_90_days',
|
||||
'last_180_days',
|
||||
'year_to_date',
|
||||
'all_time',
|
||||
]
|
||||
|
||||
const TIMEFRAME_MODE_VALUES: AnalyticsTimeframeMode[] = [
|
||||
'preset',
|
||||
'last',
|
||||
'custom_range',
|
||||
'custom_datetime_range',
|
||||
]
|
||||
const LAST_TIMEFRAME_UNIT_VALUES: AnalyticsLastTimeframeUnit[] = [
|
||||
'hours',
|
||||
'days',
|
||||
'weeks',
|
||||
'months',
|
||||
]
|
||||
|
||||
const GROUP_BY_PRESET_VALUES: AnalyticsGroupByPreset[] = [
|
||||
'1h',
|
||||
'6h',
|
||||
'day',
|
||||
'week',
|
||||
'month',
|
||||
'year',
|
||||
]
|
||||
|
||||
const BREAKDOWN_PRESET_VALUES: AnalyticsBreakdownPreset[] = [
|
||||
'none',
|
||||
'project',
|
||||
'country',
|
||||
'monetization',
|
||||
'user_agent',
|
||||
'download_reason',
|
||||
'version_id',
|
||||
'loader',
|
||||
'game_version',
|
||||
]
|
||||
|
||||
const ANALYTICS_DASHBOARD_STAT_VALUES: AnalyticsDashboardStat[] = [
|
||||
'views',
|
||||
'downloads',
|
||||
'revenue',
|
||||
'playtime',
|
||||
]
|
||||
|
||||
const ANALYTICS_GRAPH_VIEW_MODE_VALUES: AnalyticsGraphViewMode[] = ['line', 'area', 'bar']
|
||||
const ANALYTICS_TABLE_SORT_COLUMN_VALUES: AnalyticsTableSortColumn[] = [
|
||||
'date',
|
||||
'project',
|
||||
'breakdown',
|
||||
'breakdown_project',
|
||||
'breakdown_country',
|
||||
'breakdown_monetization',
|
||||
'breakdown_user_agent',
|
||||
'breakdown_download_reason',
|
||||
'breakdown_version_id',
|
||||
'breakdown_loader',
|
||||
'breakdown_game_version',
|
||||
'views',
|
||||
'downloads',
|
||||
'revenue',
|
||||
'playtime',
|
||||
]
|
||||
const ANALYTICS_TABLE_SORT_DIRECTION_VALUES: AnalyticsTableSortDirection[] = ['asc', 'desc']
|
||||
|
||||
const PROJECT_STATUS_FILTER_VALUES = [
|
||||
'approved',
|
||||
'archived',
|
||||
'rejected',
|
||||
'draft',
|
||||
'unlisted',
|
||||
'withheld',
|
||||
'private',
|
||||
'other',
|
||||
]
|
||||
|
||||
const QUERY_KEY_PROJECT_IDS = 'a_projects'
|
||||
const QUERY_KEY_TIMEFRAME_MODE = 'a_timeframe_mode'
|
||||
const QUERY_KEY_TIMEFRAME = 'a_timeframe'
|
||||
const QUERY_KEY_TIMEFRAME_LAST_AMOUNT = 'a_timeframe_last_amount'
|
||||
const QUERY_KEY_TIMEFRAME_LAST_UNIT = 'a_timeframe_last_unit'
|
||||
const QUERY_KEY_TIMEFRAME_START = 'a_timeframe_start'
|
||||
const QUERY_KEY_TIMEFRAME_END = 'a_timeframe_end'
|
||||
const QUERY_KEY_GROUP_BY = 'a_group_by'
|
||||
const QUERY_KEY_BREAKDOWN = 'a_breakdown'
|
||||
const QUERY_KEY_FILTER_PROJECT_STATUS = 'a_project_status'
|
||||
const QUERY_KEY_FILTER_COUNTRY = 'a_country'
|
||||
const QUERY_KEY_FILTER_MONETIZATION = 'a_monetization'
|
||||
const QUERY_KEY_FILTER_USER_AGENT = 'a_user_agent'
|
||||
const QUERY_KEY_FILTER_LEGACY_DOWNLOAD_SOURCE = 'a_download_source'
|
||||
const QUERY_KEY_FILTER_DOWNLOAD_REASON = 'a_download_reason'
|
||||
const QUERY_KEY_FILTER_VERSION_ID = 'a_version_id'
|
||||
const QUERY_KEY_FILTER_GAME_VERSION = 'a_game_version'
|
||||
const QUERY_KEY_FILTER_LOADER_TYPE = 'a_loader_type'
|
||||
const QUERY_KEY_STAT = 'a_stat'
|
||||
const QUERY_KEY_GRAPH_VIEW_MODE = 'a_chart'
|
||||
const QUERY_KEY_GRAPH_RATIO_MODE = 'a_ratio'
|
||||
const QUERY_KEY_GRAPH_EVENTS_VISIBILITY = 'a_events'
|
||||
const QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY = 'a_project_events'
|
||||
const QUERY_KEY_GRAPH_PREVIOUS_PERIOD_VISIBILITY = 'a_prev_period'
|
||||
const QUERY_KEY_GRAPH_HIDDEN_SERIES = 'a_hidden_series'
|
||||
const QUERY_KEY_GRAPH_SELECTED_SERIES = 'a_selected_series'
|
||||
const QUERY_KEY_TABLE_SORT = 'a_table_sort'
|
||||
const QUERY_KEY_TABLE_SORT_DIRECTION = 'a_table_sort_direction'
|
||||
const QUERY_KEY_LEGACY_GRAPH_TOP_BREAKDOWN_FILTER = 'a_top_breakdown'
|
||||
const QUERY_KEY_LEGACY_GRAPH_LEGEND_EXPANSION = 'a_legend_expanded'
|
||||
|
||||
const URL_FILTER_CATEGORIES: Exclude<AnalyticsQueryFilterCategory, 'project'>[] = [
|
||||
'project_status',
|
||||
'country',
|
||||
'monetization',
|
||||
'user_agent',
|
||||
'download_reason',
|
||||
'version_id',
|
||||
'game_version',
|
||||
'loader_type',
|
||||
]
|
||||
|
||||
const FILTER_QUERY_KEY_BY_CATEGORY: Record<
|
||||
Exclude<AnalyticsQueryFilterCategory, 'project'>,
|
||||
string
|
||||
> = {
|
||||
project_status: QUERY_KEY_FILTER_PROJECT_STATUS,
|
||||
country: QUERY_KEY_FILTER_COUNTRY,
|
||||
monetization: QUERY_KEY_FILTER_MONETIZATION,
|
||||
user_agent: QUERY_KEY_FILTER_USER_AGENT,
|
||||
download_reason: QUERY_KEY_FILTER_DOWNLOAD_REASON,
|
||||
version_id: QUERY_KEY_FILTER_VERSION_ID,
|
||||
game_version: QUERY_KEY_FILTER_GAME_VERSION,
|
||||
loader_type: QUERY_KEY_FILTER_LOADER_TYPE,
|
||||
}
|
||||
|
||||
const ANALYTICS_QUERY_KEYS = [
|
||||
QUERY_KEY_PROJECT_IDS,
|
||||
QUERY_KEY_TIMEFRAME_MODE,
|
||||
QUERY_KEY_TIMEFRAME,
|
||||
QUERY_KEY_TIMEFRAME_LAST_AMOUNT,
|
||||
QUERY_KEY_TIMEFRAME_LAST_UNIT,
|
||||
QUERY_KEY_TIMEFRAME_START,
|
||||
QUERY_KEY_TIMEFRAME_END,
|
||||
QUERY_KEY_GROUP_BY,
|
||||
QUERY_KEY_BREAKDOWN,
|
||||
QUERY_KEY_FILTER_PROJECT_STATUS,
|
||||
QUERY_KEY_FILTER_COUNTRY,
|
||||
QUERY_KEY_FILTER_MONETIZATION,
|
||||
QUERY_KEY_FILTER_USER_AGENT,
|
||||
QUERY_KEY_FILTER_LEGACY_DOWNLOAD_SOURCE,
|
||||
QUERY_KEY_FILTER_DOWNLOAD_REASON,
|
||||
QUERY_KEY_FILTER_VERSION_ID,
|
||||
QUERY_KEY_FILTER_GAME_VERSION,
|
||||
QUERY_KEY_FILTER_LOADER_TYPE,
|
||||
QUERY_KEY_STAT,
|
||||
QUERY_KEY_GRAPH_VIEW_MODE,
|
||||
QUERY_KEY_GRAPH_RATIO_MODE,
|
||||
QUERY_KEY_GRAPH_EVENTS_VISIBILITY,
|
||||
QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY,
|
||||
QUERY_KEY_GRAPH_PREVIOUS_PERIOD_VISIBILITY,
|
||||
QUERY_KEY_GRAPH_HIDDEN_SERIES,
|
||||
QUERY_KEY_GRAPH_SELECTED_SERIES,
|
||||
QUERY_KEY_LEGACY_GRAPH_TOP_BREAKDOWN_FILTER,
|
||||
QUERY_KEY_LEGACY_GRAPH_LEGEND_EXPANSION,
|
||||
]
|
||||
|
||||
export function buildEmptySelectedFilters(): AnalyticsSelectedFilters {
|
||||
return {
|
||||
project: [],
|
||||
project_status: [],
|
||||
country: [],
|
||||
monetization: [],
|
||||
user_agent: [],
|
||||
download_reason: [],
|
||||
version_id: [],
|
||||
game_version: [],
|
||||
loader_type: [],
|
||||
}
|
||||
}
|
||||
|
||||
function parseListQueryValue(
|
||||
value: LocationQueryValue | LocationQueryValue[] | undefined,
|
||||
): string[] {
|
||||
if (value === undefined) return []
|
||||
|
||||
const values = Array.isArray(value) ? value : [value]
|
||||
const parsedValues: string[] = []
|
||||
for (const item of values) {
|
||||
if (!item) continue
|
||||
const parts = item.split(',')
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim()
|
||||
if (trimmed.length > 0) {
|
||||
parsedValues.push(trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(new Set(parsedValues))
|
||||
}
|
||||
|
||||
function parseSelectedSeriesQueryValue(
|
||||
value: LocationQueryValue | LocationQueryValue[] | undefined,
|
||||
): string[] {
|
||||
return parseListQueryValue(value).filter((item) => item.toLowerCase() !== 'null')
|
||||
}
|
||||
|
||||
function normalizeFilterQueryValues(
|
||||
category: Exclude<AnalyticsQueryFilterCategory, 'project'>,
|
||||
values: string[],
|
||||
): string[] {
|
||||
if (category === 'project_status') {
|
||||
return values
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter((value) => PROJECT_STATUS_FILTER_VALUES.includes(value))
|
||||
}
|
||||
|
||||
if (category !== 'loader_type') {
|
||||
return values
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
new Set(values.map((value) => value.trim().toLowerCase()).filter((value) => value.length > 0)),
|
||||
)
|
||||
}
|
||||
|
||||
function parsePresetQueryValue<T extends string>(
|
||||
value: LocationQueryValue | LocationQueryValue[] | undefined,
|
||||
allowedValues: readonly T[],
|
||||
fallbackValue: T,
|
||||
): T {
|
||||
const rawValue = Array.isArray(value) ? value[0] : value
|
||||
if (!rawValue) return fallbackValue
|
||||
if (!allowedValues.includes(rawValue as T)) return fallbackValue
|
||||
return rawValue as T
|
||||
}
|
||||
|
||||
function parseAnalyticsBreakdownsQueryValue(
|
||||
value: LocationQueryValue | LocationQueryValue[] | undefined,
|
||||
fallbackValues: AnalyticsSelectedBreakdowns,
|
||||
): AnalyticsBreakdownPreset[] {
|
||||
const rawValues = parseListQueryValue(value)
|
||||
if (rawValues.length === 0) {
|
||||
return [...fallbackValues]
|
||||
}
|
||||
|
||||
const parsedBreakdowns: AnalyticsBreakdownPreset[] = []
|
||||
for (const rawValue of rawValues) {
|
||||
const normalizedValue = rawValue === 'download_source' ? 'user_agent' : rawValue
|
||||
if (BREAKDOWN_PRESET_VALUES.includes(normalizedValue as AnalyticsBreakdownPreset)) {
|
||||
parsedBreakdowns.push(normalizedValue as AnalyticsBreakdownPreset)
|
||||
}
|
||||
}
|
||||
|
||||
return parsedBreakdowns
|
||||
}
|
||||
|
||||
function parsePositiveIntegerQueryValue(
|
||||
value: LocationQueryValue | LocationQueryValue[] | undefined,
|
||||
fallbackValue: number,
|
||||
): number {
|
||||
const rawValue = Array.isArray(value) ? value[0] : value
|
||||
if (!rawValue) return fallbackValue
|
||||
|
||||
const parsedValue = Number.parseInt(rawValue, 10)
|
||||
if (!Number.isFinite(parsedValue) || parsedValue < 1) return fallbackValue
|
||||
return parsedValue
|
||||
}
|
||||
|
||||
function parseEnabledQueryValue(
|
||||
value: LocationQueryValue | LocationQueryValue[] | undefined,
|
||||
): boolean {
|
||||
const rawValue = Array.isArray(value) ? value[0] : value
|
||||
return rawValue === '1'
|
||||
}
|
||||
|
||||
function parseVisibleQueryValue(
|
||||
value: LocationQueryValue | LocationQueryValue[] | undefined,
|
||||
fallbackValue: boolean,
|
||||
): boolean {
|
||||
const rawValue = Array.isArray(value) ? value[0] : value
|
||||
if (rawValue === undefined) return fallbackValue
|
||||
return rawValue !== '0'
|
||||
}
|
||||
|
||||
function getLocalDateQueryValue(date: Date): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function getDefaultCustomStartDate(): string {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() - 1)
|
||||
return getLocalDateQueryValue(date)
|
||||
}
|
||||
|
||||
function getDefaultCustomEndDate(): string {
|
||||
return getLocalDateQueryValue(new Date())
|
||||
}
|
||||
|
||||
function getDefaultCustomDateTimeValue(value: string): string {
|
||||
return new Date(`${value}T00:00:00`).toISOString()
|
||||
}
|
||||
|
||||
function parseDateQueryValue(
|
||||
value: LocationQueryValue | LocationQueryValue[] | undefined,
|
||||
fallbackValue: string,
|
||||
): string {
|
||||
const rawValue = Array.isArray(value) ? value[0] : value
|
||||
if (!rawValue || !/^\d{4}-\d{2}-\d{2}$/.test(rawValue)) return fallbackValue
|
||||
|
||||
const date = new Date(`${rawValue}T00:00:00`)
|
||||
if (Number.isNaN(date.getTime())) return fallbackValue
|
||||
if (getLocalDateQueryValue(date) !== rawValue) return fallbackValue
|
||||
|
||||
return rawValue
|
||||
}
|
||||
|
||||
function parseDateTimeQueryValue(
|
||||
value: LocationQueryValue | LocationQueryValue[] | undefined,
|
||||
fallbackValue: string,
|
||||
): string {
|
||||
const rawValue = Array.isArray(value) ? value[0] : value
|
||||
if (!rawValue || !/^\d{4}-\d{2}-\d{2}T/.test(rawValue)) return fallbackValue
|
||||
|
||||
const date = new Date(rawValue)
|
||||
if (Number.isNaN(date.getTime())) return fallbackValue
|
||||
|
||||
return date.toISOString()
|
||||
}
|
||||
|
||||
function isTimeframeRangeEndBeforeStart(
|
||||
mode: AnalyticsTimeframeMode,
|
||||
startValue: string,
|
||||
endValue: string,
|
||||
): boolean {
|
||||
if (mode === 'custom_datetime_range') {
|
||||
return new Date(endValue).getTime() < new Date(startValue).getTime()
|
||||
}
|
||||
|
||||
return endValue < startValue
|
||||
}
|
||||
|
||||
export function getDefaultAnalyticsGraphProjectEventsVisibility(
|
||||
selectedProjectIds: readonly string[] = [],
|
||||
): boolean {
|
||||
return selectedProjectIds.length <= 1
|
||||
}
|
||||
|
||||
export function buildDefaultAnalyticsGraphState(
|
||||
selectedProjectIds: readonly string[] = [],
|
||||
): AnalyticsGraphState {
|
||||
return {
|
||||
activeStat: DEFAULT_ANALYTICS_DASHBOARD_STAT,
|
||||
activeGraphViewMode: DEFAULT_ANALYTICS_GRAPH_VIEW_MODE,
|
||||
isRatioMode: DEFAULT_ANALYTICS_GRAPH_RATIO_MODE,
|
||||
showChartEvents: DEFAULT_ANALYTICS_GRAPH_EVENTS_VISIBILITY,
|
||||
showProjectEvents: getDefaultAnalyticsGraphProjectEventsVisibility(selectedProjectIds),
|
||||
showPreviousPeriod: DEFAULT_ANALYTICS_GRAPH_PREVIOUS_PERIOD_VISIBILITY,
|
||||
hiddenGraphDatasetIds: [],
|
||||
selectedGraphDatasetIds: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDefaultAnalyticsQueryBuilderState(
|
||||
availableProjectIds: string[],
|
||||
): AnalyticsQueryBuilderState {
|
||||
return {
|
||||
selectedProjectIds: [...availableProjectIds],
|
||||
selectedTimeframeMode: DEFAULT_TIMEFRAME_MODE,
|
||||
selectedTimeframe: DEFAULT_TIMEFRAME_PRESET,
|
||||
selectedLastTimeframeAmount: DEFAULT_LAST_TIMEFRAME_AMOUNT,
|
||||
selectedLastTimeframeUnit: DEFAULT_LAST_TIMEFRAME_UNIT,
|
||||
selectedCustomTimeframeStartDate: getDefaultCustomStartDate(),
|
||||
selectedCustomTimeframeEndDate: getDefaultCustomEndDate(),
|
||||
selectedGroupBy: DEFAULT_GROUP_BY_PRESET,
|
||||
selectedBreakdowns: getDefaultAnalyticsBreakdownPresets(availableProjectIds),
|
||||
selectedFilters: buildEmptySelectedFilters(),
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultAnalyticsBreakdownPresets(
|
||||
selectedProjectIds: readonly string[],
|
||||
): AnalyticsSelectedBreakdowns {
|
||||
return selectedProjectIds.length > 1 ? ['project'] : []
|
||||
}
|
||||
|
||||
export function getDefaultAnalyticsBreakdownPreset(
|
||||
selectedProjectIds: readonly string[],
|
||||
): AnalyticsBreakdownPreset {
|
||||
return selectedProjectIds.length > 1 ? 'project' : DEFAULT_BREAKDOWN_PRESET
|
||||
}
|
||||
|
||||
export function getAnalyticsBreakdownPresetsForProjectSelection(
|
||||
breakdowns: readonly AnalyticsBreakdownPreset[],
|
||||
selectedProjectIds: readonly string[],
|
||||
): AnalyticsSelectedBreakdowns {
|
||||
const normalizedBreakdowns: AnalyticsSelectedBreakdowns = []
|
||||
const canBreakDownByProject = selectedProjectIds.length > 1
|
||||
|
||||
for (const breakdown of breakdowns) {
|
||||
if (breakdown === 'none') {
|
||||
continue
|
||||
}
|
||||
if (breakdown === 'project' && !canBreakDownByProject) {
|
||||
continue
|
||||
}
|
||||
if (!normalizedBreakdowns.includes(breakdown)) {
|
||||
normalizedBreakdowns.push(breakdown)
|
||||
}
|
||||
if (normalizedBreakdowns.length >= MAX_ANALYTICS_BREAKDOWN_PRESETS) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedBreakdowns
|
||||
}
|
||||
|
||||
export function getAnalyticsBreakdownPresetForProjectSelection(
|
||||
breakdown: AnalyticsBreakdownPreset,
|
||||
selectedProjectIds: readonly string[],
|
||||
): AnalyticsBreakdownPreset {
|
||||
const defaultBreakdown = getDefaultAnalyticsBreakdownPreset(selectedProjectIds)
|
||||
if (
|
||||
(breakdown === 'none' && defaultBreakdown === 'project') ||
|
||||
(breakdown === 'project' && defaultBreakdown === 'none')
|
||||
) {
|
||||
return defaultBreakdown
|
||||
}
|
||||
|
||||
return breakdown
|
||||
}
|
||||
|
||||
export function isAnalyticsQueryBuilderStateDefault(
|
||||
state: AnalyticsQueryBuilderState,
|
||||
availableProjectIds: string[],
|
||||
): boolean {
|
||||
const defaultState = buildDefaultAnalyticsQueryBuilderState(availableProjectIds)
|
||||
const areDefaultProjectsSelected =
|
||||
availableProjectIds.length === 0
|
||||
? state.selectedProjectIds.length === 0
|
||||
: areAllProjectsSelected(state.selectedProjectIds, availableProjectIds)
|
||||
|
||||
return (
|
||||
areDefaultProjectsSelected &&
|
||||
state.selectedTimeframeMode === defaultState.selectedTimeframeMode &&
|
||||
state.selectedTimeframe === defaultState.selectedTimeframe &&
|
||||
state.selectedLastTimeframeAmount === defaultState.selectedLastTimeframeAmount &&
|
||||
state.selectedLastTimeframeUnit === defaultState.selectedLastTimeframeUnit &&
|
||||
state.selectedCustomTimeframeStartDate === defaultState.selectedCustomTimeframeStartDate &&
|
||||
state.selectedCustomTimeframeEndDate === defaultState.selectedCustomTimeframeEndDate &&
|
||||
state.selectedGroupBy === defaultState.selectedGroupBy &&
|
||||
areStringArraysEqual(
|
||||
state.selectedBreakdowns,
|
||||
getDefaultAnalyticsBreakdownPresets(state.selectedProjectIds),
|
||||
) &&
|
||||
areSelectedFiltersEqual(state.selectedFilters, defaultState.selectedFilters)
|
||||
)
|
||||
}
|
||||
|
||||
export function isAnalyticsGraphStateDefault(
|
||||
state: AnalyticsGraphState,
|
||||
selectedProjectIds: readonly string[] = [],
|
||||
): boolean {
|
||||
const defaultState = buildDefaultAnalyticsGraphState(selectedProjectIds)
|
||||
|
||||
return (
|
||||
state.activeStat === defaultState.activeStat &&
|
||||
state.activeGraphViewMode === defaultState.activeGraphViewMode &&
|
||||
state.isRatioMode === defaultState.isRatioMode &&
|
||||
state.showChartEvents === defaultState.showChartEvents &&
|
||||
state.showProjectEvents === defaultState.showProjectEvents &&
|
||||
state.showPreviousPeriod === defaultState.showPreviousPeriod &&
|
||||
areStringArraysEqual(state.hiddenGraphDatasetIds, defaultState.hiddenGraphDatasetIds) &&
|
||||
state.selectedGraphDatasetIds === defaultState.selectedGraphDatasetIds
|
||||
)
|
||||
}
|
||||
|
||||
function serializeListQueryValue(values: string[]): string | undefined {
|
||||
if (values.length === 0) return undefined
|
||||
return values.join(',')
|
||||
}
|
||||
|
||||
function serializeExplicitListQueryValue(values: string[]): string {
|
||||
return values.join(',')
|
||||
}
|
||||
|
||||
function serializeVisibleQueryValue(value: boolean, defaultValue: boolean): string | undefined {
|
||||
if (value === defaultValue) return undefined
|
||||
return value ? '1' : '0'
|
||||
}
|
||||
|
||||
function normalizeQueryValue(
|
||||
value:
|
||||
| LocationQueryValue
|
||||
| LocationQueryValue[]
|
||||
| LocationQueryValueRaw
|
||||
| LocationQueryValueRaw[]
|
||||
| undefined,
|
||||
): string[] {
|
||||
if (value === undefined || value === null) return []
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.filter(
|
||||
(item): item is LocationQueryValue | LocationQueryValueRaw =>
|
||||
item !== undefined && item !== null,
|
||||
)
|
||||
.map((item) => String(item))
|
||||
}
|
||||
return [String(value)]
|
||||
}
|
||||
|
||||
function areQueryValuesEqual(
|
||||
left:
|
||||
| LocationQueryValue
|
||||
| LocationQueryValue[]
|
||||
| LocationQueryValueRaw
|
||||
| LocationQueryValueRaw[]
|
||||
| undefined,
|
||||
right:
|
||||
| LocationQueryValue
|
||||
| LocationQueryValue[]
|
||||
| LocationQueryValueRaw
|
||||
| LocationQueryValueRaw[]
|
||||
| undefined,
|
||||
): boolean {
|
||||
const leftValues = normalizeQueryValue(left)
|
||||
const rightValues = normalizeQueryValue(right)
|
||||
|
||||
if (leftValues.length !== rightValues.length) return false
|
||||
for (let index = 0; index < leftValues.length; index += 1) {
|
||||
if (leftValues[index] !== rightValues[index]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function areStringArraysEqual(left: string[], right: string[]): boolean {
|
||||
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
|
||||
}
|
||||
|
||||
export function areSelectedFiltersEqual(
|
||||
left: AnalyticsSelectedFilters,
|
||||
right: AnalyticsSelectedFilters,
|
||||
): boolean {
|
||||
if (!areStringArraysEqual(left.project, right.project)) return false
|
||||
for (const category of URL_FILTER_CATEGORIES) {
|
||||
if (!areStringArraysEqual(left[category], right[category])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function areAllProjectsSelected(selectedProjectIds: string[], allProjectIds: string[]): boolean {
|
||||
if (allProjectIds.length === 0 || selectedProjectIds.length !== allProjectIds.length) {
|
||||
return false
|
||||
}
|
||||
const allProjectIdSet = new Set(allProjectIds)
|
||||
return selectedProjectIds.every((projectId) => allProjectIdSet.has(projectId))
|
||||
}
|
||||
|
||||
export function readAnalyticsGraphState(
|
||||
query: LocationQuery,
|
||||
selectedProjectIds: readonly string[] = [],
|
||||
): AnalyticsGraphState {
|
||||
const defaultState = buildDefaultAnalyticsGraphState(selectedProjectIds)
|
||||
|
||||
return {
|
||||
activeStat: parsePresetQueryValue(
|
||||
query[QUERY_KEY_STAT],
|
||||
ANALYTICS_DASHBOARD_STAT_VALUES,
|
||||
defaultState.activeStat,
|
||||
),
|
||||
activeGraphViewMode: parsePresetQueryValue(
|
||||
query[QUERY_KEY_GRAPH_VIEW_MODE],
|
||||
ANALYTICS_GRAPH_VIEW_MODE_VALUES,
|
||||
defaultState.activeGraphViewMode,
|
||||
),
|
||||
isRatioMode: parseEnabledQueryValue(query[QUERY_KEY_GRAPH_RATIO_MODE]),
|
||||
showChartEvents: parseVisibleQueryValue(
|
||||
query[QUERY_KEY_GRAPH_EVENTS_VISIBILITY],
|
||||
defaultState.showChartEvents,
|
||||
),
|
||||
showProjectEvents: parseVisibleQueryValue(
|
||||
query[QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY],
|
||||
defaultState.showProjectEvents,
|
||||
),
|
||||
showPreviousPeriod: parseEnabledQueryValue(query[QUERY_KEY_GRAPH_PREVIOUS_PERIOD_VISIBILITY]),
|
||||
hiddenGraphDatasetIds: parseListQueryValue(query[QUERY_KEY_GRAPH_HIDDEN_SERIES]),
|
||||
selectedGraphDatasetIds:
|
||||
query[QUERY_KEY_GRAPH_SELECTED_SERIES] === undefined
|
||||
? null
|
||||
: parseSelectedSeriesQueryValue(query[QUERY_KEY_GRAPH_SELECTED_SERIES]),
|
||||
}
|
||||
}
|
||||
|
||||
export function readAnalyticsTableSortState(
|
||||
query: LocationQuery,
|
||||
defaultState: AnalyticsTableSortState,
|
||||
): AnalyticsTableSortState {
|
||||
const rawSortColumn = Array.isArray(query[QUERY_KEY_TABLE_SORT])
|
||||
? query[QUERY_KEY_TABLE_SORT][0]
|
||||
: query[QUERY_KEY_TABLE_SORT]
|
||||
const rawSortDirection = Array.isArray(query[QUERY_KEY_TABLE_SORT_DIRECTION])
|
||||
? query[QUERY_KEY_TABLE_SORT_DIRECTION][0]
|
||||
: query[QUERY_KEY_TABLE_SORT_DIRECTION]
|
||||
|
||||
if (
|
||||
!rawSortColumn ||
|
||||
!rawSortDirection ||
|
||||
!ANALYTICS_TABLE_SORT_COLUMN_VALUES.includes(rawSortColumn as AnalyticsTableSortColumn) ||
|
||||
!ANALYTICS_TABLE_SORT_DIRECTION_VALUES.includes(rawSortDirection as AnalyticsTableSortDirection)
|
||||
) {
|
||||
return defaultState
|
||||
}
|
||||
|
||||
return {
|
||||
sortColumn: rawSortColumn as AnalyticsTableSortColumn,
|
||||
sortDirection: rawSortDirection as AnalyticsTableSortDirection,
|
||||
}
|
||||
}
|
||||
|
||||
export function readAnalyticsQueryBuilderState(
|
||||
query: LocationQuery,
|
||||
availableProjectIds: string[],
|
||||
): AnalyticsQueryBuilderState {
|
||||
const defaultState = buildDefaultAnalyticsQueryBuilderState(availableProjectIds)
|
||||
const selectedProjectIdsFromQuery = parseListQueryValue(query[QUERY_KEY_PROJECT_IDS])
|
||||
const selectedProjectIds =
|
||||
selectedProjectIdsFromQuery.length > 0
|
||||
? selectedProjectIdsFromQuery
|
||||
: defaultState.selectedProjectIds
|
||||
|
||||
const selectedFilters = buildEmptySelectedFilters()
|
||||
for (const category of URL_FILTER_CATEGORIES) {
|
||||
const categoryQueryKey = FILTER_QUERY_KEY_BY_CATEGORY[category]
|
||||
const rawQueryValue =
|
||||
category === 'user_agent' && query[categoryQueryKey] === undefined
|
||||
? query[QUERY_KEY_FILTER_LEGACY_DOWNLOAD_SOURCE]
|
||||
: query[categoryQueryKey]
|
||||
selectedFilters[category] = normalizeFilterQueryValues(
|
||||
category,
|
||||
parseListQueryValue(rawQueryValue),
|
||||
)
|
||||
}
|
||||
|
||||
const selectedTimeframeMode = parsePresetQueryValue(
|
||||
query[QUERY_KEY_TIMEFRAME_MODE],
|
||||
TIMEFRAME_MODE_VALUES,
|
||||
defaultState.selectedTimeframeMode,
|
||||
)
|
||||
const isCustomDateTimeRange = selectedTimeframeMode === 'custom_datetime_range'
|
||||
const parseTimeframeRangeQueryValue = isCustomDateTimeRange
|
||||
? parseDateTimeQueryValue
|
||||
: parseDateQueryValue
|
||||
const customTimeframeStartFallback = isCustomDateTimeRange
|
||||
? getDefaultCustomDateTimeValue(defaultState.selectedCustomTimeframeStartDate)
|
||||
: defaultState.selectedCustomTimeframeStartDate
|
||||
const customTimeframeEndFallback = isCustomDateTimeRange
|
||||
? getDefaultCustomDateTimeValue(defaultState.selectedCustomTimeframeEndDate)
|
||||
: defaultState.selectedCustomTimeframeEndDate
|
||||
|
||||
const selectedCustomTimeframeStartDate = parseTimeframeRangeQueryValue(
|
||||
query[QUERY_KEY_TIMEFRAME_START],
|
||||
customTimeframeStartFallback,
|
||||
)
|
||||
const rawCustomTimeframeEndDate = parseTimeframeRangeQueryValue(
|
||||
query[QUERY_KEY_TIMEFRAME_END],
|
||||
customTimeframeEndFallback,
|
||||
)
|
||||
const selectedCustomTimeframeEndDate = isTimeframeRangeEndBeforeStart(
|
||||
selectedTimeframeMode,
|
||||
selectedCustomTimeframeStartDate,
|
||||
rawCustomTimeframeEndDate,
|
||||
)
|
||||
? selectedCustomTimeframeStartDate
|
||||
: rawCustomTimeframeEndDate
|
||||
|
||||
const selectedBreakdowns = getAnalyticsBreakdownPresetsForProjectSelection(
|
||||
parseAnalyticsBreakdownsQueryValue(
|
||||
query[QUERY_KEY_BREAKDOWN],
|
||||
getDefaultAnalyticsBreakdownPresets(selectedProjectIds),
|
||||
),
|
||||
selectedProjectIds,
|
||||
)
|
||||
|
||||
return {
|
||||
selectedProjectIds,
|
||||
selectedTimeframeMode,
|
||||
selectedTimeframe: parsePresetQueryValue(
|
||||
query[QUERY_KEY_TIMEFRAME],
|
||||
TIMEFRAME_PRESET_VALUES,
|
||||
defaultState.selectedTimeframe,
|
||||
),
|
||||
selectedLastTimeframeAmount: parsePositiveIntegerQueryValue(
|
||||
query[QUERY_KEY_TIMEFRAME_LAST_AMOUNT],
|
||||
defaultState.selectedLastTimeframeAmount,
|
||||
),
|
||||
selectedLastTimeframeUnit: parsePresetQueryValue(
|
||||
query[QUERY_KEY_TIMEFRAME_LAST_UNIT],
|
||||
LAST_TIMEFRAME_UNIT_VALUES,
|
||||
defaultState.selectedLastTimeframeUnit,
|
||||
),
|
||||
selectedCustomTimeframeStartDate,
|
||||
selectedCustomTimeframeEndDate,
|
||||
selectedGroupBy: parsePresetQueryValue(
|
||||
query[QUERY_KEY_GROUP_BY],
|
||||
GROUP_BY_PRESET_VALUES,
|
||||
defaultState.selectedGroupBy,
|
||||
),
|
||||
selectedBreakdowns,
|
||||
selectedFilters,
|
||||
}
|
||||
}
|
||||
|
||||
export function hasAnalyticsBreakdownQuery(query: LocationQuery): boolean {
|
||||
return parseListQueryValue(query[QUERY_KEY_BREAKDOWN]).length > 0
|
||||
}
|
||||
|
||||
export function hasAnalyticsProjectSelectionQuery(query: LocationQuery): boolean {
|
||||
return parseListQueryValue(query[QUERY_KEY_PROJECT_IDS]).length > 0
|
||||
}
|
||||
|
||||
export function hasAnalyticsGraphProjectEventsVisibilityQuery(query: LocationQuery): boolean {
|
||||
return query[QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY] !== undefined
|
||||
}
|
||||
|
||||
export function hasAnalyticsTableSortQuery(query: LocationQuery): boolean {
|
||||
return (
|
||||
query[QUERY_KEY_TABLE_SORT] !== undefined || query[QUERY_KEY_TABLE_SORT_DIRECTION] !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
export function buildAnalyticsQueryBuilderRouteQuery(
|
||||
currentRouteQuery: LocationQuery,
|
||||
state: AnalyticsQueryBuilderState,
|
||||
availableProjectIds: string[],
|
||||
graphState?: AnalyticsGraphState,
|
||||
): MutableRouteQuery {
|
||||
const nextRouteQuery = {
|
||||
...currentRouteQuery,
|
||||
} as MutableRouteQuery
|
||||
|
||||
const projectIdsQueryValue = areAllProjectsSelected(state.selectedProjectIds, availableProjectIds)
|
||||
? undefined
|
||||
: serializeListQueryValue(state.selectedProjectIds)
|
||||
const isCustomTimeframeMode =
|
||||
state.selectedTimeframeMode === 'custom_range' ||
|
||||
state.selectedTimeframeMode === 'custom_datetime_range'
|
||||
|
||||
nextRouteQuery[QUERY_KEY_PROJECT_IDS] = projectIdsQueryValue
|
||||
nextRouteQuery[QUERY_KEY_TIMEFRAME_MODE] =
|
||||
state.selectedTimeframeMode !== DEFAULT_TIMEFRAME_MODE ? state.selectedTimeframeMode : undefined
|
||||
nextRouteQuery[QUERY_KEY_TIMEFRAME] =
|
||||
state.selectedTimeframeMode === 'preset' && state.selectedTimeframe !== DEFAULT_TIMEFRAME_PRESET
|
||||
? state.selectedTimeframe
|
||||
: undefined
|
||||
nextRouteQuery[QUERY_KEY_TIMEFRAME_LAST_AMOUNT] =
|
||||
state.selectedTimeframeMode === 'last' ? String(state.selectedLastTimeframeAmount) : undefined
|
||||
nextRouteQuery[QUERY_KEY_TIMEFRAME_LAST_UNIT] =
|
||||
state.selectedTimeframeMode === 'last' ? state.selectedLastTimeframeUnit : undefined
|
||||
nextRouteQuery[QUERY_KEY_TIMEFRAME_START] = isCustomTimeframeMode
|
||||
? state.selectedCustomTimeframeStartDate
|
||||
: undefined
|
||||
nextRouteQuery[QUERY_KEY_TIMEFRAME_END] = isCustomTimeframeMode
|
||||
? state.selectedCustomTimeframeEndDate
|
||||
: undefined
|
||||
nextRouteQuery[QUERY_KEY_GROUP_BY] =
|
||||
state.selectedGroupBy !== DEFAULT_GROUP_BY_PRESET ? state.selectedGroupBy : undefined
|
||||
const defaultBreakdowns = getDefaultAnalyticsBreakdownPresets(state.selectedProjectIds)
|
||||
const selectedBreakdowns = getAnalyticsBreakdownPresetsForProjectSelection(
|
||||
state.selectedBreakdowns,
|
||||
state.selectedProjectIds,
|
||||
)
|
||||
nextRouteQuery[QUERY_KEY_BREAKDOWN] = areStringArraysEqual(selectedBreakdowns, defaultBreakdowns)
|
||||
? undefined
|
||||
: selectedBreakdowns.length === 0
|
||||
? 'none'
|
||||
: serializeListQueryValue(selectedBreakdowns)
|
||||
|
||||
for (const category of URL_FILTER_CATEGORIES) {
|
||||
const categoryQueryKey = FILTER_QUERY_KEY_BY_CATEGORY[category]
|
||||
nextRouteQuery[categoryQueryKey] = serializeListQueryValue(state.selectedFilters[category])
|
||||
}
|
||||
nextRouteQuery[QUERY_KEY_FILTER_LEGACY_DOWNLOAD_SOURCE] = undefined
|
||||
|
||||
if (graphState) {
|
||||
const defaultGraphState = buildDefaultAnalyticsGraphState(state.selectedProjectIds)
|
||||
|
||||
nextRouteQuery[QUERY_KEY_STAT] =
|
||||
graphState.activeStat !== DEFAULT_ANALYTICS_DASHBOARD_STAT ? graphState.activeStat : undefined
|
||||
nextRouteQuery[QUERY_KEY_GRAPH_VIEW_MODE] =
|
||||
graphState.activeGraphViewMode !== DEFAULT_ANALYTICS_GRAPH_VIEW_MODE
|
||||
? graphState.activeGraphViewMode
|
||||
: undefined
|
||||
nextRouteQuery[QUERY_KEY_GRAPH_RATIO_MODE] = graphState.isRatioMode ? '1' : undefined
|
||||
nextRouteQuery[QUERY_KEY_GRAPH_EVENTS_VISIBILITY] = serializeVisibleQueryValue(
|
||||
graphState.showChartEvents,
|
||||
defaultGraphState.showChartEvents,
|
||||
)
|
||||
nextRouteQuery[QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY] = serializeVisibleQueryValue(
|
||||
graphState.showProjectEvents,
|
||||
defaultGraphState.showProjectEvents,
|
||||
)
|
||||
nextRouteQuery[QUERY_KEY_GRAPH_PREVIOUS_PERIOD_VISIBILITY] = graphState.showPreviousPeriod
|
||||
? '1'
|
||||
: undefined
|
||||
nextRouteQuery[QUERY_KEY_LEGACY_GRAPH_TOP_BREAKDOWN_FILTER] = undefined
|
||||
nextRouteQuery[QUERY_KEY_LEGACY_GRAPH_LEGEND_EXPANSION] = undefined
|
||||
nextRouteQuery[QUERY_KEY_GRAPH_HIDDEN_SERIES] = serializeListQueryValue(
|
||||
[...graphState.hiddenGraphDatasetIds].sort((left, right) => left.localeCompare(right)),
|
||||
)
|
||||
nextRouteQuery[QUERY_KEY_GRAPH_SELECTED_SERIES] =
|
||||
graphState.selectedGraphDatasetIds === null
|
||||
? undefined
|
||||
: serializeExplicitListQueryValue(graphState.selectedGraphDatasetIds)
|
||||
}
|
||||
|
||||
return nextRouteQuery
|
||||
}
|
||||
|
||||
export function buildAnalyticsTableSortRouteQuery(
|
||||
currentRouteQuery: LocationQuery,
|
||||
state: AnalyticsTableSortState,
|
||||
defaultState: AnalyticsTableSortState,
|
||||
): MutableRouteQuery {
|
||||
const nextRouteQuery = {
|
||||
...currentRouteQuery,
|
||||
} as MutableRouteQuery
|
||||
const isDefaultSort =
|
||||
state.sortColumn === defaultState.sortColumn &&
|
||||
state.sortDirection === defaultState.sortDirection
|
||||
|
||||
nextRouteQuery[QUERY_KEY_TABLE_SORT] =
|
||||
isDefaultSort || state.sortColumn === undefined ? undefined : state.sortColumn
|
||||
nextRouteQuery[QUERY_KEY_TABLE_SORT_DIRECTION] =
|
||||
isDefaultSort || state.sortColumn === undefined ? undefined : state.sortDirection
|
||||
|
||||
return nextRouteQuery
|
||||
}
|
||||
|
||||
export function hasAnalyticsQueryBuilderRouteChange(
|
||||
currentRouteQuery: LocationQuery,
|
||||
nextRouteQuery: MutableRouteQuery,
|
||||
): boolean {
|
||||
return ANALYTICS_QUERY_KEYS.some(
|
||||
(key) => !areQueryValuesEqual(currentRouteQuery[key], nextRouteQuery[key]),
|
||||
)
|
||||
}
|
||||
|
||||
export function hasAnalyticsTableSortRouteChange(
|
||||
currentRouteQuery: LocationQuery,
|
||||
nextRouteQuery: MutableRouteQuery,
|
||||
): boolean {
|
||||
return (
|
||||
!areQueryValuesEqual(
|
||||
currentRouteQuery[QUERY_KEY_TABLE_SORT],
|
||||
nextRouteQuery[QUERY_KEY_TABLE_SORT],
|
||||
) ||
|
||||
!areQueryValuesEqual(
|
||||
currentRouteQuery[QUERY_KEY_TABLE_SORT_DIRECTION],
|
||||
nextRouteQuery[QUERY_KEY_TABLE_SORT_DIRECTION],
|
||||
)
|
||||
)
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
import type { TableColumn } from '@modrinth/ui'
|
||||
|
||||
import type {
|
||||
AnalyticsBreakdownPreset,
|
||||
AnalyticsDashboardStat,
|
||||
AnalyticsSelectedFilters,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import {
|
||||
analyticsGroupByMessages,
|
||||
formatAnalyticsBreakdownLabel,
|
||||
formatAnalyticsStatLabel,
|
||||
type FormatMessage,
|
||||
} from '../analytics-messages'
|
||||
import type {
|
||||
AnalyticsTableBreakdownColumnKey,
|
||||
AnalyticsTableBreakdownPreset,
|
||||
AnalyticsTableColumnKey,
|
||||
} from './analytics-table-types'
|
||||
|
||||
type BuildAnalyticsTableColumnsOptions = {
|
||||
includeDate: boolean
|
||||
selectedBreakdowns: readonly AnalyticsTableBreakdownPreset[]
|
||||
selectedFilters: AnalyticsSelectedFilters
|
||||
showBreakdownColumn: boolean
|
||||
showProjectVersionProjectColumn: boolean
|
||||
formatMessage: FormatMessage
|
||||
getRelevantAnalyticsDashboardStats: (
|
||||
breakdowns: readonly AnalyticsBreakdownPreset[],
|
||||
filters?: AnalyticsSelectedFilters,
|
||||
) => readonly AnalyticsDashboardStat[]
|
||||
}
|
||||
|
||||
export function getAnalyticsTableBreakdownColumnLabel(
|
||||
breakdown: AnalyticsBreakdownPreset,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
return formatAnalyticsBreakdownLabel(breakdown, formatMessage)
|
||||
}
|
||||
|
||||
export function buildAnalyticsTableColumns({
|
||||
includeDate,
|
||||
selectedBreakdowns,
|
||||
selectedFilters,
|
||||
showBreakdownColumn,
|
||||
showProjectVersionProjectColumn,
|
||||
formatMessage,
|
||||
getRelevantAnalyticsDashboardStats,
|
||||
}: BuildAnalyticsTableColumnsOptions): TableColumn<AnalyticsTableColumnKey>[] {
|
||||
const nextColumns: TableColumn<AnalyticsTableColumnKey>[] = []
|
||||
const stats = getRelevantAnalyticsDashboardStats(selectedBreakdowns, selectedFilters)
|
||||
|
||||
if (includeDate) {
|
||||
nextColumns.push({
|
||||
key: 'date',
|
||||
label: formatMessage(analyticsGroupByMessages.date),
|
||||
enableSorting: true,
|
||||
defaultSortDirection: 'desc',
|
||||
width: stats.length > 2 ? '20%' : '',
|
||||
})
|
||||
}
|
||||
|
||||
if (showBreakdownColumn) {
|
||||
for (const breakdown of selectedBreakdowns) {
|
||||
nextColumns.push({
|
||||
key: getAnalyticsTableBreakdownColumnKey(breakdown),
|
||||
label: getAnalyticsTableBreakdownColumnLabel(breakdown, formatMessage),
|
||||
enableSorting: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (showProjectVersionProjectColumn) {
|
||||
nextColumns.push({
|
||||
key: 'project',
|
||||
label: formatAnalyticsBreakdownLabel('project', formatMessage),
|
||||
enableSorting: true,
|
||||
})
|
||||
}
|
||||
|
||||
for (const stat of stats) {
|
||||
const column = getAnalyticsTableMetricColumn(stat, formatMessage)
|
||||
if (column) {
|
||||
nextColumns.push(column)
|
||||
}
|
||||
}
|
||||
|
||||
return nextColumns
|
||||
}
|
||||
|
||||
export function getAnalyticsTableMetricColumn(
|
||||
stat: AnalyticsDashboardStat,
|
||||
formatMessage: FormatMessage,
|
||||
): TableColumn<AnalyticsTableColumnKey> | null {
|
||||
switch (stat) {
|
||||
case 'views':
|
||||
return {
|
||||
key: 'views',
|
||||
label: formatAnalyticsStatLabel('views', formatMessage),
|
||||
enableSorting: true,
|
||||
defaultSortDirection: 'desc',
|
||||
align: 'right',
|
||||
}
|
||||
case 'downloads':
|
||||
return {
|
||||
key: 'downloads',
|
||||
label: formatAnalyticsStatLabel('downloads', formatMessage),
|
||||
enableSorting: true,
|
||||
defaultSortDirection: 'desc',
|
||||
align: 'right',
|
||||
}
|
||||
case 'revenue':
|
||||
return {
|
||||
key: 'revenue',
|
||||
label: formatAnalyticsStatLabel('revenue', formatMessage),
|
||||
enableSorting: true,
|
||||
defaultSortDirection: 'desc',
|
||||
align: 'right',
|
||||
}
|
||||
case 'playtime':
|
||||
return {
|
||||
key: 'playtime',
|
||||
label: formatAnalyticsStatLabel('playtime', formatMessage),
|
||||
enableSorting: true,
|
||||
defaultSortDirection: 'desc',
|
||||
align: 'right',
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getAnalyticsTableBreakdownColumnKey(
|
||||
breakdown: AnalyticsTableBreakdownPreset,
|
||||
): AnalyticsTableBreakdownColumnKey {
|
||||
return `breakdown_${breakdown}`
|
||||
}
|
||||
|
||||
export function isAnalyticsTableBreakdownColumnKey(
|
||||
key: AnalyticsTableColumnKey,
|
||||
): key is AnalyticsTableBreakdownColumnKey {
|
||||
return key.startsWith('breakdown_')
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { TableColumn } from '@modrinth/ui'
|
||||
|
||||
import { analyticsTableMessages, type FormatMessage } from '../analytics-messages'
|
||||
import { isAnalyticsTableBreakdownColumnKey } from './analytics-table-columns'
|
||||
import type { AnalyticsTableColumnKey, AnalyticsTableRow } from './analytics-table-types'
|
||||
|
||||
export function buildAnalyticsTableCsvContent(
|
||||
rows: AnalyticsTableRow[],
|
||||
visibleColumns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
const header = visibleColumns
|
||||
.map((column) =>
|
||||
escapeAnalyticsTableCsvField(getAnalyticsTableCsvHeaderLabel(column, formatMessage)),
|
||||
)
|
||||
.join(',')
|
||||
|
||||
const csvRows = rows.map((row) =>
|
||||
visibleColumns
|
||||
.map((column) => escapeAnalyticsTableCsvField(getAnalyticsTableCsvCellValue(row, column.key)))
|
||||
.join(','),
|
||||
)
|
||||
|
||||
return [header, ...csvRows].join('\n')
|
||||
}
|
||||
|
||||
export function downloadAnalyticsTableCsv(filename: string, csvContent: string) {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
const downloadLink = document.createElement('a')
|
||||
downloadLink.setAttribute('href', url)
|
||||
downloadLink.setAttribute('download', filename)
|
||||
downloadLink.style.visibility = 'hidden'
|
||||
|
||||
document.body.appendChild(downloadLink)
|
||||
downloadLink.click()
|
||||
document.body.removeChild(downloadLink)
|
||||
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export function getAnalyticsTableCsvFilename(
|
||||
breakdownColumnLabel: string,
|
||||
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
return `${sanitizeAnalyticsTableCsvFilename(
|
||||
formatMessage(analyticsTableMessages.csvFilename, {
|
||||
breakdown: breakdownColumnLabel,
|
||||
dateRange: getAnalyticsTableCsvFilenameDateRange(fetchRequest, formatMessage),
|
||||
}),
|
||||
)}.csv`
|
||||
}
|
||||
|
||||
function getAnalyticsTableCsvCellValue(
|
||||
row: AnalyticsTableRow,
|
||||
key: AnalyticsTableColumnKey,
|
||||
): string | number {
|
||||
switch (key) {
|
||||
case 'date':
|
||||
return row.date
|
||||
case 'project':
|
||||
return row.project
|
||||
case 'breakdown':
|
||||
return row.breakdownDisplay
|
||||
case 'views':
|
||||
return row.views
|
||||
case 'downloads':
|
||||
return row.downloads
|
||||
case 'revenue':
|
||||
return row.revenue
|
||||
case 'playtime':
|
||||
return row.playtime
|
||||
default:
|
||||
return isAnalyticsTableBreakdownColumnKey(key) ? String(row[key] ?? '') : ''
|
||||
}
|
||||
}
|
||||
|
||||
function getAnalyticsTableCsvHeaderLabel(
|
||||
column: TableColumn<AnalyticsTableColumnKey>,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
if (column.key === 'playtime') {
|
||||
return formatMessage(analyticsTableMessages.playtimeSecondsHeader)
|
||||
}
|
||||
|
||||
return column.label ?? column.key
|
||||
}
|
||||
|
||||
function escapeAnalyticsTableCsvField(value: string | number): string {
|
||||
const stringValue = String(value)
|
||||
if (
|
||||
stringValue.includes(',') ||
|
||||
stringValue.includes('"') ||
|
||||
stringValue.includes('\n') ||
|
||||
stringValue.includes('\r')
|
||||
) {
|
||||
return `"${stringValue.replace(/"/g, '""')}"`
|
||||
}
|
||||
return stringValue
|
||||
}
|
||||
|
||||
function formatAnalyticsTableCsvFilenameDate(date: Date): string {
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function getAnalyticsTableCsvFilenameDateRange(
|
||||
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
const timeRange = fetchRequest?.time_range
|
||||
if (!timeRange) {
|
||||
return formatMessage(analyticsTableMessages.csvSelectedRange)
|
||||
}
|
||||
|
||||
const start = new Date(timeRange.start)
|
||||
const end = new Date(timeRange.end)
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return formatMessage(analyticsTableMessages.csvSelectedRange)
|
||||
}
|
||||
|
||||
const startLabel = formatAnalyticsTableCsvFilenameDate(start)
|
||||
const endLabel = formatAnalyticsTableCsvFilenameDate(end)
|
||||
return startLabel === endLabel
|
||||
? startLabel
|
||||
: formatMessage(analyticsTableMessages.csvDateRange, {
|
||||
start: startLabel,
|
||||
end: endLabel,
|
||||
})
|
||||
}
|
||||
|
||||
function sanitizeAnalyticsTableCsvFilename(value: string): string {
|
||||
return value
|
||||
.replace(/[<>:"/\\|?*]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import type { AnalyticsGroupByPreset } from '~/providers/analytics/analytics'
|
||||
|
||||
import {
|
||||
analyticsStatCardMessages,
|
||||
analyticsTableMessages,
|
||||
formatAnalyticsGroupByLabel,
|
||||
type FormatMessage,
|
||||
} from '../analytics-messages'
|
||||
|
||||
const SECONDS_PER_MINUTE = 60
|
||||
const SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE
|
||||
|
||||
export function getAnalyticsTableGroupByLabel(
|
||||
groupBy: AnalyticsGroupByPreset,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
return formatAnalyticsGroupByLabel(groupBy, formatMessage)
|
||||
}
|
||||
|
||||
export function formatAnalyticsTableInteger(
|
||||
formatNumber: (value: number) => string,
|
||||
value: number,
|
||||
): string {
|
||||
return formatNumber(Math.round(value))
|
||||
}
|
||||
|
||||
export function formatAnalyticsTableRevenue(
|
||||
formatter: Intl.NumberFormat,
|
||||
value: number,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
const rounded = Math.round(value * 100) / 100
|
||||
return formatMessage(analyticsStatCardMessages.revenueValue, {
|
||||
value: formatter.format(rounded),
|
||||
})
|
||||
}
|
||||
|
||||
export function formatAnalyticsTableCompactPlaytime(
|
||||
value: number,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
const totalSeconds = Math.max(0, Math.round(value))
|
||||
return formatMessage(analyticsStatCardMessages.playtimeHours, {
|
||||
hours: (totalSeconds / SECONDS_PER_HOUR).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export function formatAnalyticsTableFullPlaytime(
|
||||
value: number,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
const totalMinutes = Math.max(0, Math.round(value / SECONDS_PER_MINUTE))
|
||||
const days = Math.floor(totalMinutes / (24 * 60))
|
||||
const hours = Math.floor((totalMinutes % (24 * 60)) / 60)
|
||||
const minutes = totalMinutes % 60
|
||||
|
||||
return [
|
||||
formatMessage(analyticsTableMessages.durationDays, { count: days }),
|
||||
formatMessage(analyticsTableMessages.durationHours, { count: hours }),
|
||||
formatMessage(analyticsTableMessages.durationMinutes, { count: minutes }),
|
||||
].join(', ')
|
||||
}
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import type {
|
||||
AnalyticsBreakdownPreset,
|
||||
AnalyticsDashboardStat,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import {
|
||||
formatBreakdownLabel,
|
||||
formatBucketEndLabel,
|
||||
getSliceBucketRange,
|
||||
getSliceCount,
|
||||
} from '../analytics-chart/analytics-chart-utils'
|
||||
import type { FormatMessage } from '../analytics-messages'
|
||||
import {
|
||||
ALL_BREAKDOWN_VALUE,
|
||||
COMBINED_BREAKDOWN_LABEL_SEPARATOR,
|
||||
getAnalyticsBreakdownDatasetId,
|
||||
getAnalyticsBreakdownKey,
|
||||
getAnalyticsBreakdownValues,
|
||||
} from '../breakdown'
|
||||
import { getAnalyticsTableBreakdownColumnKey } from './analytics-table-columns'
|
||||
import type {
|
||||
AnalyticsTableBreakdownDisplayValues,
|
||||
AnalyticsTableBreakdownPreset,
|
||||
AnalyticsTableMode,
|
||||
AnalyticsTableRow,
|
||||
} from './analytics-table-types'
|
||||
|
||||
const ALL_PROJECTS_BREAKDOWN_VALUE = 'all'
|
||||
|
||||
type BuildAnalyticsTableRowsOptions = {
|
||||
mode: AnalyticsTableMode
|
||||
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null
|
||||
timeSlices: Labrinth.Analytics.v3.TimeSlice[]
|
||||
selectedBreakdowns: readonly AnalyticsTableBreakdownPreset[]
|
||||
selectedProjectIds: ReadonlySet<string>
|
||||
relevantStats: ReadonlySet<AnalyticsDashboardStat>
|
||||
projectNamesById: ReadonlyMap<string, string>
|
||||
getVersionDisplayName: (versionId: string) => string
|
||||
getVersionProjectName: (versionId: string) => string | undefined
|
||||
showTimeInBucketLabel: boolean
|
||||
showYearInBucketLabel: boolean
|
||||
formatMessage: FormatMessage
|
||||
}
|
||||
|
||||
export function buildAnalyticsTableRows({
|
||||
mode,
|
||||
fetchRequest,
|
||||
timeSlices,
|
||||
selectedBreakdowns,
|
||||
selectedProjectIds,
|
||||
relevantStats,
|
||||
projectNamesById,
|
||||
getVersionDisplayName,
|
||||
getVersionProjectName,
|
||||
showTimeInBucketLabel,
|
||||
showYearInBucketLabel,
|
||||
formatMessage,
|
||||
}: BuildAnalyticsTableRowsOptions): AnalyticsTableRow[] {
|
||||
if (!fetchRequest || selectedProjectIds.size === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const timeRange = fetchRequest.time_range
|
||||
const sliceCount = getSliceCount(timeRange, timeSlices.length)
|
||||
const includeDate = mode === 'date_breakdown'
|
||||
const breakdownDisplayValues = new Map<string, string>()
|
||||
const projectDisplayValues = new Map<string, string>()
|
||||
const nextRows = new Map<string, AnalyticsTableRow>()
|
||||
const bucketLabelsBySliceIndex = new Map<number, { date: string; dateMs: number }>()
|
||||
|
||||
function getBreakdownDisplayValue(
|
||||
breakdownValue: string,
|
||||
breakdown: AnalyticsTableBreakdownPreset,
|
||||
) {
|
||||
const key = `${breakdown}:${breakdownValue}`
|
||||
let displayValue = breakdownDisplayValues.get(key)
|
||||
if (displayValue === undefined) {
|
||||
displayValue = formatAnalyticsTableBreakdownDisplayValue(
|
||||
breakdownValue,
|
||||
breakdown,
|
||||
projectNamesById,
|
||||
getVersionDisplayName,
|
||||
formatMessage,
|
||||
)
|
||||
breakdownDisplayValues.set(key, displayValue)
|
||||
}
|
||||
return displayValue
|
||||
}
|
||||
|
||||
function getProjectDisplayValueForBreakdownValues(breakdownValues: readonly string[]) {
|
||||
const versionBreakdownIndex = selectedBreakdowns.indexOf('version_id')
|
||||
if (versionBreakdownIndex === -1 || selectedBreakdowns.includes('project')) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const versionId = breakdownValues[versionBreakdownIndex]
|
||||
if (!versionId) {
|
||||
return ''
|
||||
}
|
||||
|
||||
let displayValue = projectDisplayValues.get(versionId)
|
||||
if (displayValue === undefined) {
|
||||
displayValue = getVersionProjectName(versionId) ?? ''
|
||||
projectDisplayValues.set(versionId, displayValue)
|
||||
}
|
||||
return displayValue
|
||||
}
|
||||
|
||||
function getBreakdownDisplays(breakdownValues: readonly string[]) {
|
||||
const displays: AnalyticsTableBreakdownDisplayValues = {}
|
||||
|
||||
selectedBreakdowns.forEach((breakdown, index) => {
|
||||
displays[breakdown] = getBreakdownDisplayValue(breakdownValues[index] ?? '', breakdown)
|
||||
})
|
||||
|
||||
return displays
|
||||
}
|
||||
|
||||
function getCombinedBreakdownDisplay(displays: AnalyticsTableBreakdownDisplayValues) {
|
||||
return selectedBreakdowns
|
||||
.map((breakdown) => displays[breakdown])
|
||||
.filter((displayValue): displayValue is string => Boolean(displayValue))
|
||||
.join(COMBINED_BREAKDOWN_LABEL_SEPARATOR)
|
||||
}
|
||||
|
||||
function getBucketLabel(sliceIndex: number) {
|
||||
let bucketLabel = bucketLabelsBySliceIndex.get(sliceIndex)
|
||||
if (!bucketLabel) {
|
||||
const bucketRange = getSliceBucketRange(timeRange, sliceCount, sliceIndex)
|
||||
bucketLabel = {
|
||||
date: formatBucketEndLabel(bucketRange.end, showTimeInBucketLabel, showYearInBucketLabel),
|
||||
dateMs: bucketRange.end.getTime(),
|
||||
}
|
||||
bucketLabelsBySliceIndex.set(sliceIndex, bucketLabel)
|
||||
}
|
||||
return bucketLabel
|
||||
}
|
||||
|
||||
function createRow(
|
||||
rowId: string,
|
||||
breakdownValues: readonly string[],
|
||||
bucketLabel?: { date: string; dateMs: number },
|
||||
) {
|
||||
const breakdownKey =
|
||||
breakdownValues.length === 0
|
||||
? ALL_PROJECTS_BREAKDOWN_VALUE
|
||||
: getAnalyticsBreakdownKey(breakdownValues)
|
||||
const breakdownDisplays = getBreakdownDisplays(breakdownValues)
|
||||
const row: AnalyticsTableRow = {
|
||||
id: rowId,
|
||||
date: bucketLabel?.date ?? '',
|
||||
dateMs: bucketLabel?.dateMs ?? 0,
|
||||
project: getProjectDisplayValueForBreakdownValues(breakdownValues),
|
||||
breakdown: breakdownKey,
|
||||
breakdownValues: Object.fromEntries(
|
||||
selectedBreakdowns.map((breakdown, index) => [breakdown, breakdownValues[index] ?? '']),
|
||||
) as AnalyticsTableBreakdownDisplayValues,
|
||||
breakdownDisplays,
|
||||
graphDatasetId: getAnalyticsTableGraphDatasetId(breakdownValues, selectedBreakdowns),
|
||||
breakdownDisplay: getCombinedBreakdownDisplay(breakdownDisplays),
|
||||
views: 0,
|
||||
downloads: 0,
|
||||
revenue: 0,
|
||||
playtime: 0,
|
||||
}
|
||||
|
||||
for (const breakdown of selectedBreakdowns) {
|
||||
row[getAnalyticsTableBreakdownColumnKey(breakdown)] = breakdownDisplays[breakdown] ?? ''
|
||||
}
|
||||
|
||||
nextRows.set(rowId, row)
|
||||
return row
|
||||
}
|
||||
|
||||
if (!includeDate && selectedBreakdowns.length === 0) {
|
||||
createRow(ALL_PROJECTS_BREAKDOWN_VALUE, [])
|
||||
}
|
||||
|
||||
if (!includeDate && selectedBreakdowns.length === 1 && selectedBreakdowns[0] === 'project') {
|
||||
for (const projectId of selectedProjectIds) {
|
||||
createRow(projectId, [projectId])
|
||||
}
|
||||
}
|
||||
|
||||
timeSlices.forEach((slice, sliceIndex) => {
|
||||
const bucketLabel = includeDate ? getBucketLabel(sliceIndex) : undefined
|
||||
|
||||
for (const point of slice) {
|
||||
if (!isProjectAnalyticsPoint(point)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!selectedProjectIds.has(point.source_project)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const pointStat = getAnalyticsTableStatForMetric(point.metric_kind)
|
||||
if (!pointStat || !relevantStats.has(pointStat)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const breakdownValues =
|
||||
selectedBreakdowns.length === 0
|
||||
? []
|
||||
: getAnalyticsBreakdownValues(point, selectedBreakdowns, formatMessage)
|
||||
if (breakdownValues.some((breakdownValue) => breakdownValue === ALL_BREAKDOWN_VALUE)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const nextBucketLabel = includeDate ? (bucketLabel ?? getBucketLabel(sliceIndex)) : undefined
|
||||
const breakdownKey =
|
||||
breakdownValues.length === 0
|
||||
? ALL_PROJECTS_BREAKDOWN_VALUE
|
||||
: getAnalyticsBreakdownKey(breakdownValues)
|
||||
const rowId = includeDate ? `${nextBucketLabel?.dateMs ?? 0}::${breakdownKey}` : breakdownKey
|
||||
const row = nextRows.get(rowId) ?? createRow(rowId, breakdownValues, nextBucketLabel)
|
||||
addAnalyticsMetricToTableRow(row, point)
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(nextRows.values())
|
||||
}
|
||||
|
||||
function isProjectAnalyticsPoint(
|
||||
point: Labrinth.Analytics.v3.AnalyticsData,
|
||||
): point is Labrinth.Analytics.v3.ProjectAnalytics {
|
||||
return 'source_project' in point
|
||||
}
|
||||
|
||||
function addAnalyticsMetricToTableRow(
|
||||
row: AnalyticsTableRow,
|
||||
point: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
) {
|
||||
switch (point.metric_kind) {
|
||||
case 'views':
|
||||
row.views += point.views
|
||||
break
|
||||
case 'downloads':
|
||||
row.downloads += point.downloads
|
||||
break
|
||||
case 'playtime':
|
||||
row.playtime += point.seconds
|
||||
break
|
||||
case 'revenue': {
|
||||
const parsed = Number.parseFloat(point.revenue)
|
||||
row.revenue += Number.isFinite(parsed) ? parsed : 0
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getAnalyticsTableStatForMetric(
|
||||
metricKind: Labrinth.Analytics.v3.ProjectAnalytics['metric_kind'],
|
||||
): AnalyticsDashboardStat | null {
|
||||
switch (metricKind) {
|
||||
case 'views':
|
||||
case 'downloads':
|
||||
case 'revenue':
|
||||
case 'playtime':
|
||||
return metricKind
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getAnalyticsTableGraphDatasetId(
|
||||
breakdownValues: readonly string[],
|
||||
selectedBreakdowns: readonly AnalyticsBreakdownPreset[],
|
||||
): string {
|
||||
return getAnalyticsBreakdownDatasetId(breakdownValues, selectedBreakdowns)
|
||||
}
|
||||
|
||||
function formatAnalyticsTableBreakdownDisplayValue(
|
||||
value: string,
|
||||
breakdown: AnalyticsTableBreakdownPreset,
|
||||
projectNamesById: ReadonlyMap<string, string>,
|
||||
getVersionDisplayName: (versionId: string) => string,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
if (breakdown === 'project') {
|
||||
return projectNamesById.get(value) ?? value
|
||||
}
|
||||
return formatBreakdownLabel(value, breakdown, getVersionDisplayName, formatMessage)
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import type { TableColumn } from '@modrinth/ui'
|
||||
|
||||
import { isAnalyticsTableBreakdownColumnKey } from './analytics-table-columns'
|
||||
import type { AnalyticsTableColumnKey, AnalyticsTableRow } from './analytics-table-types'
|
||||
|
||||
const SEARCHABLE_COLUMN_KEYS = new Set<AnalyticsTableColumnKey>(['date', 'project'])
|
||||
|
||||
export function getAnalyticsTableSearchableColumns(
|
||||
columns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
): TableColumn<AnalyticsTableColumnKey>[] {
|
||||
return columns.filter(
|
||||
(column) =>
|
||||
SEARCHABLE_COLUMN_KEYS.has(column.key) || isAnalyticsTableBreakdownColumnKey(column.key),
|
||||
)
|
||||
}
|
||||
|
||||
export function filterAnalyticsTableRowsBySearch(
|
||||
rows: AnalyticsTableRow[],
|
||||
searchableColumns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
query: string,
|
||||
): AnalyticsTableRow[] {
|
||||
if (!query || searchableColumns.length === 0) {
|
||||
return rows
|
||||
}
|
||||
|
||||
return rows.filter((row) =>
|
||||
searchableColumns.some((column) =>
|
||||
String(getAnalyticsTableSearchableCellValue(row, column.key)).toLowerCase().includes(query),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function getAnalyticsTableSearchableCellValue(
|
||||
row: AnalyticsTableRow,
|
||||
key: AnalyticsTableColumnKey,
|
||||
): string {
|
||||
switch (key) {
|
||||
case 'date':
|
||||
return row.date
|
||||
case 'project':
|
||||
return row.project
|
||||
case 'breakdown':
|
||||
return row.breakdownDisplay
|
||||
default:
|
||||
return isAnalyticsTableBreakdownColumnKey(key) ? String(row[key] ?? '') : ''
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import type { TableColumn } from '@modrinth/ui'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
|
||||
import {
|
||||
buildAnalyticsTableSortRouteQuery,
|
||||
readAnalyticsTableSortState,
|
||||
} from '~/components/analytics-dashboard/analytics-route-query'
|
||||
import type { AnalyticsDashboardStat } from '~/providers/analytics/analytics'
|
||||
|
||||
import { isAnalyticsTableBreakdownColumnKey } from './analytics-table-columns'
|
||||
import {
|
||||
getAnalyticsTableDefaultSortColumn,
|
||||
getAnalyticsTableDefaultSortDirection,
|
||||
} from './analytics-table-sorting'
|
||||
import type {
|
||||
AnalyticsTableColumnKey,
|
||||
AnalyticsTableSortDirectionValue,
|
||||
AnalyticsTableSortState,
|
||||
} from './analytics-table-types'
|
||||
|
||||
type GetDefaultAnalyticsTableSortStateOptions = {
|
||||
columns: TableColumn<AnalyticsTableColumnKey>[]
|
||||
showGraphDatasetSelection: boolean
|
||||
activeStat: AnalyticsDashboardStat
|
||||
}
|
||||
|
||||
export function getRouteAnalyticsTableSortState(
|
||||
query: LocationQuery,
|
||||
columns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
defaultSortOptions: GetDefaultAnalyticsTableSortStateOptions,
|
||||
): AnalyticsTableSortState {
|
||||
return getAvailableAnalyticsTableSortState(
|
||||
readAnalyticsTableSortState(query, getDefaultAnalyticsTableSortState(defaultSortOptions)),
|
||||
columns,
|
||||
defaultSortOptions,
|
||||
)
|
||||
}
|
||||
|
||||
export function getAvailableAnalyticsTableSortState(
|
||||
state: AnalyticsTableSortState,
|
||||
columns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
defaultSortOptions: GetDefaultAnalyticsTableSortStateOptions,
|
||||
): AnalyticsTableSortState {
|
||||
const availableColumns = new Set(columns.map((column) => column.key))
|
||||
if (state.sortColumn && availableColumns.has(state.sortColumn)) {
|
||||
return state
|
||||
}
|
||||
if (state.sortColumn === 'breakdown') {
|
||||
const firstBreakdownColumn = columns.find((column) =>
|
||||
isAnalyticsTableBreakdownColumnKey(column.key),
|
||||
)
|
||||
if (firstBreakdownColumn) {
|
||||
return {
|
||||
sortColumn: firstBreakdownColumn.key,
|
||||
sortDirection: state.sortDirection,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return getDefaultAnalyticsTableSortState(defaultSortOptions)
|
||||
}
|
||||
|
||||
export function getDefaultAnalyticsTableSortState({
|
||||
columns,
|
||||
showGraphDatasetSelection,
|
||||
activeStat,
|
||||
}: GetDefaultAnalyticsTableSortStateOptions): AnalyticsTableSortState {
|
||||
const nextSortColumn = getAnalyticsTableDefaultSortColumn(
|
||||
columns,
|
||||
showGraphDatasetSelection,
|
||||
activeStat,
|
||||
)
|
||||
return {
|
||||
sortColumn: nextSortColumn,
|
||||
sortDirection: getAnalyticsTableDefaultSortDirection(nextSortColumn, columns),
|
||||
}
|
||||
}
|
||||
|
||||
export function areAnalyticsTableSortStatesEqual(
|
||||
left: AnalyticsTableSortState,
|
||||
right: AnalyticsTableSortState,
|
||||
): boolean {
|
||||
return left.sortColumn === right.sortColumn && left.sortDirection === right.sortDirection
|
||||
}
|
||||
|
||||
export function buildSyncedAnalyticsTableSortRouteQuery(
|
||||
query: LocationQuery,
|
||||
sortState: AnalyticsTableSortState,
|
||||
columns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
defaultSortOptions: GetDefaultAnalyticsTableSortStateOptions,
|
||||
) {
|
||||
const nextSortState = getAvailableAnalyticsTableSortState(sortState, columns, defaultSortOptions)
|
||||
|
||||
return buildAnalyticsTableSortRouteQuery(
|
||||
query,
|
||||
nextSortState,
|
||||
getDefaultAnalyticsTableSortState(defaultSortOptions),
|
||||
)
|
||||
}
|
||||
|
||||
export function toAnalyticsTableSortState(
|
||||
sortColumn: AnalyticsTableColumnKey | undefined,
|
||||
sortDirection: AnalyticsTableSortDirectionValue,
|
||||
): AnalyticsTableSortState {
|
||||
return {
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
}
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
import type { TableColumn } from '@modrinth/ui'
|
||||
|
||||
import type { AnalyticsDashboardStat } from '~/providers/analytics/analytics'
|
||||
|
||||
import { isAnalyticsTableBreakdownColumnKey } from './analytics-table-columns'
|
||||
import type {
|
||||
AnalyticsTableColumnKey,
|
||||
AnalyticsTableRow,
|
||||
AnalyticsTableSortDirectionValue,
|
||||
} from './analytics-table-types'
|
||||
|
||||
export function sortAnalyticsTableRows(
|
||||
rows: AnalyticsTableRow[],
|
||||
sortColumn: AnalyticsTableColumnKey | undefined,
|
||||
sortDirection: AnalyticsTableSortDirectionValue,
|
||||
sortCollator: Intl.Collator,
|
||||
): AnalyticsTableRow[] {
|
||||
const nextRows = [...rows]
|
||||
|
||||
if (!sortColumn) {
|
||||
return nextRows
|
||||
}
|
||||
|
||||
const directionFactor = sortDirection === 'asc' ? 1 : -1
|
||||
nextRows.sort(getAnalyticsTableRowComparator(sortColumn, directionFactor, sortCollator))
|
||||
|
||||
return nextRows
|
||||
}
|
||||
|
||||
export function getAnalyticsTableDefaultSortColumn(
|
||||
nextColumns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
showGraphDatasetSelection: boolean,
|
||||
activeStat: AnalyticsDashboardStat,
|
||||
): AnalyticsTableColumnKey | undefined {
|
||||
const availableColumns = new Set(nextColumns.map((column) => column.key))
|
||||
if (availableColumns.has('date')) {
|
||||
return 'date'
|
||||
}
|
||||
|
||||
if (showGraphDatasetSelection && availableColumns.has(activeStat)) {
|
||||
return activeStat
|
||||
}
|
||||
|
||||
if (availableColumns.has('downloads')) {
|
||||
return 'downloads'
|
||||
}
|
||||
|
||||
return nextColumns[0]?.key
|
||||
}
|
||||
|
||||
export function getAnalyticsTableDefaultSortDirection(
|
||||
column: AnalyticsTableColumnKey | undefined,
|
||||
nextColumns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
): AnalyticsTableSortDirectionValue {
|
||||
return nextColumns.find((nextColumn) => nextColumn.key === column)?.defaultSortDirection ?? 'asc'
|
||||
}
|
||||
|
||||
export function getAnalyticsTableMetricSortedGraphDatasetIds(
|
||||
rows: AnalyticsTableRow[],
|
||||
sortColumn: AnalyticsTableColumnKey | undefined,
|
||||
sortCollator: Intl.Collator,
|
||||
): string[] {
|
||||
const metricColumn = getAnalyticsTableMetricSortColumn(sortColumn)
|
||||
if (!metricColumn) {
|
||||
return []
|
||||
}
|
||||
|
||||
const totalsByGraphDatasetId = new Map<string, number>()
|
||||
const labelsByGraphDatasetId = new Map<string, string>()
|
||||
for (const row of rows) {
|
||||
totalsByGraphDatasetId.set(
|
||||
row.graphDatasetId,
|
||||
(totalsByGraphDatasetId.get(row.graphDatasetId) ?? 0) + row[metricColumn],
|
||||
)
|
||||
if (!labelsByGraphDatasetId.has(row.graphDatasetId)) {
|
||||
labelsByGraphDatasetId.set(row.graphDatasetId, row.breakdownDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(totalsByGraphDatasetId.keys()).sort((left, right) => {
|
||||
const totalDifference =
|
||||
(totalsByGraphDatasetId.get(right) ?? 0) - (totalsByGraphDatasetId.get(left) ?? 0)
|
||||
return (
|
||||
totalDifference ||
|
||||
sortCollator.compare(
|
||||
labelsByGraphDatasetId.get(left) ?? left,
|
||||
labelsByGraphDatasetId.get(right) ?? right,
|
||||
) ||
|
||||
left.localeCompare(right)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function getAnalyticsTableMetricSortColumn(
|
||||
column: AnalyticsTableColumnKey | undefined,
|
||||
): AnalyticsDashboardStat | null {
|
||||
switch (column) {
|
||||
case 'views':
|
||||
case 'downloads':
|
||||
case 'revenue':
|
||||
case 'playtime':
|
||||
return column
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getAnalyticsTableRowComparator(
|
||||
column: AnalyticsTableColumnKey,
|
||||
directionFactor: number,
|
||||
sortCollator: Intl.Collator,
|
||||
): (left: AnalyticsTableRow, right: AnalyticsTableRow) => number {
|
||||
switch (column) {
|
||||
case 'date':
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
left.dateMs - right.dateMs,
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
case 'project':
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
sortCollator.compare(left.project, right.project),
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
case 'breakdown':
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
sortCollator.compare(left.breakdownDisplay, right.breakdownDisplay),
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
case 'views':
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
left.views - right.views,
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
case 'downloads':
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
left.downloads - right.downloads,
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
case 'revenue':
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
left.revenue - right.revenue,
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
case 'playtime':
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
left.playtime - right.playtime,
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
default:
|
||||
if (isAnalyticsTableBreakdownColumnKey(column)) {
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
sortCollator.compare(String(left[column] ?? ''), String(right[column] ?? '')),
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
}
|
||||
|
||||
return () => 0
|
||||
}
|
||||
}
|
||||
|
||||
function compareAnalyticsTableRows(
|
||||
left: AnalyticsTableRow,
|
||||
right: AnalyticsTableRow,
|
||||
primaryResult: number,
|
||||
directionFactor: number,
|
||||
sortCollator: Intl.Collator,
|
||||
): number {
|
||||
if (primaryResult !== 0) {
|
||||
return primaryResult * directionFactor
|
||||
}
|
||||
|
||||
const dateResult = left.dateMs - right.dateMs
|
||||
if (dateResult !== 0) {
|
||||
return dateResult * directionFactor
|
||||
}
|
||||
|
||||
return sortCollator.compare(left.breakdown, right.breakdown) * directionFactor
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import type {
|
||||
AnalyticsBreakdownPreset,
|
||||
AnalyticsTableSortColumn,
|
||||
AnalyticsTableSortDirection,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
export type AnalyticsTableMode = 'date_breakdown' | 'breakdown_only'
|
||||
export type AnalyticsTableBreakdownPreset = Exclude<AnalyticsBreakdownPreset, 'none'>
|
||||
export type AnalyticsTableBreakdownColumnKey = `breakdown_${AnalyticsTableBreakdownPreset}`
|
||||
export type AnalyticsTableBreakdownDisplayValues = Partial<
|
||||
Record<AnalyticsTableBreakdownPreset, string>
|
||||
>
|
||||
export type AnalyticsTableColumnKey = AnalyticsTableSortColumn
|
||||
export type AnalyticsTableSortState = {
|
||||
sortColumn: AnalyticsTableColumnKey | undefined
|
||||
sortDirection: AnalyticsTableSortDirection
|
||||
}
|
||||
export type AnalyticsTableSortDirectionValue = AnalyticsTableSortDirection
|
||||
|
||||
export type AnalyticsTableRow = {
|
||||
[key: string]: string | number | AnalyticsTableBreakdownDisplayValues
|
||||
id: string
|
||||
date: string
|
||||
dateMs: number
|
||||
project: string
|
||||
breakdown: string
|
||||
breakdownValues: AnalyticsTableBreakdownDisplayValues
|
||||
breakdownDisplays: AnalyticsTableBreakdownDisplayValues
|
||||
graphDatasetId: string
|
||||
breakdownDisplay: string
|
||||
views: number
|
||||
downloads: number
|
||||
revenue: number
|
||||
playtime: number
|
||||
}
|
||||
|
||||
export type AnalyticsTableDisplayedRowsCache = {
|
||||
generation: number
|
||||
mode: AnalyticsTableMode
|
||||
sortColumn: AnalyticsTableColumnKey | undefined
|
||||
sortDirection: AnalyticsTableSortDirectionValue
|
||||
rows: AnalyticsTableRow[]
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
<template>
|
||||
<div class="relative overflow-hidden rounded-2xl">
|
||||
<AnalyticsLoadingBar :loading="isDataLoading" />
|
||||
<Table
|
||||
v-model:selected-ids="tableSelectedGraphDatasetIds"
|
||||
:sort-column="displayedSortColumn"
|
||||
:sort-direction="displayedSortDirection"
|
||||
:columns="columns"
|
||||
:data="paginatedRows"
|
||||
row-key="id"
|
||||
selection-key="graphDatasetId"
|
||||
:selection-ids="filteredSelectableGraphDatasetIds"
|
||||
:show-selection="showGraphDatasetSelection"
|
||||
table-min-width="44rem"
|
||||
virtualized
|
||||
:virtual-row-height="56"
|
||||
@sort="applyRequestedSort"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div class="text-xl font-semibold text-contrast">
|
||||
{{ formatMessage(analyticsBreakdownMessages.breakdown) }}
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-wrap items-center gap-2 md:w-auto">
|
||||
<StyledInput
|
||||
v-model="searchQuery"
|
||||
:icon="SearchIcon"
|
||||
:placeholder="formatMessage(analyticsTableMessages.searchPlaceholder)"
|
||||
clearable
|
||||
wrapper-class="w-full sm:w-64"
|
||||
@focusin="selectSearchInputText"
|
||||
/>
|
||||
<ButtonStyled>
|
||||
<OverflowMenu
|
||||
class="!shadow-none"
|
||||
:options="csvExportOptions"
|
||||
:disabled="isDataLoading || filteredRows.length === 0"
|
||||
>
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(analyticsTableMessages.exportCsvButton) }}
|
||||
<DropdownIcon />
|
||||
<template #cumulative-csv>
|
||||
{{ formatMessage(analyticsTableMessages.cumulativeCsv) }}
|
||||
</template>
|
||||
<template #grouped-csv>
|
||||
{{ formatMessage(analyticsTableMessages.groupedCsv, { groupBy: groupByLabel }) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-date="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_project="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_country="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_monetization="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_user_agent="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_download_reason="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_version_id="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_loader="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_game_version="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-project="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-views="{ row }">
|
||||
<span>{{ formatInteger(row.views) }}</span>
|
||||
</template>
|
||||
<template #cell-downloads="{ row }">
|
||||
<span>{{ formatInteger(row.downloads) }}</span>
|
||||
</template>
|
||||
<template #cell-revenue="{ row }">
|
||||
<span>{{ formatRevenue(row.revenue) }}</span>
|
||||
</template>
|
||||
<template #cell-playtime="{ row }">
|
||||
<span v-tooltip="formatFullPlaytime(row.playtime)">
|
||||
{{ formatCompactPlaytime(row.playtime) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #empty-state>
|
||||
<div class="flex h-64 items-center justify-center text-secondary">
|
||||
{{ !isDataLoading ? emptyTableMessage : '' }}
|
||||
</div>
|
||||
</template>
|
||||
</Table>
|
||||
<div
|
||||
v-if="filteredRows.length > PAGE_SIZE"
|
||||
class="mt-3 flex flex-wrap items-center justify-between gap-3 px-1 text-sm text-secondary"
|
||||
>
|
||||
<span>
|
||||
{{
|
||||
formatMessage(analyticsTableMessages.paginationSummary, {
|
||||
start: visibleRowStart,
|
||||
end: visibleRowEnd,
|
||||
total: filteredRows.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<Pagination :page="currentPage" :count="pageCount" @switch-page="switchPage" />
|
||||
</div>
|
||||
<div v-if="isDataLoading" class="absolute inset-0 z-10 overflow-hidden rounded-xl">
|
||||
<div class="absolute inset-0 bg-surface-3 opacity-50" />
|
||||
<div class="absolute inset-0 backdrop-blur-[4px]" />
|
||||
<div class="absolute inset-0 flex h-full max-h-[500px] items-center justify-center pt-10">
|
||||
<div class="inline-flex items-center gap-2 text-lg font-semibold text-primary opacity-100">
|
||||
<span>{{ formatMessage(analyticsMessages.fetchingResults) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, DropdownIcon, SearchIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
OverflowMenu,
|
||||
type OverflowMenuOption,
|
||||
Pagination,
|
||||
StyledInput,
|
||||
Table,
|
||||
useFormatNumber,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
|
||||
import {
|
||||
hasAnalyticsTableSortQuery,
|
||||
hasAnalyticsTableSortRouteChange,
|
||||
readAnalyticsTableSortState,
|
||||
} from '~/components/analytics-dashboard/analytics-route-query'
|
||||
import {
|
||||
doesProjectStatusMatchFilters,
|
||||
injectAnalyticsDashboardContext,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import {
|
||||
isTimeRelevantForGroupBy,
|
||||
isYearRelevantForTimeRange,
|
||||
} from '../analytics-chart/analytics-chart-utils.ts'
|
||||
import {
|
||||
analyticsBreakdownMessages,
|
||||
analyticsMessages,
|
||||
analyticsTableMessages,
|
||||
} from '../analytics-messages.ts'
|
||||
import AnalyticsLoadingBar from '../AnalyticsLoadingBar.vue'
|
||||
import {
|
||||
buildAnalyticsTableColumns,
|
||||
getAnalyticsTableBreakdownColumnLabel,
|
||||
} from './analytics-table-columns.ts'
|
||||
import {
|
||||
buildAnalyticsTableCsvContent,
|
||||
downloadAnalyticsTableCsv,
|
||||
getAnalyticsTableCsvFilename,
|
||||
} from './analytics-table-csv-export.ts'
|
||||
import {
|
||||
formatAnalyticsTableCompactPlaytime,
|
||||
formatAnalyticsTableFullPlaytime,
|
||||
formatAnalyticsTableInteger,
|
||||
formatAnalyticsTableRevenue,
|
||||
getAnalyticsTableGroupByLabel,
|
||||
} from './analytics-table-formatting.ts'
|
||||
import { buildAnalyticsTableRows } from './analytics-table-row-builder.ts'
|
||||
import {
|
||||
filterAnalyticsTableRowsBySearch,
|
||||
getAnalyticsTableSearchableColumns,
|
||||
} from './analytics-table-search-filtering.ts'
|
||||
import {
|
||||
areAnalyticsTableSortStatesEqual,
|
||||
buildSyncedAnalyticsTableSortRouteQuery,
|
||||
getRouteAnalyticsTableSortState,
|
||||
toAnalyticsTableSortState,
|
||||
} from './analytics-table-sort-route.ts'
|
||||
import { sortAnalyticsTableRows } from './analytics-table-sorting.ts'
|
||||
import type {
|
||||
AnalyticsTableColumnKey,
|
||||
AnalyticsTableMode,
|
||||
AnalyticsTableSortDirectionValue,
|
||||
} from './analytics-table-types.ts'
|
||||
import { useAnalyticsTableGraphSelection } from './use-analytics-table-graph-selection.ts'
|
||||
import { useAnalyticsTablePagination } from './use-analytics-table-pagination.ts'
|
||||
import { useAnalyticsTableRowCache } from './use-analytics-table-row-cache.ts'
|
||||
|
||||
const {
|
||||
hasProjectContext,
|
||||
projects,
|
||||
selectedProjectIds: currentSelectedProjectIds,
|
||||
selectedBreakdowns: currentSelectedBreakdowns,
|
||||
displayedSelectedProjectIds: selectedProjectIds,
|
||||
displayedSelectedGroupBy: selectedGroupBy,
|
||||
displayedSelectedBreakdowns: selectedBreakdowns,
|
||||
displayedSelectedFilters: selectedFilters,
|
||||
displayedFetchRequest: fetchRequest,
|
||||
displayedTimeSlices: timeSlices,
|
||||
activeStat,
|
||||
hasExplicitGraphDatasetSelection,
|
||||
isGraphDatasetSelectionActive,
|
||||
selectedGraphDatasetIds,
|
||||
defaultGraphDatasetIds,
|
||||
topGraphDatasetIds,
|
||||
queryResetToken,
|
||||
getRelevantAnalyticsDashboardStats,
|
||||
isLoading,
|
||||
versionNumbersById,
|
||||
versionProjectNamesById,
|
||||
getVersionDisplayName,
|
||||
getVersionProjectName,
|
||||
} = injectAnalyticsDashboardContext()
|
||||
const formatNumber = useFormatNumber()
|
||||
const { formatMessage } = useVIntl()
|
||||
const isDataLoading = computed(() => isLoading.value)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const initialTableSortState = readAnalyticsTableSortState(route.query, {
|
||||
sortColumn: 'date',
|
||||
sortDirection: 'desc',
|
||||
})
|
||||
|
||||
const tableMode = ref<AnalyticsTableMode>('breakdown_only')
|
||||
const sortColumn = ref<AnalyticsTableColumnKey | undefined>(initialTableSortState.sortColumn)
|
||||
const sortDirection = ref<AnalyticsTableSortDirectionValue>(initialTableSortState.sortDirection)
|
||||
const PAGE_SIZE = 500
|
||||
const GRAPH_DATASET_SELECTION_LIMIT = 8
|
||||
const INACTIVE_MODE_WARMUP_POINT_LIMIT = 12000
|
||||
const searchQuery = ref('')
|
||||
const sortCollator = new Intl.Collator(undefined, { sensitivity: 'base' })
|
||||
|
||||
const selectedProjectIdSet = computed(
|
||||
() =>
|
||||
new Set(
|
||||
projects.value
|
||||
.filter(
|
||||
(project) =>
|
||||
selectedProjectIds.value.includes(project.id) &&
|
||||
doesProjectStatusMatchFilters(project.status, selectedFilters.value),
|
||||
)
|
||||
.map((project) => project.id),
|
||||
),
|
||||
)
|
||||
const selectedBreakdownSet = computed(() => new Set(selectedBreakdowns.value))
|
||||
const showBreakdownColumn = computed(() => selectedBreakdowns.value.length > 0)
|
||||
const showGraphDatasetSelection = computed(() =>
|
||||
selectedBreakdowns.value.length === 1 && selectedBreakdowns.value[0] === 'project'
|
||||
? selectedProjectIdSet.value.size > 1
|
||||
: selectedBreakdowns.value.length > 0,
|
||||
)
|
||||
const showProjectVersionProjectColumn = computed(
|
||||
() =>
|
||||
selectedBreakdownSet.value.has('version_id') &&
|
||||
!selectedBreakdownSet.value.has('project') &&
|
||||
selectedProjectIdSet.value.size > 1,
|
||||
)
|
||||
const includeDateColumn = computed(
|
||||
() =>
|
||||
selectedBreakdowns.value.length === 0 ||
|
||||
(!showGraphDatasetSelection.value && tableMode.value === 'date_breakdown'),
|
||||
)
|
||||
const activeTableMode = computed<AnalyticsTableMode>(() =>
|
||||
selectedBreakdowns.value.length === 0
|
||||
? 'date_breakdown'
|
||||
: showGraphDatasetSelection.value
|
||||
? 'breakdown_only'
|
||||
: tableMode.value,
|
||||
)
|
||||
const displayedIncludeDateColumn = computed(() =>
|
||||
selectedBreakdowns.value.length === 0
|
||||
? true
|
||||
: showGraphDatasetSelection.value
|
||||
? false
|
||||
: displayedTableMode.value === 'date_breakdown',
|
||||
)
|
||||
const groupByLabel = computed(() =>
|
||||
getAnalyticsTableGroupByLabel(selectedGroupBy.value, formatMessage),
|
||||
)
|
||||
const csvExportOptions = computed<OverflowMenuOption[]>(() => {
|
||||
if (showGraphDatasetSelection.value) {
|
||||
return [
|
||||
{
|
||||
id: 'cumulative-csv',
|
||||
action: () => downloadCsv('breakdown_only'),
|
||||
},
|
||||
{
|
||||
id: 'grouped-csv',
|
||||
action: () => downloadCsv('date_breakdown'),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const mode = displayedTableMode.value
|
||||
|
||||
return [
|
||||
{
|
||||
id: mode === 'date_breakdown' ? 'grouped-csv' : 'cumulative-csv',
|
||||
action: () => downloadCsv(mode),
|
||||
},
|
||||
]
|
||||
})
|
||||
const projectNamesById = computed(
|
||||
() => new Map(projects.value.map((project) => [project.id, project.name])),
|
||||
)
|
||||
const hasAvailableProjects = computed(() => projects.value.length > 0)
|
||||
const analyticsPointCount = computed(() =>
|
||||
timeSlices.value.reduce((sum, slice) => sum + slice.length, 0),
|
||||
)
|
||||
const emptyTableMessage = computed(() => {
|
||||
if (trimmedSearchQuery.value && sortedRows.value.length > 0) {
|
||||
return formatMessage(analyticsTableMessages.noMatchingRows)
|
||||
}
|
||||
|
||||
if (hasProjectContext.value) {
|
||||
return formatMessage(analyticsMessages.noDataAvailableForAnalytics)
|
||||
}
|
||||
|
||||
return hasAvailableProjects.value
|
||||
? formatMessage(analyticsMessages.noDataAvailable)
|
||||
: formatMessage(analyticsMessages.noProjectsAvailableForAnalytics)
|
||||
})
|
||||
|
||||
const breakdownColumnLabel = computed(() =>
|
||||
selectedBreakdowns.value.length === 1
|
||||
? getAnalyticsTableBreakdownColumnLabel(selectedBreakdowns.value[0], formatMessage)
|
||||
: formatMessage(analyticsBreakdownMessages.breakdown),
|
||||
)
|
||||
const relevantStats = computed(
|
||||
() =>
|
||||
new Set(getRelevantAnalyticsDashboardStats(selectedBreakdowns.value, selectedFilters.value)),
|
||||
)
|
||||
|
||||
const showTimeInBucketLabel = computed(() => isTimeRelevantForGroupBy(selectedGroupBy.value))
|
||||
const showYearInBucketLabel = computed(() => {
|
||||
const nextFetchRequest = fetchRequest.value
|
||||
return nextFetchRequest
|
||||
? isYearRelevantForTimeRange(nextFetchRequest.time_range) || selectedGroupBy.value === 'year'
|
||||
: false
|
||||
})
|
||||
|
||||
function buildTableRows(mode: AnalyticsTableMode) {
|
||||
return buildAnalyticsTableRows({
|
||||
mode,
|
||||
fetchRequest: fetchRequest.value,
|
||||
timeSlices: timeSlices.value,
|
||||
selectedBreakdowns: selectedBreakdowns.value,
|
||||
selectedProjectIds: selectedProjectIdSet.value,
|
||||
relevantStats: relevantStats.value,
|
||||
projectNamesById: projectNamesById.value,
|
||||
getVersionDisplayName,
|
||||
getVersionProjectName,
|
||||
showTimeInBucketLabel: showTimeInBucketLabel.value,
|
||||
showYearInBucketLabel: showYearInBucketLabel.value,
|
||||
formatMessage,
|
||||
})
|
||||
}
|
||||
|
||||
const columns = computed(() => buildColumns(displayedIncludeDateColumn.value))
|
||||
const activeColumns = computed(() => buildColumns(includeDateColumn.value))
|
||||
|
||||
function buildColumns(includeDate: boolean) {
|
||||
return buildAnalyticsTableColumns({
|
||||
includeDate,
|
||||
selectedBreakdowns: selectedBreakdowns.value,
|
||||
selectedFilters: selectedFilters.value,
|
||||
showBreakdownColumn: showBreakdownColumn.value,
|
||||
showProjectVersionProjectColumn: showProjectVersionProjectColumn.value,
|
||||
formatMessage,
|
||||
getRelevantAnalyticsDashboardStats,
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
activeColumns,
|
||||
(nextColumns) => {
|
||||
applyRouteOrDefaultSort(nextColumns)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function sortTableRows(rows: ReturnType<typeof buildTableRows>) {
|
||||
return sortAnalyticsTableRows(rows, sortColumn.value, sortDirection.value, sortCollator)
|
||||
}
|
||||
|
||||
const {
|
||||
displayedTableMode,
|
||||
displayedSortColumn,
|
||||
displayedSortDirection,
|
||||
displayedSortedRows,
|
||||
invalidateTableCaches,
|
||||
invalidateSortedCaches,
|
||||
scheduleRowsForMode,
|
||||
scheduleInactiveModeWarmup,
|
||||
resortDisplayedRowsForCurrentSort,
|
||||
getSortedRowsForMode,
|
||||
} = useAnalyticsTableRowCache({
|
||||
activeTableMode,
|
||||
showBreakdownColumn,
|
||||
analyticsPointCount,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
buildRows: buildTableRows,
|
||||
sortRows: sortTableRows,
|
||||
inactiveModeWarmupPointLimit: INACTIVE_MODE_WARMUP_POINT_LIMIT,
|
||||
})
|
||||
|
||||
const sortedRows = computed(() => {
|
||||
return displayedSortedRows.value
|
||||
})
|
||||
const trimmedSearchQuery = computed(() => searchQuery.value.trim().toLowerCase())
|
||||
const searchableColumns = computed(() => getAnalyticsTableSearchableColumns(columns.value))
|
||||
const filteredRows = computed(() => {
|
||||
if (!trimmedSearchQuery.value) {
|
||||
return sortedRows.value
|
||||
}
|
||||
|
||||
return filterAnalyticsTableRowsBySearch(
|
||||
sortedRows.value,
|
||||
searchableColumns.value,
|
||||
trimmedSearchQuery.value,
|
||||
)
|
||||
})
|
||||
|
||||
watch(
|
||||
[
|
||||
fetchRequest,
|
||||
timeSlices,
|
||||
selectedProjectIds,
|
||||
selectedGroupBy,
|
||||
selectedBreakdowns,
|
||||
selectedFilters,
|
||||
projects,
|
||||
versionNumbersById,
|
||||
versionProjectNamesById,
|
||||
],
|
||||
() => {
|
||||
invalidateTableCaches()
|
||||
scheduleRowsForMode(activeTableMode.value)
|
||||
scheduleInactiveModeWarmup()
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
|
||||
watch(activeTableMode, () => {
|
||||
scheduleRowsForMode(activeTableMode.value)
|
||||
scheduleInactiveModeWarmup()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.query,
|
||||
(nextQuery) => {
|
||||
const nextSortState = getRouteTableSortState(nextQuery, activeColumns.value)
|
||||
if (!areAnalyticsTableSortStatesEqual(getCurrentSortState(), nextSortState)) {
|
||||
applyTableSortState(nextSortState)
|
||||
return
|
||||
}
|
||||
|
||||
syncTableSortRouteQuery()
|
||||
},
|
||||
)
|
||||
|
||||
watch([sortColumn, sortDirection], () => {
|
||||
syncTableSortRouteQuery()
|
||||
|
||||
if (resortDisplayedRowsForCurrentSort()) {
|
||||
scheduleInactiveModeWarmup()
|
||||
return
|
||||
}
|
||||
|
||||
invalidateSortedCaches()
|
||||
scheduleRowsForMode(activeTableMode.value)
|
||||
scheduleInactiveModeWarmup()
|
||||
})
|
||||
|
||||
const { filteredSelectableGraphDatasetIds, tableSelectedGraphDatasetIds } =
|
||||
useAnalyticsTableGraphSelection({
|
||||
sortedRows,
|
||||
filteredRows,
|
||||
sortColumn,
|
||||
showGraphDatasetSelection,
|
||||
selectedGraphDatasetIds,
|
||||
hasExplicitGraphDatasetSelection,
|
||||
isGraphDatasetSelectionActive,
|
||||
defaultGraphDatasetIds,
|
||||
topGraphDatasetIds,
|
||||
queryResetToken,
|
||||
currentSelectedBreakdowns,
|
||||
currentSelectedProjectIds,
|
||||
activeStat,
|
||||
sortCollator,
|
||||
hasTableSortQuery: () => hasAnalyticsTableSortQuery(route.query),
|
||||
applyActiveStatSort,
|
||||
graphDatasetSelectionLimit: GRAPH_DATASET_SELECTION_LIMIT,
|
||||
})
|
||||
|
||||
const { currentPage, pageCount, visibleRowStart, visibleRowEnd, paginatedRows, switchPage } =
|
||||
useAnalyticsTablePagination({
|
||||
filteredRows,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
|
||||
const revenueFormatter = computed(
|
||||
() =>
|
||||
new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}),
|
||||
)
|
||||
|
||||
function formatInteger(value: number): string {
|
||||
return formatAnalyticsTableInteger(formatNumber, value)
|
||||
}
|
||||
|
||||
function formatRevenue(value: number): string {
|
||||
return formatAnalyticsTableRevenue(revenueFormatter.value, value, formatMessage)
|
||||
}
|
||||
|
||||
function formatCompactPlaytime(value: number): string {
|
||||
return formatAnalyticsTableCompactPlaytime(value, formatMessage)
|
||||
}
|
||||
|
||||
function formatFullPlaytime(value: number): string {
|
||||
return formatAnalyticsTableFullPlaytime(value, formatMessage)
|
||||
}
|
||||
|
||||
function applyRouteOrDefaultSort(nextColumns = activeColumns.value) {
|
||||
const nextSortState = getRouteTableSortState(route.query, nextColumns)
|
||||
if (!areAnalyticsTableSortStatesEqual(getCurrentSortState(), nextSortState)) {
|
||||
applyTableSortState(nextSortState)
|
||||
}
|
||||
|
||||
syncTableSortRouteQuery()
|
||||
}
|
||||
|
||||
function applyTableSortState(state: {
|
||||
sortColumn: AnalyticsTableColumnKey | undefined
|
||||
sortDirection: AnalyticsTableSortDirectionValue
|
||||
}) {
|
||||
sortColumn.value = state.sortColumn
|
||||
sortDirection.value = state.sortDirection
|
||||
}
|
||||
|
||||
function getRouteTableSortState(
|
||||
query: LocationQuery,
|
||||
nextColumns = activeColumns.value,
|
||||
): {
|
||||
sortColumn: AnalyticsTableColumnKey | undefined
|
||||
sortDirection: AnalyticsTableSortDirectionValue
|
||||
} {
|
||||
return getRouteAnalyticsTableSortState(query, nextColumns, getDefaultSortOptions(nextColumns))
|
||||
}
|
||||
|
||||
function getCurrentSortState() {
|
||||
return toAnalyticsTableSortState(sortColumn.value, sortDirection.value)
|
||||
}
|
||||
|
||||
function getDefaultSortOptions(nextColumns = activeColumns.value) {
|
||||
return {
|
||||
columns: nextColumns,
|
||||
showGraphDatasetSelection: showGraphDatasetSelection.value,
|
||||
activeStat: activeStat.value,
|
||||
}
|
||||
}
|
||||
|
||||
function syncTableSortRouteQuery() {
|
||||
if (import.meta.server) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextRouteQuery = buildSyncedAnalyticsTableSortRouteQuery(
|
||||
route.query,
|
||||
getCurrentSortState(),
|
||||
activeColumns.value,
|
||||
getDefaultSortOptions(),
|
||||
)
|
||||
|
||||
if (!hasAnalyticsTableSortRouteChange(route.query, nextRouteQuery)) {
|
||||
return
|
||||
}
|
||||
|
||||
router.replace({
|
||||
path: route.path,
|
||||
query: nextRouteQuery,
|
||||
})
|
||||
}
|
||||
|
||||
function applyActiveStatSort() {
|
||||
const availableColumns = new Set(activeColumns.value.map((column) => column.key))
|
||||
if (!availableColumns.has(activeStat.value)) {
|
||||
return
|
||||
}
|
||||
|
||||
sortColumn.value = activeStat.value
|
||||
sortDirection.value = 'desc'
|
||||
}
|
||||
|
||||
function applyRequestedSort(column: string, direction: AnalyticsTableSortDirectionValue) {
|
||||
sortColumn.value = column as AnalyticsTableColumnKey
|
||||
sortDirection.value = direction
|
||||
}
|
||||
|
||||
function selectSearchInputText(event: FocusEvent) {
|
||||
const target = event.target
|
||||
if (target instanceof HTMLInputElement) {
|
||||
target.select()
|
||||
}
|
||||
}
|
||||
|
||||
function getCsvRows(mode: AnalyticsTableMode) {
|
||||
const visibleColumns = getCsvColumns(mode)
|
||||
return filterAnalyticsTableRowsBySearch(
|
||||
getSortedRowsForMode(mode),
|
||||
visibleColumns,
|
||||
trimmedSearchQuery.value,
|
||||
)
|
||||
}
|
||||
|
||||
function getCsvColumns(mode: AnalyticsTableMode) {
|
||||
return buildColumns(mode === 'date_breakdown' || !showBreakdownColumn.value)
|
||||
}
|
||||
|
||||
function getCsvFilename(): string {
|
||||
return getAnalyticsTableCsvFilename(breakdownColumnLabel.value, fetchRequest.value, formatMessage)
|
||||
}
|
||||
|
||||
function downloadCsv(mode: AnalyticsTableMode) {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
const csvRows = getCsvRows(mode)
|
||||
if (csvRows.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const visibleColumns = getCsvColumns(mode)
|
||||
const csvContent = buildAnalyticsTableCsvContent(csvRows, visibleColumns, formatMessage)
|
||||
downloadAnalyticsTableCsv(getCsvFilename(), csvContent)
|
||||
}
|
||||
</script>
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
import type { ComputedRef, Ref, WritableComputedRef } from 'vue'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import { areStringArraysEqual } from '~/components/analytics-dashboard/analytics-route-query'
|
||||
import type {
|
||||
AnalyticsDashboardStat,
|
||||
AnalyticsSelectedBreakdowns,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import { getAnalyticsTableMetricSortedGraphDatasetIds } from './analytics-table-sorting'
|
||||
import type { AnalyticsTableColumnKey, AnalyticsTableRow } from './analytics-table-types'
|
||||
|
||||
type UseAnalyticsTableGraphSelectionOptions = {
|
||||
sortedRows: ComputedRef<AnalyticsTableRow[]>
|
||||
filteredRows: ComputedRef<AnalyticsTableRow[]>
|
||||
sortColumn: Ref<AnalyticsTableColumnKey | undefined>
|
||||
showGraphDatasetSelection: ComputedRef<boolean>
|
||||
selectedGraphDatasetIds: Ref<string[]>
|
||||
hasExplicitGraphDatasetSelection: Ref<boolean>
|
||||
isGraphDatasetSelectionActive: Ref<boolean>
|
||||
defaultGraphDatasetIds: Ref<string[]>
|
||||
topGraphDatasetIds: Ref<string[]>
|
||||
queryResetToken: Ref<number>
|
||||
currentSelectedBreakdowns: Ref<AnalyticsSelectedBreakdowns>
|
||||
currentSelectedProjectIds: Ref<string[]>
|
||||
activeStat: Ref<AnalyticsDashboardStat>
|
||||
sortCollator: Intl.Collator
|
||||
hasTableSortQuery: () => boolean
|
||||
applyActiveStatSort: () => void
|
||||
graphDatasetSelectionLimit: number
|
||||
}
|
||||
|
||||
export function useAnalyticsTableGraphSelection({
|
||||
sortedRows,
|
||||
filteredRows,
|
||||
sortColumn,
|
||||
showGraphDatasetSelection,
|
||||
selectedGraphDatasetIds,
|
||||
hasExplicitGraphDatasetSelection,
|
||||
isGraphDatasetSelectionActive,
|
||||
defaultGraphDatasetIds,
|
||||
topGraphDatasetIds,
|
||||
queryResetToken,
|
||||
currentSelectedBreakdowns,
|
||||
currentSelectedProjectIds,
|
||||
activeStat,
|
||||
sortCollator,
|
||||
hasTableSortQuery,
|
||||
applyActiveStatSort,
|
||||
graphDatasetSelectionLimit,
|
||||
}: UseAnalyticsTableGraphSelectionOptions): {
|
||||
filteredSelectableGraphDatasetIds: ComputedRef<string[]>
|
||||
tableSelectedGraphDatasetIds: WritableComputedRef<unknown[]>
|
||||
} {
|
||||
const selectableGraphDatasetIds = computed(() =>
|
||||
getAnalyticsTableSelectableGraphDatasetIds(sortedRows.value),
|
||||
)
|
||||
const filteredSelectableGraphDatasetIds = computed(() =>
|
||||
getAnalyticsTableSelectableGraphDatasetIds(filteredRows.value),
|
||||
)
|
||||
const sortedMetricGraphDatasetIds = computed(() =>
|
||||
getAnalyticsTableMetricSortedGraphDatasetIds(sortedRows.value, sortColumn.value, sortCollator),
|
||||
)
|
||||
const defaultSelectedGraphDatasetIds = computed(() => {
|
||||
const sortedMetricIds = sortedMetricGraphDatasetIds.value
|
||||
const defaultIds =
|
||||
sortedMetricIds.length > 0 ? sortedMetricIds : selectableGraphDatasetIds.value
|
||||
return defaultIds.slice(0, graphDatasetSelectionLimit)
|
||||
})
|
||||
const tableSelectedGraphDatasetIds = computed<unknown[]>({
|
||||
get: () => selectedGraphDatasetIds.value,
|
||||
set: (ids) => {
|
||||
const nextGraphDatasetIds = ids.filter((id): id is string => typeof id === 'string')
|
||||
if (showGraphDatasetSelection.value && isDefaultGraphDatasetSelection(nextGraphDatasetIds)) {
|
||||
setSelectedGraphDatasetIds(defaultSelectedGraphDatasetIds.value, false)
|
||||
return
|
||||
}
|
||||
|
||||
selectedGraphDatasetIds.value = nextGraphDatasetIds
|
||||
hasExplicitGraphDatasetSelection.value = showGraphDatasetSelection.value
|
||||
},
|
||||
})
|
||||
|
||||
function setSelectedGraphDatasetIds(ids: string[], explicit: boolean) {
|
||||
selectedGraphDatasetIds.value = ids
|
||||
hasExplicitGraphDatasetSelection.value = explicit
|
||||
}
|
||||
|
||||
function resetGraphDatasetSelection() {
|
||||
setSelectedGraphDatasetIds([], false)
|
||||
}
|
||||
|
||||
function isDefaultGraphDatasetSelection(ids: string[]) {
|
||||
const defaultIds = defaultSelectedGraphDatasetIds.value
|
||||
if (defaultIds.length === 0 || ids.length !== defaultIds.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const selectedIdSet = new Set(ids)
|
||||
return defaultIds.every((id) => selectedIdSet.has(id))
|
||||
}
|
||||
|
||||
watch(
|
||||
[showGraphDatasetSelection, queryResetToken],
|
||||
([nextShowSelection]) => {
|
||||
isGraphDatasetSelectionActive.value = nextShowSelection
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(activeStat, () => {
|
||||
if (!showGraphDatasetSelection.value) {
|
||||
return
|
||||
}
|
||||
if (hasTableSortQuery()) {
|
||||
return
|
||||
}
|
||||
|
||||
applyActiveStatSort()
|
||||
})
|
||||
|
||||
watch(
|
||||
currentSelectedBreakdowns,
|
||||
(nextBreakdowns, previousBreakdowns) => {
|
||||
if (areStringArraysEqual([...nextBreakdowns], [...(previousBreakdowns ?? [])])) {
|
||||
return
|
||||
}
|
||||
|
||||
resetGraphDatasetSelection()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
currentSelectedProjectIds,
|
||||
(nextProjectIds, previousProjectIds) => {
|
||||
if (areStringArraysEqual(nextProjectIds, previousProjectIds ?? [])) {
|
||||
return
|
||||
}
|
||||
|
||||
resetGraphDatasetSelection()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[defaultSelectedGraphDatasetIds, sortedMetricGraphDatasetIds, showGraphDatasetSelection],
|
||||
([nextDefaultGraphDatasetIds, nextTopGraphDatasetIds, nextShowGraphDatasetSelection]) => {
|
||||
defaultGraphDatasetIds.value = nextShowGraphDatasetSelection
|
||||
? [...nextDefaultGraphDatasetIds]
|
||||
: []
|
||||
topGraphDatasetIds.value = nextShowGraphDatasetSelection ? [...nextTopGraphDatasetIds] : []
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[
|
||||
defaultSelectedGraphDatasetIds,
|
||||
showGraphDatasetSelection,
|
||||
hasExplicitGraphDatasetSelection,
|
||||
queryResetToken,
|
||||
],
|
||||
([nextDefaultGraphDatasetIds, nextShowGraphDatasetSelection, nextHasExplicitSelection]) => {
|
||||
if (!nextShowGraphDatasetSelection) {
|
||||
return
|
||||
}
|
||||
|
||||
if (nextHasExplicitSelection) {
|
||||
if (isDefaultGraphDatasetSelection(selectedGraphDatasetIds.value)) {
|
||||
setSelectedGraphDatasetIds(nextDefaultGraphDatasetIds, false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!areStringArraysEqual(selectedGraphDatasetIds.value, nextDefaultGraphDatasetIds)) {
|
||||
setSelectedGraphDatasetIds(nextDefaultGraphDatasetIds, false)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function getAnalyticsTableSelectableGraphDatasetIds(rows: AnalyticsTableRow[]): string[] {
|
||||
return Array.from(new Set(rows.map((row) => row.graphDatasetId)))
|
||||
}
|
||||
|
||||
return {
|
||||
filteredSelectableGraphDatasetIds,
|
||||
tableSelectedGraphDatasetIds,
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { AnalyticsTableRow } from './analytics-table-types'
|
||||
|
||||
type UseAnalyticsTablePaginationOptions = {
|
||||
filteredRows: ComputedRef<AnalyticsTableRow[]>
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export function useAnalyticsTablePagination({
|
||||
filteredRows,
|
||||
pageSize,
|
||||
}: UseAnalyticsTablePaginationOptions): {
|
||||
currentPage: Ref<number>
|
||||
pageCount: ComputedRef<number>
|
||||
visibleRowStart: ComputedRef<number>
|
||||
visibleRowEnd: ComputedRef<number>
|
||||
paginatedRows: ComputedRef<AnalyticsTableRow[]>
|
||||
switchPage: (page: number) => void
|
||||
} {
|
||||
const currentPage = ref(1)
|
||||
const pageCount = computed(() => Math.max(Math.ceil(filteredRows.value.length / pageSize), 1))
|
||||
const visibleRowStart = computed(() =>
|
||||
filteredRows.value.length === 0 ? 0 : (currentPage.value - 1) * pageSize + 1,
|
||||
)
|
||||
const visibleRowEnd = computed(() =>
|
||||
Math.min(currentPage.value * pageSize, filteredRows.value.length),
|
||||
)
|
||||
const paginatedRows = computed<AnalyticsTableRow[]>(() =>
|
||||
filteredRows.value.slice((currentPage.value - 1) * pageSize, currentPage.value * pageSize),
|
||||
)
|
||||
|
||||
watch(filteredRows, () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
|
||||
watch(pageCount, (nextPageCount) => {
|
||||
if (currentPage.value > nextPageCount) {
|
||||
currentPage.value = nextPageCount
|
||||
}
|
||||
})
|
||||
|
||||
function switchPage(page: number) {
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
return {
|
||||
currentPage,
|
||||
pageCount,
|
||||
visibleRowStart,
|
||||
visibleRowEnd,
|
||||
paginatedRows,
|
||||
switchPage,
|
||||
}
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
import type { ComputedRef, Ref, ShallowRef } from 'vue'
|
||||
import { ref, shallowRef } from 'vue'
|
||||
|
||||
import type {
|
||||
AnalyticsTableColumnKey,
|
||||
AnalyticsTableDisplayedRowsCache,
|
||||
AnalyticsTableMode,
|
||||
AnalyticsTableRow,
|
||||
AnalyticsTableSortDirectionValue,
|
||||
} from './analytics-table-types'
|
||||
|
||||
type UseAnalyticsTableRowCacheOptions = {
|
||||
activeTableMode: ComputedRef<AnalyticsTableMode>
|
||||
showBreakdownColumn: ComputedRef<boolean>
|
||||
analyticsPointCount: ComputedRef<number>
|
||||
sortColumn: Ref<AnalyticsTableColumnKey | undefined>
|
||||
sortDirection: Ref<AnalyticsTableSortDirectionValue>
|
||||
buildRows: (mode: AnalyticsTableMode) => AnalyticsTableRow[]
|
||||
sortRows: (rows: AnalyticsTableRow[]) => AnalyticsTableRow[]
|
||||
inactiveModeWarmupPointLimit: number
|
||||
}
|
||||
|
||||
export function useAnalyticsTableRowCache({
|
||||
activeTableMode,
|
||||
showBreakdownColumn,
|
||||
analyticsPointCount,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
buildRows,
|
||||
sortRows,
|
||||
inactiveModeWarmupPointLimit,
|
||||
}: UseAnalyticsTableRowCacheOptions): {
|
||||
displayedTableMode: Ref<AnalyticsTableMode>
|
||||
displayedSortColumn: Ref<AnalyticsTableColumnKey | undefined>
|
||||
displayedSortDirection: Ref<AnalyticsTableSortDirectionValue>
|
||||
displayedSortedRows: ShallowRef<AnalyticsTableRow[]>
|
||||
invalidateTableCaches: () => void
|
||||
invalidateSortedCaches: () => void
|
||||
scheduleRowsForMode: (mode: AnalyticsTableMode) => void
|
||||
scheduleInactiveModeWarmup: () => void
|
||||
resortDisplayedRowsForCurrentSort: () => boolean
|
||||
getSortedRowsForMode: (mode: AnalyticsTableMode) => AnalyticsTableRow[]
|
||||
} {
|
||||
const modeBuildRequestIds: Record<AnalyticsTableMode, number> = {
|
||||
date_breakdown: 0,
|
||||
breakdown_only: 0,
|
||||
}
|
||||
let tableCacheGeneration = 0
|
||||
let displayedSortedRowsGeneration = 0
|
||||
const displayedTableMode = ref<AnalyticsTableMode>('breakdown_only')
|
||||
const displayedSortColumn = ref<AnalyticsTableColumnKey | undefined>(sortColumn.value)
|
||||
const displayedSortDirection = ref<AnalyticsTableSortDirectionValue>(sortDirection.value)
|
||||
const displayedSortedRows = shallowRef<AnalyticsTableRow[]>([])
|
||||
const displayedRowsCache = shallowRef<AnalyticsTableDisplayedRowsCache | null>(null)
|
||||
|
||||
function invalidateTableCaches() {
|
||||
tableCacheGeneration++
|
||||
invalidateSortedCaches()
|
||||
}
|
||||
|
||||
function invalidateSortedCaches() {
|
||||
displayedRowsCache.value = null
|
||||
}
|
||||
|
||||
function hasSortedRowsForMode(mode: AnalyticsTableMode): boolean {
|
||||
const cached = displayedRowsCache.value
|
||||
return (
|
||||
cached !== null &&
|
||||
cached.generation === tableCacheGeneration &&
|
||||
cached.mode === mode &&
|
||||
cached.sortColumn === sortColumn.value &&
|
||||
cached.sortDirection === sortDirection.value
|
||||
)
|
||||
}
|
||||
|
||||
function setDisplayedRowsForMode(
|
||||
mode: AnalyticsTableMode,
|
||||
rows: AnalyticsTableRow[],
|
||||
generation = tableCacheGeneration,
|
||||
) {
|
||||
displayedRowsCache.value = {
|
||||
generation,
|
||||
mode,
|
||||
sortColumn: sortColumn.value,
|
||||
sortDirection: sortDirection.value,
|
||||
rows,
|
||||
}
|
||||
|
||||
if (mode === activeTableMode.value) {
|
||||
displayedSortedRowsGeneration = generation
|
||||
displayedTableMode.value = mode
|
||||
displayedSortColumn.value = sortColumn.value
|
||||
displayedSortDirection.value = sortDirection.value
|
||||
displayedSortedRows.value = rows
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRowsForMode(mode: AnalyticsTableMode) {
|
||||
if (hasSortedRowsForMode(mode)) {
|
||||
if (mode === activeTableMode.value) {
|
||||
displayRowsForMode(mode)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = ++modeBuildRequestIds[mode]
|
||||
const generation = tableCacheGeneration
|
||||
|
||||
void buildRowsForMode(mode, generation, requestId)
|
||||
}
|
||||
|
||||
function displayRowsForMode(mode: AnalyticsTableMode) {
|
||||
const cached = displayedRowsCache.value
|
||||
if (!cached || cached.generation !== tableCacheGeneration || cached.mode !== mode) {
|
||||
return
|
||||
}
|
||||
|
||||
displayedSortedRowsGeneration = cached.generation
|
||||
displayedTableMode.value = mode
|
||||
displayedSortColumn.value = cached.sortColumn
|
||||
displayedSortDirection.value = cached.sortDirection
|
||||
displayedSortedRows.value = cached.rows
|
||||
}
|
||||
|
||||
async function buildRowsForMode(mode: AnalyticsTableMode, generation: number, requestId: number) {
|
||||
await waitForDeferredTableWork()
|
||||
|
||||
if (isStaleBuild(mode, generation, requestId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const rows = sortRows(buildRows(mode))
|
||||
|
||||
if (isStaleBuild(mode, generation, requestId)) {
|
||||
return
|
||||
}
|
||||
|
||||
setDisplayedRowsForMode(mode, rows, generation)
|
||||
}
|
||||
|
||||
function isStaleBuild(mode: AnalyticsTableMode, generation: number, requestId: number): boolean {
|
||||
return tableCacheGeneration !== generation || modeBuildRequestIds[mode] !== requestId
|
||||
}
|
||||
|
||||
function waitForDeferredTableWork(): Promise<void> {
|
||||
if (!import.meta.client) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => resolve())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function scheduleInactiveModeWarmup() {
|
||||
if (!showBreakdownColumn.value) {
|
||||
return
|
||||
}
|
||||
if (analyticsPointCount.value > inactiveModeWarmupPointLimit) {
|
||||
return
|
||||
}
|
||||
|
||||
const inactiveMode: AnalyticsTableMode =
|
||||
activeTableMode.value === 'date_breakdown' ? 'breakdown_only' : 'date_breakdown'
|
||||
|
||||
if (hasSortedRowsForMode(inactiveMode)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!import.meta.client) {
|
||||
scheduleRowsForMode(inactiveMode)
|
||||
return
|
||||
}
|
||||
|
||||
const windowWithIdleCallback = window as Window & {
|
||||
requestIdleCallback?: (callback: () => void, options?: { timeout?: number }) => number
|
||||
}
|
||||
|
||||
if (windowWithIdleCallback.requestIdleCallback) {
|
||||
windowWithIdleCallback.requestIdleCallback(() => scheduleRowsForMode(inactiveMode), {
|
||||
timeout: 2000,
|
||||
})
|
||||
} else {
|
||||
window.setTimeout(() => scheduleRowsForMode(inactiveMode), 250)
|
||||
}
|
||||
}
|
||||
|
||||
function resortDisplayedRowsForCurrentSort(): boolean {
|
||||
const mode = activeTableMode.value
|
||||
if (
|
||||
displayedTableMode.value !== mode ||
|
||||
displayedSortedRowsGeneration !== tableCacheGeneration
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
setDisplayedRowsForMode(mode, sortRows(displayedSortedRows.value))
|
||||
return true
|
||||
}
|
||||
|
||||
function getSortedRowsForMode(mode: AnalyticsTableMode): AnalyticsTableRow[] {
|
||||
const cached = displayedRowsCache.value
|
||||
if (
|
||||
cached &&
|
||||
cached.generation === tableCacheGeneration &&
|
||||
cached.mode === mode &&
|
||||
cached.sortColumn === sortColumn.value &&
|
||||
cached.sortDirection === sortDirection.value
|
||||
) {
|
||||
return cached.rows
|
||||
}
|
||||
|
||||
return sortRows(buildRows(mode))
|
||||
}
|
||||
|
||||
return {
|
||||
displayedTableMode,
|
||||
displayedSortColumn,
|
||||
displayedSortDirection,
|
||||
displayedSortedRows,
|
||||
invalidateTableCaches,
|
||||
invalidateSortedCaches,
|
||||
scheduleRowsForMode,
|
||||
scheduleInactiveModeWarmup,
|
||||
resortDisplayedRowsForCurrentSort,
|
||||
getSortedRowsForMode,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import type { AnalyticsBreakdownPreset } from '~/providers/analytics/analytics'
|
||||
|
||||
import { formatAnalyticsDownloadSourceLabel, type FormatMessage } from './analytics-messages'
|
||||
|
||||
export const ALL_BREAKDOWN_VALUE = '__all__'
|
||||
export const UNKNOWN_BREAKDOWN_VALUE = '__unknown__'
|
||||
export const COMBINED_BREAKDOWN_LABEL_SEPARATOR = ' + '
|
||||
export const COMBINED_BREAKDOWN_DATASET_ID_PREFIX = 'breakdowns:'
|
||||
|
||||
export function getAnalyticsBreakdownValue(
|
||||
point: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
selectedBreakdown: AnalyticsBreakdownPreset,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
switch (selectedBreakdown) {
|
||||
case 'none':
|
||||
return ALL_BREAKDOWN_VALUE
|
||||
case 'project':
|
||||
return normalizeBreakdownValue('source_project' in point ? point.source_project : undefined)
|
||||
case 'country':
|
||||
return normalizeBreakdownValue('country' in point ? point.country?.toUpperCase() : undefined)
|
||||
case 'monetization': {
|
||||
if ('monetized' in point && typeof point.monetized === 'boolean') {
|
||||
return point.monetized ? 'monetized' : 'unmonetized'
|
||||
}
|
||||
return ALL_BREAKDOWN_VALUE
|
||||
}
|
||||
case 'user_agent': {
|
||||
const downloadSource = normalizeBreakdownValue(
|
||||
'user_agent' in point ? point.user_agent : undefined,
|
||||
)
|
||||
return downloadSource === ALL_BREAKDOWN_VALUE
|
||||
? ALL_BREAKDOWN_VALUE
|
||||
: getDownloadSourceLabel(downloadSource, formatMessage)
|
||||
}
|
||||
case 'download_reason':
|
||||
return normalizeBreakdownValue(
|
||||
'reason' in point ? point.reason : undefined,
|
||||
UNKNOWN_BREAKDOWN_VALUE,
|
||||
)
|
||||
case 'version_id':
|
||||
return normalizeBreakdownValue('version_id' in point ? point.version_id : undefined)
|
||||
case 'loader':
|
||||
return normalizeBreakdownValue(
|
||||
'loader' in point ? point.loader : undefined,
|
||||
UNKNOWN_BREAKDOWN_VALUE,
|
||||
)
|
||||
case 'game_version':
|
||||
return normalizeBreakdownValue(
|
||||
'game_version' in point ? point.game_version : undefined,
|
||||
UNKNOWN_BREAKDOWN_VALUE,
|
||||
)
|
||||
default:
|
||||
return ALL_BREAKDOWN_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
export function getAnalyticsBreakdownValues(
|
||||
point: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
selectedBreakdowns: readonly AnalyticsBreakdownPreset[],
|
||||
formatMessage: FormatMessage,
|
||||
): string[] {
|
||||
return selectedBreakdowns
|
||||
.filter((breakdown) => breakdown !== 'none')
|
||||
.map((breakdown) => getAnalyticsBreakdownValue(point, breakdown, formatMessage))
|
||||
}
|
||||
|
||||
export function getAnalyticsBreakdownKey(values: readonly string[]): string {
|
||||
return values.map((value) => encodeURIComponent(value)).join('+')
|
||||
}
|
||||
|
||||
export function getAnalyticsBreakdownDatasetId(
|
||||
values: readonly string[],
|
||||
selectedBreakdowns: readonly AnalyticsBreakdownPreset[],
|
||||
): string {
|
||||
const normalizedBreakdowns = selectedBreakdowns.filter((breakdown) => breakdown !== 'none')
|
||||
if (normalizedBreakdowns.length === 0) {
|
||||
return 'all'
|
||||
}
|
||||
if (normalizedBreakdowns.length === 1) {
|
||||
if (normalizedBreakdowns[0] === 'project') {
|
||||
return values[0] ?? 'all'
|
||||
}
|
||||
return `breakdown:${values[0] ?? 'all'}`
|
||||
}
|
||||
|
||||
return `${COMBINED_BREAKDOWN_DATASET_ID_PREFIX}${getAnalyticsBreakdownKey(values)}`
|
||||
}
|
||||
|
||||
export function getDownloadSourceLabel(value: string, formatMessage: FormatMessage): string {
|
||||
return formatAnalyticsDownloadSourceLabel(value, formatMessage)
|
||||
}
|
||||
|
||||
function normalizeBreakdownValue(
|
||||
value: string | undefined,
|
||||
fallback = ALL_BREAKDOWN_VALUE,
|
||||
): string {
|
||||
const normalized = value?.trim()
|
||||
return normalized && normalized.length > 0 ? normalized : fallback
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="flex touch-manipulation flex-col gap-4 pb-20 lg:pl-4 lg:pt-1.5">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-xl font-semibold text-contrast md:text-2xl">
|
||||
{{ formatMessage(analyticsMessages.title) }}
|
||||
</span>
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="isAnalyticsQueryBuilderDefault"
|
||||
@click="resetAnalyticsQueryBuilder"
|
||||
>
|
||||
{{ formatMessage(analyticsMessages.resetButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="projects.length === 0 || !fetchRequest || isRefetching"
|
||||
@click="refreshAnalyticsQuery"
|
||||
>
|
||||
<RefreshCwIcon :class="isRefetching ? 'animate-spin' : ''" />
|
||||
{{ formatMessage(analyticsMessages.refreshButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<QueryBuilder />
|
||||
</div>
|
||||
<StatCards />
|
||||
<AnalyticsChart />
|
||||
<AnalyticsTable />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RefreshCwIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, injectProjectPageContext, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import {
|
||||
createAnalyticsDashboardContext,
|
||||
provideAnalyticsDashboardContext,
|
||||
} from '~/providers/analytics/analytics'
|
||||
import { injectOrganizationContext } from '~/providers/organization-context'
|
||||
|
||||
import AnalyticsChart from './analytics-chart/index.vue'
|
||||
import { analyticsMessages } from './analytics-messages.ts'
|
||||
import AnalyticsTable from './analytics-table/index.vue'
|
||||
import QueryBuilder from './query-builder/index.vue'
|
||||
import StatCards from './stat-cards/StatCards.vue'
|
||||
|
||||
const auth = await useAuth()
|
||||
const { formatMessage } = useVIntl()
|
||||
const projectPageContext = injectProjectPageContext(null)
|
||||
const organizationContext = injectOrganizationContext(null)
|
||||
|
||||
const analyticsDashboardContext = createAnalyticsDashboardContext({
|
||||
auth,
|
||||
projectPageContext,
|
||||
organizationContext,
|
||||
})
|
||||
const {
|
||||
fetchRequest,
|
||||
isAnalyticsQueryBuilderDefault,
|
||||
isRefetching,
|
||||
projects,
|
||||
refreshAnalyticsQuery,
|
||||
resetAnalyticsQueryBuilder,
|
||||
} = analyticsDashboardContext
|
||||
|
||||
provideAnalyticsDashboardContext(analyticsDashboardContext)
|
||||
</script>
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="shrink-0 whitespace-nowrap text-sm font-semibold text-primary">
|
||||
{{ props.label }}
|
||||
</span>
|
||||
<input
|
||||
v-model="inputValue"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
placeholder="0"
|
||||
class="h-8 rounded-lg border border-solid border-surface-5 bg-surface-3 px-2 text-center text-sm font-semibold text-primary outline-none transition-[box-shadow,color] focus:text-contrast focus:ring-4 focus:ring-brand-shadow max-sm:text-base"
|
||||
:class="props.inputWidthClass"
|
||||
:aria-label="props.inputAriaLabel"
|
||||
@blur="formatInput"
|
||||
@keydown.enter.prevent.stop="submitInput"
|
||||
/>
|
||||
<span class="shrink-0 text-sm font-semibold text-primary">{{ suffixLabel }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVIntl } from '@modrinth/ui'
|
||||
|
||||
import { analyticsMessages } from '../analytics-messages'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
inputAriaLabel: string
|
||||
threshold?: number | null
|
||||
suffix?: string
|
||||
inputWidthClass?: string
|
||||
}>(),
|
||||
{
|
||||
inputWidthClass: 'w-20',
|
||||
},
|
||||
)
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const emit = defineEmits<{
|
||||
'update:threshold': [threshold: number | null]
|
||||
submit: [event: KeyboardEvent]
|
||||
}>()
|
||||
|
||||
const suffixLabel = computed(() => props.suffix ?? formatMessage(analyticsMessages.downloadsSuffix))
|
||||
const inputValue = ref('')
|
||||
let isSyncingThreshold = false
|
||||
let hasPendingEmittedThreshold = false
|
||||
let pendingEmittedThreshold: number | null = null
|
||||
|
||||
function parseDownloadsThreshold(value: string): number | null | undefined {
|
||||
const normalizedValue = value.trim().toLowerCase().replace(/,/g, '')
|
||||
if (!normalizedValue) {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = normalizedValue.match(/^(\d+(?:\.\d+)?)([kmb])?$/)
|
||||
if (!match) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const amount = Number.parseFloat(match[1])
|
||||
if (!Number.isFinite(amount)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const multiplierBySuffix: Record<string, number> = {
|
||||
k: 1_000,
|
||||
m: 1_000_000,
|
||||
b: 1_000_000_000,
|
||||
}
|
||||
|
||||
const multiplier = match[2] ? multiplierBySuffix[match[2]] : 1
|
||||
return Math.max(0, Math.floor(amount * multiplier))
|
||||
}
|
||||
|
||||
function formatCompactNumber(value: number): string {
|
||||
const formatWithSuffix = (divisor: number, suffix: string) => {
|
||||
const dividedValue = value / divisor
|
||||
const fractionDigits = Number.isInteger(dividedValue) ? 0 : 1
|
||||
return `${dividedValue.toFixed(fractionDigits).replace(/\.0$/, '')}${suffix}`
|
||||
}
|
||||
|
||||
if (value >= 1_000_000_000) return formatWithSuffix(1_000_000_000, 'B')
|
||||
if (value >= 1_000_000) return formatWithSuffix(1_000_000, 'M')
|
||||
if (value >= 1_000) return formatWithSuffix(1_000, 'k')
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function formatInput() {
|
||||
const threshold = parseDownloadsThreshold(inputValue.value)
|
||||
if (threshold === undefined || threshold === null) {
|
||||
return
|
||||
}
|
||||
|
||||
inputValue.value = formatCompactNumber(threshold)
|
||||
}
|
||||
|
||||
function submitInput(event: KeyboardEvent) {
|
||||
const threshold = parseDownloadsThreshold(inputValue.value)
|
||||
if (threshold === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
if (threshold !== null) {
|
||||
inputValue.value = formatCompactNumber(threshold)
|
||||
}
|
||||
|
||||
emit('update:threshold', threshold)
|
||||
emit('submit', event)
|
||||
}
|
||||
|
||||
watch(inputValue, (value) => {
|
||||
if (isSyncingThreshold) {
|
||||
return
|
||||
}
|
||||
|
||||
const threshold = parseDownloadsThreshold(value)
|
||||
if (threshold === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
hasPendingEmittedThreshold = true
|
||||
pendingEmittedThreshold = threshold
|
||||
emit('update:threshold', threshold)
|
||||
nextTick(() => {
|
||||
if (hasPendingEmittedThreshold && pendingEmittedThreshold === threshold) {
|
||||
hasPendingEmittedThreshold = false
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.threshold,
|
||||
(threshold) => {
|
||||
if (hasPendingEmittedThreshold && threshold === pendingEmittedThreshold) {
|
||||
hasPendingEmittedThreshold = false
|
||||
return
|
||||
}
|
||||
|
||||
isSyncingThreshold = true
|
||||
inputValue.value =
|
||||
threshold === null || threshold === undefined ? '' : formatCompactNumber(threshold)
|
||||
nextTick(() => {
|
||||
isSyncingThreshold = false
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,944 @@
|
||||
<template>
|
||||
<DropdownFilterBar
|
||||
v-model="selectedFilterValue"
|
||||
:categories="filterCategories"
|
||||
:show-clear="showClearAction && canClearSelectedBreakdown"
|
||||
:show-label="showLabel"
|
||||
:show-preview-filter-icon="showPreviewFilterIcon"
|
||||
:preview-trigger-class="previewTriggerClass"
|
||||
:add-button-class="addButtonClass"
|
||||
:clear-label="formatMessage(analyticsMessages.resetButton)"
|
||||
:add-label="resolvedAddLabel"
|
||||
checkbox-position="right"
|
||||
@clear="clearFilterBar"
|
||||
>
|
||||
<template #search-actions="{ category, setSelectedValues }">
|
||||
<div v-if="category.key === 'game_version'" class="mr-2 flex min-w-[124px] justify-end">
|
||||
<Tabs
|
||||
:value="gameVersionType"
|
||||
:tabs="gameVersionTypeTabs"
|
||||
:aria-label="formatMessage(analyticsMessages.gameVersionTypeAria)"
|
||||
@update:value="(type) => setGameVersionType(type, setSelectedValues)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #option="{ category, option, selected }">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<template v-if="category.key === 'version_id'">
|
||||
<span
|
||||
v-for="metadata in getProjectVersionOptionProjectMetadata(option.value)"
|
||||
:key="`${option.value}-${metadata.name}`"
|
||||
v-tooltip="metadata.name"
|
||||
class="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded text-primary"
|
||||
>
|
||||
<img
|
||||
v-if="metadata.iconUrl"
|
||||
:src="metadata.iconUrl"
|
||||
:alt="formatMessage(analyticsMessages.projectIconAlt, { name: metadata.name })"
|
||||
class="h-6 w-6 rounded object-cover"
|
||||
/>
|
||||
<BoxIcon v-else class="h-full w-full" />
|
||||
</span>
|
||||
</template>
|
||||
<span
|
||||
class="min-w-0 truncate font-semibold leading-tight"
|
||||
:class="selected ? 'text-contrast' : 'text-primary'"
|
||||
>
|
||||
{{ option.label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #category-footer="{ category, setSelectedValues, closeMenu }">
|
||||
<DownloadsThresholdInput
|
||||
v-if="category.key === 'country'"
|
||||
class="border-0 border-t border-solid border-surface-5 px-3 py-2.5"
|
||||
:label="formatMessage(analyticsMessages.countriesAbove)"
|
||||
:input-aria-label="formatMessage(analyticsMessages.countryDownloadsThresholdAria)"
|
||||
:threshold="countryDownloadsThreshold"
|
||||
input-width-class="w-16"
|
||||
@update:threshold="
|
||||
(threshold) => setCountryDownloadsThreshold(threshold, setSelectedValues)
|
||||
"
|
||||
@submit="
|
||||
(event) =>
|
||||
runDownloadsThresholdQuery(
|
||||
applyCountryDownloadsThreshold,
|
||||
setSelectedValues,
|
||||
closeMenu,
|
||||
event,
|
||||
)
|
||||
"
|
||||
/>
|
||||
<DownloadsThresholdInput
|
||||
v-else-if="category.key === 'version_id'"
|
||||
class="border-0 border-t border-solid border-surface-5 px-3 py-2.5"
|
||||
:label="formatMessage(analyticsMessages.projectVersionsAbove)"
|
||||
:input-aria-label="formatMessage(analyticsMessages.projectVersionDownloadsThresholdAria)"
|
||||
:threshold="projectVersionDownloadsThreshold"
|
||||
input-width-class="w-16"
|
||||
@update:threshold="
|
||||
(threshold) => setProjectVersionDownloadsThreshold(threshold, setSelectedValues)
|
||||
"
|
||||
@submit="
|
||||
(event) =>
|
||||
runDownloadsThresholdQuery(
|
||||
applyProjectVersionDownloadsThreshold,
|
||||
setSelectedValues,
|
||||
closeMenu,
|
||||
event,
|
||||
)
|
||||
"
|
||||
/>
|
||||
<DownloadsThresholdInput
|
||||
v-else-if="category.key === 'game_version'"
|
||||
class="border-0 border-t border-solid border-surface-5 px-3 py-2.5"
|
||||
:label="formatMessage(analyticsMessages.gameVersionsAbove)"
|
||||
:input-aria-label="formatMessage(analyticsMessages.gameVersionDownloadsThresholdAria)"
|
||||
:threshold="gameVersionDownloadsThreshold"
|
||||
input-width-class="w-16"
|
||||
@update:threshold="
|
||||
(threshold) => setGameVersionDownloadsThreshold(threshold, setSelectedValues)
|
||||
"
|
||||
@submit="
|
||||
(event) =>
|
||||
runDownloadsThresholdQuery(
|
||||
applyGameVersionDownloadsThreshold,
|
||||
setSelectedValues,
|
||||
closeMenu,
|
||||
event,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #preview-footer="{ category, setSelectedValues, closeMenu }">
|
||||
<DownloadsThresholdInput
|
||||
v-if="category.key === 'country'"
|
||||
class="border-0 border-t border-solid border-surface-5 px-3 py-2.5"
|
||||
:label="formatMessage(analyticsMessages.countriesAbove)"
|
||||
:input-aria-label="formatMessage(analyticsMessages.countryDownloadsThresholdAria)"
|
||||
:threshold="countryDownloadsThreshold"
|
||||
input-width-class="w-16"
|
||||
@update:threshold="
|
||||
(threshold) => setCountryDownloadsThreshold(threshold, setSelectedValues)
|
||||
"
|
||||
@submit="
|
||||
(event) =>
|
||||
runDownloadsThresholdQuery(
|
||||
applyCountryDownloadsThreshold,
|
||||
setSelectedValues,
|
||||
closeMenu,
|
||||
event,
|
||||
)
|
||||
"
|
||||
/>
|
||||
<DownloadsThresholdInput
|
||||
v-else-if="category.key === 'version_id'"
|
||||
class="border-0 border-t border-solid border-surface-5 px-3 py-2.5"
|
||||
:label="formatMessage(analyticsMessages.projectVersionsAbove)"
|
||||
:input-aria-label="formatMessage(analyticsMessages.projectVersionDownloadsThresholdAria)"
|
||||
:threshold="projectVersionDownloadsThreshold"
|
||||
input-width-class="w-16"
|
||||
@update:threshold="
|
||||
(threshold) => setProjectVersionDownloadsThreshold(threshold, setSelectedValues)
|
||||
"
|
||||
@submit="
|
||||
(event) =>
|
||||
runDownloadsThresholdQuery(
|
||||
applyProjectVersionDownloadsThreshold,
|
||||
setSelectedValues,
|
||||
closeMenu,
|
||||
event,
|
||||
)
|
||||
"
|
||||
/>
|
||||
<DownloadsThresholdInput
|
||||
v-else-if="category.key === 'game_version'"
|
||||
class="border-0 border-t border-solid border-surface-5 px-3 py-2.5"
|
||||
:label="formatMessage(analyticsMessages.gameVersionsAbove)"
|
||||
:input-aria-label="formatMessage(analyticsMessages.gameVersionDownloadsThresholdAria)"
|
||||
:threshold="gameVersionDownloadsThreshold"
|
||||
input-width-class="w-16"
|
||||
@update:threshold="
|
||||
(threshold) => setGameVersionDownloadsThreshold(threshold, setSelectedValues)
|
||||
"
|
||||
@submit="
|
||||
(event) =>
|
||||
runDownloadsThresholdQuery(
|
||||
applyGameVersionDownloadsThreshold,
|
||||
setSelectedValues,
|
||||
closeMenu,
|
||||
event,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</DropdownFilterBar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BoxIcon } from '@modrinth/assets'
|
||||
import {
|
||||
DropdownFilterBar,
|
||||
type DropdownFilterBarCategory,
|
||||
type DropdownFilterBarOption,
|
||||
Tabs,
|
||||
type TabsTab,
|
||||
type TabsValue,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
import { useFormattedCountries } from '@/composables/country.ts'
|
||||
import {
|
||||
areStringArraysEqual,
|
||||
getDefaultAnalyticsBreakdownPresets,
|
||||
} from '~/components/analytics-dashboard/analytics-route-query'
|
||||
import { useGeneratedState } from '~/composables/generated'
|
||||
import {
|
||||
type AnalyticsQueryFilterCategory,
|
||||
type AnalyticsSelectedFilters,
|
||||
doesProjectStatusMatchFilters,
|
||||
injectAnalyticsDashboardContext,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import {
|
||||
analyticsBreakdownMessages,
|
||||
analyticsMessages,
|
||||
analyticsMonetizationMessages,
|
||||
formatAnalyticsDownloadReasonLabel,
|
||||
formatAnalyticsLoaderLabel,
|
||||
formatAnalyticsProjectStatusLabel,
|
||||
} from '../analytics-messages.ts'
|
||||
import { getDownloadSourceLabel } from '../breakdown.ts'
|
||||
import DownloadsThresholdInput from './DownloadsThresholdInput.vue'
|
||||
import {
|
||||
areSelectedFiltersEqual,
|
||||
buildProjectVersionFilterOptionProjectMetadataById,
|
||||
buildProjectVersionFilterOptions,
|
||||
cloneSelectedFilters,
|
||||
FILTER_VALUE_CATEGORIES,
|
||||
getOptionsWithSelectedValues,
|
||||
getProjectVersionFilterOptionMetadataIds,
|
||||
getProjectVersionFilterOptionProjectMetadataCacheKey,
|
||||
getProjectVersionFilterOptionsCacheKey,
|
||||
getVisibleAnalyticsFilterCategoriesForState,
|
||||
normalizeSelectedValues as normalizeSelectedFilterValues,
|
||||
type ProjectVersionFilterOption,
|
||||
type ProjectVersionFilterOptionProjectMetadata,
|
||||
} from './query-filter.ts'
|
||||
|
||||
type AnalyticsFilterValueCategory = Exclude<AnalyticsQueryFilterCategory, 'project'>
|
||||
type GameVersionType = 'release' | 'all'
|
||||
type SetDropdownFilterValues = (values: string[]) => void
|
||||
type ApplyDownloadsThreshold = (setSelectedValues: SetDropdownFilterValues) => void
|
||||
type CloseDownloadsThresholdMenu = (event?: Event) => void
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
addLabel?: string
|
||||
showLabel?: boolean
|
||||
showPreviewFilterIcon?: boolean
|
||||
previewTriggerClass?: string
|
||||
addButtonClass?: string
|
||||
showClearAction?: boolean
|
||||
}>(),
|
||||
{
|
||||
showLabel: true,
|
||||
showPreviewFilterIcon: false,
|
||||
showClearAction: true,
|
||||
},
|
||||
)
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const {
|
||||
hasProjectContext,
|
||||
projects,
|
||||
selectedProjectIds,
|
||||
availableProjectStatuses,
|
||||
filterOptions,
|
||||
projectVersionDownloadsById,
|
||||
gameVersionDownloadsByVersion,
|
||||
countryDownloadsByCode,
|
||||
isAnalyticsFilterOptionsLoading,
|
||||
selectedBreakdowns,
|
||||
selectedFilters,
|
||||
queryResetToken,
|
||||
refreshAnalyticsQuery,
|
||||
hasCompletedAnalyticsLoading,
|
||||
versionNumbersById,
|
||||
versionPublishedDatesById,
|
||||
versionProjectNamesById,
|
||||
versionProjectIconUrlsById,
|
||||
getVersionDisplayName,
|
||||
} = injectAnalyticsDashboardContext()
|
||||
const formattedCountries = useFormattedCountries()
|
||||
const generatedState = useGeneratedState()
|
||||
|
||||
const gameVersionType = ref<GameVersionType>('release')
|
||||
const countryDownloadsThreshold = ref<number | null>(null)
|
||||
const projectVersionDownloadsThreshold = ref<number | null>(null)
|
||||
const gameVersionDownloadsThreshold = ref<number | null>(null)
|
||||
const gameVersionTypeTabs = computed<TabsTab[]>(() => [
|
||||
{ value: 'release', label: formatMessage(analyticsMessages.releaseTab) },
|
||||
{ value: 'all', label: formatMessage(analyticsMessages.allTab) },
|
||||
])
|
||||
const resolvedAddLabel = computed(
|
||||
() => props.addLabel ?? formatMessage(analyticsMessages.addButton),
|
||||
)
|
||||
const filterValueCategoryKeys = new Set<string>(FILTER_VALUE_CATEGORIES)
|
||||
const downloadsThresholdFilterCategories = ['country', 'version_id', 'game_version'] as const
|
||||
type DownloadsThresholdFilterCategory = (typeof downloadsThresholdFilterCategories)[number]
|
||||
const downloadsThresholdSelections = ref<
|
||||
Partial<Record<DownloadsThresholdFilterCategory, string[]>>
|
||||
>({})
|
||||
const projectStatusFilterOptions = computed<DropdownFilterBarOption[]>(() =>
|
||||
availableProjectStatuses.value.map((status) => ({
|
||||
value: status,
|
||||
label: getProjectStatusFilterOptionLabel(status),
|
||||
})),
|
||||
)
|
||||
const selectedProjectIdSet = computed(() => new Set(selectedProjectIds.value))
|
||||
const effectiveSelectedProjectCount = computed(
|
||||
() =>
|
||||
projects.value.filter(
|
||||
(project) =>
|
||||
selectedProjectIdSet.value.has(project.id) &&
|
||||
doesProjectStatusMatchFilters(project.status, selectedFilters.value),
|
||||
).length,
|
||||
)
|
||||
const showProjectVersionProjectIcons = computed(() => effectiveSelectedProjectCount.value > 1)
|
||||
const defaultSelectedBreakdown = computed(() =>
|
||||
getDefaultAnalyticsBreakdownPresets(selectedProjectIds.value),
|
||||
)
|
||||
const canClearSelectedBreakdown = computed(
|
||||
() => !areStringArraysEqual(selectedBreakdowns.value, defaultSelectedBreakdown.value),
|
||||
)
|
||||
const analyticsFilterOptionsEmptyLabel = computed(() =>
|
||||
isAnalyticsFilterOptionsLoading.value
|
||||
? formatMessage(analyticsMessages.loadingOptions)
|
||||
: undefined,
|
||||
)
|
||||
const projectVersionFilterOptions = shallowRef<ProjectVersionFilterOption[]>([])
|
||||
const projectVersionFilterOptionProjectMetadataById = shallowRef(
|
||||
new Map<string, ProjectVersionFilterOptionProjectMetadata[]>(),
|
||||
)
|
||||
const draftSelectedFilters = ref<AnalyticsSelectedFilters>(
|
||||
cloneSelectedFilters(selectedFilters.value),
|
||||
)
|
||||
let selectedFiltersCommitRequestId = 0
|
||||
let projectVersionFilterOptionsCacheKey = ''
|
||||
let projectVersionFilterOptionProjectMetadataCacheKey = ''
|
||||
|
||||
const selectedFilterValue = computed<Record<string, string[]>>({
|
||||
get: () => getSelectedFilterBarValue(),
|
||||
set: (nextValue) => {
|
||||
const nextFilters = cloneSelectedFilters(draftSelectedFilters.value)
|
||||
|
||||
for (const [categoryKey, values] of Object.entries(nextValue)) {
|
||||
if (!isAnalyticsFilterValueCategory(categoryKey)) {
|
||||
continue
|
||||
}
|
||||
|
||||
nextFilters[categoryKey] = normalizeSelectedFilterValues(categoryKey, values, [])
|
||||
}
|
||||
|
||||
draftSelectedFilters.value = nextFilters
|
||||
void scheduleSelectedFiltersCommit()
|
||||
},
|
||||
})
|
||||
|
||||
function getSelectedFilterBarValue(): AnalyticsSelectedFilters {
|
||||
return cloneSelectedFilters(draftSelectedFilters.value)
|
||||
}
|
||||
|
||||
function clearSelectedBreakdown() {
|
||||
selectedBreakdowns.value = defaultSelectedBreakdown.value
|
||||
}
|
||||
|
||||
function clearFilterBar() {
|
||||
clearSelectedBreakdown()
|
||||
clearDownloadsThresholds()
|
||||
}
|
||||
|
||||
watch(queryResetToken, () => {
|
||||
selectedFiltersCommitRequestId++
|
||||
draftSelectedFilters.value = cloneSelectedFilters(selectedFilters.value)
|
||||
clearDownloadsThresholds()
|
||||
})
|
||||
|
||||
watch(
|
||||
selectedFilters,
|
||||
(nextFilters, previousFilters) => {
|
||||
selectedFiltersCommitRequestId++
|
||||
draftSelectedFilters.value = cloneSelectedFilters(nextFilters)
|
||||
clearDownloadsThresholdsForChangedFilters(previousFilters, nextFilters)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[
|
||||
hasCompletedAnalyticsLoading,
|
||||
filterOptions,
|
||||
versionNumbersById,
|
||||
versionPublishedDatesById,
|
||||
versionProjectNamesById,
|
||||
],
|
||||
([
|
||||
hasCompletedLoading,
|
||||
nextFilterOptions,
|
||||
nextVersionNumbersById,
|
||||
nextVersionPublishedDatesById,
|
||||
nextVersionProjectNamesById,
|
||||
]) => {
|
||||
if (!hasCompletedLoading) {
|
||||
projectVersionFilterOptionsCacheKey = ''
|
||||
if (projectVersionFilterOptions.value.length > 0) {
|
||||
projectVersionFilterOptions.value = []
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const nextCacheKey = getProjectVersionFilterOptionsCacheKey(
|
||||
nextFilterOptions.versionIds,
|
||||
nextVersionNumbersById,
|
||||
nextVersionPublishedDatesById,
|
||||
nextVersionProjectNamesById,
|
||||
)
|
||||
if (nextCacheKey === projectVersionFilterOptionsCacheKey) {
|
||||
return
|
||||
}
|
||||
|
||||
projectVersionFilterOptionsCacheKey = nextCacheKey
|
||||
projectVersionFilterOptions.value = buildProjectVersionFilterOptions(
|
||||
nextFilterOptions.versionIds,
|
||||
nextVersionNumbersById,
|
||||
nextVersionPublishedDatesById,
|
||||
nextVersionProjectNamesById,
|
||||
)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[
|
||||
hasCompletedAnalyticsLoading,
|
||||
filterOptions,
|
||||
selectedFilters,
|
||||
versionProjectNamesById,
|
||||
versionProjectIconUrlsById,
|
||||
],
|
||||
([
|
||||
hasCompletedLoading,
|
||||
nextFilterOptions,
|
||||
nextSelectedFilters,
|
||||
nextVersionProjectNamesById,
|
||||
nextVersionProjectIconUrlsById,
|
||||
]) => {
|
||||
if (!hasCompletedLoading) {
|
||||
projectVersionFilterOptionProjectMetadataCacheKey = ''
|
||||
if (projectVersionFilterOptionProjectMetadataById.value.size > 0) {
|
||||
projectVersionFilterOptionProjectMetadataById.value = new Map()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const metadataIds = getProjectVersionFilterOptionMetadataIds(
|
||||
nextFilterOptions.versionIds,
|
||||
nextSelectedFilters.version_id,
|
||||
)
|
||||
const nextCacheKey = getProjectVersionFilterOptionProjectMetadataCacheKey(
|
||||
metadataIds,
|
||||
nextVersionProjectNamesById,
|
||||
nextVersionProjectIconUrlsById,
|
||||
)
|
||||
if (nextCacheKey === projectVersionFilterOptionProjectMetadataCacheKey) {
|
||||
return
|
||||
}
|
||||
|
||||
projectVersionFilterOptionProjectMetadataCacheKey = nextCacheKey
|
||||
projectVersionFilterOptionProjectMetadataById.value =
|
||||
buildProjectVersionFilterOptionProjectMetadataById(
|
||||
metadataIds,
|
||||
nextVersionProjectNamesById,
|
||||
nextVersionProjectIconUrlsById,
|
||||
)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function scheduleSelectedFiltersCommit() {
|
||||
const requestId = ++selectedFiltersCommitRequestId
|
||||
const nextFilters = cloneSelectedFilters(draftSelectedFilters.value)
|
||||
|
||||
await waitForDeferredQueryFilterCommit()
|
||||
|
||||
if (requestId !== selectedFiltersCommitRequestId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!areSelectedFiltersEqual(selectedFilters.value, nextFilters)) {
|
||||
selectedFilters.value = nextFilters
|
||||
}
|
||||
}
|
||||
|
||||
function waitForDeferredQueryFilterCommit(): Promise<void> {
|
||||
if (!import.meta.client) {
|
||||
return nextTick()
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
nextTick(() => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => resolve())
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const filterCategories = computed<DropdownFilterBarCategory[]>(() => {
|
||||
const visibleCategoryKeys = new Set(
|
||||
getVisibleAnalyticsFilterCategoriesForState(selectedBreakdowns.value, selectedFilters.value),
|
||||
)
|
||||
const categories: DropdownFilterBarCategory[] = []
|
||||
|
||||
if (!hasProjectContext.value) {
|
||||
categories.push({
|
||||
key: 'project_status',
|
||||
label: formatMessage(analyticsBreakdownMessages.projectStatus),
|
||||
options: withSelectedOptions('project_status', projectStatusFilterOptions.value),
|
||||
})
|
||||
}
|
||||
|
||||
categories.push(
|
||||
{
|
||||
key: 'country',
|
||||
label: formatMessage(analyticsBreakdownMessages.country),
|
||||
searchable: countryFilterOptions.value.length > 6,
|
||||
searchPlaceholder: formatMessage(analyticsMessages.searchCountriesPlaceholder),
|
||||
emptyOptionsLabel: analyticsFilterOptionsEmptyLabel.value,
|
||||
emptySearchLabel: analyticsFilterOptionsEmptyLabel.value,
|
||||
options: withSelectedOptions('country', countryFilterOptions.value),
|
||||
submenuClass: 'w-fit',
|
||||
previewDropdownWidth: 'fit-content',
|
||||
},
|
||||
{
|
||||
key: 'monetization',
|
||||
label: formatMessage(analyticsBreakdownMessages.monetization),
|
||||
options: withSelectedOptions('monetization', [
|
||||
{ value: 'monetized', label: formatMessage(analyticsMonetizationMessages.monetized) },
|
||||
{ value: 'unmonetized', label: formatMessage(analyticsMonetizationMessages.unmonetized) },
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: 'user_agent',
|
||||
label: formatMessage(analyticsBreakdownMessages.userAgent),
|
||||
searchable: downloadSourceFilterOptions.value.length > 6,
|
||||
searchPlaceholder: formatMessage(analyticsMessages.searchDownloadSourcesPlaceholder),
|
||||
emptyOptionsLabel: analyticsFilterOptionsEmptyLabel.value,
|
||||
emptySearchLabel: analyticsFilterOptionsEmptyLabel.value,
|
||||
options: withSelectedOptions('user_agent', downloadSourceFilterOptions.value),
|
||||
},
|
||||
{
|
||||
key: 'download_reason',
|
||||
label: formatMessage(analyticsBreakdownMessages.downloadReason),
|
||||
emptyOptionsLabel: analyticsFilterOptionsEmptyLabel.value,
|
||||
emptySearchLabel: analyticsFilterOptionsEmptyLabel.value,
|
||||
options: withSelectedOptions('download_reason', downloadReasonFilterOptions.value),
|
||||
},
|
||||
{
|
||||
key: 'version_id',
|
||||
label: formatMessage(analyticsBreakdownMessages.versionId),
|
||||
searchable: projectVersionFilterOptions.value.length > 6,
|
||||
searchPlaceholder: formatMessage(analyticsMessages.searchProjectVersionsPlaceholder),
|
||||
submenuClass: 'w-fit',
|
||||
previewDropdownWidth: 'fit-content',
|
||||
options: withSelectedOptions('version_id', projectVersionFilterOptions.value),
|
||||
},
|
||||
{
|
||||
key: 'game_version',
|
||||
label: formatMessage(analyticsBreakdownMessages.gameVersion),
|
||||
searchable: true,
|
||||
searchPlaceholder: formatMessage(analyticsMessages.searchVersionsPlaceholder),
|
||||
submenuClass: 'w-fit max-w-[338px]',
|
||||
previewDropdownWidth: '338px',
|
||||
options: withSelectedOptions('game_version', gameVersionFilterOptions.value),
|
||||
},
|
||||
{
|
||||
key: 'loader_type',
|
||||
label: formatMessage(analyticsBreakdownMessages.loader),
|
||||
options: withSelectedOptions('loader_type', loaderTypeFilterOptions.value),
|
||||
},
|
||||
)
|
||||
|
||||
return categories.filter((category) =>
|
||||
visibleCategoryKeys.has(category.key as AnalyticsFilterValueCategory),
|
||||
)
|
||||
})
|
||||
|
||||
const countryLabelsByCode = computed(
|
||||
() =>
|
||||
new Map(
|
||||
formattedCountries.value.map(
|
||||
(country) => [country.value.toUpperCase(), country.label] as const,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const countryFilterOptions = computed<DropdownFilterBarOption[]>(() =>
|
||||
filterOptions.value.countries
|
||||
.map((countryCode) => ({
|
||||
value: countryCode,
|
||||
label: getCountryFilterOptionLabel(countryCode),
|
||||
searchTerms: [countryCode],
|
||||
}))
|
||||
.sort((left, right) => left.label.localeCompare(right.label)),
|
||||
)
|
||||
|
||||
const gameVersionReleaseDatesByVersion = computed(
|
||||
() =>
|
||||
new Map(
|
||||
generatedState.value.gameVersions.map(
|
||||
(gameVersion) => [gameVersion.version, gameVersion.date] as const,
|
||||
),
|
||||
),
|
||||
)
|
||||
const gameVersionTypesByVersion = computed(
|
||||
() =>
|
||||
new Map(
|
||||
generatedState.value.gameVersions.map(
|
||||
(gameVersion) => [gameVersion.version, gameVersion.version_type] as const,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const downloadSourceFilterOptions = computed<DropdownFilterBarOption[]>(() =>
|
||||
filterOptions.value.downloadSources
|
||||
.map((downloadSource) => ({
|
||||
value: downloadSource,
|
||||
label: getDownloadSourceLabel(downloadSource, formatMessage),
|
||||
}))
|
||||
.sort((left, right) => left.label.localeCompare(right.label)),
|
||||
)
|
||||
|
||||
const downloadReasonFilterOptions = computed<DropdownFilterBarOption[]>(() =>
|
||||
filterOptions.value.downloadReasons.map((downloadReason) => ({
|
||||
value: downloadReason,
|
||||
label: getDownloadReasonFilterOptionLabel(downloadReason),
|
||||
})),
|
||||
)
|
||||
|
||||
const gameVersionFilterOptions = computed<DropdownFilterBarOption[]>(() =>
|
||||
filterOptions.value.gameVersions
|
||||
.filter((gameVersion) => {
|
||||
const versionType = gameVersionTypesByVersion.value.get(gameVersion)
|
||||
return (
|
||||
gameVersionType.value === 'all' || versionType === undefined || versionType === 'release'
|
||||
)
|
||||
})
|
||||
.map((gameVersion) => ({
|
||||
value: gameVersion,
|
||||
label: gameVersion,
|
||||
}))
|
||||
.sort((left, right) =>
|
||||
compareOptionalDateStringsDescending(
|
||||
gameVersionReleaseDatesByVersion.value.get(left.value),
|
||||
gameVersionReleaseDatesByVersion.value.get(right.value),
|
||||
left.label,
|
||||
right.label,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const loaderTypeFilterOptions = computed<DropdownFilterBarOption[]>(() =>
|
||||
filterOptions.value.loaderTypes
|
||||
.map((loaderType) => ({
|
||||
value: loaderType,
|
||||
label: getLoaderTypeFilterOptionLabel(loaderType),
|
||||
searchTerms: [loaderType],
|
||||
}))
|
||||
.sort((left, right) => left.label.localeCompare(right.label)),
|
||||
)
|
||||
|
||||
function isAnalyticsFilterValueCategory(
|
||||
categoryKey: string,
|
||||
): categoryKey is AnalyticsFilterValueCategory {
|
||||
return filterValueCategoryKeys.has(categoryKey)
|
||||
}
|
||||
|
||||
function withSelectedOptions(
|
||||
categoryKey: AnalyticsFilterValueCategory,
|
||||
options: DropdownFilterBarOption[],
|
||||
): DropdownFilterBarOption[] {
|
||||
return getOptionsWithSelectedValues(
|
||||
options,
|
||||
selectedFilters.value[categoryKey],
|
||||
getMissingSelectedOptionLabel(categoryKey),
|
||||
)
|
||||
}
|
||||
|
||||
function getMissingSelectedOptionLabel(
|
||||
categoryKey: AnalyticsFilterValueCategory,
|
||||
): ((value: string) => string) | undefined {
|
||||
if (categoryKey === 'country') {
|
||||
return getCountryFilterOptionLabel
|
||||
}
|
||||
if (categoryKey === 'version_id') {
|
||||
return getVersionDisplayName
|
||||
}
|
||||
if (categoryKey === 'download_reason') {
|
||||
return getDownloadReasonFilterOptionLabel
|
||||
}
|
||||
if (categoryKey === 'user_agent') {
|
||||
return (value) => getDownloadSourceLabel(value, formatMessage)
|
||||
}
|
||||
if (categoryKey === 'loader_type') {
|
||||
return getLoaderTypeFilterOptionLabel
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getProjectVersionOptionProjectMetadata(versionId: string) {
|
||||
if (!showProjectVersionProjectIcons.value) {
|
||||
return []
|
||||
}
|
||||
|
||||
return projectVersionFilterOptionProjectMetadataById.value.get(versionId) ?? []
|
||||
}
|
||||
|
||||
function getCountryFilterOptionLabel(countryCode: string): string {
|
||||
const normalizedCode = countryCode.trim().toUpperCase()
|
||||
if (normalizedCode === 'XX') {
|
||||
return formatMessage(analyticsMessages.other)
|
||||
}
|
||||
|
||||
return countryLabelsByCode.value.get(normalizedCode) ?? countryCode
|
||||
}
|
||||
|
||||
function getProjectStatusFilterOptionLabel(status: string): string {
|
||||
return formatAnalyticsProjectStatusLabel(status, formatMessage)
|
||||
}
|
||||
|
||||
function getLoaderTypeFilterOptionLabel(loaderType: string): string {
|
||||
return formatAnalyticsLoaderLabel(loaderType, formatMessage)
|
||||
}
|
||||
|
||||
function getDownloadReasonFilterOptionLabel(reason: string): string {
|
||||
return formatAnalyticsDownloadReasonLabel(reason, formatMessage)
|
||||
}
|
||||
|
||||
function getDateTimestamp(date: string | undefined): number | undefined {
|
||||
if (!date) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const timestamp = new Date(date).getTime()
|
||||
return Number.isFinite(timestamp) ? timestamp : undefined
|
||||
}
|
||||
|
||||
function compareOptionalDateStringsDescending(
|
||||
leftDate: string | undefined,
|
||||
rightDate: string | undefined,
|
||||
leftFallback: string,
|
||||
rightFallback: string,
|
||||
): number {
|
||||
const leftTimestamp = getDateTimestamp(leftDate)
|
||||
const rightTimestamp = getDateTimestamp(rightDate)
|
||||
|
||||
if (leftTimestamp !== undefined && rightTimestamp !== undefined) {
|
||||
return rightTimestamp - leftTimestamp
|
||||
}
|
||||
if (leftTimestamp !== undefined) {
|
||||
return -1
|
||||
}
|
||||
if (rightTimestamp !== undefined) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return leftFallback.localeCompare(rightFallback)
|
||||
}
|
||||
|
||||
function applyGameVersionDownloadsThreshold(setSelectedValues: SetDropdownFilterValues) {
|
||||
const threshold = gameVersionDownloadsThreshold.value
|
||||
if (threshold === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const selectedValues = gameVersionFilterOptions.value
|
||||
.filter((gameVersion) => {
|
||||
return (gameVersionDownloadsByVersion.value.get(gameVersion.value) ?? 0) >= threshold
|
||||
})
|
||||
.map((gameVersion) => gameVersion.value)
|
||||
|
||||
setDownloadsThresholdSelectedValues('game_version', selectedValues, setSelectedValues)
|
||||
}
|
||||
|
||||
function applyCountryDownloadsThreshold(setSelectedValues: SetDropdownFilterValues) {
|
||||
const threshold = countryDownloadsThreshold.value
|
||||
if (threshold === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const selectedValues = countryFilterOptions.value
|
||||
.filter((country) => {
|
||||
return (
|
||||
(countryDownloadsByCode.value.get(country.value.trim().toUpperCase()) ?? 0) >= threshold
|
||||
)
|
||||
})
|
||||
.map((country) => country.value)
|
||||
|
||||
setDownloadsThresholdSelectedValues('country', selectedValues, setSelectedValues)
|
||||
}
|
||||
|
||||
function applyProjectVersionDownloadsThreshold(setSelectedValues: SetDropdownFilterValues) {
|
||||
const threshold = projectVersionDownloadsThreshold.value
|
||||
if (threshold === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const selectedValues = projectVersionFilterOptions.value
|
||||
.filter((version) => {
|
||||
return (projectVersionDownloadsById.value.get(version.value) ?? 0) >= threshold
|
||||
})
|
||||
.map((version) => version.value)
|
||||
|
||||
setDownloadsThresholdSelectedValues('version_id', selectedValues, setSelectedValues)
|
||||
}
|
||||
|
||||
function setCountryDownloadsThreshold(
|
||||
threshold: number | null,
|
||||
setSelectedValues: SetDropdownFilterValues,
|
||||
) {
|
||||
countryDownloadsThreshold.value = threshold
|
||||
if (threshold === null) {
|
||||
clearDownloadsThreshold('country')
|
||||
setSelectedValues([])
|
||||
return
|
||||
}
|
||||
|
||||
applyCountryDownloadsThreshold(setSelectedValues)
|
||||
}
|
||||
|
||||
function setProjectVersionDownloadsThreshold(
|
||||
threshold: number | null,
|
||||
setSelectedValues: SetDropdownFilterValues,
|
||||
) {
|
||||
projectVersionDownloadsThreshold.value = threshold
|
||||
if (threshold === null) {
|
||||
clearDownloadsThreshold('version_id')
|
||||
setSelectedValues([])
|
||||
return
|
||||
}
|
||||
|
||||
applyProjectVersionDownloadsThreshold(setSelectedValues)
|
||||
}
|
||||
|
||||
function setGameVersionDownloadsThreshold(
|
||||
threshold: number | null,
|
||||
setSelectedValues: SetDropdownFilterValues,
|
||||
) {
|
||||
gameVersionDownloadsThreshold.value = threshold
|
||||
if (threshold === null) {
|
||||
clearDownloadsThreshold('game_version')
|
||||
setSelectedValues([])
|
||||
return
|
||||
}
|
||||
|
||||
applyGameVersionDownloadsThreshold(setSelectedValues)
|
||||
}
|
||||
|
||||
function clearDownloadsThresholdsForChangedFilters(
|
||||
previousFilters: AnalyticsSelectedFilters,
|
||||
nextFilters: AnalyticsSelectedFilters,
|
||||
) {
|
||||
for (const categoryKey of downloadsThresholdFilterCategories) {
|
||||
if (areFilterSelectionsEqual(previousFilters[categoryKey], nextFilters[categoryKey])) {
|
||||
continue
|
||||
}
|
||||
|
||||
const thresholdSelection = downloadsThresholdSelections.value[categoryKey]
|
||||
if (
|
||||
thresholdSelection &&
|
||||
areFilterSelectionsEqual(thresholdSelection, nextFilters[categoryKey])
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (previousFilters[categoryKey].length > 0 || nextFilters[categoryKey].length > 0) {
|
||||
clearDownloadsThreshold(categoryKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setDownloadsThresholdSelectedValues(
|
||||
categoryKey: DownloadsThresholdFilterCategory,
|
||||
selectedValues: string[],
|
||||
setSelectedValues: SetDropdownFilterValues,
|
||||
) {
|
||||
downloadsThresholdSelections.value = {
|
||||
...downloadsThresholdSelections.value,
|
||||
[categoryKey]: normalizeSelectedFilterValues(categoryKey, selectedValues, []),
|
||||
}
|
||||
setSelectedValues(selectedValues)
|
||||
}
|
||||
|
||||
function clearDownloadsThreshold(categoryKey: DownloadsThresholdFilterCategory) {
|
||||
switch (categoryKey) {
|
||||
case 'country':
|
||||
countryDownloadsThreshold.value = null
|
||||
break
|
||||
case 'version_id':
|
||||
projectVersionDownloadsThreshold.value = null
|
||||
break
|
||||
case 'game_version':
|
||||
gameVersionDownloadsThreshold.value = null
|
||||
break
|
||||
}
|
||||
|
||||
const { [categoryKey]: _removedSelection, ...nextSelections } = downloadsThresholdSelections.value
|
||||
downloadsThresholdSelections.value = nextSelections
|
||||
}
|
||||
|
||||
function clearDownloadsThresholds() {
|
||||
for (const categoryKey of downloadsThresholdFilterCategories) {
|
||||
clearDownloadsThreshold(categoryKey)
|
||||
}
|
||||
}
|
||||
|
||||
function areFilterSelectionsEqual(left: string[], right: string[]): boolean {
|
||||
const leftValues = new Set(left)
|
||||
const rightValues = new Set(right)
|
||||
if (leftValues.size !== rightValues.size) {
|
||||
return false
|
||||
}
|
||||
|
||||
return [...leftValues].every((value) => rightValues.has(value))
|
||||
}
|
||||
|
||||
async function runDownloadsThresholdQuery(
|
||||
applyDownloadsThreshold: ApplyDownloadsThreshold,
|
||||
setSelectedValues: SetDropdownFilterValues,
|
||||
closeMenu: CloseDownloadsThresholdMenu,
|
||||
event?: KeyboardEvent,
|
||||
) {
|
||||
applyDownloadsThreshold(setSelectedValues)
|
||||
closeMenu(event)
|
||||
await scheduleSelectedFiltersCommit()
|
||||
await refreshAnalyticsQuery()
|
||||
}
|
||||
|
||||
function setGameVersionType(type: TabsValue, setSelectedValues: SetDropdownFilterValues) {
|
||||
if (!isGameVersionType(type)) {
|
||||
return
|
||||
}
|
||||
|
||||
gameVersionType.value = type
|
||||
applyGameVersionDownloadsThreshold(setSelectedValues)
|
||||
}
|
||||
|
||||
function isGameVersionType(type: TabsValue): type is GameVersionType {
|
||||
return type === 'release' || type === 'all'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<BaseTimeFramePicker
|
||||
v-model:mode="selectedTimeframeMode"
|
||||
v-model:preset="selectedTimeframe"
|
||||
v-model:last-amount="selectedLastTimeframeAmount"
|
||||
v-model:last-unit="selectedLastTimeframeUnit"
|
||||
v-model:custom-start-date="selectedCustomTimeframeStartDate"
|
||||
v-model:custom-end-date="selectedCustomTimeframeEndDate"
|
||||
:min-date="ANALYTICS_START_DATE_INPUT_VALUE"
|
||||
:now-timestamp="queryRefreshTimestamp"
|
||||
:trigger-class="triggerClass"
|
||||
@open="handleTimeframeOpen"
|
||||
@commit="handleTimeframeCommit"
|
||||
@apply="handleTimeframeApply"
|
||||
@draft-change="handleTimeframeDraftChange"
|
||||
@preset-select="handleTimeframePresetSelect"
|
||||
>
|
||||
<template #prefix>
|
||||
<slot name="prefix"></slot>
|
||||
</template>
|
||||
</BaseTimeFramePicker>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
type ComboboxOption,
|
||||
TimeFramePicker as BaseTimeFramePicker,
|
||||
type TimeFramePickerSelection,
|
||||
type TimeFramePreset,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
import {
|
||||
ANALYTICS_START_DATE_INPUT_VALUE,
|
||||
type AnalyticsGroupByPreset,
|
||||
type AnalyticsTimeframePreset,
|
||||
injectAnalyticsDashboardContext,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import {
|
||||
ensureMinimumTimeRange,
|
||||
getAnalyticsTimeRange,
|
||||
getDateInputValue,
|
||||
getDefaultAnalyticsGroupByForDurationMinutes,
|
||||
} from './timeframe'
|
||||
|
||||
const {
|
||||
selectedTimeframeMode,
|
||||
selectedTimeframe,
|
||||
selectedLastTimeframeAmount,
|
||||
selectedLastTimeframeUnit,
|
||||
selectedCustomTimeframeStartDate,
|
||||
selectedCustomTimeframeEndDate,
|
||||
selectedGroupBy,
|
||||
queryRefreshTimestamp,
|
||||
refreshAnalyticsQuery,
|
||||
} = injectAnalyticsDashboardContext()
|
||||
|
||||
defineProps<{
|
||||
triggerClass?: string
|
||||
}>()
|
||||
|
||||
const draftSelectedGroupBy = ref(selectedGroupBy.value)
|
||||
|
||||
const defaultGroupByForPreset: Partial<Record<AnalyticsTimeframePreset, AnalyticsGroupByPreset>> = {
|
||||
today: '1h',
|
||||
yesterday: '1h',
|
||||
last_7_days: '6h',
|
||||
last_14_days: 'day',
|
||||
last_30_days: 'day',
|
||||
last_90_days: 'day',
|
||||
last_180_days: 'week',
|
||||
year_to_date: 'week',
|
||||
}
|
||||
|
||||
function handleTimeframeOpen() {
|
||||
draftSelectedGroupBy.value = selectedGroupBy.value
|
||||
}
|
||||
|
||||
function handleTimeframeCommit() {
|
||||
selectedGroupBy.value = draftSelectedGroupBy.value
|
||||
}
|
||||
|
||||
async function handleTimeframeApply() {
|
||||
await refreshAnalyticsQuery()
|
||||
}
|
||||
|
||||
function handleTimeframePresetSelect(option: ComboboxOption<TimeFramePreset>) {
|
||||
const defaultGroupBy = defaultGroupByForPreset[option.value as AnalyticsTimeframePreset]
|
||||
if (defaultGroupBy) {
|
||||
draftSelectedGroupBy.value = defaultGroupBy
|
||||
}
|
||||
}
|
||||
|
||||
function handleTimeframeDraftChange(selection: TimeFramePickerSelection) {
|
||||
if (selection.mode !== 'last' && selection.mode !== 'custom_range') {
|
||||
return
|
||||
}
|
||||
if (selection.mode === 'custom_range' && !hasCompleteCustomDateRange(selection)) {
|
||||
return
|
||||
}
|
||||
|
||||
const range = getAnalyticsTimeRange({
|
||||
mode: selection.mode,
|
||||
preset: selection.preset,
|
||||
lastAmount: selection.lastAmount,
|
||||
lastUnit: selection.lastUnit,
|
||||
customStartDate: selection.customStartDate,
|
||||
customEndDate: selection.customEndDate,
|
||||
nowTimestamp: queryRefreshTimestamp.value,
|
||||
})
|
||||
const { start, end } = ensureMinimumTimeRange(range.start, range.end)
|
||||
const durationMinutes = Math.max(1, Math.floor((end.getTime() - start.getTime()) / 60000))
|
||||
draftSelectedGroupBy.value = getDefaultAnalyticsGroupByForDurationMinutes(durationMinutes)
|
||||
}
|
||||
|
||||
function hasCompleteCustomDateRange(selection: TimeFramePickerSelection) {
|
||||
return Boolean(
|
||||
getDateFromInputValue(selection.customStartDate) &&
|
||||
getDateFromInputValue(selection.customEndDate),
|
||||
)
|
||||
}
|
||||
|
||||
function getDateFromInputValue(value: string): Date | undefined {
|
||||
const date = new Date(`${value}T00:00:00`)
|
||||
if (Number.isNaN(date.getTime()) || getDateInputValue(date) !== value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return date
|
||||
}
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,496 @@
|
||||
import type {
|
||||
AnalyticsBreakdownPreset,
|
||||
AnalyticsDashboardStat,
|
||||
AnalyticsQueryFilterCategory,
|
||||
AnalyticsSelectedFilters,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
export type AnalyticsDashboardDimension =
|
||||
| 'project'
|
||||
| 'project_status'
|
||||
| 'version_id'
|
||||
| 'country'
|
||||
| 'monetization'
|
||||
| 'user_agent'
|
||||
| 'download_reason'
|
||||
| 'game_version'
|
||||
| 'loader_type'
|
||||
|
||||
export const ALL_FILTER_VALUE = '__all__'
|
||||
export const FILTER_VALUE_CATEGORIES: Exclude<AnalyticsQueryFilterCategory, 'project'>[] = [
|
||||
'project_status',
|
||||
'country',
|
||||
'monetization',
|
||||
'user_agent',
|
||||
'download_reason',
|
||||
'version_id',
|
||||
'game_version',
|
||||
'loader_type',
|
||||
]
|
||||
|
||||
const ANALYTICS_DASHBOARD_STAT_ORDER: AnalyticsDashboardStat[] = [
|
||||
'views',
|
||||
'downloads',
|
||||
'revenue',
|
||||
'playtime',
|
||||
]
|
||||
|
||||
const ANALYTICS_STATS_BY_DIMENSION: Record<
|
||||
AnalyticsDashboardDimension,
|
||||
readonly AnalyticsDashboardStat[]
|
||||
> = {
|
||||
project: ANALYTICS_DASHBOARD_STAT_ORDER,
|
||||
version_id: ['downloads', 'playtime'],
|
||||
country: ['views', 'downloads', 'playtime'],
|
||||
monetization: ['views', 'downloads'],
|
||||
user_agent: ['downloads'],
|
||||
download_reason: ['downloads'],
|
||||
game_version: ['downloads', 'playtime'],
|
||||
loader_type: ['downloads', 'playtime'],
|
||||
project_status: ANALYTICS_DASHBOARD_STAT_ORDER,
|
||||
}
|
||||
|
||||
const ANALYTICS_DIMENSION_BY_BREAKDOWN: Record<
|
||||
AnalyticsBreakdownPreset,
|
||||
AnalyticsDashboardDimension
|
||||
> = {
|
||||
none: 'project',
|
||||
project: 'project',
|
||||
country: 'country',
|
||||
monetization: 'monetization',
|
||||
user_agent: 'user_agent',
|
||||
download_reason: 'download_reason',
|
||||
version_id: 'version_id',
|
||||
loader: 'loader_type',
|
||||
game_version: 'game_version',
|
||||
}
|
||||
|
||||
const ANALYTICS_DIMENSION_BY_FILTER_CATEGORY: Record<
|
||||
Exclude<AnalyticsQueryFilterCategory, 'project'>,
|
||||
AnalyticsDashboardDimension
|
||||
> = {
|
||||
project_status: 'project_status',
|
||||
country: 'country',
|
||||
monetization: 'monetization',
|
||||
user_agent: 'user_agent',
|
||||
download_reason: 'download_reason',
|
||||
version_id: 'version_id',
|
||||
game_version: 'game_version',
|
||||
loader_type: 'loader_type',
|
||||
}
|
||||
|
||||
const ANALYTICS_FILTER_CATEGORY_BY_BREAKDOWN: Record<
|
||||
AnalyticsBreakdownPreset,
|
||||
Exclude<AnalyticsQueryFilterCategory, 'project'> | null
|
||||
> = {
|
||||
none: null,
|
||||
project: null,
|
||||
country: 'country',
|
||||
monetization: 'monetization',
|
||||
user_agent: 'user_agent',
|
||||
download_reason: 'download_reason',
|
||||
version_id: 'version_id',
|
||||
loader: 'loader_type',
|
||||
game_version: 'game_version',
|
||||
}
|
||||
|
||||
export type FilterOption = {
|
||||
value: string
|
||||
label: string
|
||||
searchTerms?: string[]
|
||||
}
|
||||
|
||||
export type ProjectVersionFilterOption = FilterOption
|
||||
|
||||
export type ProjectVersionFilterOptionProjectMetadata = {
|
||||
name: string
|
||||
iconUrl?: string
|
||||
}
|
||||
|
||||
type AnalyticsBreakdownInput = AnalyticsBreakdownPreset | readonly AnalyticsBreakdownPreset[]
|
||||
|
||||
function getOptionalDateTimestamp(date: string | undefined): number | undefined {
|
||||
if (!date) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const timestamp = new Date(date).getTime()
|
||||
return Number.isFinite(timestamp) ? timestamp : undefined
|
||||
}
|
||||
|
||||
export function getProjectVersionFilterOptionsCacheKey(
|
||||
versionIds: string[],
|
||||
versionNumbersById: Map<string, string>,
|
||||
versionPublishedDatesById: Map<string, string>,
|
||||
versionProjectNamesById: Map<string, string>,
|
||||
): string {
|
||||
return versionIds
|
||||
.map(
|
||||
(versionId) =>
|
||||
`${versionId}\x1f${versionNumbersById.get(versionId) ?? ''}\x1f${
|
||||
versionPublishedDatesById.get(versionId) ?? ''
|
||||
}\x1f${versionProjectNamesById.get(versionId) ?? ''}`,
|
||||
)
|
||||
.join('\x1e')
|
||||
}
|
||||
|
||||
export function getProjectVersionFilterOptionProjectMetadataCacheKey(
|
||||
versionIds: string[],
|
||||
versionProjectNamesById: Map<string, string>,
|
||||
versionProjectIconUrlsById: Map<string, string>,
|
||||
): string {
|
||||
return versionIds
|
||||
.map(
|
||||
(versionId) =>
|
||||
`${versionId}\x1f${versionProjectNamesById.get(versionId) ?? ''}\x1f${
|
||||
versionProjectIconUrlsById.get(versionId) ?? ''
|
||||
}`,
|
||||
)
|
||||
.join('\x1e')
|
||||
}
|
||||
|
||||
export function getProjectVersionFilterOptionMetadataIds(
|
||||
versionIds: string[],
|
||||
selectedVersionIds: string[],
|
||||
): string[] {
|
||||
const knownVersionIds = new Set(versionIds)
|
||||
const metadataIds = [...versionIds]
|
||||
|
||||
for (const versionId of selectedVersionIds) {
|
||||
if (!knownVersionIds.has(versionId)) {
|
||||
metadataIds.push(versionId)
|
||||
knownVersionIds.add(versionId)
|
||||
}
|
||||
}
|
||||
|
||||
return metadataIds
|
||||
}
|
||||
|
||||
export function buildProjectVersionFilterOptions(
|
||||
versionIds: string[],
|
||||
versionNumbersById: Map<string, string>,
|
||||
versionPublishedDatesById: Map<string, string>,
|
||||
versionProjectNamesById: Map<string, string>,
|
||||
): ProjectVersionFilterOption[] {
|
||||
return versionIds
|
||||
.map((versionId) => {
|
||||
const projectName = versionProjectNamesById.get(versionId)
|
||||
return {
|
||||
option: {
|
||||
value: versionId,
|
||||
label: versionNumbersById.get(versionId) ?? versionId,
|
||||
searchTerms: projectName ? [versionId, projectName] : [versionId],
|
||||
},
|
||||
publishedTimestamp: getOptionalDateTimestamp(versionPublishedDatesById.get(versionId)),
|
||||
}
|
||||
})
|
||||
.sort((left, right) => {
|
||||
if (left.publishedTimestamp !== undefined && right.publishedTimestamp !== undefined) {
|
||||
return right.publishedTimestamp - left.publishedTimestamp
|
||||
}
|
||||
if (left.publishedTimestamp !== undefined) {
|
||||
return -1
|
||||
}
|
||||
if (right.publishedTimestamp !== undefined) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return left.option.label.localeCompare(right.option.label)
|
||||
})
|
||||
.map(({ option }) => option)
|
||||
}
|
||||
|
||||
export function buildProjectVersionFilterOptionProjectMetadataById(
|
||||
versionIds: string[],
|
||||
versionProjectNamesById: Map<string, string>,
|
||||
versionProjectIconUrlsById: Map<string, string>,
|
||||
): Map<string, ProjectVersionFilterOptionProjectMetadata[]> {
|
||||
const metadataById = new Map<string, ProjectVersionFilterOptionProjectMetadata[]>()
|
||||
|
||||
for (const versionId of versionIds) {
|
||||
const projectName = versionProjectNamesById.get(versionId)
|
||||
if (!projectName) {
|
||||
continue
|
||||
}
|
||||
|
||||
const metadata: ProjectVersionFilterOptionProjectMetadata = { name: projectName }
|
||||
const iconUrl = versionProjectIconUrlsById.get(versionId)
|
||||
if (iconUrl) {
|
||||
metadata.iconUrl = iconUrl
|
||||
}
|
||||
|
||||
metadataById.set(versionId, [metadata])
|
||||
}
|
||||
|
||||
return metadataById
|
||||
}
|
||||
|
||||
function intersectAnalyticsStats(
|
||||
left: readonly AnalyticsDashboardStat[],
|
||||
right: readonly AnalyticsDashboardStat[],
|
||||
): AnalyticsDashboardStat[] {
|
||||
const rightStats = new Set(right)
|
||||
return left.filter((stat) => rightStats.has(stat))
|
||||
}
|
||||
|
||||
function haveAnalyticsStatOverlap(
|
||||
left: readonly AnalyticsDashboardStat[],
|
||||
right: readonly AnalyticsDashboardStat[],
|
||||
): boolean {
|
||||
return left.some((stat) => right.includes(stat))
|
||||
}
|
||||
|
||||
export function getAnalyticsStatsForDimension(
|
||||
dimension: AnalyticsDashboardDimension,
|
||||
): readonly AnalyticsDashboardStat[] {
|
||||
return ANALYTICS_STATS_BY_DIMENSION[dimension]
|
||||
}
|
||||
|
||||
export function getAnalyticsStatsForBreakdown(
|
||||
breakdown: AnalyticsBreakdownPreset,
|
||||
): readonly AnalyticsDashboardStat[] {
|
||||
return getAnalyticsStatsForDimension(ANALYTICS_DIMENSION_BY_BREAKDOWN[breakdown])
|
||||
}
|
||||
|
||||
function normalizeAnalyticsBreakdowns(
|
||||
breakdowns: AnalyticsBreakdownInput,
|
||||
): Exclude<AnalyticsBreakdownPreset, 'none'>[] {
|
||||
const values = Array.isArray(breakdowns) ? breakdowns : [breakdowns]
|
||||
const normalizedBreakdowns: Exclude<AnalyticsBreakdownPreset, 'none'>[] = []
|
||||
|
||||
for (const breakdown of values) {
|
||||
if (breakdown === 'none') {
|
||||
continue
|
||||
}
|
||||
if (!normalizedBreakdowns.includes(breakdown)) {
|
||||
normalizedBreakdowns.push(breakdown)
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedBreakdowns
|
||||
}
|
||||
|
||||
export function getAnalyticsStatsForBreakdowns(
|
||||
breakdowns: AnalyticsBreakdownInput,
|
||||
): readonly AnalyticsDashboardStat[] {
|
||||
const normalizedBreakdowns = normalizeAnalyticsBreakdowns(breakdowns)
|
||||
if (normalizedBreakdowns.length === 0) {
|
||||
return getAnalyticsStatsForBreakdown('none')
|
||||
}
|
||||
|
||||
let stats = [...getAnalyticsStatsForBreakdown(normalizedBreakdowns[0])]
|
||||
for (const breakdown of normalizedBreakdowns.slice(1)) {
|
||||
stats = intersectAnalyticsStats(stats, getAnalyticsStatsForBreakdown(breakdown))
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
export function getAnalyticsStatsForFilterCategory(
|
||||
category: AnalyticsQueryFilterCategory,
|
||||
): readonly AnalyticsDashboardStat[] {
|
||||
if (category === 'project') {
|
||||
return ANALYTICS_DASHBOARD_STAT_ORDER
|
||||
}
|
||||
|
||||
return getAnalyticsStatsForDimension(ANALYTICS_DIMENSION_BY_FILTER_CATEGORY[category])
|
||||
}
|
||||
|
||||
export function getAnalyticsFilterCategoryForBreakdown(
|
||||
breakdown: AnalyticsBreakdownPreset,
|
||||
): Exclude<AnalyticsQueryFilterCategory, 'project'> | null {
|
||||
return ANALYTICS_FILTER_CATEGORY_BY_BREAKDOWN[breakdown]
|
||||
}
|
||||
|
||||
function getAnalyticsStatsForFilterScope(
|
||||
breakdowns: AnalyticsBreakdownInput,
|
||||
filters: AnalyticsSelectedFilters,
|
||||
ignoredCategory?: AnalyticsQueryFilterCategory,
|
||||
): readonly AnalyticsDashboardStat[] {
|
||||
let stats = [...getAnalyticsStatsForBreakdowns(breakdowns)]
|
||||
|
||||
for (const category of FILTER_VALUE_CATEGORIES) {
|
||||
if (category === ignoredCategory || filters[category].length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
stats = intersectAnalyticsStats(stats, getAnalyticsStatsForFilterCategory(category))
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
export function getEnabledAnalyticsStatsForState(
|
||||
breakdowns: AnalyticsBreakdownInput,
|
||||
filters: AnalyticsSelectedFilters,
|
||||
): readonly AnalyticsDashboardStat[] {
|
||||
return getAnalyticsStatsForFilterScope(breakdowns, filters)
|
||||
}
|
||||
|
||||
export function getVisibleAnalyticsFilterCategoriesForState(
|
||||
breakdowns: AnalyticsBreakdownInput,
|
||||
filters: AnalyticsSelectedFilters,
|
||||
): readonly Exclude<AnalyticsQueryFilterCategory, 'project'>[] {
|
||||
return FILTER_VALUE_CATEGORIES.filter((category) =>
|
||||
haveAnalyticsStatOverlap(
|
||||
getAnalyticsStatsForFilterScope(breakdowns, filters, category),
|
||||
getAnalyticsStatsForFilterCategory(category),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function sanitizeAnalyticsSelectedFilters(
|
||||
breakdowns: AnalyticsBreakdownInput,
|
||||
filters: AnalyticsSelectedFilters,
|
||||
): AnalyticsSelectedFilters {
|
||||
const nextFilters = cloneSelectedFilters(filters)
|
||||
let availableStats = [...getAnalyticsStatsForBreakdowns(breakdowns)]
|
||||
|
||||
for (const category of FILTER_VALUE_CATEGORIES) {
|
||||
if (filters[category].length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const categoryStats = getAnalyticsStatsForFilterCategory(category)
|
||||
if (!haveAnalyticsStatOverlap(availableStats, categoryStats)) {
|
||||
nextFilters[category] = []
|
||||
continue
|
||||
}
|
||||
|
||||
availableStats = intersectAnalyticsStats(availableStats, categoryStats)
|
||||
}
|
||||
|
||||
return nextFilters
|
||||
}
|
||||
|
||||
export function cloneSelectedFilters(filters: AnalyticsSelectedFilters): AnalyticsSelectedFilters {
|
||||
return {
|
||||
project: [...filters.project],
|
||||
project_status: [...filters.project_status],
|
||||
country: [...filters.country],
|
||||
monetization: [...filters.monetization],
|
||||
user_agent: [...filters.user_agent],
|
||||
download_reason: [...filters.download_reason],
|
||||
version_id: [...filters.version_id],
|
||||
game_version: [...filters.game_version],
|
||||
loader_type: [...filters.loader_type],
|
||||
}
|
||||
}
|
||||
|
||||
export function areStringArraysEqual(left: string[], right: string[]): boolean {
|
||||
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
|
||||
}
|
||||
|
||||
export function areSelectedFiltersEqual(
|
||||
left: AnalyticsSelectedFilters,
|
||||
right: AnalyticsSelectedFilters,
|
||||
): boolean {
|
||||
if (!areStringArraysEqual(left.project, right.project)) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const categoryKey of FILTER_VALUE_CATEGORIES) {
|
||||
if (!areStringArraysEqual(left[categoryKey], right[categoryKey])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function getOptionsWithSelectedValues(
|
||||
options: FilterOption[],
|
||||
selectedValues: string[],
|
||||
getMissingSelectedOptionLabel: (value: string) => string = (value) => value,
|
||||
): FilterOption[] {
|
||||
if (selectedValues.length === 0) {
|
||||
return options
|
||||
}
|
||||
|
||||
const knownValues = new Set(options.map((option) => option.value))
|
||||
const missingSelectedOptions = selectedValues
|
||||
.filter((value) => !knownValues.has(value))
|
||||
.map((value) => ({
|
||||
value,
|
||||
label: getMissingSelectedOptionLabel(value),
|
||||
}))
|
||||
|
||||
return missingSelectedOptions.length === 0 ? options : [...options, ...missingSelectedOptions]
|
||||
}
|
||||
|
||||
export function normalizeSelectedValues(
|
||||
categoryKey: AnalyticsQueryFilterCategory,
|
||||
values: string[],
|
||||
projectIds: string[],
|
||||
): string[] {
|
||||
const uniqueValues = Array.from(new Set(values))
|
||||
|
||||
if (categoryKey === 'project') {
|
||||
if (uniqueValues.includes(ALL_FILTER_VALUE)) {
|
||||
return projectIds
|
||||
}
|
||||
|
||||
const allProjectIds = new Set(projectIds)
|
||||
const selectedProjects = uniqueValues.filter((value) => allProjectIds.has(value))
|
||||
|
||||
return selectedProjects.length > 0 ? selectedProjects : projectIds
|
||||
}
|
||||
|
||||
if (uniqueValues.includes(ALL_FILTER_VALUE) || uniqueValues.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const selectedValues = uniqueValues.filter((value) => value !== ALL_FILTER_VALUE)
|
||||
if (categoryKey === 'project_status') {
|
||||
return selectedValues
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter(isProjectStatusFilterValue)
|
||||
}
|
||||
if (categoryKey === 'loader_type') {
|
||||
return Array.from(
|
||||
new Set(
|
||||
selectedValues
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter((value) => value.length > 0),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return selectedValues
|
||||
}
|
||||
|
||||
export const PROJECT_STATUS_FILTER_VALUES = [
|
||||
'approved',
|
||||
'archived',
|
||||
'rejected',
|
||||
'draft',
|
||||
'unlisted',
|
||||
'withheld',
|
||||
'private',
|
||||
'other',
|
||||
] as const
|
||||
|
||||
export type ProjectStatusFilterValue = (typeof PROJECT_STATUS_FILTER_VALUES)[number]
|
||||
|
||||
const projectStatusFilterValueSet = new Set<string>(PROJECT_STATUS_FILTER_VALUES)
|
||||
|
||||
export function isProjectStatusFilterValue(value: string): value is ProjectStatusFilterValue {
|
||||
return projectStatusFilterValueSet.has(value)
|
||||
}
|
||||
|
||||
export function getProjectStatusFilterValue(
|
||||
status: string | null | undefined,
|
||||
): ProjectStatusFilterValue {
|
||||
const normalizedStatus = status?.trim().toLowerCase() ?? ''
|
||||
return isProjectStatusFilterValue(normalizedStatus) ? normalizedStatus : 'other'
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import {
|
||||
type AnalyticsGroupByPreset,
|
||||
type AnalyticsLastTimeframeUnit,
|
||||
type AnalyticsTimeframeMode,
|
||||
type AnalyticsTimeframePreset,
|
||||
injectAnalyticsDashboardContext,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
const MIN_RANGE_MS = 60 * 60 * 1000
|
||||
const TIME_RANGE_ROUNDING_MS = 60 * 1000
|
||||
export const MAX_ANALYTICS_TIME_SLICES = 256
|
||||
|
||||
const GROUP_BY_PRESET_MINUTES: Record<AnalyticsGroupByPreset, number> = {
|
||||
'1h': 60,
|
||||
'6h': 360,
|
||||
day: 24 * 60,
|
||||
week: 7 * 24 * 60,
|
||||
month: 30 * 24 * 60,
|
||||
year: 365 * 24 * 60,
|
||||
}
|
||||
|
||||
export type AnalyticsTimeRange = {
|
||||
start: Date
|
||||
end: Date
|
||||
}
|
||||
|
||||
export function startOfDay(date: Date): Date {
|
||||
const nextDate = new Date(date)
|
||||
nextDate.setHours(0, 0, 0, 0)
|
||||
return nextDate
|
||||
}
|
||||
|
||||
export function getRoundedNow(timestamp: number): Date {
|
||||
const roundedTimestamp = Math.floor(timestamp / TIME_RANGE_ROUNDING_MS) * TIME_RANGE_ROUNDING_MS
|
||||
return new Date(roundedTimestamp)
|
||||
}
|
||||
|
||||
export function getDateInputValue(date: Date): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
export function parseDateInputValue(value: string): Date {
|
||||
const parsedDate = new Date(`${value}T00:00:00`)
|
||||
return Number.isNaN(parsedDate.getTime()) ? startOfDay(new Date()) : parsedDate
|
||||
}
|
||||
|
||||
export function parseDateTimeInputValue(value: string): Date {
|
||||
const parsedDate = new Date(value)
|
||||
return Number.isNaN(parsedDate.getTime()) ? getRoundedNow(Date.now()) : parsedDate
|
||||
}
|
||||
|
||||
export function addDays(date: Date, days: number): Date {
|
||||
const nextDate = new Date(date)
|
||||
nextDate.setDate(nextDate.getDate() + days)
|
||||
return nextDate
|
||||
}
|
||||
|
||||
function isStartOfDay(date: Date): boolean {
|
||||
return (
|
||||
date.getHours() === 0 &&
|
||||
date.getMinutes() === 0 &&
|
||||
date.getSeconds() === 0 &&
|
||||
date.getMilliseconds() === 0
|
||||
)
|
||||
}
|
||||
|
||||
export function getInclusiveEndDateInputValue(end: Date): string {
|
||||
return getDateInputValue(isStartOfDay(end) ? addDays(end, -1) : end)
|
||||
}
|
||||
|
||||
function subtractCalendarMonths(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
|
||||
}
|
||||
|
||||
export function getTimeRangeForPreset(
|
||||
preset: AnalyticsTimeframePreset,
|
||||
nowTimestamp: number,
|
||||
): AnalyticsTimeRange {
|
||||
const now = getRoundedNow(nowTimestamp)
|
||||
const end = new Date(now)
|
||||
|
||||
switch (preset) {
|
||||
case 'today':
|
||||
return { start: startOfDay(now), end }
|
||||
case 'yesterday': {
|
||||
const todayStart = startOfDay(now)
|
||||
return {
|
||||
start: new Date(todayStart.getTime() - 24 * 60 * 60 * 1000),
|
||||
end: todayStart,
|
||||
}
|
||||
}
|
||||
case 'last_7_days':
|
||||
return {
|
||||
start: new Date(end.getTime() - 7 * 24 * 60 * 60 * 1000),
|
||||
end,
|
||||
}
|
||||
case 'last_14_days':
|
||||
return {
|
||||
start: new Date(end.getTime() - 14 * 24 * 60 * 60 * 1000),
|
||||
end,
|
||||
}
|
||||
case 'last_30_days':
|
||||
return {
|
||||
start: new Date(end.getTime() - 30 * 24 * 60 * 60 * 1000),
|
||||
end,
|
||||
}
|
||||
case 'last_90_days':
|
||||
return {
|
||||
start: new Date(end.getTime() - 90 * 24 * 60 * 60 * 1000),
|
||||
end,
|
||||
}
|
||||
case 'last_180_days':
|
||||
return {
|
||||
start: new Date(end.getTime() - 180 * 24 * 60 * 60 * 1000),
|
||||
end,
|
||||
}
|
||||
case 'year_to_date': {
|
||||
const yearStart = new Date(now.getFullYear(), 0, 1)
|
||||
yearStart.setHours(0, 0, 0, 0)
|
||||
return { start: yearStart, end }
|
||||
}
|
||||
case 'all_time':
|
||||
return {
|
||||
start: new Date(Date.UTC(2023, 0, 1, 0, 0, 0, 0)),
|
||||
end,
|
||||
}
|
||||
default:
|
||||
return {
|
||||
start: new Date(end.getTime() - 24 * 60 * 60 * 1000),
|
||||
end,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getTimeRangeForLastTimeframe(
|
||||
amountValue: number,
|
||||
unit: AnalyticsLastTimeframeUnit,
|
||||
nowTimestamp: number,
|
||||
): AnalyticsTimeRange {
|
||||
const end = getRoundedNow(nowTimestamp)
|
||||
const amount = Math.max(1, Math.floor(amountValue))
|
||||
|
||||
switch (unit) {
|
||||
case 'hours':
|
||||
return { start: new Date(end.getTime() - amount * 60 * 60 * 1000), end }
|
||||
case 'days':
|
||||
return { start: new Date(end.getTime() - amount * 24 * 60 * 60 * 1000), end }
|
||||
case 'weeks':
|
||||
return { start: new Date(end.getTime() - amount * 7 * 24 * 60 * 60 * 1000), end }
|
||||
case 'months':
|
||||
return { start: subtractCalendarMonths(end, amount), end }
|
||||
default:
|
||||
return { start: new Date(end.getTime() - 24 * 60 * 60 * 1000), end }
|
||||
}
|
||||
}
|
||||
|
||||
export function getTimeRangeForCustomDateRange(
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
): AnalyticsTimeRange {
|
||||
const start = parseDateInputValue(startDate)
|
||||
const inclusiveEnd = parseDateInputValue(endDate)
|
||||
return {
|
||||
start,
|
||||
end: addDays(inclusiveEnd, 1),
|
||||
}
|
||||
}
|
||||
|
||||
export function getTimeRangeForCustomDateTimeRange(
|
||||
startDateTime: string,
|
||||
endDateTime: string,
|
||||
): AnalyticsTimeRange {
|
||||
return {
|
||||
start: parseDateTimeInputValue(startDateTime),
|
||||
end: parseDateTimeInputValue(endDateTime),
|
||||
}
|
||||
}
|
||||
|
||||
export function getAnalyticsTimeRange({
|
||||
mode,
|
||||
preset,
|
||||
lastAmount,
|
||||
lastUnit,
|
||||
customStartDate,
|
||||
customEndDate,
|
||||
nowTimestamp,
|
||||
}: {
|
||||
mode: AnalyticsTimeframeMode
|
||||
preset: AnalyticsTimeframePreset
|
||||
lastAmount: number
|
||||
lastUnit: AnalyticsLastTimeframeUnit
|
||||
customStartDate: string
|
||||
customEndDate: string
|
||||
nowTimestamp: number
|
||||
}): AnalyticsTimeRange {
|
||||
switch (mode) {
|
||||
case 'last':
|
||||
return getTimeRangeForLastTimeframe(lastAmount, lastUnit, nowTimestamp)
|
||||
case 'custom_range':
|
||||
return getTimeRangeForCustomDateRange(customStartDate, customEndDate)
|
||||
case 'custom_datetime_range':
|
||||
return getTimeRangeForCustomDateTimeRange(customStartDate, customEndDate)
|
||||
case 'preset':
|
||||
default:
|
||||
return getTimeRangeForPreset(preset, nowTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultAnalyticsGroupByForDurationMinutes(
|
||||
durationMinutes: number,
|
||||
): AnalyticsGroupByPreset {
|
||||
const days = durationMinutes / (24 * 60)
|
||||
if (days <= 2) return '1h'
|
||||
if (days <= 7) return '6h'
|
||||
if (days <= 90) return 'day'
|
||||
if (days <= 365) return 'week'
|
||||
if (days <= 365 * 3) return 'month'
|
||||
return 'year'
|
||||
}
|
||||
|
||||
export function getAnalyticsGroupByPresetMinutes(preset: AnalyticsGroupByPreset): number {
|
||||
return GROUP_BY_PRESET_MINUTES[preset]
|
||||
}
|
||||
|
||||
export function isAnalyticsGroupByAvailableForDurationMinutes(
|
||||
preset: AnalyticsGroupByPreset,
|
||||
durationMinutes: number,
|
||||
): boolean {
|
||||
const groupByMinutes = getAnalyticsGroupByPresetMinutes(preset)
|
||||
const isTooCoarse = groupByMinutes >= durationMinutes
|
||||
const isTooFine = durationMinutes / groupByMinutes > MAX_ANALYTICS_TIME_SLICES
|
||||
|
||||
return !isTooCoarse && !isTooFine
|
||||
}
|
||||
|
||||
export function ensureMinimumTimeRange(start: Date, end: Date): AnalyticsTimeRange {
|
||||
if (end.getTime() <= start.getTime()) {
|
||||
return {
|
||||
start: new Date(end.getTime() - MIN_RANGE_MS),
|
||||
end,
|
||||
}
|
||||
}
|
||||
|
||||
if (end.getTime() - start.getTime() < MIN_RANGE_MS) {
|
||||
return {
|
||||
start: new Date(end.getTime() - MIN_RANGE_MS),
|
||||
end,
|
||||
}
|
||||
}
|
||||
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
export function useSelectedAnalyticsTimeRange() {
|
||||
const {
|
||||
selectedTimeframeMode,
|
||||
selectedTimeframe,
|
||||
selectedLastTimeframeAmount,
|
||||
selectedLastTimeframeUnit,
|
||||
selectedCustomTimeframeStartDate,
|
||||
selectedCustomTimeframeEndDate,
|
||||
queryRefreshTimestamp,
|
||||
} = injectAnalyticsDashboardContext()
|
||||
|
||||
const selectedTimeRange = computed(() =>
|
||||
getAnalyticsTimeRange({
|
||||
mode: selectedTimeframeMode.value,
|
||||
preset: selectedTimeframe.value,
|
||||
lastAmount: selectedLastTimeframeAmount.value,
|
||||
lastUnit: selectedLastTimeframeUnit.value,
|
||||
customStartDate: selectedCustomTimeframeStartDate.value,
|
||||
customEndDate: selectedCustomTimeframeEndDate.value,
|
||||
nowTimestamp: queryRefreshTimestamp.value,
|
||||
}),
|
||||
)
|
||||
|
||||
const selectedTimeframeDurationMinutes = computed(() => {
|
||||
const { start, end } = ensureMinimumTimeRange(
|
||||
selectedTimeRange.value.start,
|
||||
selectedTimeRange.value.end,
|
||||
)
|
||||
const durationMs = end.getTime() - start.getTime()
|
||||
return Math.max(1, Math.floor(durationMs / (60 * 1000)))
|
||||
})
|
||||
|
||||
return {
|
||||
selectedTimeRange,
|
||||
selectedTimeframeDurationMinutes,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<button
|
||||
v-tooltip="disabled ? formatMessage(analyticsStatCardMessages.unavailableTooltip) : ''"
|
||||
type="button"
|
||||
class="flex h-full appearance-none flex-col gap-2.5 rounded-2xl border border-solid p-5 px-4 text-left transition-colors sm:gap-4"
|
||||
:class="{
|
||||
'cursor-not-allowed border-surface-5 bg-surface-2 opacity-60': disabled,
|
||||
'cursor-default border-brand bg-highlight-green': !disabled && active,
|
||||
'border-surface-5 bg-surface-3 hover:bg-surface-4 active:scale-95': !disabled && !active,
|
||||
}"
|
||||
:disabled="disabled"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div
|
||||
class="text-base font-medium"
|
||||
:class="{
|
||||
'text-secondary': disabled,
|
||||
'text-primary': !disabled,
|
||||
}"
|
||||
>
|
||||
{{ label }}
|
||||
</div>
|
||||
|
||||
<div class="inline-flex items-center justify-center">
|
||||
<component
|
||||
:is="iconComponent"
|
||||
aria-hidden="true"
|
||||
class="size-5 sm:size-6"
|
||||
:class="{
|
||||
'text-secondary': disabled,
|
||||
'text-brand': !disabled && active,
|
||||
'text-primary': !disabled && !active,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div
|
||||
class="text-2xl font-semibold leading-none md:text-4xl"
|
||||
:class="{
|
||||
'text-primary': disabled,
|
||||
'text-contrast': !disabled,
|
||||
}"
|
||||
>
|
||||
{{ disabled ? '-' : statLabel }}
|
||||
</div>
|
||||
|
||||
<template v-if="disabled">
|
||||
<span class="inline-flex items-center gap-1 text-xs text-secondary">
|
||||
{{ formatMessage(analyticsStatCardMessages.unavailableLabel) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="vsPrevPeriodPercent" class="flex items-center gap-1 text-sm">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 font-semibold"
|
||||
:class="{
|
||||
'text-secondary': disabled,
|
||||
'text-green': !disabled && trendValue > 0,
|
||||
'text-red': !disabled && trendValue < 0,
|
||||
'text-primary': !disabled && trendValue === 0,
|
||||
}"
|
||||
>
|
||||
<component
|
||||
:is="trendDirectionIcon"
|
||||
v-if="showTrendDirectionIcon"
|
||||
aria-hidden="true"
|
||||
class="size-3"
|
||||
/>
|
||||
{{ vsPrevPeriodPercent }}
|
||||
</span>
|
||||
<span
|
||||
class="mt-px text-xs max-sm:hidden"
|
||||
:class="{
|
||||
'text-secondary': disabled,
|
||||
'text-primary': !disabled,
|
||||
}"
|
||||
>
|
||||
{{ formatMessage(analyticsStatCardMessages.previousPeriodComparison) }}
|
||||
</span>
|
||||
<span
|
||||
class="visible mt-px text-xs sm:hidden"
|
||||
:class="{
|
||||
'text-secondary': disabled,
|
||||
'text-primary': !disabled,
|
||||
}"
|
||||
>
|
||||
{{ formatMessage(analyticsStatCardMessages.previousPeriodComparisonShort) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ClockIcon,
|
||||
CurrencyIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
type IconComponent,
|
||||
PlayIcon,
|
||||
TimerIcon,
|
||||
TrendingDownIcon,
|
||||
TrendingUpIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { useVIntl } from '@modrinth/ui'
|
||||
|
||||
import { analyticsStatCardMessages } from '../analytics-messages'
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
statLabel: string
|
||||
vsPrevPeriodPercent: string | null
|
||||
icon: string
|
||||
active?: boolean
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'click'): void
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const statCardIconMap: Record<string, IconComponent> = {
|
||||
clock: ClockIcon,
|
||||
timer: TimerIcon,
|
||||
play: PlayIcon,
|
||||
eye: EyeIcon,
|
||||
download: DownloadIcon,
|
||||
currency: CurrencyIcon,
|
||||
dollar: CurrencyIcon,
|
||||
}
|
||||
|
||||
const iconComponent = computed<IconComponent>(() => {
|
||||
const normalizedIconName = props.icon
|
||||
.toLowerCase()
|
||||
.replace(/icon$/u, '')
|
||||
.replace(/[^a-z]/gu, '')
|
||||
return statCardIconMap[normalizedIconName] ?? ClockIcon
|
||||
})
|
||||
|
||||
const trendValue = computed(() => {
|
||||
const parsed = Number.parseFloat(props.vsPrevPeriodPercent?.replace(/[^0-9.-]/gu, '') ?? '')
|
||||
return Number.isNaN(parsed) ? 0 : parsed
|
||||
})
|
||||
|
||||
const showTrendDirectionIcon = computed(() => !props.disabled && trendValue.value !== 0)
|
||||
|
||||
const trendDirectionIcon = computed<IconComponent>(() =>
|
||||
trendValue.value >= 0 ? TrendingUpIcon : TrendingDownIcon,
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,119 @@
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useFormatNumber, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import {
|
||||
type AnalyticsDashboardStat,
|
||||
injectAnalyticsDashboardContext,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import { analyticsStatCardMessages, formatAnalyticsStatLabel } from '../analytics-messages.ts'
|
||||
import StatCard from './StatCard.vue'
|
||||
|
||||
const {
|
||||
activeStat,
|
||||
setActiveStat,
|
||||
currentTotals,
|
||||
percentChanges,
|
||||
hasPreviousPeriodComparison,
|
||||
selectedBreakdowns,
|
||||
isAnalyticsDashboardStatRelevant,
|
||||
} = injectAnalyticsDashboardContext()
|
||||
const formatNumber = useFormatNumber()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const compactNumberFormatter = computed(
|
||||
() =>
|
||||
new Intl.NumberFormat(undefined, {
|
||||
notation: 'compact',
|
||||
maximumSignificantDigits: 2,
|
||||
}),
|
||||
)
|
||||
|
||||
function formatStatNumber(value: number): string {
|
||||
const rounded = Math.round(value)
|
||||
|
||||
if (Math.abs(rounded) >= 1000) {
|
||||
return compactNumberFormatter.value.format(rounded)
|
||||
}
|
||||
|
||||
return formatNumber(rounded)
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
const rounded = Math.round(value * 10) / 10
|
||||
const signPrefix = rounded > 0 ? '+' : ''
|
||||
return `${signPrefix}${rounded.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function formatPreviousPeriodPercent(value: number): string | null {
|
||||
if (!hasPreviousPeriodComparison.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return formatPercent(value)
|
||||
}
|
||||
|
||||
const statCards = computed<
|
||||
{
|
||||
key: AnalyticsDashboardStat
|
||||
label: string
|
||||
statLabel: string
|
||||
vsPrevPeriodPercent: string | null
|
||||
icon: string
|
||||
disabled: boolean
|
||||
}[]
|
||||
>(() => [
|
||||
{
|
||||
key: 'views',
|
||||
label: formatAnalyticsStatLabel('views', formatMessage),
|
||||
statLabel: formatStatNumber(currentTotals.value.views),
|
||||
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.views),
|
||||
icon: 'eye',
|
||||
disabled: !isAnalyticsDashboardStatRelevant('views', selectedBreakdowns.value),
|
||||
},
|
||||
{
|
||||
key: 'downloads',
|
||||
label: formatAnalyticsStatLabel('downloads', formatMessage),
|
||||
statLabel: formatStatNumber(currentTotals.value.downloads),
|
||||
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.downloads),
|
||||
icon: 'download',
|
||||
disabled: !isAnalyticsDashboardStatRelevant('downloads', selectedBreakdowns.value),
|
||||
},
|
||||
{
|
||||
key: 'revenue',
|
||||
label: formatAnalyticsStatLabel('revenue', formatMessage),
|
||||
statLabel: formatMessage(analyticsStatCardMessages.revenueValue, {
|
||||
value: formatStatNumber(currentTotals.value.revenue),
|
||||
}),
|
||||
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.revenue),
|
||||
icon: 'dollar',
|
||||
disabled: !isAnalyticsDashboardStatRelevant('revenue', selectedBreakdowns.value),
|
||||
},
|
||||
{
|
||||
key: 'playtime',
|
||||
label: formatAnalyticsStatLabel('playtime', formatMessage),
|
||||
statLabel: formatMessage(analyticsStatCardMessages.playtimeHours, {
|
||||
hours: formatStatNumber(currentTotals.value.playtime / 3600),
|
||||
}),
|
||||
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.playtime),
|
||||
icon: 'clock',
|
||||
disabled: !isAnalyticsDashboardStatRelevant('playtime', selectedBreakdowns.value),
|
||||
},
|
||||
])
|
||||
</script>
|
||||
@@ -0,0 +1,306 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
|
||||
import {
|
||||
areSelectedFiltersEqual,
|
||||
areStringArraysEqual,
|
||||
buildAnalyticsQueryBuilderRouteQuery,
|
||||
getAnalyticsBreakdownPresetsForProjectSelection,
|
||||
hasAnalyticsQueryBuilderRouteChange,
|
||||
readAnalyticsGraphState,
|
||||
readAnalyticsQueryBuilderState,
|
||||
} from '~/components/analytics-dashboard/analytics-route-query'
|
||||
import type {
|
||||
AnalyticsBreakdownPreset,
|
||||
AnalyticsDashboardStat,
|
||||
AnalyticsGraphViewMode,
|
||||
AnalyticsGroupByPreset,
|
||||
AnalyticsLastTimeframeUnit,
|
||||
AnalyticsSelectedBreakdowns,
|
||||
AnalyticsSelectedFilters,
|
||||
AnalyticsTimeframeMode,
|
||||
AnalyticsTimeframePreset,
|
||||
} from '~/providers/analytics/analytics-types'
|
||||
|
||||
export type AnalyticsQueryBuilderRouteNavigationMode = 'push' | 'replace'
|
||||
|
||||
export interface AnalyticsQueryBuilderRefs {
|
||||
selectedProjectIds: Ref<string[]>
|
||||
selectedTimeframeMode: Ref<AnalyticsTimeframeMode>
|
||||
selectedTimeframe: Ref<AnalyticsTimeframePreset>
|
||||
selectedLastTimeframeAmount: Ref<number>
|
||||
selectedLastTimeframeUnit: Ref<AnalyticsLastTimeframeUnit>
|
||||
selectedCustomTimeframeStartDate: Ref<string>
|
||||
selectedCustomTimeframeEndDate: Ref<string>
|
||||
selectedGroupBy: Ref<AnalyticsGroupByPreset>
|
||||
selectedBreakdowns: Ref<AnalyticsSelectedBreakdowns>
|
||||
selectedFilters: Ref<AnalyticsSelectedFilters>
|
||||
}
|
||||
|
||||
export interface AnalyticsGraphRefs {
|
||||
activeStat: Ref<AnalyticsDashboardStat>
|
||||
activeGraphViewMode: Ref<AnalyticsGraphViewMode>
|
||||
isRatioMode: Ref<boolean>
|
||||
showChartEvents: Ref<boolean>
|
||||
showProjectEvents: Ref<boolean>
|
||||
showPreviousPeriod: Ref<boolean>
|
||||
hiddenGraphDatasetIds: Ref<string[]>
|
||||
hasExplicitGraphDatasetSelection: Ref<boolean>
|
||||
selectedGraphDatasetIds: Ref<string[]>
|
||||
}
|
||||
|
||||
export interface UseAnalyticsRouteSyncOptions {
|
||||
queryBuilder: AnalyticsQueryBuilderRefs
|
||||
graph: AnalyticsGraphRefs
|
||||
availableProjectIds: Ref<string[]>
|
||||
sanitizeSelectedFilters: (
|
||||
breakdowns: readonly AnalyticsBreakdownPreset[],
|
||||
filters: AnalyticsSelectedFilters,
|
||||
) => AnalyticsSelectedFilters
|
||||
}
|
||||
|
||||
export function useAnalyticsRouteSync(options: UseAnalyticsRouteSyncOptions) {
|
||||
const { queryBuilder, graph, availableProjectIds, sanitizeSelectedFilters } = options
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
let nextAnalyticsRouteNavigationMode: AnalyticsQueryBuilderRouteNavigationMode = 'replace'
|
||||
|
||||
function replaceNextAnalyticsRouteNavigation() {
|
||||
nextAnalyticsRouteNavigationMode = 'replace'
|
||||
}
|
||||
|
||||
function consumeAnalyticsRouteNavigationMode(): AnalyticsQueryBuilderRouteNavigationMode {
|
||||
const navigationMode = nextAnalyticsRouteNavigationMode
|
||||
nextAnalyticsRouteNavigationMode = 'push'
|
||||
return navigationMode
|
||||
}
|
||||
|
||||
function getSelectedAnalyticsQueryBuilderState() {
|
||||
return {
|
||||
selectedProjectIds: queryBuilder.selectedProjectIds.value,
|
||||
selectedTimeframeMode: queryBuilder.selectedTimeframeMode.value,
|
||||
selectedTimeframe: queryBuilder.selectedTimeframe.value,
|
||||
selectedLastTimeframeAmount: queryBuilder.selectedLastTimeframeAmount.value,
|
||||
selectedLastTimeframeUnit: queryBuilder.selectedLastTimeframeUnit.value,
|
||||
selectedCustomTimeframeStartDate: queryBuilder.selectedCustomTimeframeStartDate.value,
|
||||
selectedCustomTimeframeEndDate: queryBuilder.selectedCustomTimeframeEndDate.value,
|
||||
selectedGroupBy: queryBuilder.selectedGroupBy.value,
|
||||
selectedBreakdowns: queryBuilder.selectedBreakdowns.value,
|
||||
selectedFilters: queryBuilder.selectedFilters.value,
|
||||
}
|
||||
}
|
||||
|
||||
function getSelectedAnalyticsGraphState() {
|
||||
return {
|
||||
activeStat: graph.activeStat.value,
|
||||
activeGraphViewMode: graph.activeGraphViewMode.value,
|
||||
isRatioMode: graph.isRatioMode.value,
|
||||
showChartEvents: graph.showChartEvents.value,
|
||||
showProjectEvents: graph.showProjectEvents.value,
|
||||
showPreviousPeriod: graph.showPreviousPeriod.value,
|
||||
hiddenGraphDatasetIds: graph.hiddenGraphDatasetIds.value,
|
||||
selectedGraphDatasetIds: graph.hasExplicitGraphDatasetSelection.value
|
||||
? graph.selectedGraphDatasetIds.value
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
function syncAnalyticsRouteQuery(navigationMode: AnalyticsQueryBuilderRouteNavigationMode) {
|
||||
if (import.meta.server) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextRouteQuery = buildAnalyticsQueryBuilderRouteQuery(
|
||||
route.query,
|
||||
getSelectedAnalyticsQueryBuilderState(),
|
||||
availableProjectIds.value,
|
||||
getSelectedAnalyticsGraphState(),
|
||||
)
|
||||
|
||||
const hasAnalyticsQueryChange = hasAnalyticsQueryBuilderRouteChange(route.query, nextRouteQuery)
|
||||
|
||||
if (!hasAnalyticsQueryChange) return
|
||||
|
||||
if (navigationMode === 'replace') {
|
||||
router.replace({
|
||||
path: route.path,
|
||||
query: nextRouteQuery,
|
||||
})
|
||||
} else {
|
||||
router.push({
|
||||
path: route.path,
|
||||
query: nextRouteQuery,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function syncQueryBuilderRouteQuery() {
|
||||
syncAnalyticsRouteQuery(consumeAnalyticsRouteNavigationMode())
|
||||
}
|
||||
|
||||
function syncGraphRouteQuery() {
|
||||
syncAnalyticsRouteQuery('replace')
|
||||
}
|
||||
|
||||
function applyRouteQueryToState(nextQuery: LocationQuery) {
|
||||
const nextQueryState = readAnalyticsQueryBuilderState(nextQuery, availableProjectIds.value)
|
||||
const availableProjectIdSet = new Set(availableProjectIds.value)
|
||||
const nextSelectedProjectIds = nextQueryState.selectedProjectIds.filter((projectId) =>
|
||||
availableProjectIdSet.has(projectId),
|
||||
)
|
||||
const nextGraphState = readAnalyticsGraphState(nextQuery, nextSelectedProjectIds)
|
||||
const nextSelectedBreakdowns = getAnalyticsBreakdownPresetsForProjectSelection(
|
||||
nextQueryState.selectedBreakdowns,
|
||||
nextSelectedProjectIds,
|
||||
)
|
||||
const nextSelectedFilters = sanitizeSelectedFilters(
|
||||
nextSelectedBreakdowns,
|
||||
nextQueryState.selectedFilters,
|
||||
)
|
||||
const shouldUpdateSelectedProjectIds = !areStringArraysEqual(
|
||||
queryBuilder.selectedProjectIds.value,
|
||||
nextSelectedProjectIds,
|
||||
)
|
||||
const shouldUpdateSelectedTimeframeMode =
|
||||
queryBuilder.selectedTimeframeMode.value !== nextQueryState.selectedTimeframeMode
|
||||
const shouldUpdateSelectedTimeframe =
|
||||
queryBuilder.selectedTimeframe.value !== nextQueryState.selectedTimeframe
|
||||
const shouldUpdateSelectedLastTimeframeAmount =
|
||||
queryBuilder.selectedLastTimeframeAmount.value !== nextQueryState.selectedLastTimeframeAmount
|
||||
const shouldUpdateSelectedLastTimeframeUnit =
|
||||
queryBuilder.selectedLastTimeframeUnit.value !== nextQueryState.selectedLastTimeframeUnit
|
||||
const shouldUpdateSelectedCustomTimeframeStartDate =
|
||||
queryBuilder.selectedCustomTimeframeStartDate.value !==
|
||||
nextQueryState.selectedCustomTimeframeStartDate
|
||||
const shouldUpdateSelectedCustomTimeframeEndDate =
|
||||
queryBuilder.selectedCustomTimeframeEndDate.value !==
|
||||
nextQueryState.selectedCustomTimeframeEndDate
|
||||
const shouldUpdateSelectedGroupBy =
|
||||
queryBuilder.selectedGroupBy.value !== nextQueryState.selectedGroupBy
|
||||
const shouldUpdateSelectedBreakdowns = !areStringArraysEqual(
|
||||
queryBuilder.selectedBreakdowns.value,
|
||||
nextSelectedBreakdowns,
|
||||
)
|
||||
const shouldUpdateSelectedFilters = !areSelectedFiltersEqual(
|
||||
queryBuilder.selectedFilters.value,
|
||||
nextSelectedFilters,
|
||||
)
|
||||
const shouldUpdateActiveStat = graph.activeStat.value !== nextGraphState.activeStat
|
||||
const shouldUpdateActiveGraphViewMode =
|
||||
graph.activeGraphViewMode.value !== nextGraphState.activeGraphViewMode
|
||||
const shouldUpdateIsRatioMode = graph.isRatioMode.value !== nextGraphState.isRatioMode
|
||||
const shouldUpdateShowChartEvents =
|
||||
graph.showChartEvents.value !== nextGraphState.showChartEvents
|
||||
const shouldUpdateShowProjectEvents =
|
||||
graph.showProjectEvents.value !== nextGraphState.showProjectEvents
|
||||
const shouldUpdateShowPreviousPeriod =
|
||||
graph.showPreviousPeriod.value !== nextGraphState.showPreviousPeriod
|
||||
const shouldUpdateHiddenGraphDatasetIds = !areStringArraysEqual(
|
||||
graph.hiddenGraphDatasetIds.value,
|
||||
nextGraphState.hiddenGraphDatasetIds,
|
||||
)
|
||||
const nextHasExplicitGraphDatasetSelection = nextGraphState.selectedGraphDatasetIds !== null
|
||||
const nextSelectedGraphDatasetIds = nextGraphState.selectedGraphDatasetIds ?? []
|
||||
const shouldUpdateHasExplicitGraphDatasetSelection =
|
||||
graph.hasExplicitGraphDatasetSelection.value !== nextHasExplicitGraphDatasetSelection
|
||||
const shouldUpdateSelectedGraphDatasetIds =
|
||||
(nextHasExplicitGraphDatasetSelection || graph.hasExplicitGraphDatasetSelection.value) &&
|
||||
!areStringArraysEqual(graph.selectedGraphDatasetIds.value, nextSelectedGraphDatasetIds)
|
||||
const hasRouteStateUpdate =
|
||||
shouldUpdateSelectedProjectIds ||
|
||||
shouldUpdateSelectedTimeframeMode ||
|
||||
shouldUpdateSelectedTimeframe ||
|
||||
shouldUpdateSelectedLastTimeframeAmount ||
|
||||
shouldUpdateSelectedLastTimeframeUnit ||
|
||||
shouldUpdateSelectedCustomTimeframeStartDate ||
|
||||
shouldUpdateSelectedCustomTimeframeEndDate ||
|
||||
shouldUpdateSelectedGroupBy ||
|
||||
shouldUpdateSelectedBreakdowns ||
|
||||
shouldUpdateSelectedFilters ||
|
||||
shouldUpdateActiveStat ||
|
||||
shouldUpdateActiveGraphViewMode ||
|
||||
shouldUpdateIsRatioMode ||
|
||||
shouldUpdateShowChartEvents ||
|
||||
shouldUpdateShowProjectEvents ||
|
||||
shouldUpdateShowPreviousPeriod ||
|
||||
shouldUpdateHiddenGraphDatasetIds ||
|
||||
shouldUpdateHasExplicitGraphDatasetSelection ||
|
||||
shouldUpdateSelectedGraphDatasetIds
|
||||
|
||||
if (hasRouteStateUpdate) {
|
||||
replaceNextAnalyticsRouteNavigation()
|
||||
}
|
||||
|
||||
if (shouldUpdateSelectedProjectIds) {
|
||||
queryBuilder.selectedProjectIds.value = nextSelectedProjectIds
|
||||
}
|
||||
if (shouldUpdateSelectedTimeframeMode) {
|
||||
queryBuilder.selectedTimeframeMode.value = nextQueryState.selectedTimeframeMode
|
||||
}
|
||||
if (shouldUpdateSelectedTimeframe) {
|
||||
queryBuilder.selectedTimeframe.value = nextQueryState.selectedTimeframe
|
||||
}
|
||||
if (shouldUpdateSelectedLastTimeframeAmount) {
|
||||
queryBuilder.selectedLastTimeframeAmount.value = nextQueryState.selectedLastTimeframeAmount
|
||||
}
|
||||
if (shouldUpdateSelectedLastTimeframeUnit) {
|
||||
queryBuilder.selectedLastTimeframeUnit.value = nextQueryState.selectedLastTimeframeUnit
|
||||
}
|
||||
if (shouldUpdateSelectedCustomTimeframeStartDate) {
|
||||
queryBuilder.selectedCustomTimeframeStartDate.value =
|
||||
nextQueryState.selectedCustomTimeframeStartDate
|
||||
}
|
||||
if (shouldUpdateSelectedCustomTimeframeEndDate) {
|
||||
queryBuilder.selectedCustomTimeframeEndDate.value =
|
||||
nextQueryState.selectedCustomTimeframeEndDate
|
||||
}
|
||||
if (shouldUpdateSelectedGroupBy) {
|
||||
queryBuilder.selectedGroupBy.value = nextQueryState.selectedGroupBy
|
||||
}
|
||||
if (shouldUpdateSelectedBreakdowns) {
|
||||
queryBuilder.selectedBreakdowns.value = nextSelectedBreakdowns
|
||||
}
|
||||
if (shouldUpdateSelectedFilters) {
|
||||
queryBuilder.selectedFilters.value = nextSelectedFilters
|
||||
}
|
||||
if (shouldUpdateActiveStat) {
|
||||
graph.activeStat.value = nextGraphState.activeStat
|
||||
}
|
||||
if (shouldUpdateActiveGraphViewMode) {
|
||||
graph.activeGraphViewMode.value = nextGraphState.activeGraphViewMode
|
||||
}
|
||||
if (shouldUpdateIsRatioMode) {
|
||||
graph.isRatioMode.value = nextGraphState.isRatioMode
|
||||
}
|
||||
if (shouldUpdateShowChartEvents) {
|
||||
graph.showChartEvents.value = nextGraphState.showChartEvents
|
||||
}
|
||||
if (shouldUpdateShowProjectEvents) {
|
||||
graph.showProjectEvents.value = nextGraphState.showProjectEvents
|
||||
}
|
||||
if (shouldUpdateShowPreviousPeriod) {
|
||||
graph.showPreviousPeriod.value = nextGraphState.showPreviousPeriod
|
||||
}
|
||||
if (shouldUpdateHiddenGraphDatasetIds) {
|
||||
graph.hiddenGraphDatasetIds.value = nextGraphState.hiddenGraphDatasetIds
|
||||
}
|
||||
if (shouldUpdateHasExplicitGraphDatasetSelection) {
|
||||
graph.hasExplicitGraphDatasetSelection.value = nextHasExplicitGraphDatasetSelection
|
||||
}
|
||||
if (shouldUpdateSelectedGraphDatasetIds) {
|
||||
graph.selectedGraphDatasetIds.value = nextSelectedGraphDatasetIds
|
||||
}
|
||||
|
||||
if (!hasRouteStateUpdate) {
|
||||
syncAnalyticsRouteQuery('replace')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
replaceNextAnalyticsRouteNavigation,
|
||||
syncQueryBuilderRouteQuery,
|
||||
syncGraphRouteQuery,
|
||||
applyRouteQueryToState,
|
||||
}
|
||||
}
|
||||
@@ -1,495 +0,0 @@
|
||||
<script setup>
|
||||
import { useFormatDateTime, useFormatMoney, useFormatNumber } from '@modrinth/ui'
|
||||
import VueApexCharts from 'vue3-apexcharts'
|
||||
|
||||
const props = defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
labels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
formatLabels: {
|
||||
type: Function,
|
||||
},
|
||||
colors: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
prefix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
suffix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
hideToolbar: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hideLegend: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
stacked: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'bar',
|
||||
},
|
||||
hideTotal: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isMoney: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
legendPosition: {
|
||||
type: String,
|
||||
default: 'right',
|
||||
},
|
||||
xAxisType: {
|
||||
type: String,
|
||||
default: 'datetime',
|
||||
},
|
||||
percentStacked: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
horizontalBar: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disableAnimations: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const formatNumber = useFormatNumber()
|
||||
const formatMoney = useFormatMoney()
|
||||
const formatDate = useFormatDateTime({
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
|
||||
function formatTooltipValue(value, props) {
|
||||
return props.isMoney ? formatMoney(value) : formatNumber(value)
|
||||
}
|
||||
|
||||
function generateListEntry(value, index, _, w, props) {
|
||||
const color = w.globals.colors?.[index]
|
||||
|
||||
return `<div class="list-entry">
|
||||
<span class="circle" style="background-color: ${color}"></span>
|
||||
<div class="label">
|
||||
${w.globals.seriesNames[index]}
|
||||
</div>
|
||||
<div class="value">
|
||||
${props.prefix}${formatTooltipValue(value, props)}${props.suffix}
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function generateTooltip({ series, seriesIndex, dataPointIndex, w }, props) {
|
||||
const label = w.globals.lastXAxis.categories?.[dataPointIndex]
|
||||
|
||||
const formattedLabel = props.formatLabels ? props.formatLabels(label) : formatDate(label)
|
||||
|
||||
let tooltip = `<div class="bar-tooltip">
|
||||
<div class="seperated-entry title">
|
||||
<div class="label">${formattedLabel}</div>`
|
||||
|
||||
// Logic for total and percent stacked
|
||||
if (!props.hideTotal) {
|
||||
if (props.percentStacked) {
|
||||
const total = series.reduce((a, b) => a + (b?.[dataPointIndex] || 0), 0)
|
||||
const percentValue = (100 * series[seriesIndex][dataPointIndex]) / total
|
||||
tooltip += `<div class="value">${props.prefix}${formatNumber(percentValue)}%${
|
||||
props.suffix
|
||||
}</div>`
|
||||
} else {
|
||||
const totalValue = series.reduce((a, b) => a + (b?.[dataPointIndex] || 0), 0)
|
||||
tooltip += `<div class="value">${props.prefix}${formatTooltipValue(totalValue, props)}${
|
||||
props.suffix
|
||||
}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
tooltip += '</div><hr class="card-divider" />'
|
||||
|
||||
// Logic for generating list entries
|
||||
if (props.percentStacked) {
|
||||
tooltip += generateListEntry(
|
||||
series[seriesIndex][dataPointIndex],
|
||||
seriesIndex,
|
||||
seriesIndex,
|
||||
w,
|
||||
props,
|
||||
)
|
||||
} else {
|
||||
const returnTopN = 15
|
||||
|
||||
const listEntries = series
|
||||
.map((value, index) => [
|
||||
value[dataPointIndex],
|
||||
generateListEntry(value[dataPointIndex], index, seriesIndex, w, props),
|
||||
])
|
||||
.filter((value) => value[0] > 0)
|
||||
.sort((a, b) => b[0] - a[0])
|
||||
.slice(0, returnTopN) // Return only the top X entries
|
||||
.map((value) => value[1])
|
||||
.join('')
|
||||
|
||||
tooltip += listEntries
|
||||
}
|
||||
|
||||
tooltip += '</div>'
|
||||
return tooltip
|
||||
}
|
||||
|
||||
const chartOptions = computed(() => {
|
||||
return {
|
||||
chart: {
|
||||
id: props.name,
|
||||
fontFamily:
|
||||
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
|
||||
foreColor: 'var(--color-base)',
|
||||
selection: {
|
||||
enabled: true,
|
||||
fill: {
|
||||
color: 'var(--color-brand)',
|
||||
},
|
||||
},
|
||||
toolbar: {
|
||||
show: false,
|
||||
},
|
||||
stacked: props.stacked,
|
||||
stackType: props.percentStacked ? '100%' : 'normal',
|
||||
zoom: {
|
||||
autoScaleYaxis: true,
|
||||
},
|
||||
animations: {
|
||||
enabled: props.disableAnimations,
|
||||
},
|
||||
},
|
||||
xaxis: {
|
||||
type: props.xAxisType,
|
||||
categories: props.labels,
|
||||
labels: {
|
||||
style: {
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
},
|
||||
},
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
colors: props.colors,
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
background: {
|
||||
enabled: true,
|
||||
borderRadius: 20,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
borderColor: 'var(--color-button-bg)',
|
||||
tickColor: 'var(--color-button-bg)',
|
||||
},
|
||||
legend: {
|
||||
show: !props.hideLegend,
|
||||
position: props.legendPosition,
|
||||
showForZeroSeries: false,
|
||||
showForSingleSeries: false,
|
||||
showForNullSeries: false,
|
||||
fontSize: 'var(--font-size-nm)',
|
||||
fontFamily:
|
||||
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
|
||||
onItemClick: {
|
||||
toggleDataSeries: true,
|
||||
},
|
||||
},
|
||||
markers: {
|
||||
size: 0,
|
||||
strokeColor: 'var(--color-contrast)',
|
||||
strokeWidth: 3,
|
||||
strokeOpacity: 1,
|
||||
fillOpacity: 1,
|
||||
hover: {
|
||||
size: 6,
|
||||
},
|
||||
},
|
||||
plotOptions: {
|
||||
bar: {
|
||||
horizontal: props.horizontalBar,
|
||||
columnWidth: '80%',
|
||||
endingShape: 'rounded',
|
||||
borderRadius: 5,
|
||||
borderRadiusApplication: 'end',
|
||||
borderRadiusWhenStacked: 'last',
|
||||
},
|
||||
},
|
||||
stroke: {
|
||||
curve: 'smooth',
|
||||
width: 2,
|
||||
},
|
||||
tooltip: {
|
||||
custom: (d) => generateTooltip(d, props),
|
||||
},
|
||||
fill:
|
||||
props.type === 'area'
|
||||
? {
|
||||
colors: props.colors,
|
||||
type: 'gradient',
|
||||
opacity: 1,
|
||||
gradient: {
|
||||
shade: 'light',
|
||||
type: 'vertical',
|
||||
shadeIntensity: 0,
|
||||
gradientToColors: props.colors,
|
||||
inverseColors: true,
|
||||
opacityFrom: 0.5,
|
||||
opacityTo: 0,
|
||||
stops: [0, 100],
|
||||
colorStops: [],
|
||||
},
|
||||
}
|
||||
: {},
|
||||
}
|
||||
})
|
||||
|
||||
const chart = ref(null)
|
||||
|
||||
const legendValues = ref(
|
||||
[...props.data].map((project, index) => {
|
||||
return { name: project.name, visible: true, color: props.colors[index] }
|
||||
}),
|
||||
)
|
||||
|
||||
const flipLegend = (legend, newVal) => {
|
||||
legend.visible = newVal
|
||||
chart.value.toggleSeries(legend.name)
|
||||
}
|
||||
|
||||
const resetChart = () => {
|
||||
if (!chart.value?.chart) return
|
||||
chart.value.updateSeries([...props.data])
|
||||
chart.value.updateOptions({
|
||||
xaxis: {
|
||||
categories: props.labels,
|
||||
},
|
||||
})
|
||||
chart.value.resetSeries()
|
||||
legendValues.value.forEach((legend) => {
|
||||
legend.visible = true
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
resetChart,
|
||||
flipLegend,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VueApexCharts ref="chart" :type="type" :options="chartOptions" :series="data" class="chart" />
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.btn {
|
||||
svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.bar-chart {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.title-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-xs);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--gap-xs);
|
||||
z-index: 1;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-menu),
|
||||
:deep(.apexcharts-tooltip),
|
||||
:deep(.apexcharts-yaxistooltip) {
|
||||
background: var(--color-raised-bg) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
border: 1px solid var(--color-divider) !important;
|
||||
box-shadow: var(--shadow-floating) !important;
|
||||
font-size: var(--font-size-nm) !important;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-grid-borders) {
|
||||
line {
|
||||
stroke: var(--color-button-bg) !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.apexcharts-yaxistooltip),
|
||||
:deep(.apexcharts-xaxistooltip) {
|
||||
background: var(--color-raised-bg) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
border: 1px solid var(--color-divider) !important;
|
||||
font-size: var(--font-size-nm) !important;
|
||||
color: var(--color-base) !important;
|
||||
|
||||
.apexcharts-xaxistooltip-text {
|
||||
font-size: var(--font-size-nm) !important;
|
||||
color: var(--color-base) !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.apexcharts-yaxistooltip-left:after) {
|
||||
border-left-color: var(--color-raised-bg) !important;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-yaxistooltip-left:before) {
|
||||
border-left-color: var(--color-button-bg) !important;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-xaxistooltip-bottom:after) {
|
||||
border-bottom-color: var(--color-raised-bg) !important;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-xaxistooltip-bottom:before) {
|
||||
border-bottom-color: var(--color-button-bg) !important;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-menu-item) {
|
||||
border-radius: var(--radius-sm) !important;
|
||||
padding: var(--gap-xs) var(--gap-sm) !important;
|
||||
|
||||
&:hover {
|
||||
transition: all 0.3s !important;
|
||||
color: var(--color-accent-contrast) !important;
|
||||
background: var(--color-brand) !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.apexcharts-tooltip) {
|
||||
.bar-tooltip {
|
||||
min-width: 10rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
padding: var(--gap-sm);
|
||||
|
||||
.card-divider {
|
||||
margin: var(--gap-xs) 0;
|
||||
}
|
||||
|
||||
.seperated-entry {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-right: var(--gap-xl);
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
.value {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-xs);
|
||||
color: var(--color-base);
|
||||
}
|
||||
|
||||
.list-entry {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
.value {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.circle {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: var(--gap-sm);
|
||||
border: 2px solid var(--color-base);
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-lg);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
:deep(.checkbox) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.legend-checkbox :deep(.checkbox.checked) {
|
||||
background-color: var(--color);
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,281 +0,0 @@
|
||||
<script setup>
|
||||
import { Card } from '@modrinth/ui'
|
||||
import VueApexCharts from 'vue3-apexcharts'
|
||||
|
||||
// let VueApexCharts
|
||||
// if (import.meta.client) {
|
||||
// VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
|
||||
// }
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
labels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
prefix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
suffix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isMoney: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: 'var(--color-brand)',
|
||||
},
|
||||
})
|
||||
|
||||
// no grid lines, no toolbar, no legend, no data labels
|
||||
const chartOptions = {
|
||||
chart: {
|
||||
id: props.title,
|
||||
fontFamily:
|
||||
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
|
||||
foreColor: 'var(--color-base)',
|
||||
toolbar: {
|
||||
show: false,
|
||||
},
|
||||
zoom: {
|
||||
enabled: false,
|
||||
},
|
||||
sparkline: {
|
||||
enabled: true,
|
||||
},
|
||||
parentHeightOffset: 0,
|
||||
},
|
||||
stroke: {
|
||||
curve: 'smooth',
|
||||
width: 2,
|
||||
},
|
||||
fill: {
|
||||
colors: [props.color],
|
||||
type: 'gradient',
|
||||
opacity: 1,
|
||||
gradient: {
|
||||
shade: 'light',
|
||||
type: 'vertical',
|
||||
shadeIntensity: 0,
|
||||
gradientToColors: [props.color],
|
||||
inverseColors: true,
|
||||
opacityFrom: 0.5,
|
||||
opacityTo: 0,
|
||||
stops: [0, 100],
|
||||
colorStops: [],
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
show: false,
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
colors: [props.color],
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
categories: props.labels,
|
||||
labels: {
|
||||
show: false,
|
||||
},
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
show: false,
|
||||
},
|
||||
axisBorder: {
|
||||
show: false,
|
||||
},
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
const chart = ref(null)
|
||||
|
||||
const resetChart = () => {
|
||||
if (!chart.value?.chart) return
|
||||
chart.value.updateSeries([...props.data])
|
||||
chart.value.updateOptions({
|
||||
xaxis: {
|
||||
categories: props.labels,
|
||||
},
|
||||
})
|
||||
chart.value.resetSeries()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
resetChart,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="compact-chart">
|
||||
<h1 class="value">
|
||||
{{ value }}
|
||||
</h1>
|
||||
<div class="subtitle">
|
||||
{{ title }}
|
||||
</div>
|
||||
<div class="chart">
|
||||
<VueApexCharts ref="chart" type="area" :options="chartOptions" :series="data" height="70" />
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.compact-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: var(--gap-xs);
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-raised-bg);
|
||||
box-shadow: var(--shadow-card);
|
||||
|
||||
color: var(--color-base);
|
||||
font-size: var(--font-size-nm);
|
||||
|
||||
width: 100%;
|
||||
|
||||
padding-top: var(--gap-xl);
|
||||
padding-bottom: 0;
|
||||
|
||||
.value {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.chart {
|
||||
// width: calc(100% + 3rem);
|
||||
margin: 0 -1.5rem 0.25rem -1.5rem;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-menu),
|
||||
:deep(.apexcharts-tooltip),
|
||||
:deep(.apexcharts-yaxistooltip) {
|
||||
background: var(--color-raised-bg) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
border: 1px solid var(--color-divider) !important;
|
||||
box-shadow: var(--shadow-floating) !important;
|
||||
font-size: var(--font-size-nm) !important;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-graphical) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-tooltip) {
|
||||
.bar-tooltip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
padding: var(--gap-sm);
|
||||
|
||||
.card-divider {
|
||||
margin: var(--gap-xs) 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.value {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-xs);
|
||||
color: var(--color-base);
|
||||
}
|
||||
|
||||
.list-entry {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--gap-md);
|
||||
}
|
||||
|
||||
.circle {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: var(--gap-sm);
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
.divider {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-md);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-grid-borders) {
|
||||
line {
|
||||
stroke: var(--color-button-bg) !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.apexcharts-xaxis) {
|
||||
line {
|
||||
stroke: none;
|
||||
}
|
||||
}
|
||||
|
||||
.legend-checkbox :deep(.checkbox.checked) {
|
||||
background-color: var(--color);
|
||||
}
|
||||
</style>
|
||||
@@ -467,7 +467,7 @@
|
||||
<OverflowMenu
|
||||
v-if="auth.user"
|
||||
:dropdown-id="`${basePopoutId}-user`"
|
||||
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1"
|
||||
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1 pr-1"
|
||||
:options="userMenuOptions"
|
||||
>
|
||||
<Avatar :src="auth.user.avatar_url" aria-hidden="true" circle />
|
||||
@@ -1256,6 +1256,10 @@ async function logoutUser() {
|
||||
}
|
||||
|
||||
function runAnalytics() {
|
||||
if (import.meta.dev) {
|
||||
return
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const replacedUrl = config.public.apiBaseUrl.replace('v2/', '')
|
||||
|
||||
|
||||
@@ -8,6 +8,474 @@
|
||||
"admin.billing.error.not-found": {
|
||||
"message": "User not found"
|
||||
},
|
||||
"analytics.action.add": {
|
||||
"message": "Add"
|
||||
},
|
||||
"analytics.action.cancel": {
|
||||
"message": "Cancel"
|
||||
},
|
||||
"analytics.action.refresh": {
|
||||
"message": "Refresh"
|
||||
},
|
||||
"analytics.action.reset": {
|
||||
"message": "Reset"
|
||||
},
|
||||
"analytics.breakdown.country": {
|
||||
"message": "Country"
|
||||
},
|
||||
"analytics.breakdown.download-reason": {
|
||||
"message": "Download reason"
|
||||
},
|
||||
"analytics.breakdown.download-source": {
|
||||
"message": "Download source"
|
||||
},
|
||||
"analytics.breakdown.game-version": {
|
||||
"message": "Game version"
|
||||
},
|
||||
"analytics.breakdown.generic": {
|
||||
"message": "Breakdown"
|
||||
},
|
||||
"analytics.breakdown.loader": {
|
||||
"message": "Loader"
|
||||
},
|
||||
"analytics.breakdown.monetization": {
|
||||
"message": "Monetization"
|
||||
},
|
||||
"analytics.breakdown.none.selected": {
|
||||
"message": "No breakdown"
|
||||
},
|
||||
"analytics.breakdown.project": {
|
||||
"message": "Project"
|
||||
},
|
||||
"analytics.breakdown.project-status": {
|
||||
"message": "Project status"
|
||||
},
|
||||
"analytics.breakdown.project-version": {
|
||||
"message": "Project version"
|
||||
},
|
||||
"analytics.breakdown.selected": {
|
||||
"message": "Breakdown by {breakdown}"
|
||||
},
|
||||
"analytics.chart.action.show-all": {
|
||||
"message": "Show all"
|
||||
},
|
||||
"analytics.chart.action.show-limited": {
|
||||
"message": "Show limited"
|
||||
},
|
||||
"analytics.chart.action.show-top-eight": {
|
||||
"message": "Show top 8"
|
||||
},
|
||||
"analytics.chart.axis.playtime-hours": {
|
||||
"message": "{hours} h"
|
||||
},
|
||||
"analytics.chart.controls.active-count": {
|
||||
"message": "{count} active"
|
||||
},
|
||||
"analytics.chart.controls.annotations": {
|
||||
"message": "Annotations"
|
||||
},
|
||||
"analytics.chart.controls.aria": {
|
||||
"message": "Analytics graph controls, {activeCount}"
|
||||
},
|
||||
"analytics.chart.controls.button": {
|
||||
"message": "Controls"
|
||||
},
|
||||
"analytics.chart.controls.dialog-aria": {
|
||||
"message": "Analytics graph controls"
|
||||
},
|
||||
"analytics.chart.controls.display": {
|
||||
"message": "Display"
|
||||
},
|
||||
"analytics.chart.controls.modrinth-events": {
|
||||
"message": "Modrinth events"
|
||||
},
|
||||
"analytics.chart.controls.no-modrinth-events": {
|
||||
"message": "No Modrinth events in graph."
|
||||
},
|
||||
"analytics.chart.controls.no-project-events": {
|
||||
"message": "No project events in graph."
|
||||
},
|
||||
"analytics.chart.controls.previous-period": {
|
||||
"message": "Previous period"
|
||||
},
|
||||
"analytics.chart.controls.project-events": {
|
||||
"message": "Project events"
|
||||
},
|
||||
"analytics.chart.controls.ratio": {
|
||||
"message": "Ratio"
|
||||
},
|
||||
"analytics.chart.empty.select-table-items": {
|
||||
"message": "Select items from table below to visualize your data."
|
||||
},
|
||||
"analytics.chart.events.count-aria": {
|
||||
"message": "{count, plural, one {# analytics event} other {# analytics events}}"
|
||||
},
|
||||
"analytics.chart.events.project-title": {
|
||||
"message": "<project>{projectName}</project>: {title}"
|
||||
},
|
||||
"analytics.chart.events.see-announcement": {
|
||||
"message": "See announcement"
|
||||
},
|
||||
"analytics.chart.legend.monetization-details.aria": {
|
||||
"message": "View monetized analytics details"
|
||||
},
|
||||
"analytics.chart.legend.monetization-details.description": {
|
||||
"message": "Only views and downloads made through Modrinth count toward monetization, and downloads require users to be logged in."
|
||||
},
|
||||
"analytics.chart.legend.monetization-details.title": {
|
||||
"message": "Monetized analytics details"
|
||||
},
|
||||
"analytics.chart.legend.previous-period-suffix": {
|
||||
"message": "{name} (Prev.)"
|
||||
},
|
||||
"analytics.chart.render-limit.description": {
|
||||
"message": "Showing all selected lines from table may degrade page performance."
|
||||
},
|
||||
"analytics.chart.render-limit.header": {
|
||||
"message": "Show all {count} lines in graph?"
|
||||
},
|
||||
"analytics.chart.table-selection.all": {
|
||||
"message": "Showing all {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table"
|
||||
},
|
||||
"analytics.chart.table-selection.count": {
|
||||
"message": "Showing {count} {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table"
|
||||
},
|
||||
"analytics.chart.table-selection.limited": {
|
||||
"message": "Showing {limit} {itemType, select, project {{limit, plural, one {project} other {projects}}} country {{limit, plural, one {country} other {countries}}} monetization {{limit, plural, one {monetization value} other {monetization values}}} downloadSource {{limit, plural, one {download source} other {download sources}}} downloadReason {{limit, plural, one {download reason} other {download reasons}}} projectVersion {{limit, plural, one {project version} other {project versions}}} loader {{limit, plural, one {loader} other {loaders}}} gameVersion {{limit, plural, one {game version} other {game versions}}} other {{limit, plural, one {item} other {items}}}} from table"
|
||||
},
|
||||
"analytics.chart.table-selection.top": {
|
||||
"message": "Showing top {count} {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table"
|
||||
},
|
||||
"analytics.chart.tooltip.duration.days": {
|
||||
"message": "{count, plural, one {# day} other {# days}}"
|
||||
},
|
||||
"analytics.chart.tooltip.duration.hours": {
|
||||
"message": "{count, plural, one {# hour} other {# hours}}"
|
||||
},
|
||||
"analytics.chart.tooltip.duration.minutes": {
|
||||
"message": "{count, plural, one {# minute} other {# minutes}}"
|
||||
},
|
||||
"analytics.chart.tooltip.hide-entry": {
|
||||
"message": "Hide {name} in graph"
|
||||
},
|
||||
"analytics.chart.tooltip.pinned": {
|
||||
"message": "Chart tooltip pinned"
|
||||
},
|
||||
"analytics.chart.tooltip.pinned-aria": {
|
||||
"message": "Pinned"
|
||||
},
|
||||
"analytics.chart.tooltip.previous-period-short": {
|
||||
"message": "(prev.)"
|
||||
},
|
||||
"analytics.chart.tooltip.show-entry": {
|
||||
"message": "Show {name} in graph"
|
||||
},
|
||||
"analytics.chart.tooltip.total": {
|
||||
"message": "Total"
|
||||
},
|
||||
"analytics.chart.view.area": {
|
||||
"message": "Area"
|
||||
},
|
||||
"analytics.chart.view.bar": {
|
||||
"message": "Bar"
|
||||
},
|
||||
"analytics.chart.view.line": {
|
||||
"message": "Line"
|
||||
},
|
||||
"analytics.download-reason.dependency": {
|
||||
"message": "Dependency"
|
||||
},
|
||||
"analytics.download-reason.modpack": {
|
||||
"message": "Modpack"
|
||||
},
|
||||
"analytics.download-reason.standalone": {
|
||||
"message": "Standalone"
|
||||
},
|
||||
"analytics.download-reason.update": {
|
||||
"message": "Update"
|
||||
},
|
||||
"analytics.download-source.app": {
|
||||
"message": "Modrinth App"
|
||||
},
|
||||
"analytics.download-source.website": {
|
||||
"message": "Modrinth Website"
|
||||
},
|
||||
"analytics.downloads.suffix": {
|
||||
"message": "downloads"
|
||||
},
|
||||
"analytics.empty.no-data": {
|
||||
"message": "No data available"
|
||||
},
|
||||
"analytics.empty.no-data-for-analytics": {
|
||||
"message": "No data available for analytics"
|
||||
},
|
||||
"analytics.empty.no-projects": {
|
||||
"message": "No projects available"
|
||||
},
|
||||
"analytics.empty.no-projects-for-analytics": {
|
||||
"message": "No projects available for analytics"
|
||||
},
|
||||
"analytics.empty.select-project": {
|
||||
"message": "Select at least one project to view data"
|
||||
},
|
||||
"analytics.filter.game-version-type": {
|
||||
"message": "Game version type"
|
||||
},
|
||||
"analytics.filter.game-version-type.all": {
|
||||
"message": "All"
|
||||
},
|
||||
"analytics.filter.game-version-type.release": {
|
||||
"message": "Release"
|
||||
},
|
||||
"analytics.filter.search.countries": {
|
||||
"message": "Search countries..."
|
||||
},
|
||||
"analytics.filter.search.download-sources": {
|
||||
"message": "Search download sources..."
|
||||
},
|
||||
"analytics.filter.search.project-versions": {
|
||||
"message": "Search project versions..."
|
||||
},
|
||||
"analytics.filter.search.versions": {
|
||||
"message": "Search versions..."
|
||||
},
|
||||
"analytics.graph.title.downloads": {
|
||||
"message": "Downloads Over Time"
|
||||
},
|
||||
"analytics.graph.title.playtime": {
|
||||
"message": "Playtime Over Time"
|
||||
},
|
||||
"analytics.graph.title.revenue": {
|
||||
"message": "Revenue Over Time"
|
||||
},
|
||||
"analytics.graph.title.views": {
|
||||
"message": "Views Over Time"
|
||||
},
|
||||
"analytics.group-by.1h": {
|
||||
"message": "1h"
|
||||
},
|
||||
"analytics.group-by.6h": {
|
||||
"message": "6h"
|
||||
},
|
||||
"analytics.group-by.date": {
|
||||
"message": "Date"
|
||||
},
|
||||
"analytics.group-by.day": {
|
||||
"message": "Day"
|
||||
},
|
||||
"analytics.group-by.month": {
|
||||
"message": "Month"
|
||||
},
|
||||
"analytics.group-by.selected.day": {
|
||||
"message": "Group by day"
|
||||
},
|
||||
"analytics.group-by.selected.hour": {
|
||||
"message": "Group by hour"
|
||||
},
|
||||
"analytics.group-by.selected.month": {
|
||||
"message": "Group by month"
|
||||
},
|
||||
"analytics.group-by.selected.six-hours": {
|
||||
"message": "Group by 6 hours"
|
||||
},
|
||||
"analytics.group-by.selected.week": {
|
||||
"message": "Group by week"
|
||||
},
|
||||
"analytics.group-by.selected.year": {
|
||||
"message": "Group by year"
|
||||
},
|
||||
"analytics.group-by.week": {
|
||||
"message": "Week"
|
||||
},
|
||||
"analytics.group-by.year": {
|
||||
"message": "Year"
|
||||
},
|
||||
"analytics.loading.fetching-results": {
|
||||
"message": "Fetching results..."
|
||||
},
|
||||
"analytics.options.loading": {
|
||||
"message": "Loading..."
|
||||
},
|
||||
"analytics.project-event.project-approved": {
|
||||
"message": "Project approved"
|
||||
},
|
||||
"analytics.project-event.project-private": {
|
||||
"message": "Project set to private"
|
||||
},
|
||||
"analytics.project-event.project-status-changed": {
|
||||
"message": "Project status changed"
|
||||
},
|
||||
"analytics.project-event.project-unlisted": {
|
||||
"message": "Project unlisted"
|
||||
},
|
||||
"analytics.project-event.version-released": {
|
||||
"message": "{version} released"
|
||||
},
|
||||
"analytics.project-event.version-uploaded": {
|
||||
"message": "Version uploaded"
|
||||
},
|
||||
"analytics.project-status.approved": {
|
||||
"message": "Approved"
|
||||
},
|
||||
"analytics.project-status.archived": {
|
||||
"message": "Archived"
|
||||
},
|
||||
"analytics.project-status.draft": {
|
||||
"message": "Draft"
|
||||
},
|
||||
"analytics.project-status.other": {
|
||||
"message": "Other"
|
||||
},
|
||||
"analytics.project-status.private": {
|
||||
"message": "Private"
|
||||
},
|
||||
"analytics.project-status.rejected": {
|
||||
"message": "Rejected"
|
||||
},
|
||||
"analytics.project-status.unlisted": {
|
||||
"message": "Unlisted"
|
||||
},
|
||||
"analytics.project-status.withheld": {
|
||||
"message": "Withheld"
|
||||
},
|
||||
"analytics.project.all": {
|
||||
"message": "All projects"
|
||||
},
|
||||
"analytics.project.count": {
|
||||
"message": "{count, plural, one {# project} other {# projects}}"
|
||||
},
|
||||
"analytics.project.icon-alt": {
|
||||
"message": "{name} Icon"
|
||||
},
|
||||
"analytics.project.select": {
|
||||
"message": "Select projects"
|
||||
},
|
||||
"analytics.query.filter.add": {
|
||||
"message": "Add filter"
|
||||
},
|
||||
"analytics.query.label.breakdown": {
|
||||
"message": "Breakdown:"
|
||||
},
|
||||
"analytics.query.label.grouped-by": {
|
||||
"message": "Grouped by"
|
||||
},
|
||||
"analytics.query.label.project": {
|
||||
"message": "Project:"
|
||||
},
|
||||
"analytics.query.label.timeframe": {
|
||||
"message": "Timeframe:"
|
||||
},
|
||||
"analytics.stat.downloads": {
|
||||
"message": "Downloads"
|
||||
},
|
||||
"analytics.stat.playtime": {
|
||||
"message": "Playtime"
|
||||
},
|
||||
"analytics.stat.playtime-hours": {
|
||||
"message": "{hours} hrs"
|
||||
},
|
||||
"analytics.stat.previous-period-comparison": {
|
||||
"message": "vs prev. period"
|
||||
},
|
||||
"analytics.stat.previous-period-comparison-short": {
|
||||
"message": "vs prev."
|
||||
},
|
||||
"analytics.stat.revenue": {
|
||||
"message": "Revenue"
|
||||
},
|
||||
"analytics.stat.revenue-value": {
|
||||
"message": "${value}"
|
||||
},
|
||||
"analytics.stat.unavailable": {
|
||||
"message": "N/A"
|
||||
},
|
||||
"analytics.stat.unavailable-tooltip": {
|
||||
"message": "Stat unavailable for current query"
|
||||
},
|
||||
"analytics.stat.views": {
|
||||
"message": "Views"
|
||||
},
|
||||
"analytics.table.csv.date-range": {
|
||||
"message": "{start} to {end}"
|
||||
},
|
||||
"analytics.table.csv.filename": {
|
||||
"message": "Modrinth Analytics {breakdown} Breakdown - {dateRange}"
|
||||
},
|
||||
"analytics.table.csv.header.playtime-seconds": {
|
||||
"message": "Playtime (seconds)"
|
||||
},
|
||||
"analytics.table.csv.selected-range": {
|
||||
"message": "Selected Range"
|
||||
},
|
||||
"analytics.table.duration.days": {
|
||||
"message": "{count, plural, one {# day} other {# days}}"
|
||||
},
|
||||
"analytics.table.duration.hours": {
|
||||
"message": "{count, plural, one {# hour} other {# hours}}"
|
||||
},
|
||||
"analytics.table.duration.minutes": {
|
||||
"message": "{count, plural, one {# minute} other {# minutes}}"
|
||||
},
|
||||
"analytics.table.empty.no-matching-rows": {
|
||||
"message": "No matching analytics rows"
|
||||
},
|
||||
"analytics.table.export-csv": {
|
||||
"message": "Export CSV"
|
||||
},
|
||||
"analytics.table.export.cumulative": {
|
||||
"message": "Cumulative"
|
||||
},
|
||||
"analytics.table.export.grouped": {
|
||||
"message": "Grouped by {groupBy}"
|
||||
},
|
||||
"analytics.table.pagination.summary": {
|
||||
"message": "Showing {start} to {end} of {total}"
|
||||
},
|
||||
"analytics.table.search.placeholder": {
|
||||
"message": "Search..."
|
||||
},
|
||||
"analytics.threshold.countries-above": {
|
||||
"message": "Countries above"
|
||||
},
|
||||
"analytics.threshold.country-downloads-aria": {
|
||||
"message": "Country downloads threshold"
|
||||
},
|
||||
"analytics.threshold.game-version-downloads-aria": {
|
||||
"message": "Game version downloads threshold"
|
||||
},
|
||||
"analytics.threshold.game-versions-above": {
|
||||
"message": "Game versions above"
|
||||
},
|
||||
"analytics.threshold.project-downloads-aria": {
|
||||
"message": "Project downloads threshold"
|
||||
},
|
||||
"analytics.threshold.project-version-downloads-aria": {
|
||||
"message": "Project version downloads threshold"
|
||||
},
|
||||
"analytics.threshold.project-versions-above": {
|
||||
"message": "Project versions above"
|
||||
},
|
||||
"analytics.threshold.projects-above": {
|
||||
"message": "Projects above"
|
||||
},
|
||||
"analytics.title": {
|
||||
"message": "Analytics"
|
||||
},
|
||||
"analytics.value.monetized": {
|
||||
"message": "Monetized"
|
||||
},
|
||||
"analytics.value.none": {
|
||||
"message": "None"
|
||||
},
|
||||
"analytics.value.other": {
|
||||
"message": "Other"
|
||||
},
|
||||
"analytics.value.unknown": {
|
||||
"message": "Unknown"
|
||||
},
|
||||
"analytics.value.unmonetized": {
|
||||
"message": "Unmonetized"
|
||||
},
|
||||
"app-marketing.download.description": {
|
||||
"message": "Our desktop app is available across all platforms, choose your desired version."
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import type { ProjectStatusFilterValue } from '~/components/analytics-dashboard/query-builder/query-filter'
|
||||
|
||||
import { getProjectIdsMatchingStatusFilter } from './analytics-project-utils'
|
||||
import type {
|
||||
AnalyticsDashboardTotals,
|
||||
AnalyticsFetchData,
|
||||
AnalyticsGroupByPreset,
|
||||
AnalyticsLastTimeframeUnit,
|
||||
AnalyticsProjectFetchRequest,
|
||||
AnalyticsSelectedFilters,
|
||||
AnalyticsTimeframeMode,
|
||||
AnalyticsTimeframePreset,
|
||||
AnalyticsTimeSliceSplit,
|
||||
} from './analytics-types'
|
||||
|
||||
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 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
|
||||
const ANALYTICS_PROJECT_IDS_FETCH_BATCH_SIZE = 2000
|
||||
const ANALYTICS_PROJECT_IDS_FETCH_BATCH_DELAY_MS = 300
|
||||
|
||||
function isProjectAnalyticsPoint(
|
||||
dataPoint: Labrinth.Analytics.v3.AnalyticsData,
|
||||
): dataPoint is Labrinth.Analytics.v3.ProjectAnalytics {
|
||||
return 'source_project' in dataPoint
|
||||
}
|
||||
|
||||
export function buildComparisonFetchRequest(
|
||||
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
|
||||
): AnalyticsProjectFetchRequest | null {
|
||||
if (!isAnalyticsFetchRequestReady(fetchRequest)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const startTimestamp = new Date(fetchRequest.time_range.start).getTime()
|
||||
const endTimestamp = new Date(fetchRequest.time_range.end).getTime()
|
||||
const duration = endTimestamp - startTimestamp
|
||||
|
||||
if (!Number.isFinite(duration) || duration <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const previousStart = new Date(startTimestamp - duration)
|
||||
|
||||
if (previousStart.getTime() < ANALYTICS_START_TIME) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...fetchRequest,
|
||||
time_range: {
|
||||
start: previousStart.toISOString(),
|
||||
end: fetchRequest.time_range.end,
|
||||
resolution:
|
||||
'slices' in fetchRequest.time_range.resolution
|
||||
? {
|
||||
slices: fetchRequest.time_range.resolution.slices * 2,
|
||||
}
|
||||
: fetchRequest.time_range.resolution,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function isAnalyticsFetchRequestReady(
|
||||
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
|
||||
): fetchRequest is AnalyticsProjectFetchRequest {
|
||||
return Array.isArray(fetchRequest?.project_ids) && fetchRequest.project_ids.length > 0
|
||||
}
|
||||
|
||||
function getAnalyticsTimeSliceCount(
|
||||
timeRange: Labrinth.Analytics.v3.TimeRange,
|
||||
fallback: number,
|
||||
): number {
|
||||
if ('slices' in timeRange.resolution) {
|
||||
return Math.max(1, timeRange.resolution.slices)
|
||||
}
|
||||
|
||||
const startTime = new Date(timeRange.start).getTime()
|
||||
const endTime = new Date(timeRange.end).getTime()
|
||||
const bucketMs = timeRange.resolution.minutes * 60 * 1000
|
||||
if (bucketMs > 0 && endTime > startTime) {
|
||||
return Math.max(1, Math.floor((endTime - startTime) / bucketMs))
|
||||
}
|
||||
|
||||
return Math.max(1, fallback)
|
||||
}
|
||||
|
||||
export function splitAnalyticsTimeSlices(
|
||||
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
|
||||
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
|
||||
): AnalyticsTimeSliceSplit {
|
||||
if (!isAnalyticsFetchRequestReady(fetchRequest) || !buildComparisonFetchRequest(fetchRequest)) {
|
||||
return {
|
||||
currentTimeSlices: timeSlices,
|
||||
previousTimeSlices: [],
|
||||
}
|
||||
}
|
||||
|
||||
const currentSliceCount = getAnalyticsTimeSliceCount(fetchRequest.time_range, timeSlices.length)
|
||||
const currentStartIndex = Math.max(0, timeSlices.length - currentSliceCount)
|
||||
const previousStartIndex = Math.max(0, currentStartIndex - currentSliceCount)
|
||||
|
||||
return {
|
||||
currentTimeSlices: timeSlices.slice(currentStartIndex),
|
||||
previousTimeSlices: timeSlices.slice(previousStartIndex, currentStartIndex),
|
||||
}
|
||||
}
|
||||
|
||||
export function getAnalyticsProjectEventsInTimeRange(
|
||||
projectEvents: Labrinth.Analytics.v3.ProjectAnalyticsEvent[],
|
||||
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
|
||||
): Labrinth.Analytics.v3.ProjectAnalyticsEvent[] {
|
||||
if (!isAnalyticsFetchRequestReady(fetchRequest)) {
|
||||
return projectEvents
|
||||
}
|
||||
|
||||
const startTime = new Date(fetchRequest.time_range.start).getTime()
|
||||
const endTime = new Date(fetchRequest.time_range.end).getTime()
|
||||
if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime < startTime) {
|
||||
return []
|
||||
}
|
||||
|
||||
return projectEvents.filter((event) => {
|
||||
const eventTime = new Date(event.timestamp).getTime()
|
||||
return Number.isFinite(eventTime) && eventTime >= startTime && eventTime <= endTime
|
||||
})
|
||||
}
|
||||
|
||||
function buildAnalyticsFetchRequestBatches(
|
||||
fetchRequest: AnalyticsProjectFetchRequest,
|
||||
): AnalyticsProjectFetchRequest[] {
|
||||
if (fetchRequest.project_ids.length <= ANALYTICS_PROJECT_IDS_FETCH_BATCH_SIZE) {
|
||||
return [fetchRequest]
|
||||
}
|
||||
|
||||
const requests: AnalyticsProjectFetchRequest[] = []
|
||||
for (
|
||||
let index = 0;
|
||||
index < fetchRequest.project_ids.length;
|
||||
index += ANALYTICS_PROJECT_IDS_FETCH_BATCH_SIZE
|
||||
) {
|
||||
requests.push({
|
||||
...fetchRequest,
|
||||
project_ids: fetchRequest.project_ids.slice(
|
||||
index,
|
||||
index + ANALYTICS_PROJECT_IDS_FETCH_BATCH_SIZE,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return requests
|
||||
}
|
||||
|
||||
function mergeAnalyticsTimeSlices(
|
||||
timeSliceGroups: Labrinth.Analytics.v3.TimeSlice[][],
|
||||
): Labrinth.Analytics.v3.TimeSlice[] {
|
||||
const mergedTimeSlices: Labrinth.Analytics.v3.TimeSlice[] = []
|
||||
|
||||
for (const timeSlices of timeSliceGroups) {
|
||||
timeSlices.forEach((timeSlice, index) => {
|
||||
if (!mergedTimeSlices[index]) {
|
||||
mergedTimeSlices[index] = []
|
||||
}
|
||||
|
||||
for (const dataPoint of timeSlice) {
|
||||
mergedTimeSlices[index].push(dataPoint)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return mergedTimeSlices
|
||||
}
|
||||
|
||||
function mergeAnalyticsProjectEvents(
|
||||
projectEventGroups: Labrinth.Analytics.v3.ProjectAnalyticsEvent[][],
|
||||
): Labrinth.Analytics.v3.ProjectAnalyticsEvent[] {
|
||||
const mergedProjectEvents: Labrinth.Analytics.v3.ProjectAnalyticsEvent[] = []
|
||||
|
||||
for (const projectEvents of projectEventGroups) {
|
||||
for (const projectEvent of projectEvents) {
|
||||
mergedProjectEvents.push(projectEvent)
|
||||
}
|
||||
}
|
||||
|
||||
return mergedProjectEvents.sort((left, right) => {
|
||||
const timestampDifference =
|
||||
new Date(left.timestamp).getTime() - new Date(right.timestamp).getTime()
|
||||
return (
|
||||
timestampDifference ||
|
||||
left.project_id.localeCompare(right.project_id) ||
|
||||
left.kind.localeCompare(right.kind)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function waitForAnalyticsFetchBatchDelay(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ANALYTICS_PROJECT_IDS_FETCH_BATCH_DELAY_MS))
|
||||
}
|
||||
|
||||
export async function fetchAnalyticsData(
|
||||
fetchRequest: AnalyticsProjectFetchRequest,
|
||||
fetchAnalytics: (
|
||||
request: Labrinth.Analytics.v3.FetchRequest,
|
||||
) => Promise<Labrinth.Analytics.v3.FetchResponse>,
|
||||
): Promise<AnalyticsFetchData> {
|
||||
const fetchRequests = buildAnalyticsFetchRequestBatches(fetchRequest)
|
||||
const timeSliceGroups: Labrinth.Analytics.v3.TimeSlice[][] = []
|
||||
const projectEventGroups: Labrinth.Analytics.v3.ProjectAnalyticsEvent[][] = []
|
||||
|
||||
for (let index = 0; index < fetchRequests.length; index++) {
|
||||
if (index > 0) {
|
||||
await waitForAnalyticsFetchBatchDelay()
|
||||
}
|
||||
|
||||
const response = await fetchAnalytics(fetchRequests[index])
|
||||
timeSliceGroups.push(response.metrics)
|
||||
projectEventGroups.push(response.project_events ?? [])
|
||||
}
|
||||
|
||||
return {
|
||||
metrics: mergeAnalyticsTimeSlices(timeSliceGroups),
|
||||
project_events: mergeAnalyticsProjectEvents(projectEventGroups),
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAnalyticsTimeSlices(
|
||||
fetchRequest: AnalyticsProjectFetchRequest,
|
||||
fetchAnalytics: (
|
||||
request: Labrinth.Analytics.v3.FetchRequest,
|
||||
) => Promise<Labrinth.Analytics.v3.FetchResponse>,
|
||||
): Promise<Labrinth.Analytics.v3.TimeSlice[]> {
|
||||
const response = await fetchAnalyticsData(fetchRequest, fetchAnalytics)
|
||||
return response.metrics
|
||||
}
|
||||
|
||||
export function areAnalyticsFetchRequestsEqual(
|
||||
left: Labrinth.Analytics.v3.FetchRequest | null,
|
||||
right: Labrinth.Analytics.v3.FetchRequest,
|
||||
): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right)
|
||||
}
|
||||
|
||||
export function buildAnalyticsCurrentTimeSlicesQueryKey(
|
||||
userId: string | undefined,
|
||||
nextFetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
|
||||
refreshTimestamp: number,
|
||||
) {
|
||||
return ['analytics', 'dashboard', userId, 'current', nextFetchRequest, refreshTimestamp]
|
||||
}
|
||||
|
||||
export function isRevenueHourlyGroupBy(groupBy: AnalyticsGroupByPreset): boolean {
|
||||
return groupBy === '1h' || groupBy === '6h'
|
||||
}
|
||||
|
||||
export function buildDailyAnalyticsFetchRequest(
|
||||
nextFetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
|
||||
): Labrinth.Analytics.v3.FetchRequest | null {
|
||||
if (!isAnalyticsFetchRequestReady(nextFetchRequest)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const startTime = new Date(nextFetchRequest.time_range.start).getTime()
|
||||
const endTime = new Date(nextFetchRequest.time_range.end).getTime()
|
||||
const durationMs = endTime - startTime
|
||||
if (!Number.isFinite(durationMs) || durationMs <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const desiredSlices = Math.max(1, Math.floor(durationMs / ANALYTICS_DAY_MS))
|
||||
|
||||
return {
|
||||
...nextFetchRequest,
|
||||
time_range: {
|
||||
...nextFetchRequest.time_range,
|
||||
resolution: {
|
||||
slices: Math.min(ANALYTICS_MAX_TIME_SLICES, desiredSlices),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAnalyticsFacetsRequest(
|
||||
projectIds: string[],
|
||||
timeRange: Labrinth.Analytics.v3.TimeRange,
|
||||
): Labrinth.Analytics.v3.FetchRequest {
|
||||
return {
|
||||
time_range: {
|
||||
start: timeRange.start,
|
||||
end: timeRange.end,
|
||||
resolution: {
|
||||
slices: 1,
|
||||
},
|
||||
},
|
||||
project_ids: projectIds,
|
||||
return_metrics: {
|
||||
project_downloads: {
|
||||
bucket_by: [
|
||||
'project_id',
|
||||
'domain',
|
||||
'user_agent',
|
||||
'version_id',
|
||||
'monetized',
|
||||
'country',
|
||||
'reason',
|
||||
'game_version',
|
||||
'loader',
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function addAnalyticsDays(date: Date, days: number): Date {
|
||||
const nextDate = new Date(date)
|
||||
nextDate.setDate(nextDate.getDate() + days)
|
||||
return nextDate
|
||||
}
|
||||
|
||||
function parseAnalyticsDateInputValue(value: string): Date | null {
|
||||
const parsedDate = new Date(`${value}T00:00:00`)
|
||||
return Number.isNaN(parsedDate.getTime()) ? null : parsedDate
|
||||
}
|
||||
|
||||
function parseAnalyticsDateTimeInputValue(value: string): Date | null {
|
||||
const parsedDate = new Date(value)
|
||||
return Number.isNaN(parsedDate.getTime()) ? null : parsedDate
|
||||
}
|
||||
|
||||
export function getAnalyticsTimeframeDurationMs({
|
||||
mode,
|
||||
preset,
|
||||
lastAmount,
|
||||
lastUnit,
|
||||
customStartDate,
|
||||
customEndDate,
|
||||
nowTimestamp,
|
||||
}: {
|
||||
mode: AnalyticsTimeframeMode
|
||||
preset: AnalyticsTimeframePreset
|
||||
lastAmount: number
|
||||
lastUnit: AnalyticsLastTimeframeUnit
|
||||
customStartDate: string
|
||||
customEndDate: string
|
||||
nowTimestamp: number
|
||||
}): number {
|
||||
if (mode === 'preset') {
|
||||
switch (preset) {
|
||||
case 'today':
|
||||
case 'yesterday':
|
||||
return 24 * 60 * 60 * 1000
|
||||
case 'last_7_days':
|
||||
return 7 * 24 * 60 * 60 * 1000
|
||||
case 'last_14_days':
|
||||
return 14 * 24 * 60 * 60 * 1000
|
||||
case 'last_30_days':
|
||||
return 30 * 24 * 60 * 60 * 1000
|
||||
case 'last_90_days':
|
||||
return 90 * 24 * 60 * 60 * 1000
|
||||
case 'last_180_days':
|
||||
return 180 * 24 * 60 * 60 * 1000
|
||||
case 'year_to_date': {
|
||||
const now = new Date(nowTimestamp)
|
||||
const yearStart = new Date(now.getFullYear(), 0, 1)
|
||||
yearStart.setHours(0, 0, 0, 0)
|
||||
return now.getTime() - yearStart.getTime()
|
||||
}
|
||||
case 'all_time':
|
||||
return REVENUE_MIN_TIMEFRAME_MS
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'last') {
|
||||
const amount = Math.max(1, Math.floor(lastAmount))
|
||||
switch (lastUnit) {
|
||||
case 'hours':
|
||||
return amount * 60 * 60 * 1000
|
||||
case 'days':
|
||||
return amount * 24 * 60 * 60 * 1000
|
||||
case 'weeks':
|
||||
return amount * 7 * 24 * 60 * 60 * 1000
|
||||
case 'months':
|
||||
return REVENUE_MIN_TIMEFRAME_MS
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'custom_range') {
|
||||
const start = parseAnalyticsDateInputValue(customStartDate)
|
||||
const inclusiveEnd = parseAnalyticsDateInputValue(customEndDate)
|
||||
if (!start || !inclusiveEnd) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return addAnalyticsDays(inclusiveEnd, 1).getTime() - start.getTime()
|
||||
}
|
||||
|
||||
const start = parseAnalyticsDateTimeInputValue(customStartDate)
|
||||
const end = parseAnalyticsDateTimeInputValue(customEndDate)
|
||||
if (!start || !end) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return end.getTime() - start.getTime()
|
||||
}
|
||||
|
||||
export function getPercentChange(currentValue: number, previousValue: number): number {
|
||||
if (previousValue === 0) {
|
||||
if (currentValue === 0) {
|
||||
return 0
|
||||
}
|
||||
return 100
|
||||
}
|
||||
|
||||
return ((currentValue - previousValue) / previousValue) * 100
|
||||
}
|
||||
|
||||
export function computeTotals(
|
||||
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
|
||||
selectedProjectIds: Set<string>,
|
||||
availableProjectIds: Set<string>,
|
||||
projectStatusById: Map<string, ProjectStatusFilterValue>,
|
||||
filters: AnalyticsSelectedFilters,
|
||||
): AnalyticsDashboardTotals {
|
||||
const totals: AnalyticsDashboardTotals = {
|
||||
views: 0,
|
||||
downloads: 0,
|
||||
revenue: 0,
|
||||
playtime: 0,
|
||||
}
|
||||
|
||||
if (availableProjectIds.size === 0) {
|
||||
return totals
|
||||
}
|
||||
|
||||
const effectiveProjectIds = selectedProjectIds.size > 0 ? selectedProjectIds : availableProjectIds
|
||||
const filteredProjectIds = new Set(
|
||||
getProjectIdsMatchingStatusFilter([...effectiveProjectIds], projectStatusById, filters),
|
||||
)
|
||||
if (filteredProjectIds.size === 0) {
|
||||
return totals
|
||||
}
|
||||
|
||||
for (const timeSlice of timeSlices) {
|
||||
for (const dataPoint of timeSlice) {
|
||||
if (!isProjectAnalyticsPoint(dataPoint)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!filteredProjectIds.has(dataPoint.source_project)) {
|
||||
continue
|
||||
}
|
||||
|
||||
switch (dataPoint.metric_kind) {
|
||||
case 'views':
|
||||
totals.views += dataPoint.views
|
||||
break
|
||||
case 'downloads':
|
||||
totals.downloads += dataPoint.downloads
|
||||
break
|
||||
case 'playtime':
|
||||
totals.playtime += dataPoint.seconds
|
||||
break
|
||||
case 'revenue': {
|
||||
const value = Number.parseFloat(dataPoint.revenue)
|
||||
totals.revenue += Number.isFinite(value) ? value : 0
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return totals
|
||||
}
|
||||
|
||||
export function cloneAnalyticsFetchRequest(
|
||||
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
|
||||
): Labrinth.Analytics.v3.FetchRequest | null {
|
||||
return fetchRequest ? JSON.parse(JSON.stringify(fetchRequest)) : null
|
||||
}
|
||||
|
||||
export function addVersionIdsFromTimeSlices(
|
||||
versionIds: Set<string>,
|
||||
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
|
||||
) {
|
||||
for (const timeSlice of timeSlices) {
|
||||
for (const dataPoint of timeSlice) {
|
||||
if (!isProjectAnalyticsPoint(dataPoint)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
(dataPoint.metric_kind === 'downloads' || dataPoint.metric_kind === 'playtime') &&
|
||||
dataPoint.version_id
|
||||
) {
|
||||
const versionId = dataPoint.version_id.trim()
|
||||
if (versionId.length > 0) {
|
||||
versionIds.add(versionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function addVersionProjectNamesFromTimeSlices(
|
||||
versionProjectNames: Map<string, string>,
|
||||
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
|
||||
projectNamesById: Map<string, string>,
|
||||
) {
|
||||
for (const timeSlice of timeSlices) {
|
||||
for (const dataPoint of timeSlice) {
|
||||
if (!isProjectAnalyticsPoint(dataPoint)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
(dataPoint.metric_kind === 'downloads' || dataPoint.metric_kind === 'playtime') &&
|
||||
dataPoint.version_id
|
||||
) {
|
||||
const versionId = dataPoint.version_id.trim()
|
||||
const projectName = projectNamesById.get(dataPoint.source_project)
|
||||
if (versionId.length > 0 && projectName && !versionProjectNames.has(versionId)) {
|
||||
versionProjectNames.set(versionId, projectName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import type {
|
||||
AnalyticsDashboardFilterOptions,
|
||||
AnalyticsFacetsFilterOptionSummary,
|
||||
AnalyticsProjectVersionSource,
|
||||
AnalyticsSelectedFilters,
|
||||
AnalyticsVersionMetadata,
|
||||
NormalizedAnalyticsSelectedFilters,
|
||||
ProjectVersionFilterOptionSummary,
|
||||
} from './analytics-types'
|
||||
|
||||
export function sortStringValues(values: string[]): string[] {
|
||||
return [...values].sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
function toAnalyticsVersionMetadata(
|
||||
version: Labrinth.Versions.v3.Version,
|
||||
): AnalyticsVersionMetadata {
|
||||
return {
|
||||
id: version.id,
|
||||
versionNumber: version.version_number,
|
||||
datePublished: version.date_published,
|
||||
projectId: version.project_id,
|
||||
downloads: version.downloads,
|
||||
gameVersions: [...version.game_versions],
|
||||
loaders:
|
||||
version.mrpack_loaders && version.mrpack_loaders.length > 0
|
||||
? [...version.mrpack_loaders]
|
||||
: [...version.loaders],
|
||||
}
|
||||
}
|
||||
|
||||
export function getProjectVersionFilterOptionSummary(
|
||||
versions: AnalyticsVersionMetadata[],
|
||||
): ProjectVersionFilterOptionSummary {
|
||||
const gameVersions = new Set<string>()
|
||||
const loaders = new Set<string>()
|
||||
const versionIds = new Set<string>()
|
||||
|
||||
for (const version of versions) {
|
||||
versionIds.add(version.id)
|
||||
|
||||
for (const gameVersion of version.gameVersions) {
|
||||
const normalizedGameVersion = gameVersion.trim()
|
||||
if (normalizedGameVersion.length > 0) {
|
||||
gameVersions.add(normalizedGameVersion)
|
||||
}
|
||||
}
|
||||
|
||||
for (const loader of version.loaders) {
|
||||
const normalizedLoader = loader.trim().toLowerCase()
|
||||
if (normalizedLoader.length > 0 && normalizedLoader !== 'mrpack') {
|
||||
loaders.add(normalizedLoader)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
gameVersions: sortStringValues([...gameVersions]),
|
||||
loaderTypes: sortStringValues([...loaders]),
|
||||
versionIds: sortStringValues([...versionIds]),
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAnalyticsVersionMetadataByIds(
|
||||
versionIds: string[],
|
||||
getVersions: (ids: string[]) => Promise<Labrinth.Versions.v3.Version[]>,
|
||||
): Promise<AnalyticsVersionMetadata[]> {
|
||||
const metadata: AnalyticsVersionMetadata[] = []
|
||||
const segmentSize = 800
|
||||
|
||||
for (let index = 0; index < versionIds.length; index += segmentSize) {
|
||||
const versions = await getVersions(versionIds.slice(index, index + segmentSize))
|
||||
metadata.push(...versions.map(toAnalyticsVersionMetadata))
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
export function getAnalyticsVersionIdsFromProjects(
|
||||
projects: readonly AnalyticsProjectVersionSource[],
|
||||
projectIds: readonly string[],
|
||||
): string[] {
|
||||
const selectedProjectIds = new Set(projectIds)
|
||||
const versionIds = new Set<string>()
|
||||
|
||||
for (const project of projects) {
|
||||
if (!selectedProjectIds.has(project.id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const versionId of project.versions ?? []) {
|
||||
const normalizedVersionId = versionId.trim()
|
||||
if (normalizedVersionId.length > 0) {
|
||||
versionIds.add(normalizedVersionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sortStringValues([...versionIds])
|
||||
}
|
||||
|
||||
function retainAvailableSelectedFilterValues(
|
||||
values: string[],
|
||||
availableValues: string[],
|
||||
): string[] {
|
||||
const availableValueSet = new Set(availableValues)
|
||||
return values.filter((value) => availableValueSet.has(value))
|
||||
}
|
||||
|
||||
export function sanitizeAnalyticsSelectedFiltersForAvailableOptions(
|
||||
filters: AnalyticsSelectedFilters,
|
||||
filterOptions: AnalyticsDashboardFilterOptions,
|
||||
): AnalyticsSelectedFilters {
|
||||
return {
|
||||
...filters,
|
||||
download_reason: retainAvailableSelectedFilterValues(
|
||||
filters.download_reason,
|
||||
filterOptions.downloadReasons,
|
||||
),
|
||||
game_version: retainAvailableSelectedFilterValues(
|
||||
filters.game_version,
|
||||
filterOptions.gameVersions,
|
||||
),
|
||||
loader_type: retainAvailableSelectedFilterValues(
|
||||
filters.loader_type,
|
||||
filterOptions.loaderTypes,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function cloneAnalyticsSelectedFilters(
|
||||
filters: AnalyticsSelectedFilters,
|
||||
): AnalyticsSelectedFilters {
|
||||
return {
|
||||
project: [...filters.project],
|
||||
project_status: [...filters.project_status],
|
||||
country: [...filters.country],
|
||||
monetization: [...filters.monetization],
|
||||
user_agent: [...filters.user_agent],
|
||||
download_reason: [...filters.download_reason],
|
||||
version_id: [...filters.version_id],
|
||||
game_version: [...filters.game_version],
|
||||
loader_type: [...filters.loader_type],
|
||||
}
|
||||
}
|
||||
|
||||
export function cloneAnalyticsFilterOptions(
|
||||
filterOptions: AnalyticsDashboardFilterOptions,
|
||||
): AnalyticsDashboardFilterOptions {
|
||||
return {
|
||||
countries: [...filterOptions.countries],
|
||||
downloadSources: [...filterOptions.downloadSources],
|
||||
downloadReasons: [...filterOptions.downloadReasons],
|
||||
gameVersions: [...filterOptions.gameVersions],
|
||||
loaderTypes: [...filterOptions.loaderTypes],
|
||||
versionIds: [...filterOptions.versionIds],
|
||||
}
|
||||
}
|
||||
|
||||
function getEmptyAnalyticsFacetsFilterOptionSummary(): AnalyticsFacetsFilterOptionSummary {
|
||||
return {
|
||||
countries: [],
|
||||
downloadSources: [],
|
||||
downloadReasons: [],
|
||||
gameVersions: [],
|
||||
loaderTypes: [],
|
||||
versionIds: [],
|
||||
projectDownloadsById: new Map(),
|
||||
projectVersionDownloadsById: new Map(),
|
||||
gameVersionDownloadsByVersion: new Map(),
|
||||
countryDownloadsByCode: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
function getAnalyticsFacetValues<T>(
|
||||
facets: Labrinth.Analytics.v3.AnalyticsFacet<T>[] | null | undefined,
|
||||
): T[] {
|
||||
return facets?.map((facet) => facet.value) ?? []
|
||||
}
|
||||
|
||||
function getAnalyticsFacetDownloadsByValue<T>(
|
||||
facets: Labrinth.Analytics.v3.AnalyticsFacet<T>[] | null | undefined,
|
||||
getKey: (value: T) => string,
|
||||
): Map<string, number> {
|
||||
const downloadsByValue = new Map<string, number>()
|
||||
for (const facet of facets ?? []) {
|
||||
const key = getKey(facet.value)
|
||||
if (key.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const downloads = Number.isFinite(facet.downloads) ? facet.downloads : 0
|
||||
downloadsByValue.set(key, (downloadsByValue.get(key) ?? 0) + downloads)
|
||||
}
|
||||
|
||||
return downloadsByValue
|
||||
}
|
||||
|
||||
export function getAnalyticsFacetsFilterOptionSummary(
|
||||
facets: Labrinth.Analytics.v3.AnalyticsFacets | null | undefined,
|
||||
): AnalyticsFacetsFilterOptionSummary {
|
||||
if (!facets) {
|
||||
return getEmptyAnalyticsFacetsFilterOptionSummary()
|
||||
}
|
||||
|
||||
const downloadCountries = getAnalyticsFacetValues(facets.project_downloads.country)
|
||||
const downloadGameVersions = getAnalyticsFacetValues(facets.project_downloads.game_version)
|
||||
const downloadLoaders = getAnalyticsFacetValues(facets.project_downloads.loader)
|
||||
const downloadVersionIds = getAnalyticsFacetValues(facets.project_downloads.version_id)
|
||||
const viewCountries = getAnalyticsFacetValues(facets.project_views.country)
|
||||
const playtimeCountries = getAnalyticsFacetValues(facets.project_playtime.country)
|
||||
const playtimeGameVersions = getAnalyticsFacetValues(facets.project_playtime.game_version)
|
||||
const playtimeLoaders = getAnalyticsFacetValues(facets.project_playtime.loader)
|
||||
const playtimeVersionIds = getAnalyticsFacetValues(facets.project_playtime.version_id)
|
||||
const countries = new Set([...viewCountries, ...downloadCountries, ...playtimeCountries])
|
||||
const gameVersions = new Set([...downloadGameVersions, ...playtimeGameVersions])
|
||||
const loaderTypes = new Set<string>()
|
||||
for (const loader of [...downloadLoaders, ...playtimeLoaders]) {
|
||||
const normalizedLoader = loader.trim().toLowerCase()
|
||||
if (normalizedLoader.length > 0 && normalizedLoader !== 'mrpack') {
|
||||
loaderTypes.add(normalizedLoader)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
countries: sortStringValues(
|
||||
[...countries]
|
||||
.map((country) => country.trim().toUpperCase())
|
||||
.filter((country) => country.length > 0),
|
||||
),
|
||||
downloadSources: sortStringValues(getAnalyticsFacetValues(facets.project_downloads.user_agent)),
|
||||
downloadReasons: sortStringValues(getAnalyticsFacetValues(facets.project_downloads.reason)),
|
||||
gameVersions: sortStringValues(
|
||||
[...gameVersions]
|
||||
.map((gameVersion) => gameVersion.trim())
|
||||
.filter((gameVersion) => gameVersion.length > 0),
|
||||
),
|
||||
loaderTypes: sortStringValues([...loaderTypes]),
|
||||
versionIds: sortStringValues([...new Set([...downloadVersionIds, ...playtimeVersionIds])]),
|
||||
projectDownloadsById: getAnalyticsFacetDownloadsByValue(
|
||||
facets.project_downloads.project_id,
|
||||
(projectId) => projectId.trim(),
|
||||
),
|
||||
projectVersionDownloadsById: getAnalyticsFacetDownloadsByValue(
|
||||
facets.project_downloads.version_id,
|
||||
(versionId) => versionId.trim(),
|
||||
),
|
||||
gameVersionDownloadsByVersion: getAnalyticsFacetDownloadsByValue(
|
||||
facets.project_downloads.game_version,
|
||||
(gameVersion) => gameVersion.trim(),
|
||||
),
|
||||
countryDownloadsByCode: getAnalyticsFacetDownloadsByValue(
|
||||
facets.project_downloads.country,
|
||||
(country) => country.trim().toUpperCase(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function doesAnalyticsPointMatchFilters(
|
||||
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
filters: AnalyticsSelectedFilters,
|
||||
): boolean {
|
||||
return doesAnalyticsPointMatchNormalizedFilters(
|
||||
dataPoint,
|
||||
normalizeAnalyticsSelectedFilters(filters),
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizeAnalyticsSelectedFilters(
|
||||
filters: AnalyticsSelectedFilters,
|
||||
): NormalizedAnalyticsSelectedFilters {
|
||||
return {
|
||||
country: normalizeAnalyticsFilterValues(filters.country),
|
||||
monetization: normalizeAnalyticsFilterValues(filters.monetization),
|
||||
userAgent: normalizeAnalyticsFilterValues(filters.user_agent),
|
||||
downloadReason: normalizeAnalyticsFilterValues(filters.download_reason),
|
||||
versionId: normalizeAnalyticsFilterValues(filters.version_id),
|
||||
gameVersion: normalizeAnalyticsFilterValues(filters.game_version),
|
||||
loaderType: normalizeAnalyticsFilterValues(filters.loader_type),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAnalyticsFilterValues(values: string[]): ReadonlySet<string> {
|
||||
const normalizedValues = new Set<string>()
|
||||
for (const value of values) {
|
||||
const normalizedValue = value.trim().toLowerCase()
|
||||
if (normalizedValue.length > 0) {
|
||||
normalizedValues.add(normalizedValue)
|
||||
}
|
||||
}
|
||||
return normalizedValues
|
||||
}
|
||||
|
||||
export function doesAnalyticsPointMatchNormalizedFilters(
|
||||
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
filters: NormalizedAnalyticsSelectedFilters,
|
||||
): boolean {
|
||||
switch (dataPoint.metric_kind) {
|
||||
case 'views':
|
||||
return (
|
||||
doesAnalyticsPointMatchNormalizedFilter(
|
||||
dataPoint,
|
||||
filters.country,
|
||||
getCountryFilterValue,
|
||||
) &&
|
||||
doesAnalyticsPointMatchNormalizedFilter(
|
||||
dataPoint,
|
||||
filters.monetization,
|
||||
getMonetizationFilterValue,
|
||||
)
|
||||
)
|
||||
case 'downloads':
|
||||
return (
|
||||
doesAnalyticsPointMatchNormalizedFilter(
|
||||
dataPoint,
|
||||
filters.country,
|
||||
getCountryFilterValue,
|
||||
) &&
|
||||
doesAnalyticsPointMatchNormalizedFilter(
|
||||
dataPoint,
|
||||
filters.monetization,
|
||||
getMonetizationFilterValue,
|
||||
) &&
|
||||
doesAnalyticsPointMatchNormalizedFilter(
|
||||
dataPoint,
|
||||
filters.userAgent,
|
||||
getDownloadSourceFilterValue,
|
||||
) &&
|
||||
doesAnalyticsPointMatchNormalizedFilter(
|
||||
dataPoint,
|
||||
filters.downloadReason,
|
||||
getDownloadReasonFilterValue,
|
||||
) &&
|
||||
doesAnalyticsPointMatchNormalizedFilter(
|
||||
dataPoint,
|
||||
filters.versionId,
|
||||
getVersionFilterValue,
|
||||
) &&
|
||||
doesAnalyticsPointMatchNormalizedFilter(
|
||||
dataPoint,
|
||||
filters.gameVersion,
|
||||
getGameVersionFilterValue,
|
||||
) &&
|
||||
doesAnalyticsPointMatchNormalizedFilter(dataPoint, filters.loaderType, getLoaderFilterValue)
|
||||
)
|
||||
case 'playtime':
|
||||
return (
|
||||
doesAnalyticsPointMatchNormalizedFilter(
|
||||
dataPoint,
|
||||
filters.country,
|
||||
getCountryFilterValue,
|
||||
) &&
|
||||
doesAnalyticsPointMatchNormalizedFilter(
|
||||
dataPoint,
|
||||
filters.versionId,
|
||||
getVersionFilterValue,
|
||||
) &&
|
||||
doesAnalyticsPointMatchNormalizedFilter(
|
||||
dataPoint,
|
||||
filters.gameVersion,
|
||||
getGameVersionFilterValue,
|
||||
) &&
|
||||
doesAnalyticsPointMatchNormalizedFilter(dataPoint, filters.loaderType, getLoaderFilterValue)
|
||||
)
|
||||
case 'revenue':
|
||||
return true
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function doesAnalyticsPointMatchNormalizedFilter(
|
||||
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
filterValues: ReadonlySet<string>,
|
||||
getPointValue: (dataPoint: Labrinth.Analytics.v3.ProjectAnalytics) => string | null | undefined,
|
||||
): boolean {
|
||||
if (filterValues.size === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const pointValue = getPointValue(dataPoint)
|
||||
if (pointValue === undefined) {
|
||||
return true
|
||||
}
|
||||
if (pointValue === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const normalizedPointValue = pointValue.trim().toLowerCase()
|
||||
return filterValues.has(normalizedPointValue)
|
||||
}
|
||||
|
||||
function getCountryFilterValue(
|
||||
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
): string | null | undefined {
|
||||
if (
|
||||
dataPoint.metric_kind !== 'views' &&
|
||||
dataPoint.metric_kind !== 'downloads' &&
|
||||
dataPoint.metric_kind !== 'playtime'
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return dataPoint.country ?? null
|
||||
}
|
||||
|
||||
function getMonetizationFilterValue(
|
||||
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
): string | null | undefined {
|
||||
if (dataPoint.metric_kind !== 'views' && dataPoint.metric_kind !== 'downloads') {
|
||||
return undefined
|
||||
}
|
||||
if (typeof dataPoint.monetized !== 'boolean') {
|
||||
return null
|
||||
}
|
||||
|
||||
return dataPoint.monetized ? 'monetized' : 'unmonetized'
|
||||
}
|
||||
|
||||
function getDownloadSourceFilterValue(
|
||||
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
): string | null | undefined {
|
||||
if (dataPoint.metric_kind !== 'downloads') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return dataPoint.user_agent ?? null
|
||||
}
|
||||
|
||||
function getDownloadReasonFilterValue(
|
||||
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
): string | null | undefined {
|
||||
if (dataPoint.metric_kind !== 'downloads') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return dataPoint.reason ?? null
|
||||
}
|
||||
|
||||
function getVersionFilterValue(
|
||||
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
): string | null | undefined {
|
||||
if (dataPoint.metric_kind !== 'downloads' && dataPoint.metric_kind !== 'playtime') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return dataPoint.version_id ?? null
|
||||
}
|
||||
|
||||
function getGameVersionFilterValue(
|
||||
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
): string | null | undefined {
|
||||
if (dataPoint.metric_kind !== 'downloads' && dataPoint.metric_kind !== 'playtime') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return dataPoint.game_version ?? null
|
||||
}
|
||||
|
||||
function getLoaderFilterValue(
|
||||
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
): string | null | undefined {
|
||||
if (dataPoint.metric_kind !== 'downloads' && dataPoint.metric_kind !== 'playtime') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return dataPoint.loader ?? null
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
getProjectStatusFilterValue,
|
||||
type ProjectStatusFilterValue,
|
||||
} from '~/components/analytics-dashboard/query-builder/query-filter'
|
||||
|
||||
import type {
|
||||
AnalyticsDashboardProject,
|
||||
AnalyticsDashboardProjectSource,
|
||||
AnalyticsSelectedFilters,
|
||||
ProjectTypeMetadata,
|
||||
} from './analytics-types'
|
||||
|
||||
const MINECRAFT_JAVA_SERVER_PROJECT_TYPE = 'minecraft_java_server'
|
||||
|
||||
export const UNKNOWN_ORGANIZATION_NAME = 'Organization'
|
||||
|
||||
function isServerProject(project: ProjectTypeMetadata): boolean {
|
||||
if (project.project_type === MINECRAFT_JAVA_SERVER_PROJECT_TYPE) {
|
||||
return true
|
||||
}
|
||||
|
||||
return project.project_types?.includes(MINECRAFT_JAVA_SERVER_PROJECT_TYPE) ?? false
|
||||
}
|
||||
|
||||
export function isAnalyticsEligibleProject(
|
||||
project: ProjectTypeMetadata & { status?: string | null },
|
||||
): boolean {
|
||||
return !isServerProject(project) && getProjectStatusFilterValue(project.status) !== 'draft'
|
||||
}
|
||||
|
||||
export function getSingleQueryValue(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const normalizedValue = value.trim()
|
||||
return normalizedValue.length > 0 ? normalizedValue : undefined
|
||||
}
|
||||
|
||||
export function toAnalyticsDashboardProject(
|
||||
project: AnalyticsDashboardProjectSource,
|
||||
): AnalyticsDashboardProject {
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.name ?? project.title ?? project.id,
|
||||
iconUrl: project.icon_url ?? undefined,
|
||||
downloads: project.downloads ?? 0,
|
||||
status: getProjectStatusFilterValue(project.status),
|
||||
}
|
||||
}
|
||||
|
||||
export function getUniqueAnalyticsDashboardProjects(
|
||||
projects: AnalyticsDashboardProjectSource[],
|
||||
seenProjectIds: Set<string>,
|
||||
): AnalyticsDashboardProject[] {
|
||||
const analyticsProjects: AnalyticsDashboardProject[] = []
|
||||
|
||||
for (const project of projects) {
|
||||
if (seenProjectIds.has(project.id) || !isAnalyticsEligibleProject(project)) {
|
||||
continue
|
||||
}
|
||||
|
||||
seenProjectIds.add(project.id)
|
||||
analyticsProjects.push(toAnalyticsDashboardProject(project))
|
||||
}
|
||||
|
||||
return analyticsProjects
|
||||
}
|
||||
|
||||
export function getProjectOrganizationId(
|
||||
project: AnalyticsDashboardProjectSource,
|
||||
): string | undefined {
|
||||
return typeof project.organization === 'string' && project.organization.trim().length > 0
|
||||
? project.organization
|
||||
: undefined
|
||||
}
|
||||
|
||||
export function doesProjectStatusMatchFilters(
|
||||
status: string | null | undefined,
|
||||
filters: AnalyticsSelectedFilters,
|
||||
): boolean {
|
||||
if (filters.project_status.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return filters.project_status.includes(getProjectStatusFilterValue(status))
|
||||
}
|
||||
|
||||
export function getProjectIdsMatchingStatusFilter(
|
||||
projectIds: string[],
|
||||
projectStatusById: Map<string, ProjectStatusFilterValue>,
|
||||
filters: AnalyticsSelectedFilters,
|
||||
): string[] {
|
||||
if (filters.project_status.length === 0) {
|
||||
return projectIds
|
||||
}
|
||||
|
||||
return projectIds.filter((projectId) =>
|
||||
doesProjectStatusMatchFilters(projectStatusById.get(projectId), filters),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { LocationQueryValueRaw } from 'vue-router'
|
||||
|
||||
import type { ProjectStatusFilterValue } from '~/components/analytics-dashboard/query-builder/query-filter'
|
||||
|
||||
export type AnalyticsQueryFilterCategory =
|
||||
| 'project'
|
||||
| 'project_status'
|
||||
| 'country'
|
||||
| 'monetization'
|
||||
| 'user_agent'
|
||||
| 'download_reason'
|
||||
| 'version_id'
|
||||
| 'game_version'
|
||||
| 'loader_type'
|
||||
|
||||
export type AnalyticsTimeframePreset =
|
||||
| 'today'
|
||||
| 'yesterday'
|
||||
| 'last_7_days'
|
||||
| 'last_14_days'
|
||||
| 'last_30_days'
|
||||
| 'last_90_days'
|
||||
| 'last_180_days'
|
||||
| 'year_to_date'
|
||||
| 'all_time'
|
||||
|
||||
export type AnalyticsTimeframeMode = 'preset' | 'last' | 'custom_range' | 'custom_datetime_range'
|
||||
export type AnalyticsLastTimeframeUnit = 'hours' | 'days' | 'weeks' | 'months'
|
||||
|
||||
export type AnalyticsGroupByPreset = '1h' | '6h' | 'day' | 'week' | 'month' | 'year'
|
||||
|
||||
export type AnalyticsBreakdownPreset =
|
||||
| 'none'
|
||||
| 'project'
|
||||
| 'country'
|
||||
| 'monetization'
|
||||
| 'user_agent'
|
||||
| 'download_reason'
|
||||
| 'version_id'
|
||||
| 'loader'
|
||||
| 'game_version'
|
||||
|
||||
export type AnalyticsSelectedBreakdowns = Exclude<AnalyticsBreakdownPreset, 'none'>[]
|
||||
export type AnalyticsDashboardStat = 'views' | 'downloads' | 'revenue' | 'playtime'
|
||||
export type AnalyticsGraphViewMode = 'line' | 'area' | 'bar'
|
||||
export type AnalyticsTableSortColumn =
|
||||
| 'date'
|
||||
| 'project'
|
||||
| 'breakdown'
|
||||
| `breakdown_${Exclude<AnalyticsBreakdownPreset, 'none'>}`
|
||||
| 'views'
|
||||
| 'downloads'
|
||||
| 'revenue'
|
||||
| 'playtime'
|
||||
export type AnalyticsTableSortDirection = 'asc' | 'desc'
|
||||
|
||||
export type AnalyticsSelectedFilters = Record<AnalyticsQueryFilterCategory, string[]>
|
||||
|
||||
export type AnalyticsQueryBuilderState = {
|
||||
selectedProjectIds: string[]
|
||||
selectedTimeframeMode: AnalyticsTimeframeMode
|
||||
selectedTimeframe: AnalyticsTimeframePreset
|
||||
selectedLastTimeframeAmount: number
|
||||
selectedLastTimeframeUnit: AnalyticsLastTimeframeUnit
|
||||
selectedCustomTimeframeStartDate: string
|
||||
selectedCustomTimeframeEndDate: string
|
||||
selectedGroupBy: AnalyticsGroupByPreset
|
||||
selectedBreakdowns: AnalyticsSelectedBreakdowns
|
||||
selectedFilters: AnalyticsSelectedFilters
|
||||
}
|
||||
|
||||
export type AnalyticsGraphState = {
|
||||
activeStat: AnalyticsDashboardStat
|
||||
activeGraphViewMode: AnalyticsGraphViewMode
|
||||
isRatioMode: boolean
|
||||
showChartEvents: boolean
|
||||
showProjectEvents: boolean
|
||||
showPreviousPeriod: boolean
|
||||
hiddenGraphDatasetIds: string[]
|
||||
selectedGraphDatasetIds: string[] | null
|
||||
}
|
||||
|
||||
export type AnalyticsTableSortState = {
|
||||
sortColumn: AnalyticsTableSortColumn | undefined
|
||||
sortDirection: AnalyticsTableSortDirection
|
||||
}
|
||||
|
||||
export type MutableRouteQuery = Record<
|
||||
string,
|
||||
LocationQueryValueRaw | LocationQueryValueRaw[] | undefined
|
||||
>
|
||||
|
||||
export type ProjectTypeMetadata = {
|
||||
project_type?: string | null
|
||||
project_types?: readonly string[] | null
|
||||
}
|
||||
|
||||
export type AnalyticsProjectFetchRequest = Labrinth.Analytics.v3.FetchRequest & {
|
||||
project_ids: string[]
|
||||
}
|
||||
|
||||
export type AnalyticsDashboardProjectSource = ProjectTypeMetadata & {
|
||||
id: string
|
||||
name?: string | null
|
||||
title?: string | null
|
||||
organization?: string | null
|
||||
icon_url?: string | null
|
||||
downloads?: number | null
|
||||
status?: string | null
|
||||
}
|
||||
|
||||
export type AnalyticsProjectVersionSource = {
|
||||
id: string
|
||||
versions?: readonly string[] | null
|
||||
}
|
||||
|
||||
export interface AnalyticsDashboardProject {
|
||||
id: string
|
||||
name: string
|
||||
iconUrl?: string
|
||||
downloads: number
|
||||
status: ProjectStatusFilterValue
|
||||
}
|
||||
|
||||
export interface AnalyticsDashboardProjectGroup {
|
||||
key?: string
|
||||
title?: string
|
||||
projects: AnalyticsDashboardProject[]
|
||||
}
|
||||
|
||||
export interface AnalyticsDashboardTotals {
|
||||
views: number
|
||||
downloads: number
|
||||
revenue: number
|
||||
playtime: number
|
||||
}
|
||||
|
||||
export interface AnalyticsDashboardPercentChanges {
|
||||
views: number
|
||||
downloads: number
|
||||
revenue: number
|
||||
playtime: number
|
||||
}
|
||||
|
||||
export interface AnalyticsDashboardFilterOptions {
|
||||
countries: string[]
|
||||
downloadSources: string[]
|
||||
downloadReasons: string[]
|
||||
gameVersions: string[]
|
||||
loaderTypes: string[]
|
||||
versionIds: string[]
|
||||
}
|
||||
|
||||
export interface NormalizedAnalyticsSelectedFilters {
|
||||
country: ReadonlySet<string>
|
||||
monetization: ReadonlySet<string>
|
||||
userAgent: ReadonlySet<string>
|
||||
downloadReason: ReadonlySet<string>
|
||||
versionId: ReadonlySet<string>
|
||||
gameVersion: ReadonlySet<string>
|
||||
loaderType: ReadonlySet<string>
|
||||
}
|
||||
|
||||
export interface AnalyticsFacetsFilterOptionSummary {
|
||||
countries: string[]
|
||||
downloadSources: string[]
|
||||
downloadReasons: string[]
|
||||
gameVersions: string[]
|
||||
loaderTypes: string[]
|
||||
versionIds: string[]
|
||||
projectDownloadsById: Map<string, number>
|
||||
projectVersionDownloadsById: Map<string, number>
|
||||
gameVersionDownloadsByVersion: Map<string, number>
|
||||
countryDownloadsByCode: Map<string, number>
|
||||
}
|
||||
|
||||
export interface ProjectVersionFilterOptionSummary {
|
||||
gameVersions: string[]
|
||||
loaderTypes: string[]
|
||||
versionIds: string[]
|
||||
}
|
||||
|
||||
export interface AnalyticsVersionMetadata {
|
||||
id: string
|
||||
versionNumber: string
|
||||
datePublished: string
|
||||
projectId: string
|
||||
downloads: number
|
||||
gameVersions: string[]
|
||||
loaders: string[]
|
||||
}
|
||||
|
||||
export type AnalyticsTimeSliceSplit = {
|
||||
currentTimeSlices: Labrinth.Analytics.v3.TimeSlice[]
|
||||
previousTimeSlices: Labrinth.Analytics.v3.TimeSlice[]
|
||||
}
|
||||
|
||||
export type AnalyticsFetchData = {
|
||||
metrics: Labrinth.Analytics.v3.TimeSlice[]
|
||||
project_events: Labrinth.Analytics.v3.ProjectAnalyticsEvent[]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,493 +0,0 @@
|
||||
import { injectI18n, useDebugLogger } from '@modrinth/ui'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useTheme } from '~/composables/nuxt-accessors.ts'
|
||||
|
||||
// note: build step can miss unix import for some reason, so
|
||||
// we have to import it like this
|
||||
|
||||
const { unix } = dayjs
|
||||
|
||||
export function useCountryNames(style = 'long') {
|
||||
const { locale } = injectI18n()
|
||||
const displayNames = computed(
|
||||
() => new Intl.DisplayNames([locale.value], { type: 'region', style }),
|
||||
)
|
||||
return function formatCountryName(code) {
|
||||
try {
|
||||
return displayNames.value.of(code) ?? code
|
||||
} catch {
|
||||
return code
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const countryCodeToName = (code) => {
|
||||
const formatCountryName = useCountryNames()
|
||||
|
||||
return formatCountryName(code)
|
||||
}
|
||||
|
||||
export const countryCodeToFlag = (code) => {
|
||||
if (code === 'XX') {
|
||||
return undefined
|
||||
}
|
||||
return `https://flagcdn.com/h240/${code.toLowerCase()}.png`
|
||||
}
|
||||
|
||||
export const formatTimestamp = (timestamp) => {
|
||||
return unix(timestamp).format()
|
||||
}
|
||||
|
||||
export const formatPercent = (value, sum) => {
|
||||
return `${((value / sum) * 100).toFixed(2)}%`
|
||||
}
|
||||
|
||||
const hashProjectId = (projectId) => {
|
||||
return projectId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % 30
|
||||
}
|
||||
|
||||
export const defaultColors = [
|
||||
'#ff496e', // Original: Bright pink
|
||||
'#ffa347', // Original: Bright orange
|
||||
'#1bd96a', // Original: Bright green
|
||||
'#4f9cff', // Original: Bright blue
|
||||
'#c78aff', // Original: Bright purple
|
||||
'#ffeb3b', // Added: Bright yellow
|
||||
'#00bcd4', // Added: Bright cyan
|
||||
'#ff5722', // Added: Bright red-orange
|
||||
'#9c27b0', // Added: Bright deep purple
|
||||
'#3f51b5', // Added: Bright indigo
|
||||
'#009688', // Added: Bright teal
|
||||
'#cddc39', // Added: Bright lime
|
||||
'#795548', // Added: Bright brown
|
||||
'#607d8b', // Added: Bright blue-grey
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {string | number} value
|
||||
* @returns {string} color
|
||||
*/
|
||||
export const getDefaultColor = (value) => {
|
||||
if (typeof value === 'string') {
|
||||
value = hashProjectId(value)
|
||||
}
|
||||
return defaultColors[value % defaultColors.length]
|
||||
}
|
||||
|
||||
export const intToRgba = (color, projectId = 'Unknown', theme = 'dark', alpha = '1') => {
|
||||
const hash = hashProjectId(projectId)
|
||||
|
||||
if (!color || color === 0) {
|
||||
return getDefaultColor(hash)
|
||||
}
|
||||
|
||||
// if color is a string, return that instead
|
||||
if (typeof color === 'string') {
|
||||
return color
|
||||
}
|
||||
|
||||
// Extract RGB values
|
||||
let r = (color >> 16) & 255
|
||||
let g = (color >> 8) & 255
|
||||
let b = color & 255
|
||||
|
||||
// Hash function to alter color slightly based on project_id
|
||||
r = (r + hash) % 256
|
||||
g = (g + hash) % 256
|
||||
b = (b + hash) % 256
|
||||
|
||||
// Adjust brightness for theme
|
||||
const brightness = r * 0.299 + g * 0.587 + b * 0.114
|
||||
const threshold = theme === 'dark' ? 50 : 200
|
||||
if (theme === 'dark' && brightness < threshold) {
|
||||
// Increase brightness for dark theme
|
||||
r += threshold / 2
|
||||
g += threshold / 2
|
||||
b += threshold / 2
|
||||
} else if (theme === 'light' && brightness > threshold) {
|
||||
// Decrease brightness for light theme
|
||||
r -= threshold / 4
|
||||
g -= threshold / 4
|
||||
b -= threshold / 4
|
||||
}
|
||||
|
||||
// Ensure RGB values are within 0-255
|
||||
r = Math.min(255, Math.max(0, r))
|
||||
g = Math.min(255, Math.max(0, g))
|
||||
b = Math.min(255, Math.max(0, b))
|
||||
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||
}
|
||||
|
||||
const emptyAnalytics = {
|
||||
sum: 0,
|
||||
len: 0,
|
||||
chart: {
|
||||
labels: [],
|
||||
data: [],
|
||||
sumData: [
|
||||
{
|
||||
name: '',
|
||||
data: [],
|
||||
},
|
||||
],
|
||||
colors: [],
|
||||
defaultColors: [],
|
||||
},
|
||||
projectIds: [],
|
||||
}
|
||||
|
||||
export const analyticsSetToCSVString = (analytics) => {
|
||||
if (!analytics) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const newline = '\n'
|
||||
const labels = analytics.chart.labels
|
||||
const projects = analytics.chart.data
|
||||
|
||||
const projectNames = projects.map((p) => p.name)
|
||||
|
||||
const header = ['Date', ...projectNames].join(',')
|
||||
|
||||
const data = labels.map((label, i) => {
|
||||
const values = projects.map((p) => p.data?.[i] || '')
|
||||
return [label, ...values].join(',')
|
||||
})
|
||||
|
||||
return [header, ...data].join(newline)
|
||||
}
|
||||
|
||||
export const processAnalytics = (category, projects, labelFn, sortFn, mapFn, chartName, theme) => {
|
||||
if (!category || !projects) {
|
||||
return emptyAnalytics
|
||||
}
|
||||
|
||||
// Get an intersection of category keys and project ids
|
||||
const projectIds = projects.map((p) => p.id)
|
||||
const loadedProjectIds = Object.keys(category).filter((id) => projectIds.includes(id))
|
||||
|
||||
if (!loadedProjectIds?.length) {
|
||||
return emptyAnalytics
|
||||
}
|
||||
|
||||
const loadedProjectData = loadedProjectIds.map((id) => category[id])
|
||||
|
||||
// Convert each project's data into a list of [unix_ts_str, number] pairs
|
||||
const projectData = loadedProjectData
|
||||
.map((data) => Object.entries(data))
|
||||
.map((data) => data.sort(sortFn))
|
||||
.map((data) => (mapFn ? data.map(mapFn) : data))
|
||||
|
||||
// Each project may not include the same timestamps, so we should use the union of all timestamps
|
||||
const timestamps = Array.from(
|
||||
new Set(projectData.flatMap((data) => data.map(([ts]) => ts))),
|
||||
).sort()
|
||||
|
||||
const chartData = projectData
|
||||
.map((data, i) => {
|
||||
const project = projects.find((p) => p.id === loadedProjectIds[i])
|
||||
if (!project) {
|
||||
throw new Error(`Project ${loadedProjectIds[i]} not found`)
|
||||
}
|
||||
|
||||
return {
|
||||
name: `${project.title}`,
|
||||
data: timestamps.map((ts) => {
|
||||
const entry = data.find(([ets]) => ets === ts)
|
||||
return entry ? entry[1] : 0
|
||||
}),
|
||||
id: project.id,
|
||||
color: project.color,
|
||||
}
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.data.reduce((acc, cur) => acc + cur, 0) - a.data.reduce((acc, cur) => acc + cur, 0),
|
||||
)
|
||||
|
||||
const projectIdsSortedBySum = chartData.map((p) => p.id)
|
||||
|
||||
return {
|
||||
// The total count of all the values across all projects
|
||||
sum: projectData.reduce((acc, cur) => acc + cur.reduce((a, c) => a + c[1], 0), 0),
|
||||
len: timestamps.length,
|
||||
chart: {
|
||||
labels: timestamps.map(labelFn),
|
||||
data: chartData.map((x) => ({ name: x.name, data: x.data })),
|
||||
sumData: [
|
||||
{
|
||||
name: chartName,
|
||||
data: timestamps.map((ts) => {
|
||||
const entries = projectData.flat().filter(([ets]) => ets === ts)
|
||||
return entries.reduce((acc, cur) => acc + cur[1], 0)
|
||||
}),
|
||||
},
|
||||
],
|
||||
colors: projectData.map((_, i) => {
|
||||
const project = chartData[i]
|
||||
|
||||
return intToRgba(project.color, project.id, theme)
|
||||
}),
|
||||
defaultColors: projectData.map((_, i) => {
|
||||
const project = chartData[i]
|
||||
return getDefaultColor(project.id)
|
||||
}),
|
||||
},
|
||||
projectIds: projectIdsSortedBySum,
|
||||
}
|
||||
}
|
||||
|
||||
export const processAnalyticsByCountry = (category, projects, sortFn) => {
|
||||
if (!category || !projects) {
|
||||
return {
|
||||
sum: 0,
|
||||
len: 0,
|
||||
data: [],
|
||||
}
|
||||
}
|
||||
|
||||
// Get an intersection of category keys and project ids
|
||||
const projectIds = projects.map((p) => p.id)
|
||||
const loadedProjectIds = Object.keys(category).filter((id) => projectIds.includes(id))
|
||||
|
||||
if (!loadedProjectIds?.length) {
|
||||
return {
|
||||
sum: 0,
|
||||
len: 0,
|
||||
data: [],
|
||||
}
|
||||
}
|
||||
|
||||
const loadedProjectData = loadedProjectIds.map((id) => category[id])
|
||||
|
||||
// Convert each project's data into a list of [countrycode, number] pairs
|
||||
// Fold into a single list with summed values for each country over all projects
|
||||
|
||||
const countrySums = new Map()
|
||||
|
||||
loadedProjectData.forEach((data) => {
|
||||
Object.entries(data).forEach(([country, value]) => {
|
||||
const countryCode = country || 'XX'
|
||||
const current = countrySums.get(countryCode) || 0
|
||||
countrySums.set(countryCode, current + value)
|
||||
})
|
||||
})
|
||||
|
||||
const entries = Array.from(countrySums.entries())
|
||||
|
||||
return {
|
||||
sum: entries.reduce((acc, cur) => acc + cur[1], 0),
|
||||
len: entries.length,
|
||||
data: entries.sort(sortFn),
|
||||
}
|
||||
}
|
||||
|
||||
const sortCount = ([, a], [, b]) => b - a
|
||||
const sortTimestamp = ([a], [b]) => a - b
|
||||
const roundValue = ([ts, value]) => [ts, Math.round(parseFloat(value) * 100) / 100]
|
||||
|
||||
const processCountryAnalytics = (c, projects) => processAnalyticsByCountry(c, projects, sortCount)
|
||||
const processNumberAnalytics = (c, projects, theme) =>
|
||||
processAnalytics(c, projects, formatTimestamp, sortTimestamp, null, 'Downloads', theme)
|
||||
const processRevAnalytics = (c, projects, theme) =>
|
||||
processAnalytics(c, projects, formatTimestamp, sortTimestamp, roundValue, 'Revenue', theme)
|
||||
|
||||
const useFetchAnalytics = (
|
||||
url,
|
||||
baseOptions = {
|
||||
apiVersion: 3,
|
||||
},
|
||||
) => {
|
||||
return useBaseFetch(url, baseOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Ref<any[]>} projects
|
||||
* @param {undefined | () => any} onDataRefresh
|
||||
*/
|
||||
export const useFetchAllAnalytics = (
|
||||
onDataRefresh,
|
||||
projects,
|
||||
selectedProjects,
|
||||
personalRevenue = false,
|
||||
startDate = ref(dayjs().subtract(30, 'days')),
|
||||
endDate = ref(dayjs()),
|
||||
timeResolution = ref(1440),
|
||||
) => {
|
||||
const debug = useDebugLogger('useFetchAllAnalytics')
|
||||
debug('init', {
|
||||
projectCount: projects.value?.length,
|
||||
personalRevenue,
|
||||
startDate: startDate.value?.toISOString(),
|
||||
endDate: endDate.value?.toISOString(),
|
||||
})
|
||||
|
||||
const downloadData = ref(null)
|
||||
const viewData = ref(null)
|
||||
const revenueData = ref(null)
|
||||
const downloadsByCountry = ref(null)
|
||||
const viewsByCountry = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
|
||||
const formattedData = computed(() => ({
|
||||
downloads: processNumberAnalytics(downloadData.value, selectedProjects.value),
|
||||
views: processNumberAnalytics(viewData.value, selectedProjects.value),
|
||||
revenue: processRevAnalytics(revenueData.value, selectedProjects.value),
|
||||
downloadsByCountry: processCountryAnalytics(downloadsByCountry.value, selectedProjects.value),
|
||||
viewsByCountry: processCountryAnalytics(viewsByCountry.value, selectedProjects.value),
|
||||
}))
|
||||
|
||||
const theme = useTheme()
|
||||
|
||||
const totalData = computed(() => ({
|
||||
downloads: processNumberAnalytics(downloadData.value, projects.value, theme.active),
|
||||
views: processNumberAnalytics(viewData.value, projects.value, theme.active),
|
||||
revenue: processRevAnalytics(revenueData.value, projects.value, theme.active),
|
||||
}))
|
||||
|
||||
const buildQuery = () => {
|
||||
const q = {
|
||||
start_date: startDate.value.toISOString(),
|
||||
end_date: endDate.value.toISOString(),
|
||||
resolution_minutes: timeResolution.value,
|
||||
}
|
||||
|
||||
if (projects.value?.length) {
|
||||
q.project_ids = JSON.stringify(projects.value.map((p) => p.id))
|
||||
}
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
const fetchData = async (query) => {
|
||||
debug('fetchData called', { query })
|
||||
const normalQuery = new URLSearchParams(query)
|
||||
const revenueQuery = new URLSearchParams(query)
|
||||
|
||||
if (personalRevenue) {
|
||||
revenueQuery.delete('project_ids')
|
||||
}
|
||||
|
||||
const qs = normalQuery.toString()
|
||||
const revenueQs = revenueQuery.toString()
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
debug('fetching all 5 endpoints...')
|
||||
const responses = await Promise.all([
|
||||
useFetchAnalytics(`analytics/downloads?${qs}`),
|
||||
useFetchAnalytics(`analytics/views?${qs}`),
|
||||
useFetchAnalytics(`analytics/revenue?${revenueQs}`),
|
||||
useFetchAnalytics(`analytics/countries/downloads?${qs}`),
|
||||
useFetchAnalytics(`analytics/countries/views?${qs}`),
|
||||
])
|
||||
debug('all 5 endpoints resolved', {
|
||||
downloads: Object.keys(responses[0] || {}).length,
|
||||
views: Object.keys(responses[1] || {}).length,
|
||||
revenue: Object.keys(responses[2] || {}).length,
|
||||
})
|
||||
|
||||
const projectIds = new Set()
|
||||
if (projects.value) {
|
||||
projects.value.forEach((p) => projectIds.add(p.id))
|
||||
} else {
|
||||
Object.keys(responses[0] || {}).forEach((id) => projectIds.add(id))
|
||||
}
|
||||
|
||||
debug('filtering to projectIds', { count: projectIds.size })
|
||||
|
||||
const filterProjectIds = (data) => {
|
||||
const filtered = {}
|
||||
Object.entries(data).forEach(([id, values]) => {
|
||||
if (projectIds.has(id)) {
|
||||
filtered[id] = values
|
||||
}
|
||||
})
|
||||
return filtered
|
||||
}
|
||||
|
||||
downloadData.value = filterProjectIds(responses[0] || {})
|
||||
viewData.value = filterProjectIds(responses[1] || {})
|
||||
revenueData.value = filterProjectIds(responses[2] || {})
|
||||
|
||||
downloadsByCountry.value = responses[3] || {}
|
||||
viewsByCountry.value = responses[4] || {}
|
||||
} catch (e) {
|
||||
debug('fetchData error', e)
|
||||
error.value = e
|
||||
} finally {
|
||||
loading.value = false
|
||||
debug('fetchData done, loading=false')
|
||||
}
|
||||
}
|
||||
|
||||
const fetch = async () => {
|
||||
debug('fetch() called', { projectCount: projects.value?.length })
|
||||
await fetchData(buildQuery())
|
||||
if (onDataRefresh) {
|
||||
onDataRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => startDate.value, () => endDate.value, () => timeResolution.value, () => projects.value],
|
||||
(newVals, oldVals) => {
|
||||
debug('watch triggered', { new: newVals, old: oldVals })
|
||||
fetch()
|
||||
},
|
||||
)
|
||||
|
||||
const validProjectIds = computed(() => {
|
||||
const ids = new Set()
|
||||
|
||||
if (downloadData.value) {
|
||||
Object.keys(downloadData.value).forEach((id) => ids.add(id))
|
||||
}
|
||||
|
||||
if (viewData.value) {
|
||||
Object.keys(viewData.value).forEach((id) => ids.add(id))
|
||||
}
|
||||
|
||||
if (revenueData.value) {
|
||||
// revenue will always have all project ids, but the ids may have an empty object or a ton of keys below a cent (0.00...) as values. We want to filter those out
|
||||
Object.entries(revenueData.value).forEach(([id, data]) => {
|
||||
if (Object.keys(data).length) {
|
||||
if (Object.values(data).some((v) => v >= 0.01)) {
|
||||
ids.add(id)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(ids)
|
||||
})
|
||||
|
||||
return {
|
||||
// Configuration
|
||||
timeResolution,
|
||||
|
||||
startDate,
|
||||
endDate,
|
||||
|
||||
// Data
|
||||
downloadData,
|
||||
viewData,
|
||||
revenueData,
|
||||
downloadsByCountry,
|
||||
viewsByCountry,
|
||||
|
||||
// Computed state
|
||||
validProjectIds,
|
||||
formattedData,
|
||||
totalData,
|
||||
loading,
|
||||
error,
|
||||
fetch,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user