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>
@@ -55,6 +55,7 @@
|
||||
"@vueuse/core": "^11.1.0",
|
||||
"ace-builds": "^1.36.2",
|
||||
"ansi-to-html": "^0.7.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"dayjs": "^1.11.7",
|
||||
"dompurify": "^3.1.7",
|
||||
"floating-vue": "^5.2.2",
|
||||
|
||||
@@ -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>
|
||||
@@ -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%)',
|
||||
]
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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],
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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_')
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(', ')
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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] ?? '') : ''
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { KyrosLogsV1Module } from './kyros/logs/v1'
|
||||
import { KyrosUploadSessionsV1Module } from './kyros/upload-sessions/v1'
|
||||
import { LabrinthVersionsV2Module, LabrinthVersionsV3Module } from './labrinth'
|
||||
import { LabrinthAffiliateInternalModule } from './labrinth/affiliate/internal'
|
||||
import { LabrinthAnalyticsV3Module } from './labrinth/analytics/v3'
|
||||
import { LabrinthAuthInternalModule } from './labrinth/auth/internal'
|
||||
import { LabrinthAuthV2Module } from './labrinth/auth/v2'
|
||||
import { LabrinthBillingInternalModule } from './labrinth/billing/internal'
|
||||
@@ -40,6 +41,7 @@ import { LabrinthTeamsV3Module } from './labrinth/teams/v3'
|
||||
import { LabrinthTechReviewInternalModule } from './labrinth/tech-review/internal'
|
||||
import { LabrinthThreadsV3Module } from './labrinth/threads/v3'
|
||||
import { LabrinthUsersV2Module } from './labrinth/users/v2'
|
||||
import { LabrinthUsersV3Module } from './labrinth/users/v3'
|
||||
import { LauncherMetaManifestV0Module } from './launcher-meta/v0'
|
||||
import { MclogsInsightsV1Module } from './mclogs/insights/v1'
|
||||
import { MclogsLogsV1Module } from './mclogs/logs/v1'
|
||||
@@ -74,6 +76,7 @@ export const MODULE_REGISTRY = {
|
||||
kyros_logs_v1: KyrosLogsV1Module,
|
||||
kyros_upload_sessions_v1: KyrosUploadSessionsV1Module,
|
||||
labrinth_affiliate_internal: LabrinthAffiliateInternalModule,
|
||||
labrinth_analytics_v3: LabrinthAnalyticsV3Module,
|
||||
labrinth_auth_internal: LabrinthAuthInternalModule,
|
||||
labrinth_auth_v2: LabrinthAuthV2Module,
|
||||
labrinth_billing_internal: LabrinthBillingInternalModule,
|
||||
@@ -100,6 +103,7 @@ export const MODULE_REGISTRY = {
|
||||
labrinth_tech_review_internal: LabrinthTechReviewInternalModule,
|
||||
labrinth_threads_v3: LabrinthThreadsV3Module,
|
||||
labrinth_users_v2: LabrinthUsersV2Module,
|
||||
labrinth_users_v3: LabrinthUsersV3Module,
|
||||
labrinth_versions_v2: LabrinthVersionsV2Module,
|
||||
labrinth_versions_v3: LabrinthVersionsV3Module,
|
||||
paper_versions_v3: PaperVersionsV3Module,
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { Labrinth } from '../types'
|
||||
|
||||
export class LabrinthAnalyticsV3Module extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'labrinth_analytics_v3'
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch analytics data for the authenticated user's accessible projects
|
||||
* and affiliate codes.
|
||||
*
|
||||
* @param data - Analytics request body defining time range and requested metrics
|
||||
* @returns Promise resolving to the analytics response, with time slices in `metrics`
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const response = await client.labrinth.analytics_v3.fetch({
|
||||
* time_range: {
|
||||
* start: '2026-01-01T00:00:00Z',
|
||||
* end: '2026-02-01T00:00:00Z',
|
||||
* resolution: { slices: 31 },
|
||||
* },
|
||||
* project_ids: ['A1B2C3D4'],
|
||||
* return_metrics: {
|
||||
* project_views: { bucket_by: ['project_id'] },
|
||||
* },
|
||||
* })
|
||||
* const timeSlices = response.metrics
|
||||
* ```
|
||||
*/
|
||||
public async fetch(
|
||||
data: Labrinth.Analytics.v3.FetchRequest,
|
||||
): Promise<Labrinth.Analytics.v3.FetchResponse> {
|
||||
return this.client.request<Labrinth.Analytics.v3.FetchResponse>('/analytics', {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'POST',
|
||||
body: data,
|
||||
timeout: 100 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available analytics filter facets for the authenticated user's
|
||||
* accessible projects.
|
||||
*
|
||||
* POST /v3/analytics/facets
|
||||
*/
|
||||
public async fetchFacets(
|
||||
data: Labrinth.Analytics.v3.FetchRequest,
|
||||
): Promise<Labrinth.Analytics.v3.FacetsResponse> {
|
||||
return this.client.request<Labrinth.Analytics.v3.FacetsResponse>('/analytics/facets', {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'POST',
|
||||
body: data,
|
||||
timeout: 100 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all analytics events.
|
||||
* GET /v3/analytics-event
|
||||
*/
|
||||
public async getEvents(): Promise<Labrinth.Analytics.v3.AnalyticsEvent[]> {
|
||||
return this.client.request<Labrinth.Analytics.v3.AnalyticsEvent[]>('/analytics-event', {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an analytics event.
|
||||
* POST /v3/analytics-event
|
||||
*/
|
||||
public async createEvent(
|
||||
data: Labrinth.Analytics.v3.AnalyticsEventUpsert,
|
||||
): Promise<Labrinth.Analytics.v3.AnalyticsEvent> {
|
||||
return this.client.request<Labrinth.Analytics.v3.AnalyticsEvent>('/analytics-event', {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'POST',
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an analytics event.
|
||||
* PATCH /v3/analytics-event/{id}
|
||||
*/
|
||||
public async editEvent(
|
||||
id: Labrinth.Analytics.v3.AnalyticsEventId,
|
||||
data: Labrinth.Analytics.v3.AnalyticsEventUpsert,
|
||||
): Promise<Labrinth.Analytics.v3.AnalyticsEvent> {
|
||||
return this.client.request<Labrinth.Analytics.v3.AnalyticsEvent>(`/analytics-event/${id}`, {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'PATCH',
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an analytics event.
|
||||
* DELETE /v3/analytics-event/{id}
|
||||
*/
|
||||
public async deleteEvent(id: Labrinth.Analytics.v3.AnalyticsEventId): Promise<void> {
|
||||
return this.client.request<void>(`/analytics-event/${id}`, {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './analytics/v3'
|
||||
export * from './auth/internal'
|
||||
export * from './auth/v2'
|
||||
export * from './billing/internal'
|
||||
@@ -21,5 +22,6 @@ export * from './state'
|
||||
export * from './tech-review/internal'
|
||||
export * from './threads/v3'
|
||||
export * from './users/v2'
|
||||
export * from './users/v3'
|
||||
export * from './versions/v2'
|
||||
export * from './versions/v3'
|
||||
|
||||
@@ -238,6 +238,267 @@ export namespace Labrinth {
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Analytics {
|
||||
export namespace v3 {
|
||||
export type AnalyticsEventId = number
|
||||
export type AnalyticsEventMetricKind = 'views' | 'revenue' | 'downloads' | 'playtime'
|
||||
|
||||
export type AnalyticsEvent = {
|
||||
announcement_url: string | null
|
||||
for_metric_kind: AnalyticsEventMetricKind[] | null
|
||||
title: string
|
||||
ends: string
|
||||
id: AnalyticsEventId
|
||||
starts: string
|
||||
}
|
||||
|
||||
export type AnalyticsEventUpsert = {
|
||||
announcement_url: string | null
|
||||
for_metric_kind: AnalyticsEventMetricKind[] | null
|
||||
title: string
|
||||
ends: string
|
||||
starts: string
|
||||
}
|
||||
|
||||
export type FetchRequest = {
|
||||
time_range: TimeRange
|
||||
return_metrics: ReturnMetrics
|
||||
project_ids?: string[]
|
||||
}
|
||||
|
||||
export type TimeRange = {
|
||||
start: string
|
||||
end: string
|
||||
resolution: TimeRangeResolution
|
||||
}
|
||||
|
||||
export type TimeRangeResolution = { slices: number } | { minutes: number }
|
||||
|
||||
export type ReturnMetrics = {
|
||||
project_views?: Metrics<ProjectViewsField, ProjectViewsFilters>
|
||||
project_downloads?: Metrics<ProjectDownloadsField, ProjectDownloadsFilters>
|
||||
project_playtime?: Metrics<ProjectPlaytimeField, ProjectPlaytimeFilters>
|
||||
project_revenue?: Metrics<ProjectRevenueField, ProjectRevenueFilters>
|
||||
affiliate_code_clicks?: Metrics<AffiliateCodeClicksField, AffiliateCodeClicksFilters>
|
||||
affiliate_code_conversions?: Metrics<
|
||||
AffiliateCodeConversionsField,
|
||||
AffiliateCodeConversionsFilters
|
||||
>
|
||||
affiliate_code_revenue?: Metrics<AffiliateCodeRevenueField, AffiliateCodeRevenueFilters>
|
||||
}
|
||||
|
||||
export type Metrics<BucketBy, FilterBy> = {
|
||||
bucket_by?: BucketBy[]
|
||||
filter_by?: FilterBy
|
||||
}
|
||||
|
||||
export type ProjectViewsField =
|
||||
| 'project_id'
|
||||
| 'domain'
|
||||
| 'site_path'
|
||||
| 'monetized'
|
||||
| 'country'
|
||||
|
||||
export type ProjectDownloadsField =
|
||||
| 'project_id'
|
||||
| 'version_id'
|
||||
| 'user_agent'
|
||||
| 'domain'
|
||||
| 'country'
|
||||
| 'monetized'
|
||||
| 'reason'
|
||||
| 'game_version'
|
||||
| 'loader'
|
||||
|
||||
export type ProjectPlaytimeField =
|
||||
| 'project_id'
|
||||
| 'version_id'
|
||||
| 'loader'
|
||||
| 'game_version'
|
||||
| 'country'
|
||||
|
||||
export type ProjectRevenueField = 'project_id'
|
||||
|
||||
export type DownloadReason = 'standalone' | 'dependency' | 'modpack' | 'update'
|
||||
|
||||
export type AffiliateCodeClicksField = 'affiliate_code_id'
|
||||
|
||||
export type AffiliateCodeConversionsField = 'affiliate_code_id'
|
||||
|
||||
export type AffiliateCodeRevenueField = 'affiliate_code_id'
|
||||
|
||||
export type ProjectViewsFilters = {
|
||||
domain?: string[]
|
||||
site_path?: string[]
|
||||
monetized?: boolean[]
|
||||
country?: string[]
|
||||
}
|
||||
|
||||
export type ProjectDownloadsFilters = {
|
||||
version_id?: string[]
|
||||
domain?: string[]
|
||||
user_agent?: string[]
|
||||
monetized?: boolean[]
|
||||
country?: string[]
|
||||
reason?: DownloadReason[]
|
||||
game_version?: string[]
|
||||
loader?: string[]
|
||||
}
|
||||
|
||||
export type ProjectPlaytimeFilters = {
|
||||
version_id?: string[]
|
||||
loader?: string[]
|
||||
game_version?: string[]
|
||||
country?: string[]
|
||||
}
|
||||
|
||||
export type ProjectRevenueFilters = Record<string, never>
|
||||
|
||||
export type AffiliateCodeClicksFilters = {
|
||||
affiliate_code_id?: string[]
|
||||
}
|
||||
|
||||
export type AffiliateCodeConversionsFilters = {
|
||||
affiliate_code_id?: string[]
|
||||
}
|
||||
|
||||
export type AffiliateCodeRevenueFilters = {
|
||||
affiliate_code_id?: string[]
|
||||
}
|
||||
|
||||
export type FetchResponse = {
|
||||
metrics: TimeSlice[]
|
||||
project_events: ProjectAnalyticsEvent[]
|
||||
}
|
||||
|
||||
export type FacetsResponse = {
|
||||
facets: AnalyticsFacets
|
||||
}
|
||||
|
||||
export type AnalyticsFacet<T> = {
|
||||
value: T
|
||||
downloads: number
|
||||
}
|
||||
|
||||
export type AnalyticsFacets = {
|
||||
project_views: ProjectViewsFacets
|
||||
project_downloads: ProjectDownloadsFacets
|
||||
project_playtime: ProjectPlaytimeFacets
|
||||
}
|
||||
|
||||
export type ProjectViewsFacets = {
|
||||
domain: AnalyticsFacet<string>[]
|
||||
site_path: AnalyticsFacet<string>[]
|
||||
monetized: AnalyticsFacet<boolean>[]
|
||||
country: AnalyticsFacet<string>[]
|
||||
}
|
||||
|
||||
export type ProjectDownloadsFacets = {
|
||||
project_id: AnalyticsFacet<string>[]
|
||||
domain: AnalyticsFacet<string>[]
|
||||
user_agent: AnalyticsFacet<string>[]
|
||||
version_id: AnalyticsFacet<string>[]
|
||||
monetized: AnalyticsFacet<boolean>[]
|
||||
country: AnalyticsFacet<string>[]
|
||||
reason: AnalyticsFacet<DownloadReason>[]
|
||||
game_version: AnalyticsFacet<string>[]
|
||||
loader: AnalyticsFacet<string>[]
|
||||
}
|
||||
|
||||
export type ProjectPlaytimeFacets = {
|
||||
version_id: AnalyticsFacet<string>[]
|
||||
loader: AnalyticsFacet<string>[]
|
||||
game_version: AnalyticsFacet<string>[]
|
||||
country: AnalyticsFacet<string>[]
|
||||
}
|
||||
|
||||
export type TimeSlice = AnalyticsData[]
|
||||
|
||||
export type ProjectAnalyticsEvent = {
|
||||
project_id: string
|
||||
timestamp: string
|
||||
} & ProjectAnalyticsEventKind
|
||||
|
||||
export type ProjectAnalyticsEventKind =
|
||||
| {
|
||||
kind: 'version_uploaded'
|
||||
version_id: string
|
||||
version_name: string
|
||||
version_number: string
|
||||
}
|
||||
| {
|
||||
kind: 'status_changed'
|
||||
status_from: Projects.v2.ProjectStatus
|
||||
status_to: Projects.v2.ProjectStatus
|
||||
}
|
||||
|
||||
export type AnalyticsData = ProjectAnalytics | AffiliateCodeAnalytics
|
||||
|
||||
export type ProjectAnalytics = {
|
||||
source_project: string
|
||||
} & ProjectMetrics
|
||||
|
||||
export type ProjectMetrics =
|
||||
| ({ metric_kind: 'views' } & ProjectViews)
|
||||
| ({ metric_kind: 'downloads' } & ProjectDownloads)
|
||||
| ({ metric_kind: 'playtime' } & ProjectPlaytime)
|
||||
| ({ metric_kind: 'revenue' } & ProjectRevenue)
|
||||
|
||||
export type ProjectViews = {
|
||||
domain?: string
|
||||
site_path?: string
|
||||
monetized?: boolean
|
||||
country?: string
|
||||
views: number
|
||||
}
|
||||
|
||||
export type ProjectDownloads = {
|
||||
user_agent?: string
|
||||
domain?: string
|
||||
version_id?: string
|
||||
country?: string
|
||||
monetized?: boolean
|
||||
reason?: DownloadReason
|
||||
game_version?: string
|
||||
loader?: string
|
||||
downloads: number
|
||||
}
|
||||
|
||||
export type ProjectPlaytime = {
|
||||
version_id?: string
|
||||
loader?: string
|
||||
game_version?: string
|
||||
country?: string
|
||||
seconds: number
|
||||
}
|
||||
|
||||
export type ProjectRevenue = {
|
||||
revenue: string
|
||||
}
|
||||
|
||||
export type AffiliateCodeAnalytics = {
|
||||
source_affiliate_code: string
|
||||
} & AffiliateCodeMetrics
|
||||
|
||||
export type AffiliateCodeMetrics =
|
||||
| ({ metric_kind: 'clicks' } & AffiliateCodeClicks)
|
||||
| ({ metric_kind: 'conversions' } & AffiliateCodeConversions)
|
||||
| ({ metric_kind: 'revenue' } & AffiliateCodeRevenue)
|
||||
|
||||
export type AffiliateCodeClicks = {
|
||||
clicks: number
|
||||
}
|
||||
|
||||
export type AffiliateCodeConversions = {
|
||||
conversions: number
|
||||
}
|
||||
|
||||
export type AffiliateCodeRevenue = {
|
||||
revenue: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Auth {
|
||||
export namespace Internal {
|
||||
export type SubscriptionStatus = {
|
||||
@@ -1060,6 +1321,11 @@ export namespace Labrinth {
|
||||
allow_friend_requests?: boolean
|
||||
github_id?: number
|
||||
}
|
||||
|
||||
export type AllProjectsResponse = {
|
||||
projects: Projects.v3.Project[]
|
||||
organizations: Record<string, Organizations.v3.Organization>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { Labrinth } from '../types'
|
||||
|
||||
export class LabrinthUsersV3Module extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'labrinth_users_v3'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all projects the authenticated user can access directly or through
|
||||
* their organizations.
|
||||
*
|
||||
* @param idOrUsername - User ID or username. Must be the authenticated user.
|
||||
*
|
||||
* GET /v3/user/{id}/all-projects
|
||||
*/
|
||||
public async getAllProjects(
|
||||
idOrUsername: string,
|
||||
): Promise<Labrinth.Users.v3.AllProjectsResponse> {
|
||||
return this.client.request<Labrinth.Users.v3.AllProjectsResponse>(
|
||||
`/user/${encodeURIComponent(idOrUsername)}/all-projects`,
|
||||
{
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'GET',
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,9 @@ import _CalendarArrowDownIcon from './icons/calendar-arrow-down.svg?component'
|
||||
import _CardIcon from './icons/card.svg?component'
|
||||
import _ChangeSkinIcon from './icons/change-skin.svg?component'
|
||||
import _ChartIcon from './icons/chart.svg?component'
|
||||
import _ChartAreaIcon from './icons/chart-area.svg?component'
|
||||
import _ChartColumnBigIcon from './icons/chart-column-big.svg?component'
|
||||
import _ChartSplineIcon from './icons/chart-spline.svg?component'
|
||||
import _CheckIcon from './icons/check.svg?component'
|
||||
import _CheckCheckIcon from './icons/check-check.svg?component'
|
||||
import _CheckCircleIcon from './icons/check-circle.svg?component'
|
||||
@@ -135,6 +138,7 @@ import _KeyIcon from './icons/key.svg?component'
|
||||
import _KeyboardIcon from './icons/keyboard.svg?component'
|
||||
import _LandmarkIcon from './icons/landmark.svg?component'
|
||||
import _LanguagesIcon from './icons/languages.svg?component'
|
||||
import _LayersIcon from './icons/layers.svg?component'
|
||||
import _LayoutTemplateIcon from './icons/layout-template.svg?component'
|
||||
import _LeftArrowIcon from './icons/left-arrow.svg?component'
|
||||
import _LibraryIcon from './icons/library.svg?component'
|
||||
@@ -179,6 +183,7 @@ import _PaintbrushIcon from './icons/paintbrush.svg?component'
|
||||
import _PaletteIcon from './icons/palette.svg?component'
|
||||
import _PencilIcon from './icons/pencil.svg?component'
|
||||
import _PickaxeIcon from './icons/pickaxe.svg?component'
|
||||
import _PinIcon from './icons/pin.svg?component'
|
||||
import _PlayIcon from './icons/play.svg?component'
|
||||
import _PlugIcon from './icons/plug.svg?component'
|
||||
import _PlusIcon from './icons/plus.svg?component'
|
||||
@@ -370,6 +375,8 @@ import _ToggleRightIcon from './icons/toggle-right.svg?component'
|
||||
import _TransferIcon from './icons/transfer.svg?component'
|
||||
import _TrashIcon from './icons/trash.svg?component'
|
||||
import _TrashExclamationIcon from './icons/trash-exclamation.svg?component'
|
||||
import _TrendingDownIcon from './icons/trending-down.svg?component'
|
||||
import _TrendingUpIcon from './icons/trending-up.svg?component'
|
||||
import _TriangleAlertIcon from './icons/triangle-alert.svg?component'
|
||||
import _UnderlineIcon from './icons/underline.svg?component'
|
||||
import _UndoIcon from './icons/undo.svg?component'
|
||||
@@ -439,6 +446,9 @@ export const CalendarArrowDownIcon = _CalendarArrowDownIcon
|
||||
export const CardIcon = _CardIcon
|
||||
export const ChangeSkinIcon = _ChangeSkinIcon
|
||||
export const ChartIcon = _ChartIcon
|
||||
export const ChartAreaIcon = _ChartAreaIcon
|
||||
export const ChartColumnBigIcon = _ChartColumnBigIcon
|
||||
export const ChartSplineIcon = _ChartSplineIcon
|
||||
export const CheckIcon = _CheckIcon
|
||||
export const CheckCheckIcon = _CheckCheckIcon
|
||||
export const CheckCircleIcon = _CheckCircleIcon
|
||||
@@ -528,6 +538,7 @@ export const KeyIcon = _KeyIcon
|
||||
export const KeyboardIcon = _KeyboardIcon
|
||||
export const LandmarkIcon = _LandmarkIcon
|
||||
export const LanguagesIcon = _LanguagesIcon
|
||||
export const LayersIcon = _LayersIcon
|
||||
export const LayoutTemplateIcon = _LayoutTemplateIcon
|
||||
export const LeftArrowIcon = _LeftArrowIcon
|
||||
export const LibraryIcon = _LibraryIcon
|
||||
@@ -572,6 +583,7 @@ export const PaintbrushIcon = _PaintbrushIcon
|
||||
export const PaletteIcon = _PaletteIcon
|
||||
export const PencilIcon = _PencilIcon
|
||||
export const PickaxeIcon = _PickaxeIcon
|
||||
export const PinIcon = _PinIcon
|
||||
export const PlayIcon = _PlayIcon
|
||||
export const PlugIcon = _PlugIcon
|
||||
export const PlusIcon = _PlusIcon
|
||||
@@ -763,6 +775,8 @@ export const ToggleRightIcon = _ToggleRightIcon
|
||||
export const TransferIcon = _TransferIcon
|
||||
export const TrashIcon = _TrashIcon
|
||||
export const TrashExclamationIcon = _TrashExclamationIcon
|
||||
export const TrendingDownIcon = _TrendingDownIcon
|
||||
export const TrendingUpIcon = _TrendingUpIcon
|
||||
export const TriangleAlertIcon = _TriangleAlertIcon
|
||||
export const UnderlineIcon = _UnderlineIcon
|
||||
export const UndoIcon = _UndoIcon
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-chart-area"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 3v16a2 2 0 0 0 2 2h16" />
|
||||
<path d="M7 11.207a.5.5 0 0 1 .146-.353l2-2a.5.5 0 0 1 .708 0l3.292 3.292a.5.5 0 0 0 .708 0l4.292-4.292a.5.5 0 0 1 .854.353V16a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 494 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-chart-column-big"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 3v16a2 2 0 0 0 2 2h16" />
|
||||
<rect x="15" y="5" width="4" height="12" rx="1" />
|
||||
<rect x="7" y="8" width="4" height="9" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 440 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-chart-spline"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 3v16a2 2 0 0 0 2 2h16" />
|
||||
<path d="M7 16c.5-2 1.5-7 4-7 2 0 2 3 4 3 2.5 0 4.5-5 5-7" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 396 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-layers"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z" />
|
||||
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
|
||||
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 588 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-pin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 17v5" />
|
||||
<path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 525 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-trending-down"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M16 17h6v-6" />
|
||||
<path d="m22 17-8.5-8.5-5 5L2 7" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 358 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-trending-up"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M16 7h6v6" />
|
||||
<path d="m22 7-8.5 8.5-5-5L2 17" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 354 B |
@@ -168,6 +168,13 @@ textarea {
|
||||
margin: 0; /* 2 */
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the overflow in IE.
|
||||
* 1. Show the overflow in Edge.
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"lru-cache": "^11.2.4",
|
||||
"markdown-it": "^13.0.2",
|
||||
"motion-v": "^2.2.1",
|
||||
"overlayscrollbars": "^2.15.1",
|
||||
"postprocessing": "^6.37.6",
|
||||
"qrcode.vue": "^3.4.1",
|
||||
"three": "^0.172.0",
|
||||
|
||||
@@ -188,7 +188,7 @@ const colorVariables = computed(() => {
|
||||
}
|
||||
const hoverColors = JSON.parse(JSON.stringify(colors))
|
||||
const boxShadow =
|
||||
props.type === 'chip' && colorVar.value ? `0 0 0 2px ${colorVar.value}` : defaultShadow
|
||||
props.type === 'chip' && colorVar.value ? `0 0 0 1px ${colorVar.value}` : defaultShadow
|
||||
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_icon: ${colors.icon}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text}; --_hover-icon: ${hoverColors.icon}; --_box-shadow: ${boxShadow};`
|
||||
}
|
||||
|
||||
@@ -226,7 +226,7 @@ const colorVariables = computed(() => {
|
||||
}
|
||||
|
||||
const boxShadow =
|
||||
props.type === 'chip' && colorVar.value ? `0 0 0 2px ${colorVar.value}` : defaultShadow
|
||||
props.type === 'chip' && colorVar.value ? `0 0 0 1px ${colorVar.value}` : defaultShadow
|
||||
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text}; --_box-shadow: ${boxShadow};`
|
||||
})
|
||||
|
||||
@@ -266,7 +266,7 @@ const fontSize = computed(() => {
|
||||
> *:first-child
|
||||
> *:first-child
|
||||
> :is(button, a, .button-like):first-child {
|
||||
@apply flex cursor-pointer flex-row items-center justify-center border-solid border-2 border-transparent bg-[--_bg] text-[--_text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight] whitespace-nowrap;
|
||||
@apply flex touch-manipulation cursor-pointer flex-row items-center justify-center border-solid border border-transparent bg-[--_bg] text-[--_text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight] whitespace-nowrap;
|
||||
box-shadow: var(--_box-shadow, inset 0 0 0 transparent);
|
||||
transition:
|
||||
scale 0.125s ease-in-out,
|
||||
|
||||
@@ -35,7 +35,7 @@ import { CheckIcon, MinusIcon } from '@modrinth/assets'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [boolean]
|
||||
'update:modelValue': [modelValue: boolean, event?: MouseEvent]
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -59,9 +59,9 @@ const props = withDefaults(
|
||||
},
|
||||
)
|
||||
|
||||
function toggle() {
|
||||
function toggle(event: MouseEvent) {
|
||||
if (!props.disabled) {
|
||||
emit('update:modelValue', !props.modelValue)
|
||||
emit('update:modelValue', !props.modelValue, event)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -75,6 +75,7 @@ function toggleItem(item: T) {
|
||||
flex-wrap: wrap;
|
||||
|
||||
.btn {
|
||||
border: 1px solid var(--surface-5);
|
||||
&.capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
@@ -91,11 +92,9 @@ function toggleItem(item: T) {
|
||||
}
|
||||
|
||||
.selected {
|
||||
color: var(--color-contrast);
|
||||
color: var(--color-brand);
|
||||
background-color: var(--color-brand-highlight);
|
||||
box-shadow:
|
||||
inset 0 0 0 transparent,
|
||||
0 0 0 1px var(--color-brand);
|
||||
border: 1px solid var(--color-brand);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -47,13 +47,13 @@
|
||||
ref="triggerRef"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="relative flex min-h-5 w-full items-center justify-between overflow-hidden rounded-xl bg-surface-4 px-4 py-2.5 text-left transition-all duration-200 text-button-text"
|
||||
class="relative flex min-h-5 w-full items-center justify-between overflow-hidden rounded-xl bg-surface-4 px-4 py-2 text-left transition-all duration-200 text-button-text gap-2.5"
|
||||
:class="[
|
||||
props.triggerClass,
|
||||
{
|
||||
'z-[9999]': isOpen,
|
||||
'cursor-not-allowed opacity-50': disabled,
|
||||
'cursor-pointer hover:brightness-125 active:brightness-125': !disabled,
|
||||
'cursor-pointer hover:brightness-[115%] active:brightness-[115%]': !disabled,
|
||||
},
|
||||
]"
|
||||
:aria-expanded="isOpen"
|
||||
@@ -62,14 +62,14 @@
|
||||
@click="handleTriggerClick($event)"
|
||||
@keydown="handleTriggerKeydown"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<slot name="prefix"></slot>
|
||||
<component
|
||||
:is="selectedOption?.icon"
|
||||
v-if="showIconInSelected && selectedOption?.icon"
|
||||
class="h-5 w-5"
|
||||
class="h-5 w-5 shrink-0"
|
||||
/>
|
||||
<span class="text-primary font-semibold leading-tight">
|
||||
<span class="min-w-0 truncate text-primary font-semibold leading-tight">
|
||||
<slot name="selected">{{ triggerText }}</slot>
|
||||
</span>
|
||||
</div>
|
||||
@@ -95,6 +95,7 @@
|
||||
ref="dropdownRef"
|
||||
class="fixed z-[9999] flex flex-col overflow-hidden rounded-[14px] bg-surface-4 border border-solid border-surface-5"
|
||||
:class="[
|
||||
props.dropdownClass,
|
||||
openDirection === 'up' ? 'shadow-[0_-25px_50px_-12px_rgb(0,0,0,0.25)]' : 'shadow-2xl',
|
||||
]"
|
||||
:style="dropdownStyle"
|
||||
@@ -104,59 +105,73 @@
|
||||
>
|
||||
<div
|
||||
v-if="filteredOptions.length > 0"
|
||||
ref="optionsContainerRef"
|
||||
class="flex flex-col gap-2 overflow-y-auto p-3"
|
||||
:style="{ maxHeight: `${maxHeight}px` }"
|
||||
ref="optionsScrollbarRef"
|
||||
class="combobox-options-scrollbar bg-surface-4"
|
||||
data-overlayscrollbars-initialize
|
||||
>
|
||||
<template v-for="(item, index) in filteredOptions" :key="item.key">
|
||||
<div v-if="item.type === 'divider'" class="h-px bg-surface-5"></div>
|
||||
<component
|
||||
:is="item.type === 'link' ? 'a' : 'span'"
|
||||
v-else
|
||||
:ref="(el: HTMLElement) => setOptionRef(el as HTMLElement, index)"
|
||||
:href="item.type === 'link' && !item.disabled ? item.href : undefined"
|
||||
:target="item.type === 'link' && !item.disabled ? item.target : undefined"
|
||||
:role="listbox ? 'option' : 'menuitem'"
|
||||
:aria-selected="listbox && item.value === modelValue"
|
||||
:aria-disabled="item.disabled || undefined"
|
||||
:data-focused="focusedIndex === index"
|
||||
class="group/option flex items-center gap-2.5 cursor-pointer rounded-xl p-3 text-left transition-colors duration-150 text-contrast hover:bg-surface-5 focus:bg-surface-5"
|
||||
:class="getOptionClasses(item, index)"
|
||||
tabindex="-1"
|
||||
@mousedown.prevent
|
||||
@click="handleOptionClick(item, index)"
|
||||
@mouseenter="handleOptionMouseEnter(item, index)"
|
||||
>
|
||||
<slot
|
||||
name="option"
|
||||
:item="item"
|
||||
:index="index"
|
||||
:is-selected="!!(listbox && item.value === modelValue)"
|
||||
>
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<component :is="item.icon" v-if="item.icon" class="h-5 w-5" />
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span
|
||||
class="font-semibold leading-tight"
|
||||
:class="item.value === modelValue ? 'text-contrast' : 'text-primary'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.subLabel"
|
||||
class="text-sm"
|
||||
:class="item.value === modelValue ? 'text-contrast' : 'text-secondary'"
|
||||
>
|
||||
{{ item.subLabel }}
|
||||
</span>
|
||||
<div
|
||||
ref="optionsContainerRef"
|
||||
class="overflow-y-auto"
|
||||
:style="{ maxHeight: `${maxHeight}px` }"
|
||||
data-overlayscrollbars-viewport
|
||||
>
|
||||
<div ref="optionsListRef" class="flex flex-col">
|
||||
<template v-for="(item, index) in filteredOptions" :key="item.key">
|
||||
<div v-if="item.type === 'divider'" class="h-px bg-surface-5"></div>
|
||||
<component
|
||||
:is="item.type === 'link' ? 'a' : 'span'"
|
||||
v-else
|
||||
:ref="(el: HTMLElement) => setOptionRef(el as HTMLElement, index)"
|
||||
:href="item.type === 'link' && !item.disabled ? item.href : undefined"
|
||||
:target="item.type === 'link' && !item.disabled ? item.target : undefined"
|
||||
:role="listbox ? 'option' : 'menuitem'"
|
||||
:aria-selected="listbox && item.value === modelValue"
|
||||
:aria-disabled="item.disabled || undefined"
|
||||
:data-focused="focusedIndex === index"
|
||||
class="group/option flex items-center gap-2.5 cursor-pointer px-4 py-3 text-left transition-all duration-150"
|
||||
:class="getOptionClasses(item, index)"
|
||||
tabindex="-1"
|
||||
@mousedown.prevent
|
||||
@click="handleOptionClick(item, index)"
|
||||
@mouseenter="handleOptionMouseEnter(item, index)"
|
||||
>
|
||||
<slot
|
||||
name="option"
|
||||
:item="item"
|
||||
:index="index"
|
||||
:is-selected="!!(listbox && item.value === modelValue)"
|
||||
>
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<component
|
||||
:is="item.icon"
|
||||
v-if="item.icon"
|
||||
class="h-5 w-5"
|
||||
:class="item.value === modelValue ? 'text-green' : 'text-primary'"
|
||||
/>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span
|
||||
class="font-semibold leading-tight"
|
||||
:class="item.value === modelValue ? 'text-green' : 'text-primary'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.subLabel"
|
||||
class="text-sm"
|
||||
:class="item.value === modelValue ? 'text-green' : 'text-secondary'"
|
||||
>
|
||||
{{ item.subLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<slot name="option-suffix" :item="item"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<slot name="option-suffix" :item="item"></slot>
|
||||
</div>
|
||||
</slot>
|
||||
</component>
|
||||
</template>
|
||||
</slot>
|
||||
</component>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="searchQuery" class="p-4 mb-2 text-center text-sm text-secondary">
|
||||
@@ -171,8 +186,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import 'overlayscrollbars/overlayscrollbars.css'
|
||||
|
||||
import { ChevronLeftIcon, SearchIcon } from '@modrinth/assets'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { OverlayScrollbars, type PartialOptions } from 'overlayscrollbars'
|
||||
import {
|
||||
type Component,
|
||||
computed,
|
||||
@@ -200,9 +218,28 @@ export interface ComboboxOption<T> {
|
||||
searchTerms?: string[]
|
||||
}
|
||||
|
||||
type OverlayScrollbarsInstance = NonNullable<ReturnType<typeof OverlayScrollbars>>
|
||||
type ViewportRect = {
|
||||
width: number
|
||||
height: number
|
||||
offsetTop: number
|
||||
offsetLeft: number
|
||||
}
|
||||
|
||||
const DROPDOWN_VIEWPORT_MARGIN = 8
|
||||
const DROPDOWN_GAP = 12
|
||||
const DROPDOWN_GAP = 8
|
||||
const DEFAULT_MAX_HEIGHT = 300
|
||||
const OPTIONS_OVERLAY_SCROLLBARS_OPTIONS = Object.freeze<PartialOptions>({
|
||||
overflow: {
|
||||
x: 'hidden',
|
||||
y: 'scroll',
|
||||
},
|
||||
scrollbars: {
|
||||
theme: 'os-theme-modrinth',
|
||||
autoHide: 'leave',
|
||||
autoHideSuspend: true,
|
||||
},
|
||||
})
|
||||
|
||||
function isDropdownOption<T>(
|
||||
opt: ComboboxOption<T> | { type: 'divider' },
|
||||
@@ -229,6 +266,13 @@ const props = withDefaults(
|
||||
displayValue?: string
|
||||
searchValue?: string
|
||||
triggerClass?: string
|
||||
dropdownClass?: string
|
||||
/** Additional selectors to ignore when detecting outside clicks */
|
||||
outsideClickIgnore?: string[]
|
||||
/** Width for the teleported dropdown; defaults to the trigger/input width */
|
||||
dropdownWidth?: string | number
|
||||
/** Minimum width for the teleported dropdown */
|
||||
dropdownMinWidth?: string | number
|
||||
forceDirection?: 'up' | 'down'
|
||||
noOptionsMessage?: string
|
||||
disableSearchFilter?: boolean
|
||||
@@ -252,6 +296,7 @@ const props = withDefaults(
|
||||
syncWithSelection: true,
|
||||
selectSearchTextOnFocus: false,
|
||||
showSearchIcon: false,
|
||||
outsideClickIgnore: () => [],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -275,9 +320,12 @@ const containerRef = ref<HTMLElement>()
|
||||
const triggerRef = ref<HTMLElement>()
|
||||
const searchTriggerRef = ref<InstanceType<typeof StyledInput>>()
|
||||
const dropdownRef = ref<HTMLElement>()
|
||||
const optionsScrollbarRef = ref<HTMLElement>()
|
||||
const optionsContainerRef = ref<HTMLElement>()
|
||||
const optionsListRef = ref<HTMLElement>()
|
||||
const optionRefs = ref<(HTMLElement | null)[]>([])
|
||||
const rafId = ref<number | null>(null)
|
||||
const optionsOverlayScrollbars = ref<OverlayScrollbarsInstance | null>(null)
|
||||
|
||||
const effectiveTriggerEl = computed(() => {
|
||||
if (props.searchable && searchTriggerRef.value) {
|
||||
@@ -285,11 +333,17 @@ const effectiveTriggerEl = computed(() => {
|
||||
}
|
||||
return triggerRef.value
|
||||
})
|
||||
const outsideClickIgnoreTargets = computed(() => [
|
||||
triggerRef,
|
||||
containerRef,
|
||||
...props.outsideClickIgnore,
|
||||
])
|
||||
|
||||
const dropdownStyle = ref({
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
width: '0px',
|
||||
minWidth: '0px',
|
||||
})
|
||||
|
||||
const openDirection = ref<'down' | 'up'>('down')
|
||||
@@ -352,13 +406,15 @@ const shouldRenderDropdown = computed(() => {
|
||||
return isOpen.value && hasDropdownContent.value
|
||||
})
|
||||
|
||||
function getOptionClasses(item: ComboboxOption<T> & { key: string }, index: number) {
|
||||
function getOptionClasses(item: ComboboxOption<T> & { key: string }, _index: number) {
|
||||
const isSelected = props.listbox && item.value === props.modelValue
|
||||
|
||||
return [
|
||||
item.class,
|
||||
{
|
||||
'bg-surface-5':
|
||||
(props.listbox && item.value === props.modelValue) ||
|
||||
(focusedIndex.value === index && !(props.listbox && item.value === props.modelValue)),
|
||||
'bg-surface-4 text-contrast hover:brightness-[115%] focus:brightness-[115%]': !isSelected,
|
||||
'bg-highlight-green text-green !cursor-default hover:bg-highlight-green focus:bg-highlight-green':
|
||||
isSelected,
|
||||
'cursor-not-allowed opacity-50 pointer-events-none': item.disabled,
|
||||
},
|
||||
]
|
||||
@@ -381,17 +437,20 @@ function setInitialFocus() {
|
||||
function determineOpenDirection(
|
||||
triggerRect: DOMRect,
|
||||
dropdownRect: DOMRect,
|
||||
viewportHeight: number,
|
||||
viewport: ViewportRect,
|
||||
): 'up' | 'down' {
|
||||
if (props.forceDirection) {
|
||||
return props.forceDirection
|
||||
}
|
||||
|
||||
const triggerTop = triggerRect.top + viewport.offsetTop
|
||||
const triggerBottom = triggerRect.bottom + viewport.offsetTop
|
||||
const viewportTop = viewport.offsetTop
|
||||
const viewportBottom = viewport.offsetTop + viewport.height
|
||||
const hasSpaceBelow =
|
||||
triggerRect.bottom + dropdownRect.height + DROPDOWN_GAP + DROPDOWN_VIEWPORT_MARGIN <=
|
||||
viewportHeight
|
||||
triggerBottom + dropdownRect.height + DROPDOWN_GAP + DROPDOWN_VIEWPORT_MARGIN <= viewportBottom
|
||||
const hasSpaceAbove =
|
||||
triggerRect.top - dropdownRect.height - DROPDOWN_GAP - DROPDOWN_VIEWPORT_MARGIN > 0
|
||||
triggerTop - dropdownRect.height - DROPDOWN_GAP - DROPDOWN_VIEWPORT_MARGIN > viewportTop
|
||||
|
||||
return !hasSpaceBelow && hasSpaceAbove ? 'up' : 'down'
|
||||
}
|
||||
@@ -400,52 +459,129 @@ function calculateVerticalPosition(
|
||||
triggerRect: DOMRect,
|
||||
dropdownRect: DOMRect,
|
||||
direction: 'up' | 'down',
|
||||
viewport: ViewportRect,
|
||||
): number {
|
||||
return direction === 'up'
|
||||
? triggerRect.top - dropdownRect.height - DROPDOWN_GAP
|
||||
: triggerRect.bottom + DROPDOWN_GAP
|
||||
const top =
|
||||
direction === 'up'
|
||||
? triggerRect.top - dropdownRect.height - DROPDOWN_GAP
|
||||
: triggerRect.bottom + DROPDOWN_GAP
|
||||
|
||||
return top + viewport.offsetTop
|
||||
}
|
||||
|
||||
function calculateHorizontalPosition(
|
||||
triggerRect: DOMRect,
|
||||
dropdownRect: DOMRect,
|
||||
viewportWidth: number,
|
||||
viewport: ViewportRect,
|
||||
): number {
|
||||
let left = triggerRect.left
|
||||
const minLeft = viewport.offsetLeft + DROPDOWN_VIEWPORT_MARGIN
|
||||
const maxRight = viewport.offsetLeft + viewport.width - DROPDOWN_VIEWPORT_MARGIN
|
||||
let left = triggerRect.left + viewport.offsetLeft
|
||||
|
||||
if (left + dropdownRect.width > viewportWidth - DROPDOWN_VIEWPORT_MARGIN) {
|
||||
left = Math.max(
|
||||
DROPDOWN_VIEWPORT_MARGIN,
|
||||
viewportWidth - dropdownRect.width - DROPDOWN_VIEWPORT_MARGIN,
|
||||
)
|
||||
if (left + dropdownRect.width > maxRight) {
|
||||
left = Math.max(minLeft, maxRight - dropdownRect.width)
|
||||
}
|
||||
|
||||
return left
|
||||
}
|
||||
|
||||
function getViewportRect(): ViewportRect {
|
||||
const visualViewport = window.visualViewport
|
||||
|
||||
return {
|
||||
width: visualViewport?.width ?? window.innerWidth,
|
||||
height: visualViewport?.height ?? window.innerHeight,
|
||||
offsetTop: visualViewport?.offsetTop ?? 0,
|
||||
offsetLeft: visualViewport?.offsetLeft ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDropdownWidth(triggerWidth: number): string {
|
||||
if (props.dropdownWidth === undefined) return `${triggerWidth}px`
|
||||
if (typeof props.dropdownWidth === 'number') return `${props.dropdownWidth}px`
|
||||
return props.dropdownWidth
|
||||
}
|
||||
|
||||
function resolveCssSize(size: string | number | undefined): string | undefined {
|
||||
if (size === undefined) return undefined
|
||||
if (typeof size === 'number') return `${size}px`
|
||||
return size
|
||||
}
|
||||
|
||||
async function updateDropdownPosition() {
|
||||
if (!effectiveTriggerEl.value || !dropdownRef.value) return
|
||||
|
||||
await nextTick()
|
||||
|
||||
const triggerRect = effectiveTriggerEl.value.getBoundingClientRect()
|
||||
const dropdownRect = dropdownRef.value.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight
|
||||
const viewportWidth = window.innerWidth
|
||||
const width = resolveDropdownWidth(triggerRect.width)
|
||||
const minWidth = resolveCssSize(props.dropdownMinWidth) ?? '0px'
|
||||
|
||||
const direction = determineOpenDirection(triggerRect, dropdownRect, viewportHeight)
|
||||
const top = calculateVerticalPosition(triggerRect, dropdownRect, direction)
|
||||
const left = calculateHorizontalPosition(triggerRect, dropdownRect, viewportWidth)
|
||||
dropdownStyle.value = {
|
||||
...dropdownStyle.value,
|
||||
width,
|
||||
minWidth,
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
const dropdownRect = dropdownRef.value.getBoundingClientRect()
|
||||
const viewport = getViewportRect()
|
||||
|
||||
const direction = determineOpenDirection(triggerRect, dropdownRect, viewport)
|
||||
const top = calculateVerticalPosition(triggerRect, dropdownRect, direction, viewport)
|
||||
const left = calculateHorizontalPosition(triggerRect, dropdownRect, viewport)
|
||||
|
||||
dropdownStyle.value = {
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
width: `${triggerRect.width}px`,
|
||||
width,
|
||||
minWidth,
|
||||
}
|
||||
|
||||
openDirection.value = direction
|
||||
}
|
||||
|
||||
async function initializeOptionsOverlayScrollbars() {
|
||||
await nextTick()
|
||||
|
||||
if (!isOpen.value || filteredOptions.value.length === 0) {
|
||||
destroyOptionsOverlayScrollbars()
|
||||
return
|
||||
}
|
||||
|
||||
if (!optionsScrollbarRef.value || !optionsContainerRef.value || !optionsListRef.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (optionsOverlayScrollbars.value) {
|
||||
optionsOverlayScrollbars.value.update(true)
|
||||
return
|
||||
}
|
||||
|
||||
optionsOverlayScrollbars.value = OverlayScrollbars(
|
||||
{
|
||||
target: optionsScrollbarRef.value,
|
||||
elements: {
|
||||
viewport: optionsContainerRef.value,
|
||||
content: optionsListRef.value,
|
||||
},
|
||||
},
|
||||
OPTIONS_OVERLAY_SCROLLBARS_OPTIONS,
|
||||
)
|
||||
}
|
||||
|
||||
function updateOptionsOverlayScrollbars() {
|
||||
nextTick(() => {
|
||||
optionsOverlayScrollbars.value?.update(true)
|
||||
})
|
||||
}
|
||||
|
||||
function destroyOptionsOverlayScrollbars() {
|
||||
optionsOverlayScrollbars.value?.destroy()
|
||||
optionsOverlayScrollbars.value = null
|
||||
}
|
||||
|
||||
async function openDropdown() {
|
||||
if (props.disabled || isOpen.value || !hasDropdownContent.value) return
|
||||
|
||||
@@ -454,6 +590,7 @@ async function openDropdown() {
|
||||
|
||||
await nextTick()
|
||||
await updateDropdownPosition()
|
||||
await initializeOptionsOverlayScrollbars()
|
||||
|
||||
setInitialFocus()
|
||||
startPositionTracking()
|
||||
@@ -463,6 +600,7 @@ function closeDropdown() {
|
||||
if (!isOpen.value) return
|
||||
|
||||
stopPositionTracking()
|
||||
destroyOptionsOverlayScrollbars()
|
||||
isOpen.value = false
|
||||
userHasTyped.value = false
|
||||
focusedIndex.value = -1
|
||||
@@ -489,6 +627,8 @@ function handleTriggerClick(event: MouseEvent) {
|
||||
|
||||
function handleOptionClick(option: ComboboxOption<T>, index: number) {
|
||||
if (option.disabled || option.type === 'divider') return
|
||||
const isSelected = props.listbox && option.value === props.modelValue
|
||||
if (isSelected) return
|
||||
|
||||
focusedIndex.value = index
|
||||
|
||||
@@ -682,19 +822,36 @@ function handleSearchClick() {
|
||||
|
||||
function handleWindowResize() {
|
||||
if (isOpen.value) {
|
||||
scheduleDropdownPositionUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleDropdownPositionUpdate() {
|
||||
if (rafId.value !== null) return
|
||||
|
||||
rafId.value = requestAnimationFrame(() => {
|
||||
rafId.value = null
|
||||
updateDropdownPosition()
|
||||
})
|
||||
}
|
||||
|
||||
function handleViewportChange() {
|
||||
if (isOpen.value) {
|
||||
scheduleDropdownPositionUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
function startPositionTracking() {
|
||||
function track() {
|
||||
updateDropdownPosition()
|
||||
rafId.value = requestAnimationFrame(track)
|
||||
}
|
||||
rafId.value = requestAnimationFrame(track)
|
||||
window.addEventListener('scroll', handleViewportChange, true)
|
||||
window.visualViewport?.addEventListener('scroll', handleViewportChange)
|
||||
window.visualViewport?.addEventListener('resize', handleViewportChange)
|
||||
}
|
||||
|
||||
function stopPositionTracking() {
|
||||
window.removeEventListener('scroll', handleViewportChange, true)
|
||||
window.visualViewport?.removeEventListener('scroll', handleViewportChange)
|
||||
window.visualViewport?.removeEventListener('resize', handleViewportChange)
|
||||
|
||||
if (rafId.value !== null) {
|
||||
cancelAnimationFrame(rafId.value)
|
||||
rafId.value = null
|
||||
@@ -706,7 +863,7 @@ onClickOutside(
|
||||
() => {
|
||||
closeDropdown()
|
||||
},
|
||||
{ ignore: [triggerRef, containerRef] },
|
||||
{ ignore: outsideClickIgnoreTargets },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
@@ -716,6 +873,7 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleWindowResize)
|
||||
stopPositionTracking()
|
||||
destroyOptionsOverlayScrollbars()
|
||||
})
|
||||
|
||||
watch(isOpen, (value) => {
|
||||
@@ -727,12 +885,18 @@ watch(isOpen, (value) => {
|
||||
watch(shouldRenderDropdown, (value) => {
|
||||
if (value) {
|
||||
updateDropdownPosition()
|
||||
initializeOptionsOverlayScrollbars()
|
||||
}
|
||||
})
|
||||
|
||||
watch(filteredOptions, () => {
|
||||
if (isOpen.value) {
|
||||
updateDropdownPosition()
|
||||
if (filteredOptions.value.length > 0) {
|
||||
initializeOptionsOverlayScrollbars()
|
||||
} else {
|
||||
destroyOptionsOverlayScrollbars()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -749,7 +913,37 @@ watch(
|
||||
const opt = props.options.find((o) => isDropdownOption(o) && o.value === val)
|
||||
searchQuery.value = opt && isDropdownOption(opt) ? opt.label : ''
|
||||
}
|
||||
if (isOpen.value) {
|
||||
updateOptionsOverlayScrollbars()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.maxHeight,
|
||||
() => {
|
||||
if (isOpen.value) {
|
||||
updateOptionsOverlayScrollbars()
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.combobox-options-scrollbar :deep(.os-theme-modrinth) {
|
||||
--os-size: 10px;
|
||||
--os-padding-perpendicular: 2px;
|
||||
--os-padding-axis: 2px;
|
||||
--os-track-bg: transparent;
|
||||
--os-track-bg-hover: transparent;
|
||||
--os-track-bg-active: transparent;
|
||||
--os-handle-border-radius: 9999px;
|
||||
--os-handle-border: 2px solid var(--color-surface-4);
|
||||
--os-handle-border-hover: 2px solid var(--color-surface-4);
|
||||
--os-handle-border-active: 2px solid var(--color-surface-4);
|
||||
--os-handle-bg: var(--color-scrollbar, var(--color-surface-5));
|
||||
--os-handle-bg-hover: var(--color-scrollbar, var(--color-surface-5));
|
||||
--os-handle-bg-active: var(--color-scrollbar, var(--color-surface-5));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -32,13 +32,22 @@
|
||||
:aria-hidden="calendarOnly ? 'true' : undefined"
|
||||
type="text"
|
||||
/>
|
||||
<button
|
||||
v-if="hasClearButton"
|
||||
type="button"
|
||||
class="absolute right-0.5 z-[1] touch-manipulation cursor-pointer select-none border-none bg-transparent p-2 text-secondary transition-colors hover:text-contrast"
|
||||
aria-label="Clear date"
|
||||
@click.stop="clearValue"
|
||||
>
|
||||
<XIcon class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
|
||||
import { CalendarIcon } from '@modrinth/assets'
|
||||
import { CalendarIcon, XIcon } from '@modrinth/assets'
|
||||
import chevronLeftIcon from '@modrinth/assets/icons/chevron-left.svg?raw'
|
||||
import chevronRightIcon from '@modrinth/assets/icons/chevron-right.svg?raw'
|
||||
import flatpickr from 'flatpickr'
|
||||
@@ -127,6 +136,7 @@ const props = withDefaults(
|
||||
showIcon?: boolean
|
||||
showToday?: boolean
|
||||
calendarOnly?: boolean
|
||||
closeOnSelect?: boolean
|
||||
/**
|
||||
* Controls where the calendar opens relative to the input. Use `above`
|
||||
* to force the calendar to open above the input.
|
||||
@@ -146,7 +156,7 @@ const props = withDefaults(
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
readonly: true,
|
||||
enableTime: false,
|
||||
mode: 'single',
|
||||
showMonths: 1,
|
||||
@@ -156,6 +166,7 @@ const props = withDefaults(
|
||||
showIcon: true,
|
||||
showToday: false,
|
||||
calendarOnly: false,
|
||||
closeOnSelect: false,
|
||||
position: 'auto',
|
||||
preserveDay: false,
|
||||
viewDateAlignment: 'left',
|
||||
@@ -176,13 +187,18 @@ const intendedViewMonth = ref<CalendarViewMonth | null>(null)
|
||||
const preserveViewOnNextModelSync = ref(false)
|
||||
const intendedDay = ref<number | null>(null)
|
||||
const isPreservingDay = ref(false)
|
||||
const timeInputDigits = ref(new WeakMap<HTMLInputElement, string>())
|
||||
const rangeDragState = ref<RangeDragState | null>(null)
|
||||
const rangeEndpointMoveState = ref<RangeEndpointMoveState | null>(null)
|
||||
const suppressNextRangeClick = ref(false)
|
||||
let rangeClickSuppressionTimeout: number | null = null
|
||||
let monthSelectSyncFrame: number | null = null
|
||||
let calendarPortal: HTMLElement | null = null
|
||||
let inputFocusScrollSuppressionTarget: HTMLInputElement | null = null
|
||||
let originalInputFocus: HTMLInputElement['focus'] | null = null
|
||||
let suppressNextInputFocusScroll = false
|
||||
const calendarBaseClass = 'modrinth-date-picker-calendar'
|
||||
const twoCalendarClass = 'has-two-calendars'
|
||||
const calendarStateClasses = [
|
||||
'calendar-only',
|
||||
'show-today',
|
||||
@@ -299,6 +315,7 @@ function syncCalendarClasses(instance?: Instance) {
|
||||
if (!container) return
|
||||
|
||||
container.classList.add(calendarBaseClass)
|
||||
container.classList.toggle(twoCalendarClass, resolvedShowMonths.value === 2)
|
||||
|
||||
for (const cls of appliedCalendarClasses) {
|
||||
container.classList.remove(cls)
|
||||
@@ -868,7 +885,7 @@ function getTimeInputDigits(value: string) {
|
||||
|
||||
function sanitizeTimeInputValue(input: HTMLInputElement) {
|
||||
const nextValue = getTimeInputDigits(input.value)
|
||||
if (input.value === nextValue) return
|
||||
if (input.value === nextValue) return nextValue
|
||||
|
||||
const cursorPosition = input.selectionStart ?? nextValue.length
|
||||
const removedBeforeCursor =
|
||||
@@ -878,6 +895,7 @@ function sanitizeTimeInputValue(input: HTMLInputElement) {
|
||||
|
||||
input.value = nextValue
|
||||
input.setSelectionRange(nextCursorPosition, nextCursorPosition)
|
||||
return nextValue
|
||||
}
|
||||
|
||||
function normalizeTimeInputValue(input: HTMLInputElement) {
|
||||
@@ -917,14 +935,81 @@ function preventNonNumericTimeKeydown(event: KeyboardEvent) {
|
||||
if (event.key.length === 1 && /\D/.test(event.key)) event.preventDefault()
|
||||
}
|
||||
|
||||
function sanitizeNumericTimeInput(event: Event) {
|
||||
if (isTimeInput(event.target)) sanitizeTimeInputValue(event.target)
|
||||
function getTimeInputs(instance: Instance) {
|
||||
return [instance.hourElement, instance.minuteElement, instance.secondElement].filter(
|
||||
(input): input is HTMLInputElement => Boolean(input),
|
||||
)
|
||||
}
|
||||
|
||||
function getTimeInputDraft(input: HTMLInputElement) {
|
||||
return document.activeElement === input
|
||||
? (timeInputDigits.value.get(input) ?? getTimeInputDigits(input.value))
|
||||
: getTimeInputDigits(input.value)
|
||||
}
|
||||
|
||||
function getNormalizedTimeInputDraft(input: HTMLInputElement) {
|
||||
const draft = getTimeInputDraft(input)
|
||||
if (!draft) return null
|
||||
if (draft.length === 2) return draft
|
||||
|
||||
const minValue = Number.parseInt(input.min, 10)
|
||||
const maxValue = Number.parseInt(input.max, 10)
|
||||
let nextValue = Number.parseInt(draft, 10)
|
||||
|
||||
if (Number.isFinite(minValue)) nextValue = Math.max(nextValue, minValue)
|
||||
if (Number.isFinite(maxValue)) nextValue = Math.min(nextValue, maxValue)
|
||||
|
||||
return String(nextValue).padStart(2, '0')
|
||||
}
|
||||
|
||||
function prepareTimeInputValuesForCommit(instance: Instance) {
|
||||
for (const input of getTimeInputs(instance)) {
|
||||
const nextValue = getNormalizedTimeInputDraft(input)
|
||||
if (nextValue === null) return false
|
||||
|
||||
input.value = nextValue
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function syncTimeInputDrafts(instance: Instance) {
|
||||
for (const input of getTimeInputs(instance)) {
|
||||
timeInputDigits.value.set(input, getTimeInputDigits(input.value))
|
||||
}
|
||||
}
|
||||
|
||||
function commitTimeInputForInput(event: Event) {
|
||||
if (!isTimeInput(event.target)) return
|
||||
|
||||
const instance = picker.value
|
||||
if (!instance) return
|
||||
|
||||
const previousValue = instance.input.value
|
||||
const nextDigits = sanitizeTimeInputValue(event.target)
|
||||
timeInputDigits.value.set(event.target, nextDigits)
|
||||
if (!prepareTimeInputValuesForCommit(instance)) return
|
||||
|
||||
event.target.dispatchEvent(new Event('increment', { bubbles: true }))
|
||||
if (nextDigits.length === 1 && document.activeElement === event.target) {
|
||||
event.target.value = nextDigits
|
||||
event.target.setSelectionRange(nextDigits.length, nextDigits.length)
|
||||
}
|
||||
syncTimeInputDrafts(instance)
|
||||
if (instance.input.value === previousValue) return
|
||||
|
||||
const nextValue =
|
||||
props.mode === 'single'
|
||||
? instance.input.value || null
|
||||
: instance.selectedDates.map((date) => instance.formatDate(date, resolvedDateFormat.value))
|
||||
model.value = nextValue
|
||||
emit('change', nextValue)
|
||||
}
|
||||
|
||||
function syncTimeInputTypes(instance: Instance) {
|
||||
const timeInputs = [instance.hourElement, instance.minuteElement, instance.secondElement].filter(
|
||||
(input): input is HTMLInputElement => Boolean(input),
|
||||
)
|
||||
const timeInputs = getTimeInputs(instance)
|
||||
const activeTimeInput = timeInputs.find((input) => document.activeElement === input)
|
||||
const activeDraft = activeTimeInput ? timeInputDigits.value.get(activeTimeInput) : undefined
|
||||
|
||||
for (const input of timeInputs) {
|
||||
input.type = 'text'
|
||||
@@ -932,6 +1017,71 @@ function syncTimeInputTypes(instance: Instance) {
|
||||
input.pattern = '[0-9]*'
|
||||
normalizeTimeInputValue(input)
|
||||
}
|
||||
|
||||
if (activeTimeInput && activeDraft !== undefined && activeDraft.length < 2) {
|
||||
activeTimeInput.value = activeDraft
|
||||
activeTimeInput.setSelectionRange(activeDraft.length, activeDraft.length)
|
||||
}
|
||||
|
||||
syncTimeInputDrafts(instance)
|
||||
}
|
||||
|
||||
function openPickerWithoutInputFocus(event: PointerEvent) {
|
||||
if (!props.readonly || props.disabled || props.calendarOnly) return
|
||||
if (event.button !== 0) return
|
||||
if (event.pointerType === 'mouse') return
|
||||
|
||||
event.preventDefault()
|
||||
suppressNextInputFocusScroll = true
|
||||
picker.value?.open()
|
||||
}
|
||||
|
||||
function suppressInputFocusScrollForCalendarPointer(event: PointerEvent) {
|
||||
if (!props.readonly || props.disabled || props.calendarOnly || !props.closeOnSelect) return
|
||||
if (event.button !== 0) return
|
||||
if (event.pointerType === 'mouse') return
|
||||
|
||||
const dayElem = getRangeDayElement(event.target)
|
||||
if (!dayElem || !isSelectableDay(dayElem)) return
|
||||
|
||||
suppressNextInputFocusScroll = true
|
||||
}
|
||||
|
||||
function patchInputFocusScrollSuppression(target: HTMLInputElement) {
|
||||
originalInputFocus = target.focus
|
||||
target.focus = ((options?: FocusOptions) => {
|
||||
if (suppressNextInputFocusScroll) {
|
||||
originalInputFocus?.call(target, { preventScroll: true })
|
||||
return
|
||||
}
|
||||
|
||||
originalInputFocus?.call(target, options)
|
||||
}) as HTMLInputElement['focus']
|
||||
}
|
||||
|
||||
function teardownInputFocusScrollSuppression() {
|
||||
inputFocusScrollSuppressionTarget?.removeEventListener('pointerdown', openPickerWithoutInputFocus)
|
||||
if (inputFocusScrollSuppressionTarget && originalInputFocus) {
|
||||
inputFocusScrollSuppressionTarget.focus = originalInputFocus
|
||||
}
|
||||
inputFocusScrollSuppressionTarget = null
|
||||
originalInputFocus = null
|
||||
suppressNextInputFocusScroll = false
|
||||
}
|
||||
|
||||
function syncInputFocusScrollSuppression() {
|
||||
const target = picker.value?.altInput ?? inputRef.value ?? null
|
||||
if (!target || !props.readonly || props.disabled || props.calendarOnly) {
|
||||
teardownInputFocusScrollSuppression()
|
||||
return
|
||||
}
|
||||
|
||||
if (inputFocusScrollSuppressionTarget === target) return
|
||||
|
||||
teardownInputFocusScrollSuppression()
|
||||
target.addEventListener('pointerdown', openPickerWithoutInputFocus)
|
||||
patchInputFocusScrollSuppression(target)
|
||||
inputFocusScrollSuppressionTarget = target
|
||||
}
|
||||
|
||||
const resolvedDateFormat = computed(
|
||||
@@ -944,19 +1094,6 @@ const resolvedShowMonths = computed(() =>
|
||||
Number.isFinite(props.showMonths) ? Math.max(1, Math.floor(props.showMonths)) : 1,
|
||||
)
|
||||
|
||||
const inputClasses = computed(() => [
|
||||
props.calendarOnly
|
||||
? 'sr-only pointer-events-none absolute h-0 w-0 opacity-0'
|
||||
: 'w-full text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow !outline-0',
|
||||
!props.calendarOnly && props.showIcon ? 'pl-10' : '',
|
||||
!props.calendarOnly && !props.showIcon ? 'pl-3' : '',
|
||||
!props.calendarOnly
|
||||
? 'pr-3 h-9 py-2 text-base outline-none bg-surface-4 border-none rounded-xl'
|
||||
: '',
|
||||
props.disabled && !props.calendarOnly ? 'cursor-not-allowed' : '',
|
||||
props.inputClass,
|
||||
])
|
||||
|
||||
const selectedDates = computed(() => {
|
||||
const value = model.value
|
||||
if (Array.isArray(value)) {
|
||||
@@ -966,6 +1103,28 @@ const selectedDates = computed(() => {
|
||||
return value ? [value] : []
|
||||
})
|
||||
|
||||
const hasClearButton = computed(
|
||||
() =>
|
||||
!props.calendarOnly &&
|
||||
props.clearable &&
|
||||
!props.disabled &&
|
||||
!props.readonly &&
|
||||
selectedDates.value.length > 0,
|
||||
)
|
||||
|
||||
const inputClasses = computed(() => [
|
||||
props.calendarOnly
|
||||
? 'sr-only pointer-events-none absolute h-0 w-0 opacity-0'
|
||||
: 'w-full touch-manipulation text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow !outline-0',
|
||||
!props.calendarOnly && props.showIcon ? 'pl-10' : '',
|
||||
!props.calendarOnly && !props.showIcon ? 'pl-3' : '',
|
||||
!props.calendarOnly
|
||||
? `${hasClearButton.value ? 'pr-10' : 'pr-3'} h-9 py-2 text-base outline-none bg-surface-4 border-none rounded-xl`
|
||||
: '',
|
||||
props.disabled && !props.calendarOnly ? 'cursor-not-allowed' : '',
|
||||
props.inputClass,
|
||||
])
|
||||
|
||||
watch(
|
||||
() => [
|
||||
model.value,
|
||||
@@ -982,6 +1141,7 @@ watch(
|
||||
props.altFormat,
|
||||
props.time24hr,
|
||||
props.calendarOnly,
|
||||
props.closeOnSelect,
|
||||
props.position,
|
||||
],
|
||||
() => {
|
||||
@@ -1042,6 +1202,11 @@ onMounted(async () => {
|
||||
onReady: (_selectedDates, _dateStr, instance) => {
|
||||
syncCalendarView(instance)
|
||||
|
||||
instance.calendarContainer.addEventListener(
|
||||
'pointerdown',
|
||||
suppressInputFocusScrollForCalendarPointer,
|
||||
true,
|
||||
)
|
||||
instance.calendarContainer.addEventListener('pointerdown', startRangeDrag, true)
|
||||
instance.calendarContainer.addEventListener('mousedown', stopRangeEndpointMouseEvent, true)
|
||||
instance.calendarContainer.addEventListener('mouseup', stopRangeEndpointMouseEvent, true)
|
||||
@@ -1061,7 +1226,7 @@ onMounted(async () => {
|
||||
)
|
||||
instance.calendarContainer.addEventListener('beforeinput', preventNonNumericTimeInput, true)
|
||||
instance.calendarContainer.addEventListener('keydown', preventNonNumericTimeKeydown, true)
|
||||
instance.calendarContainer.addEventListener('input', sanitizeNumericTimeInput, true)
|
||||
instance.calendarContainer.addEventListener('input', commitTimeInputForInput, true)
|
||||
|
||||
instance.calendarContainer.addEventListener('mousedown', (event) => {
|
||||
if (props.mode !== 'range') return
|
||||
@@ -1119,6 +1284,7 @@ onMounted(async () => {
|
||||
syncCalendarStateClasses(instance)
|
||||
syncRangeEndpointMoveState(instance)
|
||||
syncMultiMonthSelects(instance)
|
||||
syncInputFocusScrollSuppression()
|
||||
},
|
||||
onChange: (_selectedDates, dateStr, instance) => {
|
||||
if (isSyncingFromModel.value) return
|
||||
@@ -1145,6 +1311,12 @@ onMounted(async () => {
|
||||
},
|
||||
onClose: (_selectedDates, dateStr, instance) => {
|
||||
cancelRangeEndpointMovePreview()
|
||||
if (suppressNextInputFocusScroll) {
|
||||
const focusTarget = instance.altInput ?? inputRef.value
|
||||
focusTarget?.blur()
|
||||
suppressNextInputFocusScroll = false
|
||||
}
|
||||
|
||||
if (hasCompleteModelRange()) {
|
||||
syncPickerFromModel()
|
||||
return
|
||||
@@ -1192,6 +1364,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
syncAltInputState()
|
||||
syncInputFocusScrollSuppression()
|
||||
syncPickerFromModel()
|
||||
syncHeaderControlState(picker.value)
|
||||
syncRangeEndpointMoveState(picker.value)
|
||||
@@ -1204,6 +1377,7 @@ onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointermove', updateRangeDrag, true)
|
||||
document.removeEventListener('pointerup', stopRangeDrag, true)
|
||||
document.removeEventListener('pointercancel', stopRangeDrag, true)
|
||||
teardownInputFocusScrollSuppression()
|
||||
picker.value?.destroy()
|
||||
destroyCalendarPortal()
|
||||
})
|
||||
@@ -1215,7 +1389,7 @@ function flatpickrOptions(): Options {
|
||||
altInputClass: props.calendarOnly ? undefined : inputClasses.value.filter(Boolean).join(' '),
|
||||
altFormat: resolvedAltFormat.value,
|
||||
appendTo: ensureCalendarPortal(),
|
||||
closeOnSelect: false,
|
||||
closeOnSelect: props.closeOnSelect,
|
||||
dateFormat: resolvedDateFormat.value,
|
||||
disableMobile: true,
|
||||
enableTime: props.enableTime,
|
||||
@@ -1267,25 +1441,31 @@ function syncPickerFromModel() {
|
||||
}
|
||||
|
||||
function syncAltInputState() {
|
||||
if (!picker.value?.altInput) return
|
||||
if (!picker.value?.altInput) {
|
||||
syncInputFocusScrollSuppression()
|
||||
return
|
||||
}
|
||||
|
||||
picker.value.altInput.disabled = props.disabled
|
||||
picker.value.altInput.readOnly = props.readonly
|
||||
syncInputFocusScrollSuppression()
|
||||
}
|
||||
|
||||
function clearValue() {
|
||||
const nextValue = props.mode === 'single' ? null : []
|
||||
model.value = nextValue
|
||||
picker.value?.clear(false)
|
||||
setRangeEndpointMoveState(null)
|
||||
syncMissingRangeEndState()
|
||||
emit('clear')
|
||||
emit('change', nextValue)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focus: () => picker.value?.altInput?.focus() ?? inputRef.value?.focus(),
|
||||
open: () => picker.value?.open(),
|
||||
close: () => picker.value?.close(),
|
||||
clear: () => {
|
||||
const nextValue = props.mode === 'single' ? null : []
|
||||
model.value = nextValue
|
||||
picker.value?.clear(false)
|
||||
setRangeEndpointMoveState(null)
|
||||
syncMissingRangeEndState()
|
||||
emit('clear')
|
||||
emit('change', nextValue)
|
||||
},
|
||||
clear: clearValue,
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1295,7 +1475,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-calendar) {
|
||||
@apply mt-2 rounded-2xl border border-solid border-surface-5 bg-surface-3 shadow-none p-3 text-primary select-none;
|
||||
@apply mt-2 touch-manipulation rounded-2xl border border-solid border-surface-5 bg-surface-3 shadow-none p-3 text-primary select-none;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
@@ -1333,6 +1513,27 @@ defineExpose({
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-calendar.has-two-calendars) {
|
||||
width: calc(615.75px + 0.75rem) !important;
|
||||
}
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-calendar.has-two-calendars .flatpickr-months),
|
||||
.modrinth-date-picker :deep(.flatpickr-calendar.has-two-calendars .flatpickr-weekdays),
|
||||
.modrinth-date-picker :deep(.flatpickr-calendar.has-two-calendars .flatpickr-days) {
|
||||
@apply gap-3;
|
||||
}
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-calendar.has-two-calendars .flatpickr-rContainer),
|
||||
.modrinth-date-picker :deep(.flatpickr-calendar.has-two-calendars .flatpickr-weekdays),
|
||||
.modrinth-date-picker :deep(.flatpickr-calendar.has-two-calendars .flatpickr-days) {
|
||||
width: calc(615.75px + 0.75rem) !important;
|
||||
}
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-calendar.has-two-calendars .flatpickr-month),
|
||||
.modrinth-date-picker :deep(.flatpickr-calendar.has-two-calendars .flatpickr-weekdaycontainer) {
|
||||
@apply max-w-[307.875px] min-w-[307.875px] flex-none;
|
||||
}
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-calendar::before),
|
||||
.modrinth-date-picker :deep(.flatpickr-calendar::after) {
|
||||
display: none;
|
||||
@@ -1356,7 +1557,7 @@ defineExpose({
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-current-month input.cur-year),
|
||||
.modrinth-date-picker :deep(.flatpickr-current-month .flatpickr-monthDropdown-months) {
|
||||
@apply rounded-xl bg-surface-4 py-1 font-semibold text-contrast hover:bg-surface-5 min-h-10;
|
||||
@apply touch-manipulation rounded-xl bg-surface-4 py-1 font-semibold text-contrast hover:bg-surface-5 min-h-10;
|
||||
}
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-current-month .flatpickr-monthDropdown-months) {
|
||||
@@ -1414,7 +1615,7 @@ defineExpose({
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-prev-month),
|
||||
.modrinth-date-picker :deep(.flatpickr-next-month) {
|
||||
@apply top-2.5 mx-3.5 flex h-10 w-10 items-center justify-center rounded-full p-0 text-secondary hover:bg-surface-4 hover:text-contrast;
|
||||
@apply top-2.5 mx-3.5 flex h-10 w-10 touch-manipulation items-center justify-center rounded-full p-0 text-secondary hover:bg-surface-4 hover:text-contrast;
|
||||
}
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-prev-month.flatpickr-disabled),
|
||||
@@ -1439,7 +1640,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-day) {
|
||||
@apply relative z-0 m-0 max-w-none rounded-full border border-solid border-transparent text-primary hover:bg-surface-4 hover:text-contrast font-semibold aspect-square h-auto;
|
||||
@apply relative z-0 m-0 max-w-none touch-manipulation rounded-full border border-solid border-transparent text-primary hover:bg-surface-4 hover:text-contrast font-semibold aspect-square h-auto;
|
||||
}
|
||||
.modrinth-date-picker
|
||||
:deep(
|
||||
@@ -1625,7 +1826,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-time) {
|
||||
@apply mt-2 flex h-11 max-h-none items-center gap-2 border-0 border-t border-solid border-surface-5 px-1 pt-2 leading-none;
|
||||
@apply mt-2 flex h-11 max-h-none items-center gap-2 border-0 border-t border-solid border-surface-5 px-1 pt-2 overflow-visible leading-none;
|
||||
}
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-time .numInputWrapper) {
|
||||
@@ -1634,7 +1835,7 @@ defineExpose({
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-time input),
|
||||
.modrinth-date-picker :deep(.flatpickr-time .flatpickr-am-pm) {
|
||||
@apply h-full rounded-xl bg-transparent px-2 text-center font-semibold text-primary hover:bg-surface-5 focus:bg-surface-5;
|
||||
@apply h-full touch-manipulation rounded-xl bg-transparent px-2 text-center font-semibold text-primary hover:bg-surface-5 focus:bg-surface-5;
|
||||
}
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-time .flatpickr-time-separator) {
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
:autocomplete="autocomplete"
|
||||
:maxlength="maxlength"
|
||||
:rows="rows"
|
||||
class="w-full text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow bg-surface-4 border-none rounded-xl"
|
||||
class="w-full touch-manipulation text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow bg-surface-4 border-none rounded-xl"
|
||||
:class="[
|
||||
inputClass,
|
||||
'pl-3 pr-3 py-2 text-base',
|
||||
@@ -60,7 +60,7 @@
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
class="w-full text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow"
|
||||
class="w-full touch-manipulation text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow"
|
||||
:class="[
|
||||
inputClass,
|
||||
variant === 'filled' && icon ? 'pl-10' : 'pl-3',
|
||||
@@ -84,7 +84,7 @@
|
||||
<button
|
||||
v-if="!multiline && clearable && model && !disabled && !readonly && variant === 'filled'"
|
||||
type="button"
|
||||
class="absolute right-0.5 z-[1] p-2 bg-transparent border-none text-secondary hover:text-contrast transition-colors cursor-pointer select-none"
|
||||
class="absolute right-0.5 z-[1] p-2 touch-manipulation bg-transparent border-none text-secondary hover:text-contrast transition-colors cursor-pointer select-none"
|
||||
aria-label="Clear input"
|
||||
@click="clear"
|
||||
>
|
||||
@@ -95,7 +95,7 @@
|
||||
<button
|
||||
v-if="!multiline && variant === 'outlined'"
|
||||
type="button"
|
||||
class="flex items-center justify-center px-2 bg-transparent border border-solid border-button-bg rounded-r-xl text-secondary hover:text-contrast transition-colors shrink-0"
|
||||
class="flex touch-manipulation items-center justify-center px-2 bg-transparent border border-solid border-button-bg rounded-r-xl text-secondary hover:text-contrast transition-colors shrink-0"
|
||||
:aria-label="clearable && model ? 'Clear input' : 'Search'"
|
||||
:tabindex="clearable && model ? undefined : -1"
|
||||
@click="clearable && model ? clear() : undefined"
|
||||
|
||||
@@ -6,114 +6,123 @@
|
||||
>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<table class="w-full table-fixed border-separate border-spacing-0 border-surface-5">
|
||||
<colgroup>
|
||||
<col v-if="showSelection" class="w-10" />
|
||||
<col
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
:style="column.width ? { width: column.width } : undefined"
|
||||
/>
|
||||
</colgroup>
|
||||
<thead class="">
|
||||
<tr class="bg-surface-3">
|
||||
<th v-if="showSelection" class="w-10 pl-4">
|
||||
<Checkbox
|
||||
:model-value="allSelected"
|
||||
:indeterminate="someSelected"
|
||||
class="shrink-0 py-4"
|
||||
@update:model-value="toggleSelectAll"
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
<div class="overflow-x-auto overflow-y-hidden">
|
||||
<table
|
||||
class="w-full table-fixed border-separate border-spacing-0 border-surface-5"
|
||||
:style="tableMinWidth ? { minWidth: tableMinWidth } : undefined"
|
||||
>
|
||||
<colgroup>
|
||||
<col v-if="showSelection" class="w-12" />
|
||||
<col
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class="h-14 first:pl-4 last:pr-4"
|
||||
:class="[
|
||||
`text-${column.align ?? 'left'}`,
|
||||
column.enableSorting ? 'cursor-pointer select-none' : '',
|
||||
]"
|
||||
@click="column.enableSorting ? handleSort(column.key) : undefined"
|
||||
>
|
||||
<slot :name="`header-${column.key}`" :column="column">
|
||||
<span
|
||||
v-if="column.label || column.enableSorting"
|
||||
class="inline-flex min-w-0 max-w-full items-center gap-1 font-semibold"
|
||||
:class="`${sortColumn === column.key ? 'text-contrast' : ''}`"
|
||||
>
|
||||
<span class="min-w-0 truncate">{{ column.label ?? '' }}</span>
|
||||
<template v-if="column.enableSorting">
|
||||
<ChevronUpIcon
|
||||
v-if="sortColumn === column.key && sortDirection === 'asc'"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-else-if="sortColumn === column.key && sortDirection === 'desc'"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
</template>
|
||||
</span>
|
||||
</slot>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody :ref="setListContainer">
|
||||
<tr v-if="data.length === 0" class="bg-surface-2">
|
||||
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-5 p-0">
|
||||
<slot name="empty-state">
|
||||
<div class="text-secondary flex h-64 items-center justify-center">
|
||||
No data available.
|
||||
</div>
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
<template v-else>
|
||||
<tr v-if="virtualized && topSpacerHeight > 0" aria-hidden="true">
|
||||
<td
|
||||
:colspan="columnSpan"
|
||||
class="border-0 p-0"
|
||||
:style="{ height: `${topSpacerHeight}px` }"
|
||||
></td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="(row, rowIndex) in renderedRows"
|
||||
:key="getRowRenderKey(row, getAbsoluteRowIndex(rowIndex))"
|
||||
:class="getAbsoluteRowIndex(rowIndex) % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'"
|
||||
>
|
||||
<td v-if="showSelection" class="w-10 border-solid border-0 border-t border-surface-5">
|
||||
:style="column.width ? { width: column.width } : undefined"
|
||||
/>
|
||||
</colgroup>
|
||||
<thead class="">
|
||||
<tr class="bg-surface-3">
|
||||
<th v-if="showSelection" class="w-12">
|
||||
<Checkbox
|
||||
:model-value="isSelected(row)"
|
||||
class="shrink-0 p-4"
|
||||
@update:model-value="toggleSelection(row)"
|
||||
:model-value="allSelected"
|
||||
:indeterminate="someSelected"
|
||||
class="shrink-0 p-4 focus-visible:!outline-none"
|
||||
@update:model-value="toggleSelectAll"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
</th>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-5"
|
||||
:class="`text-${column.align ?? 'left'}`"
|
||||
class="h-14 first:pl-4 last:pr-4"
|
||||
:class="[
|
||||
`text-${column.align ?? 'left'}`,
|
||||
column.enableSorting ? 'cursor-pointer select-none' : '',
|
||||
]"
|
||||
:style="column.width ? { width: column.width } : undefined"
|
||||
@click="column.enableSorting ? handleSort(column.key) : undefined"
|
||||
>
|
||||
<slot
|
||||
:name="`cell-${column.key}`"
|
||||
:row="row"
|
||||
:value="row[column.key]"
|
||||
:column="column"
|
||||
:index="getAbsoluteRowIndex(rowIndex)"
|
||||
>
|
||||
{{ row[column.key] ?? '' }}
|
||||
<slot :name="`header-${column.key}`" :column="column">
|
||||
<span
|
||||
v-if="column.label || column.enableSorting"
|
||||
class="inline-flex min-w-0 max-w-full items-center gap-1 font-semibold"
|
||||
:class="`${sortColumn === column.key ? 'text-contrast' : ''}`"
|
||||
>
|
||||
<span class="min-w-0 truncate">{{ column.label ?? '' }}</span>
|
||||
<template v-if="column.enableSorting">
|
||||
<ChevronUpIcon
|
||||
v-if="sortColumn === column.key && sortDirection === 'asc'"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-else-if="sortColumn === column.key && sortDirection === 'desc'"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
</template>
|
||||
</span>
|
||||
</slot>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody :ref="setListContainer">
|
||||
<tr v-if="data.length === 0" class="bg-surface-2">
|
||||
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-5 p-0">
|
||||
<slot name="empty-state">
|
||||
<div class="text-secondary flex h-64 items-center justify-center">
|
||||
No data available.
|
||||
</div>
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="virtualized && bottomSpacerHeight > 0" aria-hidden="true">
|
||||
<td
|
||||
:colspan="columnSpan"
|
||||
class="border-0 p-0"
|
||||
:style="{ height: `${bottomSpacerHeight}px` }"
|
||||
></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<template v-else>
|
||||
<tr v-if="virtualized && topSpacerHeight > 0" aria-hidden="true">
|
||||
<td
|
||||
:colspan="columnSpan"
|
||||
class="border-0 p-0"
|
||||
:style="{ height: `${topSpacerHeight}px` }"
|
||||
></td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="(row, rowIndex) in renderedRows"
|
||||
:key="getRowRenderKey(row, getAbsoluteRowIndex(rowIndex))"
|
||||
:class="getAbsoluteRowIndex(rowIndex) % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'"
|
||||
>
|
||||
<td
|
||||
v-if="showSelection"
|
||||
class="w-12 border-solid border-0 border-t border-surface-5 focus:outline-none"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="isSelected(row)"
|
||||
class="shrink-0 p-4 -outline-offset-[14px] outline rounded-2xl"
|
||||
@update:model-value="(selectRow, event) => toggleSelection(row, selectRow, event)"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-5"
|
||||
:class="`text-${column.align ?? 'left'}`"
|
||||
>
|
||||
<slot
|
||||
:name="`cell-${column.key}`"
|
||||
:row="row"
|
||||
:value="row[column.key]"
|
||||
:column="column"
|
||||
:index="getAbsoluteRowIndex(rowIndex)"
|
||||
>
|
||||
{{ row[column.key] ?? '' }}
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="virtualized && bottomSpacerHeight > 0" aria-hidden="true">
|
||||
<td
|
||||
:colspan="columnSpan"
|
||||
class="border-0 p-0"
|
||||
:style="{ height: `${bottomSpacerHeight}px` }"
|
||||
></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -123,7 +132,7 @@
|
||||
generic="K extends string = string, T extends Record<string, unknown> = Record<K, unknown>"
|
||||
>
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@modrinth/assets'
|
||||
import { computed, toRef, useSlots } from 'vue'
|
||||
import { computed, ref, toRef, useSlots } from 'vue'
|
||||
|
||||
import { useVirtualScroll } from '../../composables/virtual-scroll'
|
||||
import Checkbox from './Checkbox.vue'
|
||||
@@ -140,6 +149,7 @@ export interface TableColumn<K extends string = string> {
|
||||
label?: string
|
||||
align?: TableColumnAlign
|
||||
enableSorting?: boolean
|
||||
defaultSortDirection?: SortDirection
|
||||
/**
|
||||
* CSS width value for the column.
|
||||
* Accepts any valid CSS width (e.g., '200px', '20%', '10rem', 'auto', 'fit-content').
|
||||
@@ -153,9 +163,16 @@ const props = withDefaults(
|
||||
data: T[] /* Row data for table */
|
||||
showSelection?: boolean
|
||||
rowKey?: keyof T /* The key used to uniquely identify each row */
|
||||
selectionKey?: keyof T /* The key used to identify selectable rows */
|
||||
selectionData?: T[] /* The complete selectable data set when data is paginated */
|
||||
selectionIds?: unknown[] /* Complete selectable IDs when callers do not want to retain row objects */
|
||||
virtualized?: boolean
|
||||
virtualRowHeight?: number
|
||||
virtualBufferSize?: number /* The number of extra rows rendered above and below the visible viewport */
|
||||
/**
|
||||
* Sets a minimum width for the table content, allowing horizontal overflow below that width.
|
||||
*/
|
||||
tableMinWidth?: string
|
||||
}>(),
|
||||
{
|
||||
showSelection: false,
|
||||
@@ -170,6 +187,7 @@ const selectedIds = defineModel<unknown[]>('selectedIds', { default: () => [] })
|
||||
const sortColumn = defineModel<string | undefined>('sortColumn')
|
||||
const sortDirection = defineModel<SortDirection>('sortDirection', { default: 'asc' })
|
||||
const slots = useSlots()
|
||||
const selectionAnchorId = ref<unknown>()
|
||||
const hasHeaderSlot = computed(() => Boolean(slots.header))
|
||||
const columnSpan = computed(() => Math.max(props.columns.length + (props.showSelection ? 1 : 0), 1))
|
||||
|
||||
@@ -201,17 +219,39 @@ const emit = defineEmits<{
|
||||
sort: [column: string, direction: SortDirection]
|
||||
}>()
|
||||
|
||||
const selectableRows = computed(() => props.selectionData ?? props.data)
|
||||
const selectableRowIds = computed(
|
||||
() => props.selectionIds ?? selectableRows.value.map((row) => getSelectionId(row)),
|
||||
)
|
||||
const selectedIdSet = computed(() => new Set(selectedIds.value))
|
||||
const selectedSelectableIdCount = computed(() => {
|
||||
let count = 0
|
||||
for (const id of selectableRowIds.value) {
|
||||
if (selectedIdSet.value.has(id)) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
})
|
||||
const allSelected = computed(
|
||||
() => props.data.length > 0 && selectedIds.value.length === props.data.length,
|
||||
() =>
|
||||
selectableRowIds.value.length > 0 &&
|
||||
selectedSelectableIdCount.value === selectableRowIds.value.length,
|
||||
)
|
||||
const someSelected = computed(
|
||||
() => selectedIds.value.length > 0 && selectedIds.value.length < props.data.length,
|
||||
() =>
|
||||
selectedSelectableIdCount.value > 0 &&
|
||||
selectedSelectableIdCount.value < selectableRowIds.value.length,
|
||||
)
|
||||
|
||||
function getRowId(row: T): unknown {
|
||||
return row[props.rowKey as keyof T]
|
||||
}
|
||||
|
||||
function getSelectionId(row: T): unknown {
|
||||
return row[(props.selectionKey ?? props.rowKey) as keyof T]
|
||||
}
|
||||
|
||||
function setListContainer(element: unknown) {
|
||||
listContainer.value = props.virtualized ? (element as HTMLElement | null) : null
|
||||
}
|
||||
@@ -230,31 +270,66 @@ function getRowRenderKey(row: T, rowIndex: number): PropertyKey {
|
||||
}
|
||||
|
||||
function isSelected(row: T): boolean {
|
||||
return selectedIds.value.includes(getRowId(row))
|
||||
return selectedIdSet.value.has(getSelectionId(row))
|
||||
}
|
||||
|
||||
function toggleSelection(row: T) {
|
||||
const id = getRowId(row)
|
||||
if (isSelected(row)) {
|
||||
selectedIds.value = selectedIds.value.filter((selectedId) => selectedId !== id)
|
||||
function toggleSelection(row: T, selectRow: boolean, event?: MouseEvent) {
|
||||
const id = getSelectionId(row)
|
||||
const rowIndex = selectableRowIds.value.findIndex((selectableId) => selectableId === id)
|
||||
const anchorIndex = selectableRowIds.value.findIndex(
|
||||
(selectableId) => selectableId === selectionAnchorId.value,
|
||||
)
|
||||
|
||||
if (event?.shiftKey && rowIndex !== -1 && anchorIndex !== -1) {
|
||||
const startIndex = Math.min(rowIndex, anchorIndex)
|
||||
const endIndex = Math.max(rowIndex, anchorIndex)
|
||||
const rangeIds = selectableRowIds.value.slice(startIndex, endIndex + 1)
|
||||
|
||||
if (selectRow) {
|
||||
const nextSelectedIds = [...selectedIds.value]
|
||||
const nextSelectedIdSet = new Set(nextSelectedIds)
|
||||
for (const rangeId of rangeIds) {
|
||||
if (!nextSelectedIdSet.has(rangeId)) {
|
||||
nextSelectedIds.push(rangeId)
|
||||
nextSelectedIdSet.add(rangeId)
|
||||
}
|
||||
}
|
||||
selectedIds.value = nextSelectedIds
|
||||
} else {
|
||||
const rangeIdSet = new Set(rangeIds)
|
||||
selectedIds.value = selectedIds.value.filter((selectedId) => !rangeIdSet.has(selectedId))
|
||||
}
|
||||
} else {
|
||||
selectedIds.value = [...selectedIds.value, id]
|
||||
selectedIds.value = selectRow
|
||||
? [...selectedIds.value, id]
|
||||
: selectedIds.value.filter((selectedId) => selectedId !== id)
|
||||
}
|
||||
|
||||
selectionAnchorId.value = id
|
||||
}
|
||||
|
||||
function toggleSelectAll(selectAll: boolean) {
|
||||
selectionAnchorId.value = undefined
|
||||
if (selectAll) {
|
||||
selectedIds.value = props.data.map((row) => getRowId(row))
|
||||
selectedIds.value = [...selectableRowIds.value]
|
||||
} else {
|
||||
selectedIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function handleSort(columnKey: string) {
|
||||
const column = props.columns.find((column) => column.key === columnKey)
|
||||
const defaultDirection = column?.defaultSortDirection ?? 'asc'
|
||||
const newDirection: SortDirection =
|
||||
sortColumn.value === columnKey && sortDirection.value === 'asc' ? 'desc' : 'asc'
|
||||
sortColumn.value === columnKey && sortDirection.value === defaultDirection
|
||||
? getOppositeSortDirection(defaultDirection)
|
||||
: defaultDirection
|
||||
sortColumn.value = columnKey
|
||||
sortDirection.value = newDirection
|
||||
emit('sort', columnKey, newDirection)
|
||||
}
|
||||
|
||||
function getOppositeSortDirection(direction: SortDirection): SortDirection {
|
||||
return direction === 'asc' ? 'desc' : 'asc'
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="tabs.length > 0"
|
||||
class="inline-flex w-fit items-center overflow-x-auto rounded-xl border border-solid border-surface-5 p-0.5 shadow-sm gap-1"
|
||||
class="inline-flex w-fit items-center overflow-x-auto rounded-xl border border-solid border-surface-5 p-0.5 shadow-sm gap-1 h-[38px]"
|
||||
role="tablist"
|
||||
>
|
||||
<button
|
||||
@@ -9,7 +9,7 @@
|
||||
:key="tab.value"
|
||||
ref="tabButtons"
|
||||
type="button"
|
||||
class="flex min-h-6 shrink-0 cursor-pointer items-center justify-center gap-2 rounded-lg border border-solid px-2.5 py-1 text-sm font-medium outline-none transition-all active:scale-[0.97] focus-visible:ring-4 focus-visible:ring-brand-shadow"
|
||||
class="flex min-h-6 shrink-0 cursor-pointer items-center justify-center gap-2 rounded-lg border border-solid px-2.5 h-full text-sm font-medium outline-none transition-all active:scale-[0.97] focus-visible:ring-4 focus-visible:ring-brand-shadow"
|
||||
:class="
|
||||
tab.value === value
|
||||
? 'border-green bg-highlight-green text-green'
|
||||
@@ -27,7 +27,7 @@
|
||||
class="size-5 shrink-0"
|
||||
:class="tab.value === value ? 'text-green' : 'text-secondary'"
|
||||
/>
|
||||
<span class="text-nowrap">{{ tab.label }}</span>
|
||||
<span v-if="tab.label" class="text-nowrap">{{ tab.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
:disabled="disabled"
|
||||
class="group inline-flex shrink-0 items-center rounded-full m-0 p-1 transition-all duration-200 cursor-pointer border-none"
|
||||
class="group inline-flex shrink-0 touch-manipulation items-center rounded-full m-0 p-1 transition-all duration-200 cursor-pointer border-none"
|
||||
:class="[
|
||||
small ? 'h-5 !w-[40px]' : 'h-6 !w-[48px]',
|
||||
modelValue ? 'bg-brand' : 'bg-button-bg',
|
||||
|
||||
@@ -50,7 +50,11 @@ export { default as LoadingBar } from './LoadingBar.vue'
|
||||
export { default as LoadingIndicator } from './LoadingIndicator.vue'
|
||||
export { default as ManySelect } from './ManySelect.vue'
|
||||
export { default as MarkdownEditor } from './MarkdownEditor.vue'
|
||||
export type { MultiSelectOption } from './MultiSelect.vue'
|
||||
export type {
|
||||
MultiSelectItem,
|
||||
MultiSelectOption,
|
||||
MultiSelectSectionHeader,
|
||||
} from './MultiSelect.vue'
|
||||
export { default as MultiSelect } from './MultiSelect.vue'
|
||||
export type { MaybeCtxFn, StageButtonConfig, StageConfigInput } from './MultiStageModal.vue'
|
||||
export { default as MultiStageModal, resolveCtxFn } from './MultiStageModal.vue'
|
||||
@@ -77,12 +81,20 @@ export type { StackedAdmonitionItem, StackedAdmonitionType } from './StackedAdmo
|
||||
export { default as StackedAdmonitions } from './StackedAdmonitions.vue'
|
||||
export { default as StatItem } from './StatItem.vue'
|
||||
export { default as StyledInput } from './StyledInput.vue'
|
||||
export type { TableColumn } from './Table.vue'
|
||||
export type { SortDirection, TableColumn } from './Table.vue'
|
||||
export { default as Table } from './Table.vue'
|
||||
export type { TabsTab, TabsValue } from './Tabs.vue'
|
||||
export { default as Tabs } from './Tabs.vue'
|
||||
export { default as TagItem } from './TagItem.vue'
|
||||
export { default as TagTagItem } from './TagTagItem.vue'
|
||||
export type {
|
||||
TimeFrameLastUnit,
|
||||
TimeFrameLastUnitOption,
|
||||
TimeFrameMode,
|
||||
TimeFramePickerSelection,
|
||||
TimeFramePreset,
|
||||
} from './TimeFramePicker.vue'
|
||||
export { default as TimeFramePicker } from './TimeFramePicker.vue'
|
||||
export { default as Timeline } from './Timeline.vue'
|
||||
export { default as Toggle } from './Toggle.vue'
|
||||
export { default as UnsavedChangesPopup } from './UnsavedChangesPopup.vue'
|
||||
|
||||
@@ -60,12 +60,22 @@ export function useScrollViewport(options: ScrollViewportOptions = {}) {
|
||||
}
|
||||
|
||||
function syncScrollState() {
|
||||
if (!scrollContainer.value) return
|
||||
scrollTop.value = getScrollTop(scrollContainer.value)
|
||||
viewportHeight.value = getViewportHeight(scrollContainer.value)
|
||||
const listEl = listContainer.value
|
||||
if (!listEl) return
|
||||
|
||||
const container = findScrollableAncestor(listEl)
|
||||
scrollContainer.value = container
|
||||
scrollTop.value = getScrollTop(container)
|
||||
viewportHeight.value = getViewportHeight(container)
|
||||
updateContainerOffset()
|
||||
}
|
||||
|
||||
function resetScrollState() {
|
||||
scrollTop.value = 0
|
||||
viewportHeight.value = 0
|
||||
containerOffset.value = 0
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
if (scrollContainer.value) {
|
||||
scrollTop.value = getScrollTop(scrollContainer.value)
|
||||
@@ -109,6 +119,7 @@ export function useScrollViewport(options: ScrollViewportOptions = {}) {
|
||||
})
|
||||
|
||||
return {
|
||||
resetScrollState,
|
||||
containerOffset,
|
||||
listContainer,
|
||||
relativeScrollTop,
|
||||
@@ -130,10 +141,16 @@ export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptio
|
||||
nearEndThreshold = 0.2,
|
||||
} = options
|
||||
|
||||
const { listContainer, relativeScrollTop, scrollContainer, syncScrollState, viewportHeight } =
|
||||
useScrollViewport({
|
||||
onScroll: checkNearEnd,
|
||||
})
|
||||
const {
|
||||
listContainer,
|
||||
relativeScrollTop,
|
||||
resetScrollState,
|
||||
scrollContainer,
|
||||
syncScrollState,
|
||||
viewportHeight,
|
||||
} = useScrollViewport({
|
||||
onScroll: checkNearEnd,
|
||||
})
|
||||
|
||||
const totalHeight = computed(() => items.value.length * itemHeight)
|
||||
|
||||
@@ -191,5 +208,7 @@ export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptio
|
||||
visibleRange,
|
||||
visibleTop,
|
||||
visibleItems,
|
||||
resetScrollState,
|
||||
syncScrollState,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4373,6 +4373,93 @@
|
||||
"tag.loader.waterfall": {
|
||||
"defaultMessage": "Waterfall"
|
||||
},
|
||||
"time-frame-picker.apply": {
|
||||
"defaultMessage": "Apply"
|
||||
},
|
||||
"time-frame-picker.cancel": {
|
||||
"defaultMessage": "Cancel"
|
||||
},
|
||||
"time-frame-picker.clear-range": {
|
||||
"defaultMessage": "Clear"
|
||||
},
|
||||
"time-frame-picker.custom-range": {
|
||||
"defaultMessage": "Custom fixed date range..."
|
||||
},
|
||||
"time-frame-picker.decrease-amount": {
|
||||
"defaultMessage": "Decrease timeframe amount"
|
||||
},
|
||||
"time-frame-picker.empty-range": {
|
||||
"defaultMessage": "No date range selected."
|
||||
},
|
||||
"time-frame-picker.end-date": {
|
||||
"defaultMessage": "End date"
|
||||
},
|
||||
"time-frame-picker.increase-amount": {
|
||||
"defaultMessage": "Increase timeframe amount"
|
||||
},
|
||||
"time-frame-picker.last-timeframe": {
|
||||
"defaultMessage": "In the last {amount} {unit, select, hours {{amount, plural, one {hour} other {hours}}} days {{amount, plural, one {day} other {days}}} weeks {{amount, plural, one {week} other {weeks}}} months {{amount, plural, one {month} other {months}}} other {days}}"
|
||||
},
|
||||
"time-frame-picker.last-timeframe-prefix": {
|
||||
"defaultMessage": "In the last"
|
||||
},
|
||||
"time-frame-picker.option.all-time": {
|
||||
"defaultMessage": "All time"
|
||||
},
|
||||
"time-frame-picker.option.last-14-days": {
|
||||
"defaultMessage": "Last 14 days"
|
||||
},
|
||||
"time-frame-picker.option.last-180-days": {
|
||||
"defaultMessage": "Last 180 days"
|
||||
},
|
||||
"time-frame-picker.option.last-30-days": {
|
||||
"defaultMessage": "Last 30 days"
|
||||
},
|
||||
"time-frame-picker.option.last-7-days": {
|
||||
"defaultMessage": "Last 7 days"
|
||||
},
|
||||
"time-frame-picker.option.last-90-days": {
|
||||
"defaultMessage": "Last 90 days"
|
||||
},
|
||||
"time-frame-picker.option.today": {
|
||||
"defaultMessage": "Today"
|
||||
},
|
||||
"time-frame-picker.option.year-to-date": {
|
||||
"defaultMessage": "Year to date"
|
||||
},
|
||||
"time-frame-picker.option.yesterday": {
|
||||
"defaultMessage": "Yesterday"
|
||||
},
|
||||
"time-frame-picker.select-timeframe": {
|
||||
"defaultMessage": "Select timeframe"
|
||||
},
|
||||
"time-frame-picker.selected-range": {
|
||||
"defaultMessage": "Selected"
|
||||
},
|
||||
"time-frame-picker.selecting-range": {
|
||||
"defaultMessage": "Selecting"
|
||||
},
|
||||
"time-frame-picker.start-date": {
|
||||
"defaultMessage": "Start date"
|
||||
},
|
||||
"time-frame-picker.timeframe-amount": {
|
||||
"defaultMessage": "Timeframe amount"
|
||||
},
|
||||
"time-frame-picker.timeframe-unit": {
|
||||
"defaultMessage": "Timeframe unit"
|
||||
},
|
||||
"time-frame-picker.unit.days": {
|
||||
"defaultMessage": "days"
|
||||
},
|
||||
"time-frame-picker.unit.hours": {
|
||||
"defaultMessage": "hours"
|
||||
},
|
||||
"time-frame-picker.unit.months": {
|
||||
"defaultMessage": "months"
|
||||
},
|
||||
"time-frame-picker.unit.weeks": {
|
||||
"defaultMessage": "weeks"
|
||||
},
|
||||
"ui.component.unsaved-changes-popup.body": {
|
||||
"defaultMessage": "You have unsaved changes."
|
||||
},
|
||||
|
||||
@@ -32,6 +32,17 @@ export const Default: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
export const WithSelectedOption: Story = {
|
||||
args: {
|
||||
modelValue: '2',
|
||||
options: [
|
||||
{ value: '1', label: 'Option 1' },
|
||||
{ value: '2', label: 'Option 2' },
|
||||
{ value: '3', label: 'Option 3' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const Searchable: Story = {
|
||||
args: {
|
||||
options: [
|
||||
@@ -64,6 +75,54 @@ export const SearchableEmpty: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
export const DropdownMinWidth: StoryObj = {
|
||||
render: () => ({
|
||||
components: { Combobox },
|
||||
data: () => ({
|
||||
selected: undefined,
|
||||
options: [
|
||||
{ value: 'fabric', label: 'Fabric', subLabel: 'Lightweight modding toolchain' },
|
||||
{ value: 'forge', label: 'Forge', subLabel: 'The original Minecraft modding API' },
|
||||
{ value: 'neoforge', label: 'NeoForge', subLabel: 'Community-driven Forge fork' },
|
||||
],
|
||||
}),
|
||||
template: /*html*/ `
|
||||
<div style="width: 11rem;">
|
||||
<Combobox
|
||||
v-model="selected"
|
||||
:options="options"
|
||||
:dropdown-min-width="320"
|
||||
placeholder="Loader"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const DropdownClass: StoryObj = {
|
||||
render: () => ({
|
||||
components: { Combobox },
|
||||
data: () => ({
|
||||
selected: undefined,
|
||||
options: [
|
||||
{ value: 'fabric', label: 'Fabric' },
|
||||
{ value: 'forge', label: 'Forge' },
|
||||
{ value: 'neoforge', label: 'NeoForge' },
|
||||
],
|
||||
}),
|
||||
template: /*html*/ `
|
||||
<div style="width: 14rem;">
|
||||
<Combobox
|
||||
v-model="selected"
|
||||
:options="options"
|
||||
dropdown-class="!border-brand"
|
||||
placeholder="Loader"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
options: [{ value: '1', label: 'Option 1' }],
|
||||
@@ -72,23 +131,6 @@ export const Disabled: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
export const WithSubLabels: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ value: 'download', label: 'Download', icon: DownloadIcon },
|
||||
{ value: 'share', label: 'Share', icon: ShareIcon },
|
||||
{ value: 'favorite', label: 'Add to favorites', icon: HeartIcon },
|
||||
{ type: 'divider' },
|
||||
{ value: 'settings', label: 'Settings', icon: SettingsIcon },
|
||||
{ value: 'profile', label: 'Profile', icon: UserIcon },
|
||||
{ type: 'divider' },
|
||||
{ value: 'delete', label: 'Delete', icon: TrashIcon, disabled: true },
|
||||
],
|
||||
placeholder: 'Select an action',
|
||||
listbox: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const SearchableWithIcons: Story = {
|
||||
args: {
|
||||
options: [
|
||||
@@ -105,7 +147,24 @@ export const SearchableWithIcons: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
export const WithSelectedOption: Story = {
|
||||
export const WithDividers: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ value: 'download', label: 'Download', icon: DownloadIcon },
|
||||
{ value: 'share', label: 'Share', icon: ShareIcon },
|
||||
{ value: 'favorite', label: 'Add to favorites', icon: HeartIcon },
|
||||
{ type: 'divider' },
|
||||
{ value: 'settings', label: 'Settings', icon: SettingsIcon },
|
||||
{ value: 'profile', label: 'Profile', icon: UserIcon },
|
||||
{ type: 'divider' },
|
||||
{ value: 'delete', label: 'Delete', icon: TrashIcon, disabled: true },
|
||||
],
|
||||
placeholder: 'Select an action',
|
||||
listbox: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const WithSubLabel: Story = {
|
||||
args: {
|
||||
modelValue: '2',
|
||||
options: [
|
||||
@@ -117,6 +176,18 @@ export const WithSelectedOption: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
export const MixedSubLabels: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ value: '1', label: 'Minecraft', subLabel: 'The base game' },
|
||||
{ value: '2', label: 'Fabric' },
|
||||
{ value: '3', label: 'Forge', subLabel: 'Supports most mods' },
|
||||
{ value: '4', label: 'NeoForge' },
|
||||
{ value: '5', label: 'Quilt', subLabel: 'Fabric-compatible' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const SearchableNoFilter: Story = {
|
||||
args: {
|
||||
options: [
|
||||
@@ -132,20 +203,6 @@ export const SearchableNoFilter: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
export const SearchableModpacks: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ value: 'download', label: 'Download', icon: DownloadIcon },
|
||||
{ value: 'share', label: 'Share', icon: ShareIcon },
|
||||
{ value: 'favorite', label: 'Add to favorites', icon: HeartIcon },
|
||||
{ value: 'settings', label: 'Settings', icon: SettingsIcon },
|
||||
],
|
||||
searchable: true,
|
||||
searchPlaceholder: 'Search modpacks...',
|
||||
noOptionsMessage: 'No modpacks found',
|
||||
},
|
||||
}
|
||||
|
||||
export const WithDropdownFooter: StoryObj = {
|
||||
render: () => ({
|
||||
components: { Combobox, EyeIcon, EyeOffIcon },
|
||||
@@ -194,15 +251,48 @@ export const WithDropdownFooter: StoryObj = {
|
||||
}),
|
||||
}
|
||||
|
||||
export const MixedSubLabels: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ value: '1', label: 'Minecraft', subLabel: 'The base game' },
|
||||
{ value: '2', label: 'Fabric' },
|
||||
{ value: '3', label: 'Forge', subLabel: 'Supports most mods' },
|
||||
{ value: '4', label: 'NeoForge' },
|
||||
{ value: '5', label: 'Quilt', subLabel: 'Fabric-compatible' },
|
||||
],
|
||||
export const DropdownFooterOnly: StoryObj = {
|
||||
render: () => ({
|
||||
components: { Combobox },
|
||||
data: () => ({
|
||||
selected: undefined,
|
||||
options: [],
|
||||
}),
|
||||
template: /*html*/ `
|
||||
<div style="width: 240px;">
|
||||
<Combobox
|
||||
v-model="selected"
|
||||
:options="options"
|
||||
display-value="Custom range"
|
||||
dropdown-min-width="320"
|
||||
>
|
||||
<template #dropdown-footer>
|
||||
<div style="display: flex; flex-direction: column; gap: 0.75rem; padding: 1rem; color: var(--color-text-primary);">
|
||||
<div style="font-size: 0.875rem; font-weight: 700;">Dropdown footer content</div>
|
||||
<div style="font-size: 0.8125rem; color: var(--color-text-secondary);">
|
||||
This dropdown has no options and stays open because its footer slot is content.
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem;">
|
||||
<button type="button" style="height: 2rem; border: 1px solid var(--color-surface-5); border-radius: 0.5rem; background: var(--color-surface-3); color: var(--color-text-primary); font-weight: 600;">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" style="height: 2rem; border: 0; border-radius: 0.5rem; background: var(--color-brand); color: var(--color-bg); font-weight: 700;">
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Combobox>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Covers dropdowns whose only rendered content is the footer slot, such as the analytics custom date range picker.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -258,3 +348,56 @@ export const SearchableWithOptionAndSelectionAffix: StoryObj = {
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const ManyOptionsOverflow: Story = {
|
||||
args: {
|
||||
options: Array.from({ length: 40 }, (_, index) => ({
|
||||
value: `${index + 1}`,
|
||||
label: `Option ${index + 1}`,
|
||||
})),
|
||||
placeholder: 'Select an option',
|
||||
maxHeight: 380,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Covers long option lists where the dropdown content should scroll within its max height.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const ScrollRepositioning: StoryObj = {
|
||||
render: () => ({
|
||||
components: { Combobox },
|
||||
data: () => ({
|
||||
selected: undefined,
|
||||
options: Array.from({ length: 16 }, (_, index) => ({
|
||||
value: `loader-${index + 1}`,
|
||||
label: `Loader ${index + 1}`,
|
||||
})),
|
||||
}),
|
||||
template: /*html*/ `
|
||||
<div style="min-height: 150vh; padding-top: 45vh;">
|
||||
<div style="width: min(100%, 22rem);">
|
||||
<Combobox
|
||||
v-model="selected"
|
||||
:options="options"
|
||||
searchable
|
||||
placeholder="Select loader"
|
||||
search-placeholder="Search loaders..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Covers fixed dropdown repositioning while the page scrolls with a searchable input open.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -48,6 +48,26 @@ export const WithTime: Story = {
|
||||
}),
|
||||
}
|
||||
|
||||
export const Clearable: Story = {
|
||||
render: () => ({
|
||||
components: { DatePicker },
|
||||
setup() {
|
||||
const value = ref('2026-04-27')
|
||||
return { value }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="flex max-w-sm flex-col gap-2">
|
||||
<DatePicker
|
||||
v-model="value"
|
||||
wrapperClass="w-[300px]"
|
||||
placeholder="Select a date..."
|
||||
/>
|
||||
<p class="text-sm text-secondary">Selected value: {{ value || 'None' }}</p>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const OpenWithTime: Story = {
|
||||
render: () => ({
|
||||
components: { DatePicker },
|
||||
@@ -292,6 +312,59 @@ export const OpenCalendar: Story = {
|
||||
}),
|
||||
}
|
||||
|
||||
export const CloseOnSelect: Story = {
|
||||
render: () => ({
|
||||
components: { DatePicker },
|
||||
setup() {
|
||||
const value = ref('2026-06-15')
|
||||
return { value }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="flex max-w-sm flex-col gap-2">
|
||||
<DatePicker
|
||||
v-model="value"
|
||||
wrapperClass="w-[300px]"
|
||||
close-on-select
|
||||
default-view-date="2026-06-01"
|
||||
/>
|
||||
<p class="text-sm text-secondary">Selected value: {{ value || 'None' }}</p>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const CloseOnSelectInScrollablePanel: Story = {
|
||||
render: () => ({
|
||||
components: { DatePicker },
|
||||
setup() {
|
||||
const value = ref('2026-06-15')
|
||||
return { value }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="h-[320px] w-[360px] overflow-y-auto rounded-xl border border-solid border-surface-5 bg-surface-2 p-4">
|
||||
<div class="h-[360px]"></div>
|
||||
<div class="flex max-w-sm flex-col gap-2">
|
||||
<DatePicker
|
||||
v-model="value"
|
||||
wrapperClass="w-[300px]"
|
||||
close-on-select
|
||||
default-view-date="2026-06-01"
|
||||
/>
|
||||
<p class="text-sm text-secondary">Selected value: {{ value || 'None' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Readonly touch selection closes without focusing the input in a way that scrolls its container.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const ForceAbove: Story = {
|
||||
render: () => ({
|
||||
components: { DatePicker },
|
||||
@@ -415,6 +488,26 @@ export const Disabled: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
export const Editable: Story = {
|
||||
render: () => ({
|
||||
components: { DatePicker },
|
||||
setup() {
|
||||
const value = ref('2026-04-27')
|
||||
return { value }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="flex max-w-sm flex-col gap-2">
|
||||
<DatePicker
|
||||
v-model="value"
|
||||
wrapperClass="w-[300px]"
|
||||
:readonly="false"
|
||||
/>
|
||||
<p class="text-sm text-secondary">Selected value: {{ value || 'None' }}</p>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const CalendarClass: Story = {
|
||||
render: () => ({
|
||||
components: { DatePicker },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BoxIcon } from '@modrinth/assets'
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
@@ -55,10 +56,75 @@ const searchableCategories = [
|
||||
searchPlaceholder: 'Search versions...',
|
||||
submenuClass: 'w-[360px]',
|
||||
options: [
|
||||
{ value: '1.21.5', label: '1.21.5' },
|
||||
{ value: '1.21.4', label: '1.21.4' },
|
||||
{ value: '1.20.1', label: '1.20.1' },
|
||||
{ value: '1.19.2', label: '1.19.2' },
|
||||
{ value: '1.21.5', label: '1.21.5', searchTerms: ['Sodium'] },
|
||||
{ value: '1.21.4', label: '1.21.4', searchTerms: ['Sodium'] },
|
||||
{ value: '1.20.1', label: '1.20.1', searchTerms: ['Iris'] },
|
||||
{ value: '1.19.2', label: '1.19.2', searchTerms: ['Mod Menu'] },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const largeVersionOptions = Array.from({ length: 250 }, (_, index) => {
|
||||
const version = `1.${Math.floor(index / 10) + 1}.${index % 10}`
|
||||
const project = `Project ${Math.floor(index / 25) + 1}`
|
||||
return {
|
||||
value: `version-${index + 1}`,
|
||||
label: version,
|
||||
searchTerms: [project],
|
||||
}
|
||||
})
|
||||
|
||||
const mixedWidthCategories = [
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
options: [
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'archived', label: 'Archived' },
|
||||
{ value: 'draft', label: 'Draft' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'country',
|
||||
label: 'Country',
|
||||
searchable: true,
|
||||
searchPlaceholder: 'Search countries...',
|
||||
submenuClass: 'w-[324px]',
|
||||
options: [
|
||||
{ value: 'US', label: 'United States' },
|
||||
{ value: 'CA', label: 'Canada' },
|
||||
{ value: 'DE', label: 'Germany' },
|
||||
{ value: 'JP', label: 'Japan' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'version',
|
||||
label: 'Project version',
|
||||
searchable: true,
|
||||
searchPlaceholder: 'Search project versions...',
|
||||
submenuClass: 'w-[368px]',
|
||||
options: [
|
||||
{ value: 'sodium-1.21.5', label: 'Sodium 1.21.5' },
|
||||
{ value: 'iris-1.21.4', label: 'Iris 1.21.4' },
|
||||
{ value: 'mod-menu-1.20.1', label: 'Mod Menu 1.20.1' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const mobileWidthCategories = [
|
||||
{
|
||||
key: 'country',
|
||||
label: 'Country',
|
||||
searchable: true,
|
||||
searchPlaceholder: 'Search countries...',
|
||||
submenuClass: 'w-[360px]',
|
||||
options: [
|
||||
{ value: 'US', label: 'United States' },
|
||||
{ value: 'CA', label: 'Canada' },
|
||||
{ value: 'DE', label: 'Germany' },
|
||||
{ value: 'JP', label: 'Japan' },
|
||||
{ value: 'BR', label: 'Brazil' },
|
||||
{ value: 'AU', label: 'Australia' },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -90,11 +156,21 @@ export const WithAppliedFilters: Story = {
|
||||
status: ['active'],
|
||||
type: ['mod', 'plugin'],
|
||||
})
|
||||
return { categories: defaultCategories, selected }
|
||||
const clearEvents = ref(0)
|
||||
function handleClear() {
|
||||
clearEvents.value += 1
|
||||
}
|
||||
|
||||
return { categories: defaultCategories, clearEvents, handleClear, selected }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<DropdownFilterBar v-model="selected" :categories="categories" />
|
||||
<DropdownFilterBar
|
||||
v-model="selected"
|
||||
:categories="categories"
|
||||
@clear="handleClear"
|
||||
/>
|
||||
<span class="text-sm font-medium text-secondary">Clear events: {{ clearEvents }}</span>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
@@ -107,6 +183,75 @@ export const WithAppliedFilters: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
export const WithRightCheckmarks: Story = {
|
||||
render: () => ({
|
||||
components: { DropdownFilterBar },
|
||||
setup() {
|
||||
const selected = ref<Record<string, string[]>>({
|
||||
status: ['active'],
|
||||
type: ['mod', 'plugin'],
|
||||
})
|
||||
return { categories: defaultCategories, selected }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<DropdownFilterBar
|
||||
v-model="selected"
|
||||
:categories="categories"
|
||||
checkbox-position="right"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
modelValue: {
|
||||
status: ['active'],
|
||||
type: ['mod', 'plugin'],
|
||||
},
|
||||
categories: defaultCategories,
|
||||
checkboxPosition: 'right',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Renders selected options with the same right-side checkmark placement as MultiSelect.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const WithClearOverride: Story = {
|
||||
render: () => ({
|
||||
components: { DropdownFilterBar },
|
||||
setup() {
|
||||
const selected = ref<Record<string, string[]>>({})
|
||||
const clearEvents = ref(0)
|
||||
function handleClear() {
|
||||
clearEvents.value += 1
|
||||
}
|
||||
|
||||
return { categories: defaultCategories, clearEvents, handleClear, selected }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<DropdownFilterBar
|
||||
v-model="selected"
|
||||
:categories="categories"
|
||||
show-clear
|
||||
@clear="handleClear"
|
||||
/>
|
||||
<span class="text-sm font-medium text-secondary">Clear events: {{ clearEvents }}</span>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
modelValue: {},
|
||||
categories: defaultCategories,
|
||||
showClear: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const WithFilterIcon: Story = {
|
||||
render: () => ({
|
||||
components: { DropdownFilterBar },
|
||||
@@ -133,14 +278,41 @@ export const WithFilterIcon: Story = {
|
||||
|
||||
export const SearchableCategories: Story = {
|
||||
render: () => ({
|
||||
components: { DropdownFilterBar },
|
||||
components: { BoxIcon, DropdownFilterBar },
|
||||
setup() {
|
||||
const selected = ref<Record<string, string[]>>({})
|
||||
return { categories: searchableCategories, selected }
|
||||
const versionProjects: Record<string, string> = {
|
||||
'1.21.5': 'Sodium',
|
||||
'1.21.4': 'Sodium',
|
||||
'1.20.1': 'Iris',
|
||||
'1.19.2': 'Mod Menu',
|
||||
}
|
||||
function getVersionProject(categoryKey: string, optionValue: string) {
|
||||
return categoryKey === 'version' ? versionProjects[optionValue] : undefined
|
||||
}
|
||||
return { categories: searchableCategories, getVersionProject, selected }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<DropdownFilterBar v-model="selected" :categories="categories" />
|
||||
<DropdownFilterBar v-model="selected" :categories="categories">
|
||||
<template #option="{ category, option, selected }">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span
|
||||
v-if="getVersionProject(category.key, option.value)"
|
||||
v-tooltip="getVersionProject(category.key, option.value)"
|
||||
class="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded text-primary"
|
||||
>
|
||||
<BoxIcon class="size-6" />
|
||||
</span>
|
||||
<span
|
||||
class="min-w-0 truncate font-semibold leading-tight"
|
||||
:class="selected ? 'text-contrast' : 'text-primary'"
|
||||
>
|
||||
{{ option.label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</DropdownFilterBar>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
@@ -148,13 +320,218 @@ export const SearchableCategories: Story = {
|
||||
modelValue: {},
|
||||
categories: searchableCategories,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'On mobile and narrow viewports, tapping a category replaces the add menu with the category submenu; both surfaces should stay anchored while scrolling or when the visual viewport changes.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const MixedSubmenuWidthsNearEdge: Story = {
|
||||
render: () => ({
|
||||
components: { DropdownFilterBar },
|
||||
setup() {
|
||||
const selected = ref<Record<string, string[]>>({})
|
||||
return { categories: mixedWidthCategories, selected }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="flex min-h-96 justify-end px-4 py-8">
|
||||
<DropdownFilterBar v-model="selected" :categories="categories" />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
modelValue: {},
|
||||
categories: mixedWidthCategories,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Covers mixed submenu widths near the viewport edge so all add-menu submenus open on the same side.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const MobileSubmenuWidth: Story = {
|
||||
render: () => ({
|
||||
components: { DropdownFilterBar },
|
||||
setup() {
|
||||
const selected = ref<Record<string, string[]>>({})
|
||||
return { categories: mobileWidthCategories, selected }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="flex min-h-96 max-w-[390px] items-start px-4 py-8">
|
||||
<DropdownFilterBar v-model="selected" :categories="categories" />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
modelValue: {},
|
||||
categories: mobileWidthCategories,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Covers mobile submenu page swaps where the submenu keeps its assigned width instead of inheriting the add-menu width.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const VirtualizedPreview: Story = {
|
||||
render: () => ({
|
||||
components: { BoxIcon, DropdownFilterBar },
|
||||
setup() {
|
||||
const selected = ref<Record<string, string[]>>({
|
||||
version: ['version-3', 'version-47', 'version-132'],
|
||||
})
|
||||
const categories = [
|
||||
{
|
||||
key: 'version',
|
||||
label: 'Version',
|
||||
searchable: true,
|
||||
searchPlaceholder: 'Search versions...',
|
||||
submenuClass: 'w-[360px]',
|
||||
previewDropdownWidth: '360px',
|
||||
options: largeVersionOptions,
|
||||
},
|
||||
]
|
||||
function getVersionProject(categoryKey: string, optionValue: string) {
|
||||
if (categoryKey !== 'version') {
|
||||
return undefined
|
||||
}
|
||||
const optionIndex = Number(optionValue.replace('version-', '')) - 1
|
||||
return `Project ${Math.floor(optionIndex / 25) + 1}`
|
||||
}
|
||||
return { categories, getVersionProject, selected }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<DropdownFilterBar v-model="selected" :categories="categories">
|
||||
<template #option="{ category, option, selected }">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span
|
||||
v-if="getVersionProject(category.key, option.value)"
|
||||
v-tooltip="getVersionProject(category.key, option.value)"
|
||||
class="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded text-primary"
|
||||
>
|
||||
<BoxIcon class="size-6" />
|
||||
</span>
|
||||
<span
|
||||
class="min-w-0 truncate font-semibold leading-tight"
|
||||
:class="selected ? 'text-contrast' : 'text-primary'"
|
||||
>
|
||||
{{ option.label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</DropdownFilterBar>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
modelValue: {
|
||||
version: ['version-3', 'version-47', 'version-132'],
|
||||
},
|
||||
categories: [
|
||||
{
|
||||
key: 'version',
|
||||
label: 'Version',
|
||||
searchable: true,
|
||||
searchPlaceholder: 'Search versions...',
|
||||
submenuClass: 'w-[360px]',
|
||||
previewDropdownWidth: '360px',
|
||||
options: largeVersionOptions,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const VirtualizedSubmenu: Story = {
|
||||
render: () => ({
|
||||
components: { BoxIcon, DropdownFilterBar },
|
||||
setup() {
|
||||
const selected = ref<Record<string, string[]>>({})
|
||||
const categories = [
|
||||
{
|
||||
key: 'version',
|
||||
label: 'Version',
|
||||
searchable: true,
|
||||
searchPlaceholder: 'Search versions...',
|
||||
submenuClass: 'w-[360px]',
|
||||
options: largeVersionOptions,
|
||||
},
|
||||
]
|
||||
function getVersionProject(categoryKey: string, optionValue: string) {
|
||||
if (categoryKey !== 'version') {
|
||||
return undefined
|
||||
}
|
||||
const optionIndex = Number(optionValue.replace('version-', '')) - 1
|
||||
return `Project ${Math.floor(optionIndex / 25) + 1}`
|
||||
}
|
||||
return { categories, getVersionProject, selected }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<DropdownFilterBar v-model="selected" :categories="categories">
|
||||
<template #option="{ category, option, selected }">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span
|
||||
v-if="getVersionProject(category.key, option.value)"
|
||||
v-tooltip="getVersionProject(category.key, option.value)"
|
||||
class="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded text-primary"
|
||||
>
|
||||
<BoxIcon class="size-6" />
|
||||
</span>
|
||||
<span
|
||||
class="min-w-0 truncate font-semibold leading-tight"
|
||||
:class="selected ? 'text-contrast' : 'text-primary'"
|
||||
>
|
||||
{{ option.label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</DropdownFilterBar>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
modelValue: {},
|
||||
categories: [
|
||||
{
|
||||
key: 'version',
|
||||
label: 'Version',
|
||||
searchable: true,
|
||||
searchPlaceholder: 'Search versions...',
|
||||
submenuClass: 'w-[360px]',
|
||||
options: largeVersionOptions,
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Covers the add-menu submenu with a large unselected category, custom option content, flush rows, square hover states, and OverlayScrollbars.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const CustomControls: Story = {
|
||||
render: () => ({
|
||||
components: { DropdownFilterBar },
|
||||
setup() {
|
||||
const selected = ref<Record<string, string[]>>({})
|
||||
const selected = ref<Record<string, string[]>>({
|
||||
version: ['1.21.5'],
|
||||
})
|
||||
const minimumDownloads = ref('1k')
|
||||
const releaseOnly = ref(true)
|
||||
const categories = [
|
||||
{
|
||||
@@ -171,7 +548,7 @@ export const CustomControls: Story = {
|
||||
],
|
||||
},
|
||||
]
|
||||
return { categories, releaseOnly, selected }
|
||||
return { categories, minimumDownloads, releaseOnly, selected }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@@ -193,14 +570,43 @@ export const CustomControls: Story = {
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template #preview-footer="{ category, setSelectedValues, closeMenu }">
|
||||
<div
|
||||
v-if="category.key === 'version'"
|
||||
class="flex flex-wrap items-center gap-3 border-0 border-t border-solid border-surface-5 px-6 py-2.5"
|
||||
>
|
||||
<span class="shrink-0 whitespace-nowrap text-sm font-semibold text-primary">
|
||||
Versions above
|
||||
</span>
|
||||
<input
|
||||
v-model="minimumDownloads"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
class="h-8 w-16 rounded-lg border border-solid border-surface-5 bg-surface-3 px-2 text-center text-sm font-semibold text-primary outline-none"
|
||||
aria-label="Version downloads threshold"
|
||||
@keydown.enter.prevent.stop="setSelectedValues(['1.21.5', '1.21.4']); closeMenu($event)"
|
||||
/>
|
||||
<span class="shrink-0 text-sm font-semibold text-primary">downloads</span>
|
||||
</div>
|
||||
</template>
|
||||
</DropdownFilterBar>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
modelValue: {},
|
||||
modelValue: {
|
||||
version: ['1.21.5'],
|
||||
},
|
||||
categories: searchableCategories,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Covers category search actions and footer controls in both add-menu and applied-filter preview dropdowns.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const EmptyCategory: Story = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CheckIcon } from '@modrinth/assets'
|
||||
import { BoxIcon, CheckIcon } from '@modrinth/assets'
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
@@ -43,6 +43,29 @@ export const Default: Story = {
|
||||
modelValue: ['en', 'es', 'fr', 'zh-CN'],
|
||||
placeholder: 'Select languages',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Options render flush to the dropdown edges with full-width hover and selected states.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const DeselectFocusState: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
modelValue: ['en'],
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Mouse focus after deselecting an option should not keep the selected brightness state applied.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const WithSearch: Story = {
|
||||
@@ -51,6 +74,56 @@ export const WithSearch: Story = {
|
||||
searchable: true,
|
||||
searchPlaceholder: 'Search versions',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Searchable dropdowns avoid auto-focusing search on mobile so opening the menu does not summon the soft keyboard.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const WithOptionRightSlot: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ value: 'sodium-1.21.5', label: '1.21.5', searchTerms: ['Sodium'] },
|
||||
{ value: 'sodium-1.21.4', label: '1.21.4', searchTerms: ['Sodium'] },
|
||||
{ value: 'iris-1.20.1', label: '1.20.1', searchTerms: ['Iris'] },
|
||||
{ value: 'modmenu-1.19.2', label: '1.19.2', searchTerms: ['Mod Menu'] },
|
||||
],
|
||||
modelValue: ['sodium-1.21.5'],
|
||||
placeholder: 'Select versions',
|
||||
searchable: true,
|
||||
searchPlaceholder: 'Search versions',
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { BoxIcon, MultiSelect },
|
||||
setup() {
|
||||
const selected = ref(args.modelValue)
|
||||
const projectNames: Record<string, string> = {
|
||||
'sodium-1.21.5': 'Sodium',
|
||||
'sodium-1.21.4': 'Sodium',
|
||||
'iris-1.20.1': 'Iris',
|
||||
'modmenu-1.19.2': 'Mod Menu',
|
||||
}
|
||||
return { args, projectNames, selected }
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div style="width: 400px;">
|
||||
<MultiSelect v-bind="args" v-model="selected">
|
||||
<template #option-right="{ item }">
|
||||
<span
|
||||
v-tooltip="projectNames[item.value]"
|
||||
class="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded text-primary"
|
||||
>
|
||||
<BoxIcon class="size-6" />
|
||||
</span>
|
||||
</template>
|
||||
</MultiSelect>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const WithSelectAll: Story = {
|
||||
@@ -62,12 +135,72 @@ export const WithSelectAll: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
export const WithSelectionActions: Story = {
|
||||
export const SingleOptionWithSelectAll: Story = {
|
||||
args: {
|
||||
options: [{ value: 'sodium', label: 'Sodium' }],
|
||||
modelValue: [],
|
||||
placeholder: 'Select projects',
|
||||
searchable: true,
|
||||
includeSelectAllOption: true,
|
||||
searchPlaceholder: 'Search projects',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Select all is hidden when there is only one enabled option.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const WithRightCheckbox: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
searchable: true,
|
||||
includeSelectAllOption: true,
|
||||
checkboxPosition: 'right',
|
||||
searchPlaceholder: 'Search languages',
|
||||
},
|
||||
}
|
||||
|
||||
export const WithSelectionActions: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
modelValue: [],
|
||||
searchable: true,
|
||||
showSelectionActions: true,
|
||||
searchPlaceholder: 'Search versions',
|
||||
maxHeight: 180,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Selection actions stay above the scrollable options and compensate the scroll position when they appear.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const WithSections: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ value: 'iris', label: 'Iris' },
|
||||
{ value: 'sodium', label: 'Sodium' },
|
||||
{ type: 'section-header', label: 'Single project group' },
|
||||
{ value: 'lithium', label: 'Lithium', searchTerms: ['Single project group'] },
|
||||
{ type: 'section-header', label: 'LambdAurora' },
|
||||
{ value: 'lambda-better-grass', label: 'LambdaBetterGrass', searchTerms: ['LambdAurora'] },
|
||||
{ value: 'auroras-decorations', label: "Aurora's Decorations", searchTerms: ['LambdAurora'] },
|
||||
{ type: 'section-header', label: 'Terraformers' },
|
||||
{ value: 'modmenu', label: 'Mod Menu', searchTerms: ['Terraformers'] },
|
||||
{ value: 'terraform-api', label: 'Terraform API', searchTerms: ['Terraformers'] },
|
||||
],
|
||||
modelValue: ['iris', 'modmenu'],
|
||||
placeholder: 'Select projects',
|
||||
searchable: true,
|
||||
showSelectionActions: true,
|
||||
searchPlaceholder: 'Search projects',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -249,6 +382,50 @@ export const WithBottomSlot: Story = {
|
||||
}),
|
||||
}
|
||||
|
||||
export const VirtualizedLargeList: Story = {
|
||||
args: {
|
||||
options: Array.from({ length: 250 }, (_, index) => {
|
||||
const version = `1.${Math.floor(index / 10) + 1}.${index % 10}`
|
||||
return {
|
||||
value: `version-${index + 1}`,
|
||||
label: version,
|
||||
searchTerms: [`Project ${Math.floor(index / 25) + 1}`],
|
||||
}
|
||||
}),
|
||||
modelValue: ['version-3', 'version-47', 'version-132'],
|
||||
placeholder: 'Select versions',
|
||||
searchable: true,
|
||||
searchPlaceholder: 'Search versions',
|
||||
showSelectionActions: true,
|
||||
maxHeight: 320,
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { BoxIcon, MultiSelect },
|
||||
setup() {
|
||||
const selected = ref(args.modelValue)
|
||||
function getProjectName(value: string) {
|
||||
const optionIndex = Number(value.replace('version-', '')) - 1
|
||||
return `Project ${Math.floor(optionIndex / 25) + 1}`
|
||||
}
|
||||
return { args, getProjectName, selected }
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div style="width: 400px;">
|
||||
<MultiSelect v-bind="args" v-model="selected">
|
||||
<template #option-right="{ item }">
|
||||
<span
|
||||
v-tooltip="getProjectName(item.value)"
|
||||
class="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded text-primary"
|
||||
>
|
||||
<BoxIcon class="size-6" />
|
||||
</span>
|
||||
</template>
|
||||
</MultiSelect>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const NoOptions: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
@@ -265,3 +442,37 @@ export const Empty: Story = {
|
||||
modelValue: [],
|
||||
},
|
||||
}
|
||||
|
||||
export const ScrollRepositioning: Story = {
|
||||
args: {
|
||||
options: Array.from({ length: 16 }, (_, index) => ({
|
||||
value: `version-${index + 1}`,
|
||||
label: `Version ${index + 1}`,
|
||||
})),
|
||||
modelValue: [],
|
||||
placeholder: 'Select versions',
|
||||
searchable: true,
|
||||
searchPlaceholder: 'Search versions',
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected = ref(args.modelValue)
|
||||
return { args, selected }
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div style="min-height: 150vh; padding-top: 45vh;">
|
||||
<div style="width: min(100%, 22rem);">
|
||||
<MultiSelect v-bind="args" v-model="selected" />
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Covers fixed dropdown repositioning while the page scrolls with the menu open.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -28,6 +28,20 @@ const sampleUsers: User[] = [
|
||||
role: 'Admin',
|
||||
},
|
||||
]
|
||||
const rangeSelectionUsers: User[] = Array.from({ length: 10 }, (_, index): User => {
|
||||
const id = String(index + 1)
|
||||
const paddedId = id.padStart(2, '0')
|
||||
const statuses: User['status'][] = ['active', 'inactive', 'pending']
|
||||
const roles = ['Admin', 'Editor', 'Maintainer', 'Reviewer', 'User']
|
||||
|
||||
return {
|
||||
id,
|
||||
name: `Member ${paddedId}`,
|
||||
email: `member-${paddedId}@example.com`,
|
||||
status: statuses[index % statuses.length],
|
||||
role: roles[index % roles.length],
|
||||
}
|
||||
})
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Table',
|
||||
@@ -57,7 +71,7 @@ export const Default: StoryObj = {
|
||||
}),
|
||||
}
|
||||
|
||||
export const WithSelection: StoryObj = {
|
||||
export const HorizontalOverflow: StoryObj = {
|
||||
args: {},
|
||||
render: () => ({
|
||||
components: { Table },
|
||||
@@ -69,6 +83,35 @@ export const WithSelection: StoryObj = {
|
||||
{ key: 'role', label: 'Role' },
|
||||
]
|
||||
const data = sampleUsers
|
||||
return { columns, data }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="max-w-80">
|
||||
<Table :columns="columns" :data="data" table-min-width="44rem">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="text-lg font-semibold text-contrast">Members</div>
|
||||
<div class="text-sm text-secondary">{{ data.length }} rows</div>
|
||||
</div>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const WithSelection: StoryObj = {
|
||||
args: {},
|
||||
render: () => ({
|
||||
components: { Table },
|
||||
setup() {
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'email', label: 'Email' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'role', label: 'Role' },
|
||||
]
|
||||
const data = rangeSelectionUsers
|
||||
const selectedIds = ref<string[]>([])
|
||||
return { columns, data, selectedIds }
|
||||
},
|
||||
@@ -81,6 +124,73 @@ export const WithSelection: StoryObj = {
|
||||
row-key="id"
|
||||
v-model:selected-ids="selectedIds"
|
||||
/>
|
||||
<p class="text-secondary text-sm">Click a checkbox, then Shift-click another checkbox to select or clear the range.</p>
|
||||
<p class="text-secondary">Selected IDs: {{ selectedIds.join(', ') || 'None' }}</p>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const WithSelectionData: StoryObj = {
|
||||
args: {},
|
||||
render: () => ({
|
||||
components: { Table },
|
||||
setup() {
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'email', label: 'Email' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'role', label: 'Role' },
|
||||
]
|
||||
const selectionData = rangeSelectionUsers
|
||||
const data = selectionData.filter((_, index) => index === 1 || index === 5)
|
||||
const selectedIds = ref<string[]>([])
|
||||
return { columns, data, selectionData, selectedIds }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="space-y-4">
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
:selection-data="selectionData"
|
||||
show-selection
|
||||
row-key="id"
|
||||
v-model:selected-ids="selectedIds"
|
||||
/>
|
||||
<p class="text-secondary text-sm">Only rows 2 and 6 are visible; Shift-clicking between them selects IDs 2 through 6 from selectionData.</p>
|
||||
<p class="text-secondary">Selected IDs: {{ selectedIds.join(', ') || 'None' }}</p>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const WithSelectionIds: StoryObj = {
|
||||
args: {},
|
||||
render: () => ({
|
||||
components: { Table },
|
||||
setup() {
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'email', label: 'Email' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'role', label: 'Role' },
|
||||
]
|
||||
const data = rangeSelectionUsers.filter((_, index) => index === 1 || index === 5)
|
||||
const selectionIds = rangeSelectionUsers.map((user) => user.id)
|
||||
const selectedIds = ref<string[]>([])
|
||||
return { columns, data, selectionIds, selectedIds }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="space-y-4">
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
:selection-ids="selectionIds"
|
||||
show-selection
|
||||
row-key="id"
|
||||
v-model:selected-ids="selectedIds"
|
||||
/>
|
||||
<p class="text-secondary text-sm">Only rows 2 and 6 are visible; Shift-clicking between them selects IDs 2 through 6 from selectionIds.</p>
|
||||
<p class="text-secondary">Selected IDs: {{ selectedIds.join(', ') || 'None' }}</p>
|
||||
</div>
|
||||
`,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import TimeFramePicker, {
|
||||
type TimeFrameLastUnit,
|
||||
type TimeFrameMode,
|
||||
type TimeFramePreset,
|
||||
} from '../../components/base/TimeFramePicker.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/TimeFramePicker',
|
||||
component: TimeFramePicker,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template: '<div style="width: 20rem;"><story /></div>',
|
||||
}),
|
||||
],
|
||||
} satisfies Meta<typeof TimeFramePicker>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
function renderPicker(
|
||||
initial: {
|
||||
mode?: TimeFrameMode
|
||||
preset?: TimeFramePreset
|
||||
lastAmount?: number
|
||||
lastUnit?: TimeFrameLastUnit
|
||||
customStartDate?: string
|
||||
customEndDate?: string
|
||||
} = {},
|
||||
) {
|
||||
return () => ({
|
||||
components: { TimeFramePicker },
|
||||
setup() {
|
||||
const mode = ref<TimeFrameMode>(initial.mode ?? 'preset')
|
||||
const preset = ref<TimeFramePreset>(initial.preset ?? 'last_30_days')
|
||||
const lastAmount = ref(initial.lastAmount ?? 1)
|
||||
const lastUnit = ref<TimeFrameLastUnit>(initial.lastUnit ?? 'days')
|
||||
const customStartDate = ref(initial.customStartDate ?? '2026-04-23')
|
||||
const customEndDate = ref(initial.customEndDate ?? '2026-05-22')
|
||||
|
||||
return {
|
||||
customEndDate,
|
||||
customStartDate,
|
||||
lastAmount,
|
||||
lastUnit,
|
||||
mode,
|
||||
preset,
|
||||
}
|
||||
},
|
||||
template: /* html */ `
|
||||
<TimeFramePicker
|
||||
v-model:mode="mode"
|
||||
v-model:preset="preset"
|
||||
v-model:last-amount="lastAmount"
|
||||
v-model:last-unit="lastUnit"
|
||||
v-model:custom-start-date="customStartDate"
|
||||
v-model:custom-end-date="customEndDate"
|
||||
min-date="2023-01-01"
|
||||
/>
|
||||
`,
|
||||
})
|
||||
}
|
||||
|
||||
export const Preset: Story = {
|
||||
render: renderPicker(),
|
||||
}
|
||||
|
||||
export const LastTimeframe: Story = {
|
||||
render: renderPicker({
|
||||
mode: 'last',
|
||||
lastAmount: 12,
|
||||
lastUnit: 'hours',
|
||||
}),
|
||||
}
|
||||
|
||||
export const CustomRange: Story = {
|
||||
render: renderPicker({
|
||||
mode: 'custom_range',
|
||||
customStartDate: '2026-04-23',
|
||||
customEndDate: '2026-05-22',
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'On mobile and narrow viewports, the custom range panel uses separate one-month start and end date pickers whose portaled calendars should not close the timeframe dropdown when selecting dates.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -5,6 +5,13 @@ import Toggle from '../../components/base/Toggle.vue'
|
||||
const meta = {
|
||||
title: 'Base/Toggle',
|
||||
component: Toggle,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Toggle uses touch manipulation so double tapping on mobile does not zoom.',
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Toggle>
|
||||
|
||||
export default meta
|
||||
|
||||
@@ -308,6 +308,9 @@ importers:
|
||||
ansi-to-html:
|
||||
specifier: ^0.7.2
|
||||
version: 0.7.2
|
||||
chart.js:
|
||||
specifier: ^4.5.1
|
||||
version: 4.5.1
|
||||
dayjs:
|
||||
specifier: ^1.11.7
|
||||
version: 1.11.19
|
||||
@@ -718,6 +721,9 @@ importers:
|
||||
motion-v:
|
||||
specifier: ^2.2.1
|
||||
version: 2.2.1(@vueuse/core@11.3.0(vue@3.5.27(typescript@5.9.3)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vue@3.5.27(typescript@5.9.3))
|
||||
overlayscrollbars:
|
||||
specifier: ^2.15.1
|
||||
version: 2.15.1
|
||||
postprocessing:
|
||||
specifier: ^6.37.6
|
||||
version: 6.38.2(three@0.172.0)
|
||||
@@ -2502,6 +2508,9 @@ packages:
|
||||
'@jsdevtools/ono@7.1.3':
|
||||
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
|
||||
|
||||
'@kurkle/color@0.3.4':
|
||||
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
|
||||
|
||||
'@kwsites/file-exists@1.1.1':
|
||||
resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==}
|
||||
|
||||
@@ -5415,6 +5424,10 @@ packages:
|
||||
character-reference-invalid@2.0.1:
|
||||
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
|
||||
|
||||
chart.js@4.5.1:
|
||||
resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==}
|
||||
engines: {pnpm: '>=8'}
|
||||
|
||||
check-error@2.1.3:
|
||||
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
|
||||
engines: {node: '>= 16'}
|
||||
@@ -11585,6 +11598,8 @@ snapshots:
|
||||
|
||||
'@jsdevtools/ono@7.1.3': {}
|
||||
|
||||
'@kurkle/color@0.3.4': {}
|
||||
|
||||
'@kwsites/file-exists@1.1.1':
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
@@ -13302,7 +13317,7 @@ snapshots:
|
||||
eslint-visitor-keys: 4.2.1
|
||||
espree: 10.4.0
|
||||
estraverse: 5.3.0
|
||||
picomatch: 4.0.3
|
||||
picomatch: 4.0.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
- typescript
|
||||
@@ -14835,6 +14850,10 @@ snapshots:
|
||||
|
||||
character-reference-invalid@2.0.1: {}
|
||||
|
||||
chart.js@4.5.1:
|
||||
dependencies:
|
||||
'@kurkle/color': 0.3.4
|
||||
|
||||
check-error@2.1.3: {}
|
||||
|
||||
chevrotain@7.1.1:
|
||||
@@ -15507,7 +15526,7 @@ snapshots:
|
||||
eslint-import-context: 0.1.9(unrs-resolver@1.11.1)
|
||||
is-glob: 4.0.3
|
||||
minimatch: 10.1.2
|
||||
semver: 7.7.3
|
||||
semver: 7.7.4
|
||||
stable-hash-x: 0.2.0
|
||||
unrs-resolver: 1.11.1
|
||||
optionalDependencies:
|
||||
@@ -15526,7 +15545,7 @@ snapshots:
|
||||
espree: 10.4.0
|
||||
esquery: 1.7.0
|
||||
parse-imports-exports: 0.2.4
|
||||
semver: 7.7.3
|
||||
semver: 7.7.4
|
||||
spdx-expression-parse: 4.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -15587,7 +15606,7 @@ snapshots:
|
||||
read-pkg-up: 7.0.1
|
||||
regexp-tree: 0.1.27
|
||||
regjsparser: 0.10.0
|
||||
semver: 7.7.3
|
||||
semver: 7.7.4
|
||||
strip-indent: 3.0.0
|
||||
|
||||
eslint-plugin-vue@10.7.0(@stylistic/eslint-plugin@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@2.6.1))):
|
||||
@@ -15612,7 +15631,7 @@ snapshots:
|
||||
natural-compare: 1.4.0
|
||||
nth-check: 2.1.1
|
||||
postcss-selector-parser: 6.1.2
|
||||
semver: 7.7.3
|
||||
semver: 7.7.4
|
||||
vue-eslint-parser: 9.4.3(eslint@9.39.2(jiti@2.6.1))
|
||||
xml-name-validator: 4.0.0
|
||||
transitivePeerDependencies:
|
||||
@@ -20222,7 +20241,7 @@ snapshots:
|
||||
espree: 9.6.1
|
||||
esquery: 1.7.0
|
||||
lodash: 4.17.23
|
||||
semver: 7.7.3
|
||||
semver: 7.7.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
||||