feat: improve analytics dashboard (#5897)

* feat: implement cancel/apply for custom timeframe range picker

* feat: implement dot for showing todays date

* feat: add max date to be today and show todays date

* feat: if ratio mode, dont show total

* feat: implement show more batching excess lines into "Other" bucket

* refactor: pnpm prepr

* feat: add pick and plop for date range start/end dates

* feat: implement reset query button

* feat: clear button to clear breakdown

* feat: more aggressively trim allowed minimum group by option

* fix: dont show project status filter when from project settings/analytics

* fix: clear selected X above number when appropriate

* feat: graph style updates and dont show year in x axis unless more than 2 year timeframe

* fix: loading state to include legend in blur

* feat: add project icon to project select

* feat: filter out draft projects from analytics

* feat: implement multiselect sections headers, project select org sections, and project options icons

* feat: implement click and drag to select date range

* feat: implement windows history for query builder

* revert: no longer switch breakdown/filter option if same category

* feat: implement showing project for project version breakdown/filter when there are multiple projects

* feat: implement modrinth sided events

* fix: border radius

* feat: implement analytics range highlight

* fix: loading state showing empty state text

* refactor: pnpm prepr

* feat: improve dropdown filter bar and multiselect performance

* fix: multiselect keyboard use

* fix: graph overflow issues

* fix: loading state text on table

* feat: implement tooltip scroll

* fix: adjust charts event tooltip

* feat: shorten time to not repeat am/pm

* feat: implement query params for graph component settings

* fix: qa

* feat: add reset timeframe button

* fix: legend colors moving between metric by determining color based on only downloads metric index

* feat: implement auto switching temporarily to group by day for renvenue metric and disable revenue metric for time range < 2 days

* fix: change to > 1 day

* fix: custom timeframe picker

* feat: implement big performance improvement for table

* feat: implement hover on legend to highlight graph

* fix: defer commit in query builder/filter and style fixes

* feat: more performance optimization to analytics dashboard state, chart, and table

* feat: add tooltip for other item

* feat: improve custom time frame range select

* feat: implement analytics events admin page

* fix: switch column order

* pnpm prepr

* feat: implement mock analytics events

* feat: improve analytics events admin page

* feat: focus title input on analytics create event modal

* fix: remove labels annoying

* feat: hook up analytics events backend

* fix: type error

* feat: reduce combobox padding

* feat: reduce padding on multiselect

* feat: add overlay scrollbar for combobox

* feat: a bunch of style fixes to combobox, multiselect and dropdown filter bar

* feat: MORE PADDING fixes

* feat: use user_agent for download source

* Revert "feat: use user_agent for download source"

This reverts commit d6dc8a99f11f94660872427796cdcf6fc93bb21d.

* fix: query filter project version lag and borked virtualization

* feat: rename breakdown "none" to "project"

* feat: implement right side checkmark for multiselect

* feat: keep crossed out legend items still shown in tooltip but also crossed out

* fix: focus styles

* fix: focus styles pt2

* feat: implement filter by top 8

* fix: preview is incorrect when selecting same date in range date picker

* feat (playtest): cross out legend items in tooltip and allow hide/show in tooltip

* feat (playtest): table component controls what graph shows

* feat: change download source to use user_agent

* feat: fix click to cross out in legend

* feat: add hover legend item to highlight line in tooltip

* fix: export csv to always be dropdown

* feat: implement breakdown = none

* performance: frontend memory reduction

* performance: reduce memory usage from project versions query by keeping only whats necessary

* fix: table checked items not in graph if 0

* feat: add shift click to select a range in table

* performance: add caching for metric types so switching between them is snappy

* performance: batch analytics requests by 15 project ids, with 150 ms delay between, so backend is happy

* feat: add analytics table search

* refactor: pnpm prepr

* fix: query filter options not coming in from analytics fetch

* feat: remove breakdown = none when there are multiple projects

* feat: improve table sorting

* feat: sort projects in project dropdown

* fix: getting project name for project versions

* fix: add loading state for filter and parallel fetch

* performance: use precomputed map for project version options to remove first hover lag

* feat: dropdown filter always open on one side and improve styles

* fix: custom time range picker being weird

* refactor: pnpm prepr

* fix: add back in batch with 300ms interval for projects to prevent backend rate limiting

* performance: only do queries to populate graph first before other analytics queries

* fix: QA polish issues around style and copy

* feat: dont show select all when its just one item in section

* fix: bugs with ratio mode and hiding chart lines

* fix: adjust padding in combobox and multiselect and fix not unfocusing when deselect

* fix: small styles

* fix: polish admin analytic events

* fix: keep scroll position with selection action row appearing when selecting one

* feat: add subheading in graph for showing N items from table

* feat: add unmonetized explaination tooltip

* performance: implement limit on how many lines can be shown in graph

* feat: mobile pass

* refactor: pnpm prepr

* add clear button

* feat: add time in analytics event and normalize date/time so its correct to timezones

* fix: padding

* feat: implement show prev period toggle

* feat: extract TimeFramePicker to packages/ui

* fix: adjust style

* feat: keep table selected persisted in query parameter

* fix: style on prev item value in legend

* fix: when breakdown switches, reset selected series

* fix: tooltip styles

* feat: change project selection to reset to show top 8 only if reconciled down to 0 items

* feat: implement show top 8 button in graph subheading

* fix: rename download type to download reason

* fix: formatting label for table

* feat: persist table sort by and sort direction

* fix: show top 8 button in graph not defaulting to top 8 for other metrics

* feat: implement prev period analytics fetch into the same current period fetch by shifting start date

* refactor: pnpm prepr

* fix: remove number if its just top 1

* fix: brief select items empty state when switch breakdown

* feat: implement format table playtime column

* feat: update export csv filename

* feat: change playtime column to display in hours

* refactor: pnpm prepr

* fix: still download type in filter

* feat: update analytics tooltip

* fix: wrong all projects icon

* feat: force legend order and graph colour for monetization

* refactor: pnpm prepr

* fix: multiselect and combobox sizes

* fix: chart icon add hover delay

* feat: (to playtest) implement multiple breakdowns

* fix: couple UX things for multiple breakdown

* fix: cannot unpin on page click

* fix: multiple breakdown legend and tooltip labels

* feat: add right side checkmark for dropdown filtr bar

* feat: enabling prev period will cross out prev for current ones already crossed out

* feat-mobile: remove drag to select time frame in graph

* feat-mobile: dropdown filter to replace dropdown for submenus on small screen

* feat-mobile: time frame picker to use different start and end date pickers for mobile

* fix-mobile: fix multiselect scroll on mobile

* feat: consolidate is mobile ref into context

* fix-mobile: combobox and multiselect scroll bug when mobile search bar open, fix timeframe picker mobile pick date, and dropdown filter bar click outside to close

* fix-mobile: smaller metric card font

* fix: dropdown filter bar scroll while search

* feat: implement project side events

* feat: implement better mobile view design for query builder

* feat: handle events overflow

* small: add select none

* feat: remove clear project and breakdown

* fix: event icon hover color

* feat: default hide project events if there are multiple projects, and default show if only 1 project

* feat: implement analytics performance updates, including facets, and v3 user projects

* feat: grey out dimmed lined on legend item hover

* feat-mobile: style fixes

* add close on select prop

* feat: add close on select for time frame picker mobile

* feat: date picker default read only

* refactor: pnpm prepr

* feat: default to projects breakdown instead of no breakdown with multiple projects

* fix-mobile: improve graph touch interactions

* small: 2 sig figs on playtime

* feat: deduplicate version uploads that have same version number and are uploaded on same day

* fix: analytics events grouping causing overflow

* feat: improve performance on analytics events grouping

* fix: tooltip expanding page width briefly

* fix: prevent double tap to zoom on inputs

* feat: add click to show chart event for mobile

* fix: toggle not having touch manipulation

* fix: chart tooltip scroll in mobile

* fix: remove project breakdownoption as it is default breakdown when none are selected

* fix: dropdown filter bar briefly empty when switching pages in mobile

* feat: keep tooltip open after drag in mobile

* fix: using plural instead of single for project breakdown

* fix: date picker scrolling page after picking date in mobile by suppressing focus

* fix: callback to Organization instead of org id

* feat: improve chart tooltip date range label formatter to be much more consistent

* feat: tap to toggle event tooltip

* fix: add user select none on graph and fix zoom into download threshold input

* fix: frontend still filtering after backend already filters

* feat: fix emptys state height content shift

* fix: qa issues

* fix: a number of qa issues

- Hide project events based on visible project legend/table selection
- Filter project status events by end status and add explicit copy for approved, private, and unlisted
- Style Modrinth analytics events with blue icon, marker, guide, and range borders
- Add scroll fade shadows to analytics chart and event tooltips
- Show previous-period date range in the chart tooltip
- Make project breakdown conditional on multiple selected projects and allow no breakdown when none are selected
- Add breakdown selection actions and fix “Group by day” copy

* feat: implement graph controls dropdown

* fix date picker typing into time input

* fix: styles in events table

* small: style

* feat: implement using new backend facets route

* feat: implement user get all projects

* performance: deter non-critical fetches to after analytics is in

* fix: refreshing causes multiple projects to do breakdown=none

* performance: cache project version options to fix lag on open sub menu

* refactor: remove chart event height being controlled by parent

* feat: update controls dropdown to have fainter border

* fix: loading bar not fading away

* fix: cannot click in graph

* feat: dont conditionally show multiselect selection actions

* fix: z-index and padding issues

* fix: project events incorrectly toggling on for first page load

* feat: remove show more and show less in legend, always show all

* fix: playtime y axis labels

* feat: improve y axis formatting for playtime and others

* feat: use tabs for game version select, and remove prev period when change breakdown or project selection

* refactor: pnpm prepr

* feat: change hidden legend items to not contribute to ratio percentages

* feat: event icon consume scroll for tooltip panel

* feat: remove gap inside chart tooltip

* feat: add gap for date picker 2 calendar view

* feat: improve analytics events grouping logic for modrinth events to be close to target

* pnpm prepr

* fix: cant click in gap in toggle

* fix: bugs around selected series from table not persisting with timeframe or filter changes

* refactor: kabab case

* refactor: split up large analytics chart and table component files into smaller components and ts modules

* fix: legend is stale after resetting query

* refactor: split up giant analytics provider with utils

* i18n pass

* revert: format number composable change

* fix: playtime was choosing y axis ticks in seconds instead of hours

* refactor: rename folder that with components to match main component name

* refactor: same rename for analytics table for consistency

* refactor: name main components to index.vue and keep folder name as component name

* refactor: pnpm prepr

* refactor: rename types

* refactor: move query builder types into types file and move components into components/analytics-dashboard

* refactor: colocate query builder url with analytics-dashboard component

* refactor: pnpm prepr:frontend

* fix: download threshold not width fit

* fix: no option to see release/all game versions in selected filter dropdown

* fix: game version dropdown width

---------

Signed-off-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
This commit is contained in:
Truman Gao
2026-05-29 13:39:55 -06:00
committed by GitHub
parent f49951084e
commit 11b2b6e6c0
100 changed files with 23707 additions and 2981 deletions
+1
View File
@@ -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>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,496 @@
import type {
AnalyticsBreakdownPreset,
AnalyticsDashboardStat,
AnalyticsQueryFilterCategory,
AnalyticsSelectedFilters,
} from '~/providers/analytics/analytics'
export type AnalyticsDashboardDimension =
| 'project'
| 'project_status'
| 'version_id'
| 'country'
| 'monetization'
| 'user_agent'
| 'download_reason'
| 'game_version'
| 'loader_type'
export const ALL_FILTER_VALUE = '__all__'
export const FILTER_VALUE_CATEGORIES: Exclude<AnalyticsQueryFilterCategory, 'project'>[] = [
'project_status',
'country',
'monetization',
'user_agent',
'download_reason',
'version_id',
'game_version',
'loader_type',
]
const ANALYTICS_DASHBOARD_STAT_ORDER: AnalyticsDashboardStat[] = [
'views',
'downloads',
'revenue',
'playtime',
]
const ANALYTICS_STATS_BY_DIMENSION: Record<
AnalyticsDashboardDimension,
readonly AnalyticsDashboardStat[]
> = {
project: ANALYTICS_DASHBOARD_STAT_ORDER,
version_id: ['downloads', 'playtime'],
country: ['views', 'downloads', 'playtime'],
monetization: ['views', 'downloads'],
user_agent: ['downloads'],
download_reason: ['downloads'],
game_version: ['downloads', 'playtime'],
loader_type: ['downloads', 'playtime'],
project_status: ANALYTICS_DASHBOARD_STAT_ORDER,
}
const ANALYTICS_DIMENSION_BY_BREAKDOWN: Record<
AnalyticsBreakdownPreset,
AnalyticsDashboardDimension
> = {
none: 'project',
project: 'project',
country: 'country',
monetization: 'monetization',
user_agent: 'user_agent',
download_reason: 'download_reason',
version_id: 'version_id',
loader: 'loader_type',
game_version: 'game_version',
}
const ANALYTICS_DIMENSION_BY_FILTER_CATEGORY: Record<
Exclude<AnalyticsQueryFilterCategory, 'project'>,
AnalyticsDashboardDimension
> = {
project_status: 'project_status',
country: 'country',
monetization: 'monetization',
user_agent: 'user_agent',
download_reason: 'download_reason',
version_id: 'version_id',
game_version: 'game_version',
loader_type: 'loader_type',
}
const ANALYTICS_FILTER_CATEGORY_BY_BREAKDOWN: Record<
AnalyticsBreakdownPreset,
Exclude<AnalyticsQueryFilterCategory, 'project'> | null
> = {
none: null,
project: null,
country: 'country',
monetization: 'monetization',
user_agent: 'user_agent',
download_reason: 'download_reason',
version_id: 'version_id',
loader: 'loader_type',
game_version: 'game_version',
}
export type FilterOption = {
value: string
label: string
searchTerms?: string[]
}
export type ProjectVersionFilterOption = FilterOption
export type ProjectVersionFilterOptionProjectMetadata = {
name: string
iconUrl?: string
}
type AnalyticsBreakdownInput = AnalyticsBreakdownPreset | readonly AnalyticsBreakdownPreset[]
function getOptionalDateTimestamp(date: string | undefined): number | undefined {
if (!date) {
return undefined
}
const timestamp = new Date(date).getTime()
return Number.isFinite(timestamp) ? timestamp : undefined
}
export function getProjectVersionFilterOptionsCacheKey(
versionIds: string[],
versionNumbersById: Map<string, string>,
versionPublishedDatesById: Map<string, string>,
versionProjectNamesById: Map<string, string>,
): string {
return versionIds
.map(
(versionId) =>
`${versionId}\x1f${versionNumbersById.get(versionId) ?? ''}\x1f${
versionPublishedDatesById.get(versionId) ?? ''
}\x1f${versionProjectNamesById.get(versionId) ?? ''}`,
)
.join('\x1e')
}
export function getProjectVersionFilterOptionProjectMetadataCacheKey(
versionIds: string[],
versionProjectNamesById: Map<string, string>,
versionProjectIconUrlsById: Map<string, string>,
): string {
return versionIds
.map(
(versionId) =>
`${versionId}\x1f${versionProjectNamesById.get(versionId) ?? ''}\x1f${
versionProjectIconUrlsById.get(versionId) ?? ''
}`,
)
.join('\x1e')
}
export function getProjectVersionFilterOptionMetadataIds(
versionIds: string[],
selectedVersionIds: string[],
): string[] {
const knownVersionIds = new Set(versionIds)
const metadataIds = [...versionIds]
for (const versionId of selectedVersionIds) {
if (!knownVersionIds.has(versionId)) {
metadataIds.push(versionId)
knownVersionIds.add(versionId)
}
}
return metadataIds
}
export function buildProjectVersionFilterOptions(
versionIds: string[],
versionNumbersById: Map<string, string>,
versionPublishedDatesById: Map<string, string>,
versionProjectNamesById: Map<string, string>,
): ProjectVersionFilterOption[] {
return versionIds
.map((versionId) => {
const projectName = versionProjectNamesById.get(versionId)
return {
option: {
value: versionId,
label: versionNumbersById.get(versionId) ?? versionId,
searchTerms: projectName ? [versionId, projectName] : [versionId],
},
publishedTimestamp: getOptionalDateTimestamp(versionPublishedDatesById.get(versionId)),
}
})
.sort((left, right) => {
if (left.publishedTimestamp !== undefined && right.publishedTimestamp !== undefined) {
return right.publishedTimestamp - left.publishedTimestamp
}
if (left.publishedTimestamp !== undefined) {
return -1
}
if (right.publishedTimestamp !== undefined) {
return 1
}
return left.option.label.localeCompare(right.option.label)
})
.map(({ option }) => option)
}
export function buildProjectVersionFilterOptionProjectMetadataById(
versionIds: string[],
versionProjectNamesById: Map<string, string>,
versionProjectIconUrlsById: Map<string, string>,
): Map<string, ProjectVersionFilterOptionProjectMetadata[]> {
const metadataById = new Map<string, ProjectVersionFilterOptionProjectMetadata[]>()
for (const versionId of versionIds) {
const projectName = versionProjectNamesById.get(versionId)
if (!projectName) {
continue
}
const metadata: ProjectVersionFilterOptionProjectMetadata = { name: projectName }
const iconUrl = versionProjectIconUrlsById.get(versionId)
if (iconUrl) {
metadata.iconUrl = iconUrl
}
metadataById.set(versionId, [metadata])
}
return metadataById
}
function intersectAnalyticsStats(
left: readonly AnalyticsDashboardStat[],
right: readonly AnalyticsDashboardStat[],
): AnalyticsDashboardStat[] {
const rightStats = new Set(right)
return left.filter((stat) => rightStats.has(stat))
}
function haveAnalyticsStatOverlap(
left: readonly AnalyticsDashboardStat[],
right: readonly AnalyticsDashboardStat[],
): boolean {
return left.some((stat) => right.includes(stat))
}
export function getAnalyticsStatsForDimension(
dimension: AnalyticsDashboardDimension,
): readonly AnalyticsDashboardStat[] {
return ANALYTICS_STATS_BY_DIMENSION[dimension]
}
export function getAnalyticsStatsForBreakdown(
breakdown: AnalyticsBreakdownPreset,
): readonly AnalyticsDashboardStat[] {
return getAnalyticsStatsForDimension(ANALYTICS_DIMENSION_BY_BREAKDOWN[breakdown])
}
function normalizeAnalyticsBreakdowns(
breakdowns: AnalyticsBreakdownInput,
): Exclude<AnalyticsBreakdownPreset, 'none'>[] {
const values = Array.isArray(breakdowns) ? breakdowns : [breakdowns]
const normalizedBreakdowns: Exclude<AnalyticsBreakdownPreset, 'none'>[] = []
for (const breakdown of values) {
if (breakdown === 'none') {
continue
}
if (!normalizedBreakdowns.includes(breakdown)) {
normalizedBreakdowns.push(breakdown)
}
}
return normalizedBreakdowns
}
export function getAnalyticsStatsForBreakdowns(
breakdowns: AnalyticsBreakdownInput,
): readonly AnalyticsDashboardStat[] {
const normalizedBreakdowns = normalizeAnalyticsBreakdowns(breakdowns)
if (normalizedBreakdowns.length === 0) {
return getAnalyticsStatsForBreakdown('none')
}
let stats = [...getAnalyticsStatsForBreakdown(normalizedBreakdowns[0])]
for (const breakdown of normalizedBreakdowns.slice(1)) {
stats = intersectAnalyticsStats(stats, getAnalyticsStatsForBreakdown(breakdown))
}
return stats
}
export function getAnalyticsStatsForFilterCategory(
category: AnalyticsQueryFilterCategory,
): readonly AnalyticsDashboardStat[] {
if (category === 'project') {
return ANALYTICS_DASHBOARD_STAT_ORDER
}
return getAnalyticsStatsForDimension(ANALYTICS_DIMENSION_BY_FILTER_CATEGORY[category])
}
export function getAnalyticsFilterCategoryForBreakdown(
breakdown: AnalyticsBreakdownPreset,
): Exclude<AnalyticsQueryFilterCategory, 'project'> | null {
return ANALYTICS_FILTER_CATEGORY_BY_BREAKDOWN[breakdown]
}
function getAnalyticsStatsForFilterScope(
breakdowns: AnalyticsBreakdownInput,
filters: AnalyticsSelectedFilters,
ignoredCategory?: AnalyticsQueryFilterCategory,
): readonly AnalyticsDashboardStat[] {
let stats = [...getAnalyticsStatsForBreakdowns(breakdowns)]
for (const category of FILTER_VALUE_CATEGORIES) {
if (category === ignoredCategory || filters[category].length === 0) {
continue
}
stats = intersectAnalyticsStats(stats, getAnalyticsStatsForFilterCategory(category))
}
return stats
}
export function getEnabledAnalyticsStatsForState(
breakdowns: AnalyticsBreakdownInput,
filters: AnalyticsSelectedFilters,
): readonly AnalyticsDashboardStat[] {
return getAnalyticsStatsForFilterScope(breakdowns, filters)
}
export function getVisibleAnalyticsFilterCategoriesForState(
breakdowns: AnalyticsBreakdownInput,
filters: AnalyticsSelectedFilters,
): readonly Exclude<AnalyticsQueryFilterCategory, 'project'>[] {
return FILTER_VALUE_CATEGORIES.filter((category) =>
haveAnalyticsStatOverlap(
getAnalyticsStatsForFilterScope(breakdowns, filters, category),
getAnalyticsStatsForFilterCategory(category),
),
)
}
export function sanitizeAnalyticsSelectedFilters(
breakdowns: AnalyticsBreakdownInput,
filters: AnalyticsSelectedFilters,
): AnalyticsSelectedFilters {
const nextFilters = cloneSelectedFilters(filters)
let availableStats = [...getAnalyticsStatsForBreakdowns(breakdowns)]
for (const category of FILTER_VALUE_CATEGORIES) {
if (filters[category].length === 0) {
continue
}
const categoryStats = getAnalyticsStatsForFilterCategory(category)
if (!haveAnalyticsStatOverlap(availableStats, categoryStats)) {
nextFilters[category] = []
continue
}
availableStats = intersectAnalyticsStats(availableStats, categoryStats)
}
return nextFilters
}
export function cloneSelectedFilters(filters: AnalyticsSelectedFilters): AnalyticsSelectedFilters {
return {
project: [...filters.project],
project_status: [...filters.project_status],
country: [...filters.country],
monetization: [...filters.monetization],
user_agent: [...filters.user_agent],
download_reason: [...filters.download_reason],
version_id: [...filters.version_id],
game_version: [...filters.game_version],
loader_type: [...filters.loader_type],
}
}
export function areStringArraysEqual(left: string[], right: string[]): boolean {
if (left.length !== right.length) {
return false
}
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) {
return false
}
}
return true
}
export function areSelectedFiltersEqual(
left: AnalyticsSelectedFilters,
right: AnalyticsSelectedFilters,
): boolean {
if (!areStringArraysEqual(left.project, right.project)) {
return false
}
for (const categoryKey of FILTER_VALUE_CATEGORIES) {
if (!areStringArraysEqual(left[categoryKey], right[categoryKey])) {
return false
}
}
return true
}
export function getOptionsWithSelectedValues(
options: FilterOption[],
selectedValues: string[],
getMissingSelectedOptionLabel: (value: string) => string = (value) => value,
): FilterOption[] {
if (selectedValues.length === 0) {
return options
}
const knownValues = new Set(options.map((option) => option.value))
const missingSelectedOptions = selectedValues
.filter((value) => !knownValues.has(value))
.map((value) => ({
value,
label: getMissingSelectedOptionLabel(value),
}))
return missingSelectedOptions.length === 0 ? options : [...options, ...missingSelectedOptions]
}
export function normalizeSelectedValues(
categoryKey: AnalyticsQueryFilterCategory,
values: string[],
projectIds: string[],
): string[] {
const uniqueValues = Array.from(new Set(values))
if (categoryKey === 'project') {
if (uniqueValues.includes(ALL_FILTER_VALUE)) {
return projectIds
}
const allProjectIds = new Set(projectIds)
const selectedProjects = uniqueValues.filter((value) => allProjectIds.has(value))
return selectedProjects.length > 0 ? selectedProjects : projectIds
}
if (uniqueValues.includes(ALL_FILTER_VALUE) || uniqueValues.length === 0) {
return []
}
const selectedValues = uniqueValues.filter((value) => value !== ALL_FILTER_VALUE)
if (categoryKey === 'project_status') {
return selectedValues
.map((value) => value.trim().toLowerCase())
.filter(isProjectStatusFilterValue)
}
if (categoryKey === 'loader_type') {
return Array.from(
new Set(
selectedValues
.map((value) => value.trim().toLowerCase())
.filter((value) => value.length > 0),
),
)
}
return selectedValues
}
export const PROJECT_STATUS_FILTER_VALUES = [
'approved',
'archived',
'rejected',
'draft',
'unlisted',
'withheld',
'private',
'other',
] as const
export type ProjectStatusFilterValue = (typeof PROJECT_STATUS_FILTER_VALUES)[number]
const projectStatusFilterValueSet = new Set<string>(PROJECT_STATUS_FILTER_VALUES)
export function isProjectStatusFilterValue(value: string): value is ProjectStatusFilterValue {
return projectStatusFilterValueSet.has(value)
}
export function getProjectStatusFilterValue(
status: string | null | undefined,
): ProjectStatusFilterValue {
const normalizedStatus = status?.trim().toLowerCase() ?? ''
return isProjectStatusFilterValue(normalizedStatus) ? normalizedStatus : 'other'
}
@@ -0,0 +1,299 @@
import {
type AnalyticsGroupByPreset,
type AnalyticsLastTimeframeUnit,
type AnalyticsTimeframeMode,
type AnalyticsTimeframePreset,
injectAnalyticsDashboardContext,
} from '~/providers/analytics/analytics'
const MIN_RANGE_MS = 60 * 60 * 1000
const TIME_RANGE_ROUNDING_MS = 60 * 1000
export const MAX_ANALYTICS_TIME_SLICES = 256
const GROUP_BY_PRESET_MINUTES: Record<AnalyticsGroupByPreset, number> = {
'1h': 60,
'6h': 360,
day: 24 * 60,
week: 7 * 24 * 60,
month: 30 * 24 * 60,
year: 365 * 24 * 60,
}
export type AnalyticsTimeRange = {
start: Date
end: Date
}
export function startOfDay(date: Date): Date {
const nextDate = new Date(date)
nextDate.setHours(0, 0, 0, 0)
return nextDate
}
export function getRoundedNow(timestamp: number): Date {
const roundedTimestamp = Math.floor(timestamp / TIME_RANGE_ROUNDING_MS) * TIME_RANGE_ROUNDING_MS
return new Date(roundedTimestamp)
}
export function getDateInputValue(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export function parseDateInputValue(value: string): Date {
const parsedDate = new Date(`${value}T00:00:00`)
return Number.isNaN(parsedDate.getTime()) ? startOfDay(new Date()) : parsedDate
}
export function parseDateTimeInputValue(value: string): Date {
const parsedDate = new Date(value)
return Number.isNaN(parsedDate.getTime()) ? getRoundedNow(Date.now()) : parsedDate
}
export function addDays(date: Date, days: number): Date {
const nextDate = new Date(date)
nextDate.setDate(nextDate.getDate() + days)
return nextDate
}
function isStartOfDay(date: Date): boolean {
return (
date.getHours() === 0 &&
date.getMinutes() === 0 &&
date.getSeconds() === 0 &&
date.getMilliseconds() === 0
)
}
export function getInclusiveEndDateInputValue(end: Date): string {
return getDateInputValue(isStartOfDay(end) ? addDays(end, -1) : end)
}
function subtractCalendarMonths(date: Date, months: number): Date {
const nextDate = new Date(date)
const day = nextDate.getDate()
nextDate.setDate(1)
nextDate.setMonth(nextDate.getMonth() - months)
const daysInMonth = new Date(nextDate.getFullYear(), nextDate.getMonth() + 1, 0).getDate()
nextDate.setDate(Math.min(day, daysInMonth))
return nextDate
}
export function getTimeRangeForPreset(
preset: AnalyticsTimeframePreset,
nowTimestamp: number,
): AnalyticsTimeRange {
const now = getRoundedNow(nowTimestamp)
const end = new Date(now)
switch (preset) {
case 'today':
return { start: startOfDay(now), end }
case 'yesterday': {
const todayStart = startOfDay(now)
return {
start: new Date(todayStart.getTime() - 24 * 60 * 60 * 1000),
end: todayStart,
}
}
case 'last_7_days':
return {
start: new Date(end.getTime() - 7 * 24 * 60 * 60 * 1000),
end,
}
case 'last_14_days':
return {
start: new Date(end.getTime() - 14 * 24 * 60 * 60 * 1000),
end,
}
case 'last_30_days':
return {
start: new Date(end.getTime() - 30 * 24 * 60 * 60 * 1000),
end,
}
case 'last_90_days':
return {
start: new Date(end.getTime() - 90 * 24 * 60 * 60 * 1000),
end,
}
case 'last_180_days':
return {
start: new Date(end.getTime() - 180 * 24 * 60 * 60 * 1000),
end,
}
case 'year_to_date': {
const yearStart = new Date(now.getFullYear(), 0, 1)
yearStart.setHours(0, 0, 0, 0)
return { start: yearStart, end }
}
case 'all_time':
return {
start: new Date(Date.UTC(2023, 0, 1, 0, 0, 0, 0)),
end,
}
default:
return {
start: new Date(end.getTime() - 24 * 60 * 60 * 1000),
end,
}
}
}
export function getTimeRangeForLastTimeframe(
amountValue: number,
unit: AnalyticsLastTimeframeUnit,
nowTimestamp: number,
): AnalyticsTimeRange {
const end = getRoundedNow(nowTimestamp)
const amount = Math.max(1, Math.floor(amountValue))
switch (unit) {
case 'hours':
return { start: new Date(end.getTime() - amount * 60 * 60 * 1000), end }
case 'days':
return { start: new Date(end.getTime() - amount * 24 * 60 * 60 * 1000), end }
case 'weeks':
return { start: new Date(end.getTime() - amount * 7 * 24 * 60 * 60 * 1000), end }
case 'months':
return { start: subtractCalendarMonths(end, amount), end }
default:
return { start: new Date(end.getTime() - 24 * 60 * 60 * 1000), end }
}
}
export function getTimeRangeForCustomDateRange(
startDate: string,
endDate: string,
): AnalyticsTimeRange {
const start = parseDateInputValue(startDate)
const inclusiveEnd = parseDateInputValue(endDate)
return {
start,
end: addDays(inclusiveEnd, 1),
}
}
export function getTimeRangeForCustomDateTimeRange(
startDateTime: string,
endDateTime: string,
): AnalyticsTimeRange {
return {
start: parseDateTimeInputValue(startDateTime),
end: parseDateTimeInputValue(endDateTime),
}
}
export function getAnalyticsTimeRange({
mode,
preset,
lastAmount,
lastUnit,
customStartDate,
customEndDate,
nowTimestamp,
}: {
mode: AnalyticsTimeframeMode
preset: AnalyticsTimeframePreset
lastAmount: number
lastUnit: AnalyticsLastTimeframeUnit
customStartDate: string
customEndDate: string
nowTimestamp: number
}): AnalyticsTimeRange {
switch (mode) {
case 'last':
return getTimeRangeForLastTimeframe(lastAmount, lastUnit, nowTimestamp)
case 'custom_range':
return getTimeRangeForCustomDateRange(customStartDate, customEndDate)
case 'custom_datetime_range':
return getTimeRangeForCustomDateTimeRange(customStartDate, customEndDate)
case 'preset':
default:
return getTimeRangeForPreset(preset, nowTimestamp)
}
}
export function getDefaultAnalyticsGroupByForDurationMinutes(
durationMinutes: number,
): AnalyticsGroupByPreset {
const days = durationMinutes / (24 * 60)
if (days <= 2) return '1h'
if (days <= 7) return '6h'
if (days <= 90) return 'day'
if (days <= 365) return 'week'
if (days <= 365 * 3) return 'month'
return 'year'
}
export function getAnalyticsGroupByPresetMinutes(preset: AnalyticsGroupByPreset): number {
return GROUP_BY_PRESET_MINUTES[preset]
}
export function isAnalyticsGroupByAvailableForDurationMinutes(
preset: AnalyticsGroupByPreset,
durationMinutes: number,
): boolean {
const groupByMinutes = getAnalyticsGroupByPresetMinutes(preset)
const isTooCoarse = groupByMinutes >= durationMinutes
const isTooFine = durationMinutes / groupByMinutes > MAX_ANALYTICS_TIME_SLICES
return !isTooCoarse && !isTooFine
}
export function ensureMinimumTimeRange(start: Date, end: Date): AnalyticsTimeRange {
if (end.getTime() <= start.getTime()) {
return {
start: new Date(end.getTime() - MIN_RANGE_MS),
end,
}
}
if (end.getTime() - start.getTime() < MIN_RANGE_MS) {
return {
start: new Date(end.getTime() - MIN_RANGE_MS),
end,
}
}
return { start, end }
}
export function useSelectedAnalyticsTimeRange() {
const {
selectedTimeframeMode,
selectedTimeframe,
selectedLastTimeframeAmount,
selectedLastTimeframeUnit,
selectedCustomTimeframeStartDate,
selectedCustomTimeframeEndDate,
queryRefreshTimestamp,
} = injectAnalyticsDashboardContext()
const selectedTimeRange = computed(() =>
getAnalyticsTimeRange({
mode: selectedTimeframeMode.value,
preset: selectedTimeframe.value,
lastAmount: selectedLastTimeframeAmount.value,
lastUnit: selectedLastTimeframeUnit.value,
customStartDate: selectedCustomTimeframeStartDate.value,
customEndDate: selectedCustomTimeframeEndDate.value,
nowTimestamp: queryRefreshTimestamp.value,
}),
)
const selectedTimeframeDurationMinutes = computed(() => {
const { start, end } = ensureMinimumTimeRange(
selectedTimeRange.value.start,
selectedTimeRange.value.end,
)
const durationMs = end.getTime() - start.getTime()
return Math.max(1, Math.floor(durationMs / (60 * 1000)))
})
return {
selectedTimeRange,
selectedTimeframeDurationMinutes,
}
}
@@ -0,0 +1,156 @@
<template>
<button
v-tooltip="disabled ? formatMessage(analyticsStatCardMessages.unavailableTooltip) : ''"
type="button"
class="flex h-full appearance-none flex-col gap-2.5 rounded-2xl border border-solid p-5 px-4 text-left transition-colors sm:gap-4"
:class="{
'cursor-not-allowed border-surface-5 bg-surface-2 opacity-60': disabled,
'cursor-default border-brand bg-highlight-green': !disabled && active,
'border-surface-5 bg-surface-3 hover:bg-surface-4 active:scale-95': !disabled && !active,
}"
:disabled="disabled"
@click="emit('click')"
>
<div class="flex items-center justify-between gap-3">
<div
class="text-base font-medium"
:class="{
'text-secondary': disabled,
'text-primary': !disabled,
}"
>
{{ label }}
</div>
<div class="inline-flex items-center justify-center">
<component
:is="iconComponent"
aria-hidden="true"
class="size-5 sm:size-6"
:class="{
'text-secondary': disabled,
'text-brand': !disabled && active,
'text-primary': !disabled && !active,
}"
/>
</div>
</div>
<div class="flex flex-col gap-2.5">
<div
class="text-2xl font-semibold leading-none md:text-4xl"
:class="{
'text-primary': disabled,
'text-contrast': !disabled,
}"
>
{{ disabled ? '-' : statLabel }}
</div>
<template v-if="disabled">
<span class="inline-flex items-center gap-1 text-xs text-secondary">
{{ formatMessage(analyticsStatCardMessages.unavailableLabel) }}
</span>
</template>
<template v-else>
<div v-if="vsPrevPeriodPercent" class="flex items-center gap-1 text-sm">
<span
class="inline-flex items-center gap-1 font-semibold"
:class="{
'text-secondary': disabled,
'text-green': !disabled && trendValue > 0,
'text-red': !disabled && trendValue < 0,
'text-primary': !disabled && trendValue === 0,
}"
>
<component
:is="trendDirectionIcon"
v-if="showTrendDirectionIcon"
aria-hidden="true"
class="size-3"
/>
{{ vsPrevPeriodPercent }}
</span>
<span
class="mt-px text-xs max-sm:hidden"
:class="{
'text-secondary': disabled,
'text-primary': !disabled,
}"
>
{{ formatMessage(analyticsStatCardMessages.previousPeriodComparison) }}
</span>
<span
class="visible mt-px text-xs sm:hidden"
:class="{
'text-secondary': disabled,
'text-primary': !disabled,
}"
>
{{ formatMessage(analyticsStatCardMessages.previousPeriodComparisonShort) }}
</span>
</div>
</template>
</div>
</button>
</template>
<script setup lang="ts">
import {
ClockIcon,
CurrencyIcon,
DownloadIcon,
EyeIcon,
type IconComponent,
PlayIcon,
TimerIcon,
TrendingDownIcon,
TrendingUpIcon,
} from '@modrinth/assets'
import { useVIntl } from '@modrinth/ui'
import { analyticsStatCardMessages } from '../analytics-messages'
const props = defineProps<{
label: string
statLabel: string
vsPrevPeriodPercent: string | null
icon: string
active?: boolean
disabled?: boolean
}>()
const emit = defineEmits<{
(event: 'click'): void
}>()
const { formatMessage } = useVIntl()
const statCardIconMap: Record<string, IconComponent> = {
clock: ClockIcon,
timer: TimerIcon,
play: PlayIcon,
eye: EyeIcon,
download: DownloadIcon,
currency: CurrencyIcon,
dollar: CurrencyIcon,
}
const iconComponent = computed<IconComponent>(() => {
const normalizedIconName = props.icon
.toLowerCase()
.replace(/icon$/u, '')
.replace(/[^a-z]/gu, '')
return statCardIconMap[normalizedIconName] ?? ClockIcon
})
const trendValue = computed(() => {
const parsed = Number.parseFloat(props.vsPrevPeriodPercent?.replace(/[^0-9.-]/gu, '') ?? '')
return Number.isNaN(parsed) ? 0 : parsed
})
const showTrendDirectionIcon = computed(() => !props.disabled && trendValue.value !== 0)
const trendDirectionIcon = computed<IconComponent>(() =>
trendValue.value >= 0 ? TrendingUpIcon : TrendingDownIcon,
)
</script>
@@ -0,0 +1,119 @@
<template>
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard
v-for="card in statCards"
:key="card.key"
:label="card.label"
:stat-label="card.statLabel"
:vs-prev-period-percent="card.vsPrevPeriodPercent"
:icon="card.icon"
:active="activeStat === card.key"
:disabled="card.disabled"
@click="setActiveStat(card.key)"
/>
</div>
</template>
<script setup lang="ts">
import { useFormatNumber, useVIntl } from '@modrinth/ui'
import {
type AnalyticsDashboardStat,
injectAnalyticsDashboardContext,
} from '~/providers/analytics/analytics'
import { analyticsStatCardMessages, formatAnalyticsStatLabel } from '../analytics-messages.ts'
import StatCard from './StatCard.vue'
const {
activeStat,
setActiveStat,
currentTotals,
percentChanges,
hasPreviousPeriodComparison,
selectedBreakdowns,
isAnalyticsDashboardStatRelevant,
} = injectAnalyticsDashboardContext()
const formatNumber = useFormatNumber()
const { formatMessage } = useVIntl()
const compactNumberFormatter = computed(
() =>
new Intl.NumberFormat(undefined, {
notation: 'compact',
maximumSignificantDigits: 2,
}),
)
function formatStatNumber(value: number): string {
const rounded = Math.round(value)
if (Math.abs(rounded) >= 1000) {
return compactNumberFormatter.value.format(rounded)
}
return formatNumber(rounded)
}
function formatPercent(value: number): string {
const rounded = Math.round(value * 10) / 10
const signPrefix = rounded > 0 ? '+' : ''
return `${signPrefix}${rounded.toFixed(1)}%`
}
function formatPreviousPeriodPercent(value: number): string | null {
if (!hasPreviousPeriodComparison.value) {
return null
}
return formatPercent(value)
}
const statCards = computed<
{
key: AnalyticsDashboardStat
label: string
statLabel: string
vsPrevPeriodPercent: string | null
icon: string
disabled: boolean
}[]
>(() => [
{
key: 'views',
label: formatAnalyticsStatLabel('views', formatMessage),
statLabel: formatStatNumber(currentTotals.value.views),
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.views),
icon: 'eye',
disabled: !isAnalyticsDashboardStatRelevant('views', selectedBreakdowns.value),
},
{
key: 'downloads',
label: formatAnalyticsStatLabel('downloads', formatMessage),
statLabel: formatStatNumber(currentTotals.value.downloads),
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.downloads),
icon: 'download',
disabled: !isAnalyticsDashboardStatRelevant('downloads', selectedBreakdowns.value),
},
{
key: 'revenue',
label: formatAnalyticsStatLabel('revenue', formatMessage),
statLabel: formatMessage(analyticsStatCardMessages.revenueValue, {
value: formatStatNumber(currentTotals.value.revenue),
}),
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.revenue),
icon: 'dollar',
disabled: !isAnalyticsDashboardStatRelevant('revenue', selectedBreakdowns.value),
},
{
key: 'playtime',
label: formatAnalyticsStatLabel('playtime', formatMessage),
statLabel: formatMessage(analyticsStatCardMessages.playtimeHours, {
hours: formatStatNumber(currentTotals.value.playtime / 3600),
}),
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.playtime),
icon: 'clock',
disabled: !isAnalyticsDashboardStatRelevant('playtime', selectedBreakdowns.value),
},
])
</script>
@@ -0,0 +1,306 @@
import type { Ref } from 'vue'
import type { LocationQuery } from 'vue-router'
import {
areSelectedFiltersEqual,
areStringArraysEqual,
buildAnalyticsQueryBuilderRouteQuery,
getAnalyticsBreakdownPresetsForProjectSelection,
hasAnalyticsQueryBuilderRouteChange,
readAnalyticsGraphState,
readAnalyticsQueryBuilderState,
} from '~/components/analytics-dashboard/analytics-route-query'
import type {
AnalyticsBreakdownPreset,
AnalyticsDashboardStat,
AnalyticsGraphViewMode,
AnalyticsGroupByPreset,
AnalyticsLastTimeframeUnit,
AnalyticsSelectedBreakdowns,
AnalyticsSelectedFilters,
AnalyticsTimeframeMode,
AnalyticsTimeframePreset,
} from '~/providers/analytics/analytics-types'
export type AnalyticsQueryBuilderRouteNavigationMode = 'push' | 'replace'
export interface AnalyticsQueryBuilderRefs {
selectedProjectIds: Ref<string[]>
selectedTimeframeMode: Ref<AnalyticsTimeframeMode>
selectedTimeframe: Ref<AnalyticsTimeframePreset>
selectedLastTimeframeAmount: Ref<number>
selectedLastTimeframeUnit: Ref<AnalyticsLastTimeframeUnit>
selectedCustomTimeframeStartDate: Ref<string>
selectedCustomTimeframeEndDate: Ref<string>
selectedGroupBy: Ref<AnalyticsGroupByPreset>
selectedBreakdowns: Ref<AnalyticsSelectedBreakdowns>
selectedFilters: Ref<AnalyticsSelectedFilters>
}
export interface AnalyticsGraphRefs {
activeStat: Ref<AnalyticsDashboardStat>
activeGraphViewMode: Ref<AnalyticsGraphViewMode>
isRatioMode: Ref<boolean>
showChartEvents: Ref<boolean>
showProjectEvents: Ref<boolean>
showPreviousPeriod: Ref<boolean>
hiddenGraphDatasetIds: Ref<string[]>
hasExplicitGraphDatasetSelection: Ref<boolean>
selectedGraphDatasetIds: Ref<string[]>
}
export interface UseAnalyticsRouteSyncOptions {
queryBuilder: AnalyticsQueryBuilderRefs
graph: AnalyticsGraphRefs
availableProjectIds: Ref<string[]>
sanitizeSelectedFilters: (
breakdowns: readonly AnalyticsBreakdownPreset[],
filters: AnalyticsSelectedFilters,
) => AnalyticsSelectedFilters
}
export function useAnalyticsRouteSync(options: UseAnalyticsRouteSyncOptions) {
const { queryBuilder, graph, availableProjectIds, sanitizeSelectedFilters } = options
const route = useRoute()
const router = useRouter()
let nextAnalyticsRouteNavigationMode: AnalyticsQueryBuilderRouteNavigationMode = 'replace'
function replaceNextAnalyticsRouteNavigation() {
nextAnalyticsRouteNavigationMode = 'replace'
}
function consumeAnalyticsRouteNavigationMode(): AnalyticsQueryBuilderRouteNavigationMode {
const navigationMode = nextAnalyticsRouteNavigationMode
nextAnalyticsRouteNavigationMode = 'push'
return navigationMode
}
function getSelectedAnalyticsQueryBuilderState() {
return {
selectedProjectIds: queryBuilder.selectedProjectIds.value,
selectedTimeframeMode: queryBuilder.selectedTimeframeMode.value,
selectedTimeframe: queryBuilder.selectedTimeframe.value,
selectedLastTimeframeAmount: queryBuilder.selectedLastTimeframeAmount.value,
selectedLastTimeframeUnit: queryBuilder.selectedLastTimeframeUnit.value,
selectedCustomTimeframeStartDate: queryBuilder.selectedCustomTimeframeStartDate.value,
selectedCustomTimeframeEndDate: queryBuilder.selectedCustomTimeframeEndDate.value,
selectedGroupBy: queryBuilder.selectedGroupBy.value,
selectedBreakdowns: queryBuilder.selectedBreakdowns.value,
selectedFilters: queryBuilder.selectedFilters.value,
}
}
function getSelectedAnalyticsGraphState() {
return {
activeStat: graph.activeStat.value,
activeGraphViewMode: graph.activeGraphViewMode.value,
isRatioMode: graph.isRatioMode.value,
showChartEvents: graph.showChartEvents.value,
showProjectEvents: graph.showProjectEvents.value,
showPreviousPeriod: graph.showPreviousPeriod.value,
hiddenGraphDatasetIds: graph.hiddenGraphDatasetIds.value,
selectedGraphDatasetIds: graph.hasExplicitGraphDatasetSelection.value
? graph.selectedGraphDatasetIds.value
: null,
}
}
function syncAnalyticsRouteQuery(navigationMode: AnalyticsQueryBuilderRouteNavigationMode) {
if (import.meta.server) {
return
}
const nextRouteQuery = buildAnalyticsQueryBuilderRouteQuery(
route.query,
getSelectedAnalyticsQueryBuilderState(),
availableProjectIds.value,
getSelectedAnalyticsGraphState(),
)
const hasAnalyticsQueryChange = hasAnalyticsQueryBuilderRouteChange(route.query, nextRouteQuery)
if (!hasAnalyticsQueryChange) return
if (navigationMode === 'replace') {
router.replace({
path: route.path,
query: nextRouteQuery,
})
} else {
router.push({
path: route.path,
query: nextRouteQuery,
})
}
}
function syncQueryBuilderRouteQuery() {
syncAnalyticsRouteQuery(consumeAnalyticsRouteNavigationMode())
}
function syncGraphRouteQuery() {
syncAnalyticsRouteQuery('replace')
}
function applyRouteQueryToState(nextQuery: LocationQuery) {
const nextQueryState = readAnalyticsQueryBuilderState(nextQuery, availableProjectIds.value)
const availableProjectIdSet = new Set(availableProjectIds.value)
const nextSelectedProjectIds = nextQueryState.selectedProjectIds.filter((projectId) =>
availableProjectIdSet.has(projectId),
)
const nextGraphState = readAnalyticsGraphState(nextQuery, nextSelectedProjectIds)
const nextSelectedBreakdowns = getAnalyticsBreakdownPresetsForProjectSelection(
nextQueryState.selectedBreakdowns,
nextSelectedProjectIds,
)
const nextSelectedFilters = sanitizeSelectedFilters(
nextSelectedBreakdowns,
nextQueryState.selectedFilters,
)
const shouldUpdateSelectedProjectIds = !areStringArraysEqual(
queryBuilder.selectedProjectIds.value,
nextSelectedProjectIds,
)
const shouldUpdateSelectedTimeframeMode =
queryBuilder.selectedTimeframeMode.value !== nextQueryState.selectedTimeframeMode
const shouldUpdateSelectedTimeframe =
queryBuilder.selectedTimeframe.value !== nextQueryState.selectedTimeframe
const shouldUpdateSelectedLastTimeframeAmount =
queryBuilder.selectedLastTimeframeAmount.value !== nextQueryState.selectedLastTimeframeAmount
const shouldUpdateSelectedLastTimeframeUnit =
queryBuilder.selectedLastTimeframeUnit.value !== nextQueryState.selectedLastTimeframeUnit
const shouldUpdateSelectedCustomTimeframeStartDate =
queryBuilder.selectedCustomTimeframeStartDate.value !==
nextQueryState.selectedCustomTimeframeStartDate
const shouldUpdateSelectedCustomTimeframeEndDate =
queryBuilder.selectedCustomTimeframeEndDate.value !==
nextQueryState.selectedCustomTimeframeEndDate
const shouldUpdateSelectedGroupBy =
queryBuilder.selectedGroupBy.value !== nextQueryState.selectedGroupBy
const shouldUpdateSelectedBreakdowns = !areStringArraysEqual(
queryBuilder.selectedBreakdowns.value,
nextSelectedBreakdowns,
)
const shouldUpdateSelectedFilters = !areSelectedFiltersEqual(
queryBuilder.selectedFilters.value,
nextSelectedFilters,
)
const shouldUpdateActiveStat = graph.activeStat.value !== nextGraphState.activeStat
const shouldUpdateActiveGraphViewMode =
graph.activeGraphViewMode.value !== nextGraphState.activeGraphViewMode
const shouldUpdateIsRatioMode = graph.isRatioMode.value !== nextGraphState.isRatioMode
const shouldUpdateShowChartEvents =
graph.showChartEvents.value !== nextGraphState.showChartEvents
const shouldUpdateShowProjectEvents =
graph.showProjectEvents.value !== nextGraphState.showProjectEvents
const shouldUpdateShowPreviousPeriod =
graph.showPreviousPeriod.value !== nextGraphState.showPreviousPeriod
const shouldUpdateHiddenGraphDatasetIds = !areStringArraysEqual(
graph.hiddenGraphDatasetIds.value,
nextGraphState.hiddenGraphDatasetIds,
)
const nextHasExplicitGraphDatasetSelection = nextGraphState.selectedGraphDatasetIds !== null
const nextSelectedGraphDatasetIds = nextGraphState.selectedGraphDatasetIds ?? []
const shouldUpdateHasExplicitGraphDatasetSelection =
graph.hasExplicitGraphDatasetSelection.value !== nextHasExplicitGraphDatasetSelection
const shouldUpdateSelectedGraphDatasetIds =
(nextHasExplicitGraphDatasetSelection || graph.hasExplicitGraphDatasetSelection.value) &&
!areStringArraysEqual(graph.selectedGraphDatasetIds.value, nextSelectedGraphDatasetIds)
const hasRouteStateUpdate =
shouldUpdateSelectedProjectIds ||
shouldUpdateSelectedTimeframeMode ||
shouldUpdateSelectedTimeframe ||
shouldUpdateSelectedLastTimeframeAmount ||
shouldUpdateSelectedLastTimeframeUnit ||
shouldUpdateSelectedCustomTimeframeStartDate ||
shouldUpdateSelectedCustomTimeframeEndDate ||
shouldUpdateSelectedGroupBy ||
shouldUpdateSelectedBreakdowns ||
shouldUpdateSelectedFilters ||
shouldUpdateActiveStat ||
shouldUpdateActiveGraphViewMode ||
shouldUpdateIsRatioMode ||
shouldUpdateShowChartEvents ||
shouldUpdateShowProjectEvents ||
shouldUpdateShowPreviousPeriod ||
shouldUpdateHiddenGraphDatasetIds ||
shouldUpdateHasExplicitGraphDatasetSelection ||
shouldUpdateSelectedGraphDatasetIds
if (hasRouteStateUpdate) {
replaceNextAnalyticsRouteNavigation()
}
if (shouldUpdateSelectedProjectIds) {
queryBuilder.selectedProjectIds.value = nextSelectedProjectIds
}
if (shouldUpdateSelectedTimeframeMode) {
queryBuilder.selectedTimeframeMode.value = nextQueryState.selectedTimeframeMode
}
if (shouldUpdateSelectedTimeframe) {
queryBuilder.selectedTimeframe.value = nextQueryState.selectedTimeframe
}
if (shouldUpdateSelectedLastTimeframeAmount) {
queryBuilder.selectedLastTimeframeAmount.value = nextQueryState.selectedLastTimeframeAmount
}
if (shouldUpdateSelectedLastTimeframeUnit) {
queryBuilder.selectedLastTimeframeUnit.value = nextQueryState.selectedLastTimeframeUnit
}
if (shouldUpdateSelectedCustomTimeframeStartDate) {
queryBuilder.selectedCustomTimeframeStartDate.value =
nextQueryState.selectedCustomTimeframeStartDate
}
if (shouldUpdateSelectedCustomTimeframeEndDate) {
queryBuilder.selectedCustomTimeframeEndDate.value =
nextQueryState.selectedCustomTimeframeEndDate
}
if (shouldUpdateSelectedGroupBy) {
queryBuilder.selectedGroupBy.value = nextQueryState.selectedGroupBy
}
if (shouldUpdateSelectedBreakdowns) {
queryBuilder.selectedBreakdowns.value = nextSelectedBreakdowns
}
if (shouldUpdateSelectedFilters) {
queryBuilder.selectedFilters.value = nextSelectedFilters
}
if (shouldUpdateActiveStat) {
graph.activeStat.value = nextGraphState.activeStat
}
if (shouldUpdateActiveGraphViewMode) {
graph.activeGraphViewMode.value = nextGraphState.activeGraphViewMode
}
if (shouldUpdateIsRatioMode) {
graph.isRatioMode.value = nextGraphState.isRatioMode
}
if (shouldUpdateShowChartEvents) {
graph.showChartEvents.value = nextGraphState.showChartEvents
}
if (shouldUpdateShowProjectEvents) {
graph.showProjectEvents.value = nextGraphState.showProjectEvents
}
if (shouldUpdateShowPreviousPeriod) {
graph.showPreviousPeriod.value = nextGraphState.showPreviousPeriod
}
if (shouldUpdateHiddenGraphDatasetIds) {
graph.hiddenGraphDatasetIds.value = nextGraphState.hiddenGraphDatasetIds
}
if (shouldUpdateHasExplicitGraphDatasetSelection) {
graph.hasExplicitGraphDatasetSelection.value = nextHasExplicitGraphDatasetSelection
}
if (shouldUpdateSelectedGraphDatasetIds) {
graph.selectedGraphDatasetIds.value = nextSelectedGraphDatasetIds
}
if (!hasRouteStateUpdate) {
syncAnalyticsRouteQuery('replace')
}
}
return {
replaceNextAnalyticsRouteNavigation,
syncQueryBuilderRouteQuery,
syncGraphRouteQuery,
applyRouteQueryToState,
}
}
@@ -1,495 +0,0 @@
<script setup>
import { useFormatDateTime, useFormatMoney, useFormatNumber } from '@modrinth/ui'
import VueApexCharts from 'vue3-apexcharts'
const props = defineProps({
name: {
type: String,
required: true,
},
labels: {
type: Array,
required: true,
},
data: {
type: Array,
required: true,
},
formatLabels: {
type: Function,
},
colors: {
type: Array,
default: () => [],
},
prefix: {
type: String,
default: '',
},
suffix: {
type: String,
default: '',
},
hideToolbar: {
type: Boolean,
default: false,
},
hideLegend: {
type: Boolean,
default: false,
},
stacked: {
type: Boolean,
default: false,
},
type: {
type: String,
default: 'bar',
},
hideTotal: {
type: Boolean,
default: false,
},
isMoney: {
type: Boolean,
default: false,
},
legendPosition: {
type: String,
default: 'right',
},
xAxisType: {
type: String,
default: 'datetime',
},
percentStacked: {
type: Boolean,
default: false,
},
horizontalBar: {
type: Boolean,
default: false,
},
disableAnimations: {
type: Boolean,
default: false,
},
})
const formatNumber = useFormatNumber()
const formatMoney = useFormatMoney()
const formatDate = useFormatDateTime({
month: 'short',
day: 'numeric',
})
function formatTooltipValue(value, props) {
return props.isMoney ? formatMoney(value) : formatNumber(value)
}
function generateListEntry(value, index, _, w, props) {
const color = w.globals.colors?.[index]
return `<div class="list-entry">
<span class="circle" style="background-color: ${color}"></span>
<div class="label">
${w.globals.seriesNames[index]}
</div>
<div class="value">
${props.prefix}${formatTooltipValue(value, props)}${props.suffix}
</div>
</div>`
}
function generateTooltip({ series, seriesIndex, dataPointIndex, w }, props) {
const label = w.globals.lastXAxis.categories?.[dataPointIndex]
const formattedLabel = props.formatLabels ? props.formatLabels(label) : formatDate(label)
let tooltip = `<div class="bar-tooltip">
<div class="seperated-entry title">
<div class="label">${formattedLabel}</div>`
// Logic for total and percent stacked
if (!props.hideTotal) {
if (props.percentStacked) {
const total = series.reduce((a, b) => a + (b?.[dataPointIndex] || 0), 0)
const percentValue = (100 * series[seriesIndex][dataPointIndex]) / total
tooltip += `<div class="value">${props.prefix}${formatNumber(percentValue)}%${
props.suffix
}</div>`
} else {
const totalValue = series.reduce((a, b) => a + (b?.[dataPointIndex] || 0), 0)
tooltip += `<div class="value">${props.prefix}${formatTooltipValue(totalValue, props)}${
props.suffix
}</div>`
}
}
tooltip += '</div><hr class="card-divider" />'
// Logic for generating list entries
if (props.percentStacked) {
tooltip += generateListEntry(
series[seriesIndex][dataPointIndex],
seriesIndex,
seriesIndex,
w,
props,
)
} else {
const returnTopN = 15
const listEntries = series
.map((value, index) => [
value[dataPointIndex],
generateListEntry(value[dataPointIndex], index, seriesIndex, w, props),
])
.filter((value) => value[0] > 0)
.sort((a, b) => b[0] - a[0])
.slice(0, returnTopN) // Return only the top X entries
.map((value) => value[1])
.join('')
tooltip += listEntries
}
tooltip += '</div>'
return tooltip
}
const chartOptions = computed(() => {
return {
chart: {
id: props.name,
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
foreColor: 'var(--color-base)',
selection: {
enabled: true,
fill: {
color: 'var(--color-brand)',
},
},
toolbar: {
show: false,
},
stacked: props.stacked,
stackType: props.percentStacked ? '100%' : 'normal',
zoom: {
autoScaleYaxis: true,
},
animations: {
enabled: props.disableAnimations,
},
},
xaxis: {
type: props.xAxisType,
categories: props.labels,
labels: {
style: {
borderRadius: 'var(--radius-sm)',
},
},
axisTicks: {
show: false,
},
tooltip: {
enabled: false,
},
},
yaxis: {
tooltip: {
enabled: false,
},
},
colors: props.colors,
dataLabels: {
enabled: false,
background: {
enabled: true,
borderRadius: 20,
},
},
grid: {
borderColor: 'var(--color-button-bg)',
tickColor: 'var(--color-button-bg)',
},
legend: {
show: !props.hideLegend,
position: props.legendPosition,
showForZeroSeries: false,
showForSingleSeries: false,
showForNullSeries: false,
fontSize: 'var(--font-size-nm)',
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
onItemClick: {
toggleDataSeries: true,
},
},
markers: {
size: 0,
strokeColor: 'var(--color-contrast)',
strokeWidth: 3,
strokeOpacity: 1,
fillOpacity: 1,
hover: {
size: 6,
},
},
plotOptions: {
bar: {
horizontal: props.horizontalBar,
columnWidth: '80%',
endingShape: 'rounded',
borderRadius: 5,
borderRadiusApplication: 'end',
borderRadiusWhenStacked: 'last',
},
},
stroke: {
curve: 'smooth',
width: 2,
},
tooltip: {
custom: (d) => generateTooltip(d, props),
},
fill:
props.type === 'area'
? {
colors: props.colors,
type: 'gradient',
opacity: 1,
gradient: {
shade: 'light',
type: 'vertical',
shadeIntensity: 0,
gradientToColors: props.colors,
inverseColors: true,
opacityFrom: 0.5,
opacityTo: 0,
stops: [0, 100],
colorStops: [],
},
}
: {},
}
})
const chart = ref(null)
const legendValues = ref(
[...props.data].map((project, index) => {
return { name: project.name, visible: true, color: props.colors[index] }
}),
)
const flipLegend = (legend, newVal) => {
legend.visible = newVal
chart.value.toggleSeries(legend.name)
}
const resetChart = () => {
if (!chart.value?.chart) return
chart.value.updateSeries([...props.data])
chart.value.updateOptions({
xaxis: {
categories: props.labels,
},
})
chart.value.resetSeries()
legendValues.value.forEach((legend) => {
legend.visible = true
})
}
defineExpose({
resetChart,
flipLegend,
})
</script>
<template>
<VueApexCharts ref="chart" :type="type" :options="chartOptions" :series="data" class="chart" />
</template>
<style scoped lang="scss">
.chart {
width: 100%;
height: 100%;
}
svg {
width: 100%;
height: 100%;
}
.btn {
svg {
width: 1.25rem;
height: 1.25rem;
}
}
.bar-chart {
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
}
.title-bar {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-xs);
}
.toolbar {
display: flex;
flex-direction: row;
gap: var(--gap-xs);
z-index: 1;
margin-left: auto;
}
:deep(.apexcharts-menu),
:deep(.apexcharts-tooltip),
:deep(.apexcharts-yaxistooltip) {
background: var(--color-raised-bg) !important;
border-radius: var(--radius-sm) !important;
border: 1px solid var(--color-divider) !important;
box-shadow: var(--shadow-floating) !important;
font-size: var(--font-size-nm) !important;
}
:deep(.apexcharts-grid-borders) {
line {
stroke: var(--color-button-bg) !important;
}
}
:deep(.apexcharts-yaxistooltip),
:deep(.apexcharts-xaxistooltip) {
background: var(--color-raised-bg) !important;
border-radius: var(--radius-sm) !important;
border: 1px solid var(--color-divider) !important;
font-size: var(--font-size-nm) !important;
color: var(--color-base) !important;
.apexcharts-xaxistooltip-text {
font-size: var(--font-size-nm) !important;
color: var(--color-base) !important;
}
}
:deep(.apexcharts-yaxistooltip-left:after) {
border-left-color: var(--color-raised-bg) !important;
}
:deep(.apexcharts-yaxistooltip-left:before) {
border-left-color: var(--color-button-bg) !important;
}
:deep(.apexcharts-xaxistooltip-bottom:after) {
border-bottom-color: var(--color-raised-bg) !important;
}
:deep(.apexcharts-xaxistooltip-bottom:before) {
border-bottom-color: var(--color-button-bg) !important;
}
:deep(.apexcharts-menu-item) {
border-radius: var(--radius-sm) !important;
padding: var(--gap-xs) var(--gap-sm) !important;
&:hover {
transition: all 0.3s !important;
color: var(--color-accent-contrast) !important;
background: var(--color-brand) !important;
}
}
:deep(.apexcharts-tooltip) {
.bar-tooltip {
min-width: 10rem;
display: flex;
flex-direction: column;
gap: var(--gap-xs);
padding: var(--gap-sm);
.card-divider {
margin: var(--gap-xs) 0;
}
.seperated-entry {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.title {
font-weight: bolder;
}
.label {
margin-right: var(--gap-xl);
color: var(--color-contrast);
}
.value {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-xs);
color: var(--color-base);
}
.list-entry {
display: flex;
flex-direction: row;
align-items: center;
font-size: var(--font-size-sm);
.value {
margin-left: auto;
}
}
.circle {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
display: inline-block;
margin-right: var(--gap-sm);
border: 2px solid var(--color-base);
}
svg {
height: 1em;
width: 1em;
}
}
}
.legend {
display: flex;
flex-wrap: wrap;
flex-direction: row;
align-items: center;
gap: var(--gap-lg);
justify-content: center;
}
:deep(.checkbox) {
white-space: nowrap;
}
.legend-checkbox :deep(.checkbox.checked) {
background-color: var(--color);
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,281 +0,0 @@
<script setup>
import { Card } from '@modrinth/ui'
import VueApexCharts from 'vue3-apexcharts'
// let VueApexCharts
// if (import.meta.client) {
// VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
// }
const props = defineProps({
value: {
type: String,
default: '',
},
title: {
type: String,
default: '',
},
data: {
type: Array,
default: () => [],
},
labels: {
type: Array,
default: () => [],
},
prefix: {
type: String,
default: '',
},
suffix: {
type: String,
default: '',
},
isMoney: {
type: Boolean,
default: false,
},
color: {
type: String,
default: 'var(--color-brand)',
},
})
// no grid lines, no toolbar, no legend, no data labels
const chartOptions = {
chart: {
id: props.title,
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
foreColor: 'var(--color-base)',
toolbar: {
show: false,
},
zoom: {
enabled: false,
},
sparkline: {
enabled: true,
},
parentHeightOffset: 0,
},
stroke: {
curve: 'smooth',
width: 2,
},
fill: {
colors: [props.color],
type: 'gradient',
opacity: 1,
gradient: {
shade: 'light',
type: 'vertical',
shadeIntensity: 0,
gradientToColors: [props.color],
inverseColors: true,
opacityFrom: 0.5,
opacityTo: 0,
stops: [0, 100],
colorStops: [],
},
},
grid: {
show: false,
},
legend: {
show: false,
},
colors: [props.color],
dataLabels: {
enabled: false,
},
xaxis: {
type: 'datetime',
categories: props.labels,
labels: {
show: false,
},
axisTicks: {
show: false,
},
tooltip: {
enabled: false,
},
},
yaxis: {
labels: {
show: false,
},
axisBorder: {
show: false,
},
axisTicks: {
show: false,
},
tooltip: {
enabled: false,
},
},
tooltip: {
enabled: false,
},
}
const chart = ref(null)
const resetChart = () => {
if (!chart.value?.chart) return
chart.value.updateSeries([...props.data])
chart.value.updateOptions({
xaxis: {
categories: props.labels,
},
})
chart.value.resetSeries()
}
defineExpose({
resetChart,
})
</script>
<template>
<Card class="compact-chart">
<h1 class="value">
{{ value }}
</h1>
<div class="subtitle">
{{ title }}
</div>
<div class="chart">
<VueApexCharts ref="chart" type="area" :options="chartOptions" :series="data" height="70" />
</div>
</Card>
</template>
<style scoped lang="scss">
.compact-chart {
display: flex;
flex-direction: column;
gap: var(--gap-xs);
border: 1px solid var(--color-divider);
border-radius: var(--radius-md);
background-color: var(--color-raised-bg);
box-shadow: var(--shadow-card);
color: var(--color-base);
font-size: var(--font-size-nm);
width: 100%;
padding-top: var(--gap-xl);
padding-bottom: 0;
.value {
margin: 0;
}
}
.chart {
// width: calc(100% + 3rem);
margin: 0 -1.5rem 0.25rem -1.5rem;
}
svg {
width: 100%;
height: 100%;
}
:deep(.apexcharts-menu),
:deep(.apexcharts-tooltip),
:deep(.apexcharts-yaxistooltip) {
background: var(--color-raised-bg) !important;
border-radius: var(--radius-sm) !important;
border: 1px solid var(--color-divider) !important;
box-shadow: var(--shadow-floating) !important;
font-size: var(--font-size-nm) !important;
}
:deep(.apexcharts-graphical) {
width: 100%;
}
:deep(.apexcharts-tooltip) {
.bar-tooltip {
display: flex;
flex-direction: column;
gap: var(--gap-xs);
padding: var(--gap-sm);
.card-divider {
margin: var(--gap-xs) 0;
}
.label {
display: flex;
flex-direction: row;
align-items: center;
}
.value {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-xs);
color: var(--color-base);
}
.list-entry {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: var(--gap-md);
}
.circle {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
display: inline-block;
margin-right: var(--gap-sm);
}
svg {
height: 1em;
width: 1em;
}
.divider {
font-size: var(--font-size-lg);
font-weight: 400;
}
}
}
.legend {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-md);
justify-content: center;
}
:deep(.apexcharts-grid-borders) {
line {
stroke: var(--color-button-bg) !important;
}
}
:deep(.apexcharts-xaxis) {
line {
stroke: none;
}
}
.legend-checkbox :deep(.checkbox.checked) {
background-color: var(--color);
}
</style>
+5 -1
View File
@@ -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/', '')
+468
View File
@@ -8,6 +8,474 @@
"admin.billing.error.not-found": {
"message": "User not found"
},
"analytics.action.add": {
"message": "Add"
},
"analytics.action.cancel": {
"message": "Cancel"
},
"analytics.action.refresh": {
"message": "Refresh"
},
"analytics.action.reset": {
"message": "Reset"
},
"analytics.breakdown.country": {
"message": "Country"
},
"analytics.breakdown.download-reason": {
"message": "Download reason"
},
"analytics.breakdown.download-source": {
"message": "Download source"
},
"analytics.breakdown.game-version": {
"message": "Game version"
},
"analytics.breakdown.generic": {
"message": "Breakdown"
},
"analytics.breakdown.loader": {
"message": "Loader"
},
"analytics.breakdown.monetization": {
"message": "Monetization"
},
"analytics.breakdown.none.selected": {
"message": "No breakdown"
},
"analytics.breakdown.project": {
"message": "Project"
},
"analytics.breakdown.project-status": {
"message": "Project status"
},
"analytics.breakdown.project-version": {
"message": "Project version"
},
"analytics.breakdown.selected": {
"message": "Breakdown by {breakdown}"
},
"analytics.chart.action.show-all": {
"message": "Show all"
},
"analytics.chart.action.show-limited": {
"message": "Show limited"
},
"analytics.chart.action.show-top-eight": {
"message": "Show top 8"
},
"analytics.chart.axis.playtime-hours": {
"message": "{hours} h"
},
"analytics.chart.controls.active-count": {
"message": "{count} active"
},
"analytics.chart.controls.annotations": {
"message": "Annotations"
},
"analytics.chart.controls.aria": {
"message": "Analytics graph controls, {activeCount}"
},
"analytics.chart.controls.button": {
"message": "Controls"
},
"analytics.chart.controls.dialog-aria": {
"message": "Analytics graph controls"
},
"analytics.chart.controls.display": {
"message": "Display"
},
"analytics.chart.controls.modrinth-events": {
"message": "Modrinth events"
},
"analytics.chart.controls.no-modrinth-events": {
"message": "No Modrinth events in graph."
},
"analytics.chart.controls.no-project-events": {
"message": "No project events in graph."
},
"analytics.chart.controls.previous-period": {
"message": "Previous period"
},
"analytics.chart.controls.project-events": {
"message": "Project events"
},
"analytics.chart.controls.ratio": {
"message": "Ratio"
},
"analytics.chart.empty.select-table-items": {
"message": "Select items from table below to visualize your data."
},
"analytics.chart.events.count-aria": {
"message": "{count, plural, one {# analytics event} other {# analytics events}}"
},
"analytics.chart.events.project-title": {
"message": "<project>{projectName}</project>: {title}"
},
"analytics.chart.events.see-announcement": {
"message": "See announcement"
},
"analytics.chart.legend.monetization-details.aria": {
"message": "View monetized analytics details"
},
"analytics.chart.legend.monetization-details.description": {
"message": "Only views and downloads made through Modrinth count toward monetization, and downloads require users to be logged in."
},
"analytics.chart.legend.monetization-details.title": {
"message": "Monetized analytics details"
},
"analytics.chart.legend.previous-period-suffix": {
"message": "{name} (Prev.)"
},
"analytics.chart.render-limit.description": {
"message": "Showing all selected lines from table may degrade page performance."
},
"analytics.chart.render-limit.header": {
"message": "Show all {count} lines in graph?"
},
"analytics.chart.table-selection.all": {
"message": "Showing all {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table"
},
"analytics.chart.table-selection.count": {
"message": "Showing {count} {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table"
},
"analytics.chart.table-selection.limited": {
"message": "Showing {limit} {itemType, select, project {{limit, plural, one {project} other {projects}}} country {{limit, plural, one {country} other {countries}}} monetization {{limit, plural, one {monetization value} other {monetization values}}} downloadSource {{limit, plural, one {download source} other {download sources}}} downloadReason {{limit, plural, one {download reason} other {download reasons}}} projectVersion {{limit, plural, one {project version} other {project versions}}} loader {{limit, plural, one {loader} other {loaders}}} gameVersion {{limit, plural, one {game version} other {game versions}}} other {{limit, plural, one {item} other {items}}}} from table"
},
"analytics.chart.table-selection.top": {
"message": "Showing top {count} {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table"
},
"analytics.chart.tooltip.duration.days": {
"message": "{count, plural, one {# day} other {# days}}"
},
"analytics.chart.tooltip.duration.hours": {
"message": "{count, plural, one {# hour} other {# hours}}"
},
"analytics.chart.tooltip.duration.minutes": {
"message": "{count, plural, one {# minute} other {# minutes}}"
},
"analytics.chart.tooltip.hide-entry": {
"message": "Hide {name} in graph"
},
"analytics.chart.tooltip.pinned": {
"message": "Chart tooltip pinned"
},
"analytics.chart.tooltip.pinned-aria": {
"message": "Pinned"
},
"analytics.chart.tooltip.previous-period-short": {
"message": "(prev.)"
},
"analytics.chart.tooltip.show-entry": {
"message": "Show {name} in graph"
},
"analytics.chart.tooltip.total": {
"message": "Total"
},
"analytics.chart.view.area": {
"message": "Area"
},
"analytics.chart.view.bar": {
"message": "Bar"
},
"analytics.chart.view.line": {
"message": "Line"
},
"analytics.download-reason.dependency": {
"message": "Dependency"
},
"analytics.download-reason.modpack": {
"message": "Modpack"
},
"analytics.download-reason.standalone": {
"message": "Standalone"
},
"analytics.download-reason.update": {
"message": "Update"
},
"analytics.download-source.app": {
"message": "Modrinth App"
},
"analytics.download-source.website": {
"message": "Modrinth Website"
},
"analytics.downloads.suffix": {
"message": "downloads"
},
"analytics.empty.no-data": {
"message": "No data available"
},
"analytics.empty.no-data-for-analytics": {
"message": "No data available for analytics"
},
"analytics.empty.no-projects": {
"message": "No projects available"
},
"analytics.empty.no-projects-for-analytics": {
"message": "No projects available for analytics"
},
"analytics.empty.select-project": {
"message": "Select at least one project to view data"
},
"analytics.filter.game-version-type": {
"message": "Game version type"
},
"analytics.filter.game-version-type.all": {
"message": "All"
},
"analytics.filter.game-version-type.release": {
"message": "Release"
},
"analytics.filter.search.countries": {
"message": "Search countries..."
},
"analytics.filter.search.download-sources": {
"message": "Search download sources..."
},
"analytics.filter.search.project-versions": {
"message": "Search project versions..."
},
"analytics.filter.search.versions": {
"message": "Search versions..."
},
"analytics.graph.title.downloads": {
"message": "Downloads Over Time"
},
"analytics.graph.title.playtime": {
"message": "Playtime Over Time"
},
"analytics.graph.title.revenue": {
"message": "Revenue Over Time"
},
"analytics.graph.title.views": {
"message": "Views Over Time"
},
"analytics.group-by.1h": {
"message": "1h"
},
"analytics.group-by.6h": {
"message": "6h"
},
"analytics.group-by.date": {
"message": "Date"
},
"analytics.group-by.day": {
"message": "Day"
},
"analytics.group-by.month": {
"message": "Month"
},
"analytics.group-by.selected.day": {
"message": "Group by day"
},
"analytics.group-by.selected.hour": {
"message": "Group by hour"
},
"analytics.group-by.selected.month": {
"message": "Group by month"
},
"analytics.group-by.selected.six-hours": {
"message": "Group by 6 hours"
},
"analytics.group-by.selected.week": {
"message": "Group by week"
},
"analytics.group-by.selected.year": {
"message": "Group by year"
},
"analytics.group-by.week": {
"message": "Week"
},
"analytics.group-by.year": {
"message": "Year"
},
"analytics.loading.fetching-results": {
"message": "Fetching results..."
},
"analytics.options.loading": {
"message": "Loading..."
},
"analytics.project-event.project-approved": {
"message": "Project approved"
},
"analytics.project-event.project-private": {
"message": "Project set to private"
},
"analytics.project-event.project-status-changed": {
"message": "Project status changed"
},
"analytics.project-event.project-unlisted": {
"message": "Project unlisted"
},
"analytics.project-event.version-released": {
"message": "{version} released"
},
"analytics.project-event.version-uploaded": {
"message": "Version uploaded"
},
"analytics.project-status.approved": {
"message": "Approved"
},
"analytics.project-status.archived": {
"message": "Archived"
},
"analytics.project-status.draft": {
"message": "Draft"
},
"analytics.project-status.other": {
"message": "Other"
},
"analytics.project-status.private": {
"message": "Private"
},
"analytics.project-status.rejected": {
"message": "Rejected"
},
"analytics.project-status.unlisted": {
"message": "Unlisted"
},
"analytics.project-status.withheld": {
"message": "Withheld"
},
"analytics.project.all": {
"message": "All projects"
},
"analytics.project.count": {
"message": "{count, plural, one {# project} other {# projects}}"
},
"analytics.project.icon-alt": {
"message": "{name} Icon"
},
"analytics.project.select": {
"message": "Select projects"
},
"analytics.query.filter.add": {
"message": "Add filter"
},
"analytics.query.label.breakdown": {
"message": "Breakdown:"
},
"analytics.query.label.grouped-by": {
"message": "Grouped by"
},
"analytics.query.label.project": {
"message": "Project:"
},
"analytics.query.label.timeframe": {
"message": "Timeframe:"
},
"analytics.stat.downloads": {
"message": "Downloads"
},
"analytics.stat.playtime": {
"message": "Playtime"
},
"analytics.stat.playtime-hours": {
"message": "{hours} hrs"
},
"analytics.stat.previous-period-comparison": {
"message": "vs prev. period"
},
"analytics.stat.previous-period-comparison-short": {
"message": "vs prev."
},
"analytics.stat.revenue": {
"message": "Revenue"
},
"analytics.stat.revenue-value": {
"message": "${value}"
},
"analytics.stat.unavailable": {
"message": "N/A"
},
"analytics.stat.unavailable-tooltip": {
"message": "Stat unavailable for current query"
},
"analytics.stat.views": {
"message": "Views"
},
"analytics.table.csv.date-range": {
"message": "{start} to {end}"
},
"analytics.table.csv.filename": {
"message": "Modrinth Analytics {breakdown} Breakdown - {dateRange}"
},
"analytics.table.csv.header.playtime-seconds": {
"message": "Playtime (seconds)"
},
"analytics.table.csv.selected-range": {
"message": "Selected Range"
},
"analytics.table.duration.days": {
"message": "{count, plural, one {# day} other {# days}}"
},
"analytics.table.duration.hours": {
"message": "{count, plural, one {# hour} other {# hours}}"
},
"analytics.table.duration.minutes": {
"message": "{count, plural, one {# minute} other {# minutes}}"
},
"analytics.table.empty.no-matching-rows": {
"message": "No matching analytics rows"
},
"analytics.table.export-csv": {
"message": "Export CSV"
},
"analytics.table.export.cumulative": {
"message": "Cumulative"
},
"analytics.table.export.grouped": {
"message": "Grouped by {groupBy}"
},
"analytics.table.pagination.summary": {
"message": "Showing {start} to {end} of {total}"
},
"analytics.table.search.placeholder": {
"message": "Search..."
},
"analytics.threshold.countries-above": {
"message": "Countries above"
},
"analytics.threshold.country-downloads-aria": {
"message": "Country downloads threshold"
},
"analytics.threshold.game-version-downloads-aria": {
"message": "Game version downloads threshold"
},
"analytics.threshold.game-versions-above": {
"message": "Game versions above"
},
"analytics.threshold.project-downloads-aria": {
"message": "Project downloads threshold"
},
"analytics.threshold.project-version-downloads-aria": {
"message": "Project version downloads threshold"
},
"analytics.threshold.project-versions-above": {
"message": "Project versions above"
},
"analytics.threshold.projects-above": {
"message": "Projects above"
},
"analytics.title": {
"message": "Analytics"
},
"analytics.value.monetized": {
"message": "Monetized"
},
"analytics.value.none": {
"message": "None"
},
"analytics.value.other": {
"message": "Other"
},
"analytics.value.unknown": {
"message": "Unknown"
},
"analytics.value.unmonetized": {
"message": "Unmonetized"
},
"app-marketing.download.description": {
"message": "Our desktop app is available across all platforms, choose your desired version."
},
@@ -1,30 +1,7 @@
<template>
<div>
<div class="universal-card">
<h2>Analytics</h2>
<p>
This page shows you the analytics for your project,
<strong>{{ project.title }}</strong
>. You can see the number of downloads, page views and revenue earned for your project, as
well as the total downloads and page views for {{ project.title }} by country.
</p>
</div>
<ChartDisplay :projects="[project]" />
</div>
<AnalyticsDashboard />
</template>
<script setup>
import { injectProjectPageContext } from '@modrinth/ui'
import ChartDisplay from '~/components/ui/charts/ChartDisplay.vue'
const { projectV2: project } = injectProjectPageContext()
<script setup lang="ts">
import AnalyticsDashboard from '~/components/analytics-dashboard/index.vue'
</script>
<style scoped lang="scss">
.markdown-body {
margin-bottom: var(--gap-md);
}
</style>
@@ -0,0 +1,842 @@
<template>
<ConfirmModal
ref="deleteEventModal"
title="Delete analytics event?"
:description="deleteEventDescription"
proceed-label="Delete event"
@proceed="confirmDeleteEvent"
/>
<NewModal
ref="eventModal"
:header="modalMode === 'create' ? 'New event' : 'Edit event'"
width="480px"
max-width="calc(100vw - 2rem)"
:on-hide="resetForm"
:close-on-click-outside="false"
>
<div class="flex flex-col gap-5" @submit.prevent="saveEvent">
<div class="flex flex-col gap-2">
<span class="label__title font-semibold">Title</span>
<StyledInput
id="analytics-event-title"
ref="titleInput"
v-model="form.title"
type="text"
autocomplete="off"
placeholder="Event title..."
:maxlength="120"
/>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="label__title font-semibold">Announcement link (optional)</span>
<ButtonStyled v-if="committedNormalizedAnnouncementUrl" type="transparent" size="small">
<a
:href="committedNormalizedAnnouncementUrl"
target="_blank"
rel="noopener noreferrer"
aria-label="Check announcement link"
title="Check announcement link"
class="text-sm"
>
<ExternalIcon aria-hidden="true" />
Open link
</a>
</ButtonStyled>
</div>
<div class="flex items-center gap-2">
<StyledInput
id="analytics-event-link"
v-model="form.announcementUrl"
type="url"
autocomplete="off"
placeholder="Announcement link..."
wrapper-class="w-full"
@change="commitAnnouncementUrl"
/>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-1">
<span class="label__title font-semibold">Start date ({{ EVENT_TIME_ZONE_LABEL }})</span>
</div>
<DatePicker
id="analytics-event-starts"
v-model="form.startsAt"
enable-time
date-format="Y-m-d H:i"
alt-format="F j, Y at h:i K"
placeholder="Select start..."
input-class="w-full"
wrapper-class="w-full"
show-today
/>
</div>
<div class="flex flex-col gap-2">
<span class="label__title font-semibold"
>End date ({{ EVENT_TIME_ZONE_LABEL }}, optional)</span
>
<DatePicker
id="analytics-event-ends"
v-model="form.endsAt"
enable-time
date-format="Y-m-d H:i"
alt-format="F j, Y at h:i K"
placeholder="Select end..."
input-class="w-full"
wrapper-class="w-full"
show-today
/>
</div>
<div class="flex flex-col gap-2">
<span class="label__title font-semibold">Metric</span>
<MultiSelect
v-model="form.metricKinds"
:options="metricKindOptions"
:clearable="false"
:max-tag-rows="2"
placeholder="Select metrics this applies to"
include-select-all-option
/>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="transparent">
<button @click="eventModal?.hide()">Cancel</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!canSaveEvent || isSaving" @click="saveEvent">
<SaveIcon aria-hidden="true" />
{{ modalMode === 'create' ? 'Create event' : 'Save' }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
<div class="normal-page no-sidebar">
<div class="normal-page__content flex flex-col gap-4">
<div class="mt-8 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<h1 class="m-0 text-2xl font-extrabold text-contrast">Analytics Events</h1>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<StyledInput
v-model="searchQuery"
:icon="SearchIcon"
type="search"
placeholder="Search..."
clearable
wrapper-class="w-full sm:w-72"
/>
<ButtonStyled color="brand">
<button :disabled="isSaving" @click="openCreateModal">
<PlusIcon aria-hidden="true" />
New event
</button>
</ButtonStyled>
</div>
</div>
<Table
v-model:sort-column="sortColumn"
v-model:sort-direction="sortDirection"
:columns="columns"
:data="sortedEvents"
row-key="id"
>
<template #cell-title="{ row }">
<span class="line-clamp-2 font-medium text-primary">{{ row.title }}</span>
</template>
<template #cell-announcement="{ row }">
<a
v-if="row.announcement_url"
:href="row.announcement_url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 font-medium text-primary hover:text-contrast"
>
Open link
<ExternalIcon class="size-4" aria-hidden="true" />
</a>
<span v-else class="text-xs font-medium text-primary"></span>
</template>
<template #cell-date="{ row }">
<div
v-if="isEventDateRange(row)"
class="flex flex-col gap-0.5 text-sm font-medium leading-5 text-primary"
>
<span>{{ formatEventDateRangeStart(row) }} -</span>
<span>{{ formatEventDateRangeEnd(row) }}</span>
</div>
<span v-else class="font-medium text-primary">{{ formatEventDateRange(row) }}</span>
</template>
<template #cell-metrics="{ row }">
<div class="flex flex-wrap gap-1">
<span
v-for="metric in getMetricKindOptions(row.for_metric_kind)"
:key="metric.value"
class="inline-flex items-center rounded-full border border-solid border-surface-5 px-2 py-0.5 text-xs font-medium text-secondary"
>
{{ metric.label }}
</span>
</div>
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end gap-2">
<ButtonStyled circular type="outlined" color="red">
<button
:aria-label="`Delete ${row.title}`"
:disabled="isDeletingEvent(row.id)"
@click="openDeleteEventModal(row)"
>
<TrashIcon aria-hidden="true" />
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button :disabled="isSaving || isDeletingEvent(row.id)" @click="openEditModal(row)">
Edit
<EditIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
</template>
<template #empty-state>
<div class="flex h-64 items-center justify-center text-secondary">
{{ isLoadingEvents ? 'Loading analytics events...' : 'No results.' }}
</div>
</template>
</Table>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { EditIcon, ExternalIcon, PlusIcon, SaveIcon, SearchIcon, TrashIcon } from '@modrinth/assets'
import {
ButtonStyled,
ConfirmModal,
DatePicker,
injectModrinthClient,
injectNotificationManager,
MultiSelect,
type MultiSelectOption,
NewModal,
type SortDirection,
StyledInput,
Table,
type TableColumn,
} from '@modrinth/ui'
import { isAdmin } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
definePageMeta({
middleware: [
'auth',
async () => {
const auth = await useAuth()
if (!auth.value.user || !isAdmin(auth.value.user)) {
throw createError({
fatal: true,
statusCode: 401,
statusMessage: 'Unauthorized',
})
}
},
],
})
type EventColumnKey = 'title' | 'announcement' | 'date' | 'metrics' | 'actions'
type AnalyticsEventMetricKind = Labrinth.Analytics.v3.AnalyticsEventMetricKind
type AnalyticsEventRow = Labrinth.Analytics.v3.AnalyticsEvent & {
announcement: string
date: string
metrics: string
actions: string
}
type EventForm = {
title: string
announcementUrl: string
startsAt: DatePickerValue
endsAt: DatePickerValue
metricKinds: AnalyticsEventMetricKind[]
}
type DatePickerValue = string | Date | null | undefined
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const analyticsEventsQueryKey = ['analytics-events'] as const
const EVENT_TIME_ZONE = 'America/Los_Angeles'
const EVENT_TIME_ZONE_LABEL = 'PST'
const columns: TableColumn<EventColumnKey>[] = [
{ key: 'date', label: 'Date (PST)', width: '18%', enableSorting: true },
{ key: 'title', label: 'Title' },
{ key: 'announcement', label: 'Announcement link', width: '18%' },
{ key: 'metrics', label: 'Metric', width: '18%' },
{ key: 'actions', label: 'Actions', width: '15%', align: 'right' },
]
const metricKindOptions: MultiSelectOption<AnalyticsEventMetricKind>[] = [
{ value: 'views', label: 'Views' },
{ value: 'downloads', label: 'Downloads' },
{ value: 'revenue', label: 'Revenue' },
{ value: 'playtime', label: 'Playtime' },
]
const allMetricKinds = metricKindOptions.map((option) => option.value)
const deleteEventModal = ref<InstanceType<typeof ConfirmModal> | null>(null)
const eventModal = ref<InstanceType<typeof NewModal> | null>(null)
const titleInput = ref<InstanceType<typeof StyledInput> | null>(null)
const searchQuery = ref('')
const sortColumn = ref<EventColumnKey | undefined>('date')
const sortDirection = ref<SortDirection>('desc')
const modalMode = ref<'create' | 'edit'>('create')
const editingEventId = ref<Labrinth.Analytics.v3.AnalyticsEventId | null>(null)
const pendingDeleteEvent = ref<Labrinth.Analytics.v3.AnalyticsEvent | null>(null)
const form = ref<EventForm>(getEmptyForm())
const isSaving = ref(false)
const deletingEventIds = ref(new Set<Labrinth.Analytics.v3.AnalyticsEventId>())
const notifiedEventsErrorMessage = ref<string | null>(null)
const committedAnnouncementUrl = ref('')
let resetFormTimeout: ReturnType<typeof setTimeout> | null = null
const {
data: analyticsEvents,
error: eventsError,
isLoading: isLoadingEvents,
} = useQuery({
queryKey: analyticsEventsQueryKey,
queryFn: () => client.labrinth.analytics_v3.getEvents(),
placeholderData: [],
refetchOnMount: 'always',
retry: false,
staleTime: 0,
})
watch(eventsError, (error) => {
if (!error) {
notifiedEventsErrorMessage.value = null
return
}
const message = error.message
if (notifiedEventsErrorMessage.value === message) {
return
}
notifiedEventsErrorMessage.value = message
addNotification({
title: 'Failed to load analytics events',
text: message,
type: 'error',
})
})
onBeforeUnmount(() => {
clearResetFormTimeout()
})
const trimmedSearchQuery = computed(() => searchQuery.value.trim().toLowerCase())
const normalizedAnnouncementUrl = computed(() => normalizeUrl(form.value.announcementUrl))
const committedNormalizedAnnouncementUrl = computed(() =>
normalizeUrl(committedAnnouncementUrl.value),
)
const canSaveEvent = computed(
() =>
form.value.title.trim().length > 0 &&
Boolean(getEventFormDateRange()) &&
form.value.metricKinds.length > 0,
)
const deleteEventDescription = computed(() => {
if (!pendingDeleteEvent.value) {
return 'This analytics event will be deleted. This cannot be undone.'
}
return `This will delete "${pendingDeleteEvent.value.title}" from analytics events. This cannot be undone.`
})
const eventRows = computed<AnalyticsEventRow[]>(() =>
(analyticsEvents.value ?? []).map((event) => ({
...event,
announcement: '',
date: '',
metrics: '',
actions: '',
})),
)
const filteredEvents = computed(() => {
if (!trimmedSearchQuery.value) {
return eventRows.value
}
return eventRows.value.filter((event) => {
const dateRange = formatEventDateRange(event).toLowerCase()
return [event.title, event.announcement_url ?? '', dateRange].some((value) =>
value.toLowerCase().includes(trimmedSearchQuery.value),
)
})
})
const sortedEvents = computed(() => {
const sorted = [...filteredEvents.value]
if (sortColumn.value === 'date') {
sorted.sort((left, right) => {
const direction = sortDirection.value === 'asc' ? 1 : -1
return (getDateTime(left.starts) - getDateTime(right.starts)) * direction
})
}
return sorted
})
function getEmptyForm(): EventForm {
return {
title: '',
announcementUrl: '',
startsAt: '',
endsAt: '',
metricKinds: [],
}
}
function openCreateModal() {
modalMode.value = 'create'
editingEventId.value = null
form.value = {
...getEmptyForm(),
}
committedAnnouncementUrl.value = ''
clearResetFormTimeout()
eventModal.value?.show()
void focusTitleInput()
}
function openEditModal(event: Labrinth.Analytics.v3.AnalyticsEvent) {
modalMode.value = 'edit'
editingEventId.value = event.id
form.value = {
title: event.title,
announcementUrl: event.announcement_url ?? '',
startsAt: getDateTimeInputValue(event.starts),
endsAt: getDateTimeInputValue(event.ends),
metricKinds: event.for_metric_kind?.length ? [...event.for_metric_kind] : [...allMetricKinds],
}
committedAnnouncementUrl.value = event.announcement_url ?? ''
clearResetFormTimeout()
eventModal.value?.show()
void focusTitleInput()
}
function openDeleteEventModal(event: Labrinth.Analytics.v3.AnalyticsEvent) {
pendingDeleteEvent.value = event
deleteEventModal.value?.show()
}
async function focusTitleInput() {
await nextTick()
setTimeout(() => {
titleInput.value?.focus()
}, 75)
}
async function saveEvent() {
if (!canSaveEvent.value || isSaving.value) {
return
}
isSaving.value = true
try {
const payload = buildEventPayload()
if (modalMode.value === 'edit' && editingEventId.value !== null) {
await client.labrinth.analytics_v3.editEvent(editingEventId.value, payload)
} else {
await client.labrinth.analytics_v3.createEvent(payload)
}
await queryClient.invalidateQueries({ queryKey: analyticsEventsQueryKey })
eventModal.value?.hide()
addNotification({
title: modalMode.value === 'edit' ? 'Analytics event updated' : 'Analytics event created',
type: 'success',
})
} catch (error) {
addNotification({
title:
modalMode.value === 'edit'
? 'Failed to update analytics event'
: 'Failed to create analytics event',
text: getErrorMessage(error),
type: 'error',
})
} finally {
isSaving.value = false
}
}
async function confirmDeleteEvent() {
if (!pendingDeleteEvent.value) {
return
}
const eventId = pendingDeleteEvent.value.id
pendingDeleteEvent.value = null
await deleteEvent(eventId)
}
async function deleteEvent(eventId: Labrinth.Analytics.v3.AnalyticsEventId) {
if (isDeletingEvent(eventId)) {
return
}
setDeletingEvent(eventId, true)
try {
await client.labrinth.analytics_v3.deleteEvent(eventId)
await queryClient.invalidateQueries({ queryKey: analyticsEventsQueryKey })
addNotification({
title: 'Analytics event deleted',
type: 'success',
})
} catch (error) {
addNotification({
title: 'Failed to delete analytics event',
text: getErrorMessage(error),
type: 'error',
})
} finally {
setDeletingEvent(eventId, false)
}
}
function resetForm() {
clearResetFormTimeout()
resetFormTimeout = setTimeout(() => {
form.value = getEmptyForm()
editingEventId.value = null
committedAnnouncementUrl.value = ''
resetFormTimeout = null
}, 500)
}
function clearResetFormTimeout() {
if (!resetFormTimeout) {
return
}
clearTimeout(resetFormTimeout)
resetFormTimeout = null
}
function normalizeUrl(value: string): string | undefined {
const trimmed = value.trim()
if (!trimmed) {
return undefined
}
if (/^https?:\/\//i.test(trimmed)) {
return trimmed
}
return `https://${trimmed}`
}
function commitAnnouncementUrl() {
committedAnnouncementUrl.value = form.value.announcementUrl
}
function buildEventPayload(): Labrinth.Analytics.v3.AnalyticsEventUpsert {
const selectedRange = getEventFormDateRange()
if (!selectedRange) {
throw new Error('Select a valid start and end date')
}
const starts = parseDateTimeInputValue(selectedRange[0]).toISOString()
const ends = parseDateTimeInputValue(selectedRange[1]).toISOString()
return {
announcement_url: normalizedAnnouncementUrl.value ?? null,
for_metric_kind: [...form.value.metricKinds],
title: form.value.title.trim(),
ends,
starts,
}
}
function getMetricKindOptions(
metricKinds: AnalyticsEventMetricKind[] | null,
): MultiSelectOption<AnalyticsEventMetricKind>[] {
const visibleKinds = metricKinds?.length ? metricKinds : allMetricKinds
return metricKindOptions.filter((option) => visibleKinds.includes(option.value))
}
function formatEventDateRange(event: Labrinth.Analytics.v3.AnalyticsEvent): string {
const startDate = new Date(event.starts)
const endDate = new Date(event.ends)
const startDateValue = getDateInputValueInTimeZone(startDate, EVENT_TIME_ZONE)
const endDateValue = getDateInputValueInTimeZone(endDate, EVENT_TIME_ZONE)
if (startDate.getTime() === endDate.getTime()) {
return formatDateTime(startDate)
}
const sameYear = startDateValue.slice(0, 4) === endDateValue.slice(0, 4)
const sameMonth = sameYear && startDateValue.slice(5, 7) === endDateValue.slice(5, 7)
const sameDay = startDateValue === endDateValue
if (sameDay) {
return `${formatLongDate(startDate)}, ${formatTime(startDate)} - ${formatTime(endDate)}`
}
if (sameMonth) {
return `${formatMonthDayTime(startDate)} - ${formatMonthDayTime(endDate)}, ${endDateValue.slice(0, 4)}`
}
if (sameYear) {
return `${formatMonthDayTime(startDate)} - ${formatLongDateTime(endDate)}`
}
return `${formatLongDateTime(startDate)} - ${formatLongDateTime(endDate)}`
}
function isEventDateRange(event: Labrinth.Analytics.v3.AnalyticsEvent): boolean {
return new Date(event.starts).getTime() !== new Date(event.ends).getTime()
}
function formatEventDateRangeStart(event: Labrinth.Analytics.v3.AnalyticsEvent): string {
return formatLongDateTime(new Date(event.starts))
}
function formatEventDateRangeEnd(event: Labrinth.Analytics.v3.AnalyticsEvent): string {
return formatLongDateTime(new Date(event.ends))
}
function getEventFormDateRange(): [string, string] | null {
const startValue = getDatePickerValueString(form.value.startsAt)
const endValue = isEmptyDatePickerValue(form.value.endsAt)
? startValue
: getDatePickerValueString(form.value.endsAt)
if (!startValue || !endValue) {
return null
}
const startDate = parseDateTimeInputValue(startValue)
const endDate = parseDateTimeInputValue(endValue)
if (startDate.getTime() > endDate.getTime()) {
return null
}
return [startValue, endValue]
}
function isEmptyDatePickerValue(value: DatePickerValue): boolean {
return value === '' || value === null || value === undefined
}
function getDatePickerValueString(value: DatePickerValue): string | null {
if (typeof value === 'string') {
return isValidDateTimeInputValue(value) ? value : null
}
if (value instanceof Date && !Number.isNaN(value.getTime())) {
return formatDateTimeInputValue(value)
}
return null
}
function isValidDateTimeInputValue(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(value)) return false
const parsedDate = parseDateTimeInputValue(value)
return (
!Number.isNaN(parsedDate.getTime()) &&
formatDateTimeInputValueInTimeZone(parsedDate, EVENT_TIME_ZONE) === value
)
}
function getDateTimeInputValue(value: string): string {
const date = new Date(value)
return Number.isNaN(date.getTime())
? ''
: formatDateTimeInputValueInTimeZone(date, EVENT_TIME_ZONE)
}
function formatDateTimeInputValue(date: Date): string {
return formatDateTimeInputValueInTimeZone(date, EVENT_TIME_ZONE)
}
function formatDateTimeInputValueInTimeZone(date: Date, timeZone: string): string {
const parts = getTimeZoneDateParts(date, timeZone)
if (!parts) return ''
const year = `${parts.year}`.padStart(4, '0')
const month = `${parts.month}`.padStart(2, '0')
const day = `${parts.day}`.padStart(2, '0')
const hours = `${parts.hour}`.padStart(2, '0')
const minutes = `${parts.minute}`.padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
function getDateInputValueInTimeZone(date: Date, timeZone: string): string {
const parts = getTimeZoneDateParts(date, timeZone)
if (!parts) return ''
const year = `${parts.year}`.padStart(4, '0')
const month = `${parts.month}`.padStart(2, '0')
const day = `${parts.day}`.padStart(2, '0')
return `${year}-${month}-${day}`
}
function parseDateTimeInputValue(value: string): Date {
return getDateTimeInTimeZone(value, EVENT_TIME_ZONE)
}
function getDateTimeInTimeZone(value: string, timeZone: string): Date {
const match = value.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/)
if (!match) return new Date(Number.NaN)
const [, yearValue, monthValue, dayValue, hourValue, minuteValue] = match
const year = Number(yearValue)
const month = Number(monthValue)
const day = Number(dayValue)
const hour = Number(hourValue)
const minute = Number(minuteValue)
const utcGuess = Date.UTC(year, month - 1, day, hour, minute)
let offset = getTimeZoneOffsetMs(new Date(utcGuess), timeZone)
offset = getTimeZoneOffsetMs(new Date(utcGuess - offset), timeZone)
return new Date(utcGuess - offset)
}
function getTimeZoneOffsetMs(date: Date, timeZone: string): number {
const parts = getTimeZoneDateParts(date, timeZone)
if (!parts) return 0
const zonedDateAsUtc = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second,
)
return zonedDateAsUtc - date.getTime()
}
function getTimeZoneDateParts(date: Date, timeZone: string) {
if (Number.isNaN(date.getTime())) return null
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
}).formatToParts(date)
const valueByType = Object.fromEntries(parts.map((part) => [part.type, part.value]))
return {
year: Number(valueByType.year),
month: Number(valueByType.month),
day: Number(valueByType.day),
hour: Number(valueByType.hour),
minute: Number(valueByType.minute),
second: Number(valueByType.second),
}
}
function formatDateTime(date: Date): string {
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZone: EVENT_TIME_ZONE,
}).format(date)
}
function formatLongDateTime(date: Date): string {
return `${formatLongDate(date)}, ${formatTime(date)}`
}
function formatMonthDayTime(date: Date): string {
return `${formatMonthDay(date)}, ${formatTime(date)}`
}
function formatLongDate(date: Date): string {
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: EVENT_TIME_ZONE,
}).format(date)
}
function formatMonthDay(date: Date): string {
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
timeZone: EVENT_TIME_ZONE,
}).format(date)
}
function formatTime(date: Date): string {
return new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: '2-digit',
timeZone: EVENT_TIME_ZONE,
}).format(date)
}
function getDateTime(value: string): number {
return Date.parse(value)
}
function isDeletingEvent(eventId: Labrinth.Analytics.v3.AnalyticsEventId): boolean {
return deletingEventIds.value.has(eventId)
}
function setDeletingEvent(eventId: Labrinth.Analytics.v3.AnalyticsEventId, deleting: boolean) {
const nextIds = new Set(deletingEventIds.value)
if (deleting) {
nextIds.add(eventId)
} else {
nextIds.delete(eventId)
}
deletingEventIds.value = nextIds
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
</script>
@@ -1,30 +1,14 @@
<template>
<div>
<Suspense>
<ChartDisplay :projects="projects" :personal="true" />
<template #fallback>
<div class="universal-card">
<h2><span class="label__title">Loading analytics...</span></h2>
</div>
</template>
</Suspense>
</div>
<AnalyticsDashboard />
</template>
<script setup>
import {
commonProjectSettingsMessages,
injectModrinthClient,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import { commonProjectSettingsMessages, useVIntl } from '@modrinth/ui'
import ChartDisplay from '~/components/ui/charts/ChartDisplay.vue'
import AnalyticsDashboard from '~/components/analytics-dashboard/index.vue'
const { formatMessage } = useVIntl()
const debug = useDebugLogger('analytics.vue')
definePageMeta({
middleware: 'auth',
})
@@ -32,14 +16,4 @@ definePageMeta({
useHead({
title: () => `${formatMessage(commonProjectSettingsMessages.analytics)} - Modrinth`,
})
const auth = await useAuth()
const client = injectModrinthClient()
const id = auth.value?.user?.id
debug('auth resolved', { id })
const projects = await client.labrinth.users_v2.getProjects(id)
debug('projects resolved', { count: projects?.length })
</script>
@@ -5,7 +5,7 @@
:preloaded-payment-data="preloadedPaymentMethods"
@refresh-data="refreshData"
/>
<div class="mb-20 flex flex-col gap-6 lg:pl-8">
<div class="mb-20 flex flex-col gap-6 lg:pl-4 lg:pt-1.5">
<div class="flex flex-col gap-4 md:gap-5">
<div class="flex flex-col gap-1">
<span class="text-xl font-semibold text-contrast md:text-2xl">{{
@@ -1,28 +1,9 @@
<template>
<div class="normal-page__content">
<div class="universal-card">
<h2>Analytics</h2>
<p>
This page shows you the analytics for your organization's projects. You can see the number
of downloads, page views and revenue earned for all of your projects, as well as the total
downloads and page views for each project by country.
</p>
</div>
<ChartDisplay :projects="projects.map((x) => ({ title: x.name, ...x }))" />
<AnalyticsDashboard />
</div>
</template>
<script setup>
import ChartDisplay from '~/components/ui/charts/ChartDisplay.vue'
import { injectOrganizationContext } from '~/providers/organization-context.ts'
const { projects } = injectOrganizationContext()
<script setup lang="ts">
import AnalyticsDashboard from '~/components/analytics-dashboard/index.vue'
</script>
<style scoped lang="scss">
.markdown-body {
margin-bottom: var(--gap-md);
}
</style>
@@ -0,0 +1,531 @@
import type { Labrinth } from '@modrinth/api-client'
import type { ProjectStatusFilterValue } from '~/components/analytics-dashboard/query-builder/query-filter'
import { getProjectIdsMatchingStatusFilter } from './analytics-project-utils'
import type {
AnalyticsDashboardTotals,
AnalyticsFetchData,
AnalyticsGroupByPreset,
AnalyticsLastTimeframeUnit,
AnalyticsProjectFetchRequest,
AnalyticsSelectedFilters,
AnalyticsTimeframeMode,
AnalyticsTimeframePreset,
AnalyticsTimeSliceSplit,
} from './analytics-types'
const ANALYTICS_START_TIMESTAMP = '2023-01-01T00:00:00.000Z'
export const ANALYTICS_START_DATE_INPUT_VALUE = ANALYTICS_START_TIMESTAMP.slice(0, 10)
const ANALYTICS_START_TIME = new Date(ANALYTICS_START_TIMESTAMP).getTime()
export const REVENUE_MIN_TIMEFRAME_MS = 1 * 24 * 60 * 60 * 1000 // need at least 1 day in timeframe range to show revenue
const ANALYTICS_DAY_MS = 24 * 60 * 60 * 1000
const ANALYTICS_MAX_TIME_SLICES = 256 // controls granularity allowed in "group by" for timeframe ranges
const ANALYTICS_PROJECT_IDS_FETCH_BATCH_SIZE = 2000
const ANALYTICS_PROJECT_IDS_FETCH_BATCH_DELAY_MS = 300
function isProjectAnalyticsPoint(
dataPoint: Labrinth.Analytics.v3.AnalyticsData,
): dataPoint is Labrinth.Analytics.v3.ProjectAnalytics {
return 'source_project' in dataPoint
}
export function buildComparisonFetchRequest(
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
): AnalyticsProjectFetchRequest | null {
if (!isAnalyticsFetchRequestReady(fetchRequest)) {
return null
}
const startTimestamp = new Date(fetchRequest.time_range.start).getTime()
const endTimestamp = new Date(fetchRequest.time_range.end).getTime()
const duration = endTimestamp - startTimestamp
if (!Number.isFinite(duration) || duration <= 0) {
return null
}
const previousStart = new Date(startTimestamp - duration)
if (previousStart.getTime() < ANALYTICS_START_TIME) {
return null
}
return {
...fetchRequest,
time_range: {
start: previousStart.toISOString(),
end: fetchRequest.time_range.end,
resolution:
'slices' in fetchRequest.time_range.resolution
? {
slices: fetchRequest.time_range.resolution.slices * 2,
}
: fetchRequest.time_range.resolution,
},
}
}
export function isAnalyticsFetchRequestReady(
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
): fetchRequest is AnalyticsProjectFetchRequest {
return Array.isArray(fetchRequest?.project_ids) && fetchRequest.project_ids.length > 0
}
function getAnalyticsTimeSliceCount(
timeRange: Labrinth.Analytics.v3.TimeRange,
fallback: number,
): number {
if ('slices' in timeRange.resolution) {
return Math.max(1, timeRange.resolution.slices)
}
const startTime = new Date(timeRange.start).getTime()
const endTime = new Date(timeRange.end).getTime()
const bucketMs = timeRange.resolution.minutes * 60 * 1000
if (bucketMs > 0 && endTime > startTime) {
return Math.max(1, Math.floor((endTime - startTime) / bucketMs))
}
return Math.max(1, fallback)
}
export function splitAnalyticsTimeSlices(
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
): AnalyticsTimeSliceSplit {
if (!isAnalyticsFetchRequestReady(fetchRequest) || !buildComparisonFetchRequest(fetchRequest)) {
return {
currentTimeSlices: timeSlices,
previousTimeSlices: [],
}
}
const currentSliceCount = getAnalyticsTimeSliceCount(fetchRequest.time_range, timeSlices.length)
const currentStartIndex = Math.max(0, timeSlices.length - currentSliceCount)
const previousStartIndex = Math.max(0, currentStartIndex - currentSliceCount)
return {
currentTimeSlices: timeSlices.slice(currentStartIndex),
previousTimeSlices: timeSlices.slice(previousStartIndex, currentStartIndex),
}
}
export function getAnalyticsProjectEventsInTimeRange(
projectEvents: Labrinth.Analytics.v3.ProjectAnalyticsEvent[],
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
): Labrinth.Analytics.v3.ProjectAnalyticsEvent[] {
if (!isAnalyticsFetchRequestReady(fetchRequest)) {
return projectEvents
}
const startTime = new Date(fetchRequest.time_range.start).getTime()
const endTime = new Date(fetchRequest.time_range.end).getTime()
if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime < startTime) {
return []
}
return projectEvents.filter((event) => {
const eventTime = new Date(event.timestamp).getTime()
return Number.isFinite(eventTime) && eventTime >= startTime && eventTime <= endTime
})
}
function buildAnalyticsFetchRequestBatches(
fetchRequest: AnalyticsProjectFetchRequest,
): AnalyticsProjectFetchRequest[] {
if (fetchRequest.project_ids.length <= ANALYTICS_PROJECT_IDS_FETCH_BATCH_SIZE) {
return [fetchRequest]
}
const requests: AnalyticsProjectFetchRequest[] = []
for (
let index = 0;
index < fetchRequest.project_ids.length;
index += ANALYTICS_PROJECT_IDS_FETCH_BATCH_SIZE
) {
requests.push({
...fetchRequest,
project_ids: fetchRequest.project_ids.slice(
index,
index + ANALYTICS_PROJECT_IDS_FETCH_BATCH_SIZE,
),
})
}
return requests
}
function mergeAnalyticsTimeSlices(
timeSliceGroups: Labrinth.Analytics.v3.TimeSlice[][],
): Labrinth.Analytics.v3.TimeSlice[] {
const mergedTimeSlices: Labrinth.Analytics.v3.TimeSlice[] = []
for (const timeSlices of timeSliceGroups) {
timeSlices.forEach((timeSlice, index) => {
if (!mergedTimeSlices[index]) {
mergedTimeSlices[index] = []
}
for (const dataPoint of timeSlice) {
mergedTimeSlices[index].push(dataPoint)
}
})
}
return mergedTimeSlices
}
function mergeAnalyticsProjectEvents(
projectEventGroups: Labrinth.Analytics.v3.ProjectAnalyticsEvent[][],
): Labrinth.Analytics.v3.ProjectAnalyticsEvent[] {
const mergedProjectEvents: Labrinth.Analytics.v3.ProjectAnalyticsEvent[] = []
for (const projectEvents of projectEventGroups) {
for (const projectEvent of projectEvents) {
mergedProjectEvents.push(projectEvent)
}
}
return mergedProjectEvents.sort((left, right) => {
const timestampDifference =
new Date(left.timestamp).getTime() - new Date(right.timestamp).getTime()
return (
timestampDifference ||
left.project_id.localeCompare(right.project_id) ||
left.kind.localeCompare(right.kind)
)
})
}
function waitForAnalyticsFetchBatchDelay(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ANALYTICS_PROJECT_IDS_FETCH_BATCH_DELAY_MS))
}
export async function fetchAnalyticsData(
fetchRequest: AnalyticsProjectFetchRequest,
fetchAnalytics: (
request: Labrinth.Analytics.v3.FetchRequest,
) => Promise<Labrinth.Analytics.v3.FetchResponse>,
): Promise<AnalyticsFetchData> {
const fetchRequests = buildAnalyticsFetchRequestBatches(fetchRequest)
const timeSliceGroups: Labrinth.Analytics.v3.TimeSlice[][] = []
const projectEventGroups: Labrinth.Analytics.v3.ProjectAnalyticsEvent[][] = []
for (let index = 0; index < fetchRequests.length; index++) {
if (index > 0) {
await waitForAnalyticsFetchBatchDelay()
}
const response = await fetchAnalytics(fetchRequests[index])
timeSliceGroups.push(response.metrics)
projectEventGroups.push(response.project_events ?? [])
}
return {
metrics: mergeAnalyticsTimeSlices(timeSliceGroups),
project_events: mergeAnalyticsProjectEvents(projectEventGroups),
}
}
export async function fetchAnalyticsTimeSlices(
fetchRequest: AnalyticsProjectFetchRequest,
fetchAnalytics: (
request: Labrinth.Analytics.v3.FetchRequest,
) => Promise<Labrinth.Analytics.v3.FetchResponse>,
): Promise<Labrinth.Analytics.v3.TimeSlice[]> {
const response = await fetchAnalyticsData(fetchRequest, fetchAnalytics)
return response.metrics
}
export function areAnalyticsFetchRequestsEqual(
left: Labrinth.Analytics.v3.FetchRequest | null,
right: Labrinth.Analytics.v3.FetchRequest,
): boolean {
return JSON.stringify(left) === JSON.stringify(right)
}
export function buildAnalyticsCurrentTimeSlicesQueryKey(
userId: string | undefined,
nextFetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
refreshTimestamp: number,
) {
return ['analytics', 'dashboard', userId, 'current', nextFetchRequest, refreshTimestamp]
}
export function isRevenueHourlyGroupBy(groupBy: AnalyticsGroupByPreset): boolean {
return groupBy === '1h' || groupBy === '6h'
}
export function buildDailyAnalyticsFetchRequest(
nextFetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
): Labrinth.Analytics.v3.FetchRequest | null {
if (!isAnalyticsFetchRequestReady(nextFetchRequest)) {
return null
}
const startTime = new Date(nextFetchRequest.time_range.start).getTime()
const endTime = new Date(nextFetchRequest.time_range.end).getTime()
const durationMs = endTime - startTime
if (!Number.isFinite(durationMs) || durationMs <= 0) {
return null
}
const desiredSlices = Math.max(1, Math.floor(durationMs / ANALYTICS_DAY_MS))
return {
...nextFetchRequest,
time_range: {
...nextFetchRequest.time_range,
resolution: {
slices: Math.min(ANALYTICS_MAX_TIME_SLICES, desiredSlices),
},
},
}
}
export function buildAnalyticsFacetsRequest(
projectIds: string[],
timeRange: Labrinth.Analytics.v3.TimeRange,
): Labrinth.Analytics.v3.FetchRequest {
return {
time_range: {
start: timeRange.start,
end: timeRange.end,
resolution: {
slices: 1,
},
},
project_ids: projectIds,
return_metrics: {
project_downloads: {
bucket_by: [
'project_id',
'domain',
'user_agent',
'version_id',
'monetized',
'country',
'reason',
'game_version',
'loader',
],
},
},
}
}
function addAnalyticsDays(date: Date, days: number): Date {
const nextDate = new Date(date)
nextDate.setDate(nextDate.getDate() + days)
return nextDate
}
function parseAnalyticsDateInputValue(value: string): Date | null {
const parsedDate = new Date(`${value}T00:00:00`)
return Number.isNaN(parsedDate.getTime()) ? null : parsedDate
}
function parseAnalyticsDateTimeInputValue(value: string): Date | null {
const parsedDate = new Date(value)
return Number.isNaN(parsedDate.getTime()) ? null : parsedDate
}
export function getAnalyticsTimeframeDurationMs({
mode,
preset,
lastAmount,
lastUnit,
customStartDate,
customEndDate,
nowTimestamp,
}: {
mode: AnalyticsTimeframeMode
preset: AnalyticsTimeframePreset
lastAmount: number
lastUnit: AnalyticsLastTimeframeUnit
customStartDate: string
customEndDate: string
nowTimestamp: number
}): number {
if (mode === 'preset') {
switch (preset) {
case 'today':
case 'yesterday':
return 24 * 60 * 60 * 1000
case 'last_7_days':
return 7 * 24 * 60 * 60 * 1000
case 'last_14_days':
return 14 * 24 * 60 * 60 * 1000
case 'last_30_days':
return 30 * 24 * 60 * 60 * 1000
case 'last_90_days':
return 90 * 24 * 60 * 60 * 1000
case 'last_180_days':
return 180 * 24 * 60 * 60 * 1000
case 'year_to_date': {
const now = new Date(nowTimestamp)
const yearStart = new Date(now.getFullYear(), 0, 1)
yearStart.setHours(0, 0, 0, 0)
return now.getTime() - yearStart.getTime()
}
case 'all_time':
return REVENUE_MIN_TIMEFRAME_MS
}
}
if (mode === 'last') {
const amount = Math.max(1, Math.floor(lastAmount))
switch (lastUnit) {
case 'hours':
return amount * 60 * 60 * 1000
case 'days':
return amount * 24 * 60 * 60 * 1000
case 'weeks':
return amount * 7 * 24 * 60 * 60 * 1000
case 'months':
return REVENUE_MIN_TIMEFRAME_MS
}
}
if (mode === 'custom_range') {
const start = parseAnalyticsDateInputValue(customStartDate)
const inclusiveEnd = parseAnalyticsDateInputValue(customEndDate)
if (!start || !inclusiveEnd) {
return 0
}
return addAnalyticsDays(inclusiveEnd, 1).getTime() - start.getTime()
}
const start = parseAnalyticsDateTimeInputValue(customStartDate)
const end = parseAnalyticsDateTimeInputValue(customEndDate)
if (!start || !end) {
return 0
}
return end.getTime() - start.getTime()
}
export function getPercentChange(currentValue: number, previousValue: number): number {
if (previousValue === 0) {
if (currentValue === 0) {
return 0
}
return 100
}
return ((currentValue - previousValue) / previousValue) * 100
}
export function computeTotals(
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
selectedProjectIds: Set<string>,
availableProjectIds: Set<string>,
projectStatusById: Map<string, ProjectStatusFilterValue>,
filters: AnalyticsSelectedFilters,
): AnalyticsDashboardTotals {
const totals: AnalyticsDashboardTotals = {
views: 0,
downloads: 0,
revenue: 0,
playtime: 0,
}
if (availableProjectIds.size === 0) {
return totals
}
const effectiveProjectIds = selectedProjectIds.size > 0 ? selectedProjectIds : availableProjectIds
const filteredProjectIds = new Set(
getProjectIdsMatchingStatusFilter([...effectiveProjectIds], projectStatusById, filters),
)
if (filteredProjectIds.size === 0) {
return totals
}
for (const timeSlice of timeSlices) {
for (const dataPoint of timeSlice) {
if (!isProjectAnalyticsPoint(dataPoint)) {
continue
}
if (!filteredProjectIds.has(dataPoint.source_project)) {
continue
}
switch (dataPoint.metric_kind) {
case 'views':
totals.views += dataPoint.views
break
case 'downloads':
totals.downloads += dataPoint.downloads
break
case 'playtime':
totals.playtime += dataPoint.seconds
break
case 'revenue': {
const value = Number.parseFloat(dataPoint.revenue)
totals.revenue += Number.isFinite(value) ? value : 0
break
}
}
}
}
return totals
}
export function cloneAnalyticsFetchRequest(
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
): Labrinth.Analytics.v3.FetchRequest | null {
return fetchRequest ? JSON.parse(JSON.stringify(fetchRequest)) : null
}
export function addVersionIdsFromTimeSlices(
versionIds: Set<string>,
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
) {
for (const timeSlice of timeSlices) {
for (const dataPoint of timeSlice) {
if (!isProjectAnalyticsPoint(dataPoint)) {
continue
}
if (
(dataPoint.metric_kind === 'downloads' || dataPoint.metric_kind === 'playtime') &&
dataPoint.version_id
) {
const versionId = dataPoint.version_id.trim()
if (versionId.length > 0) {
versionIds.add(versionId)
}
}
}
}
}
export function addVersionProjectNamesFromTimeSlices(
versionProjectNames: Map<string, string>,
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
projectNamesById: Map<string, string>,
) {
for (const timeSlice of timeSlices) {
for (const dataPoint of timeSlice) {
if (!isProjectAnalyticsPoint(dataPoint)) {
continue
}
if (
(dataPoint.metric_kind === 'downloads' || dataPoint.metric_kind === 'playtime') &&
dataPoint.version_id
) {
const versionId = dataPoint.version_id.trim()
const projectName = projectNamesById.get(dataPoint.source_project)
if (versionId.length > 0 && projectName && !versionProjectNames.has(versionId)) {
versionProjectNames.set(versionId, projectName)
}
}
}
}
}
@@ -0,0 +1,470 @@
import type { Labrinth } from '@modrinth/api-client'
import type {
AnalyticsDashboardFilterOptions,
AnalyticsFacetsFilterOptionSummary,
AnalyticsProjectVersionSource,
AnalyticsSelectedFilters,
AnalyticsVersionMetadata,
NormalizedAnalyticsSelectedFilters,
ProjectVersionFilterOptionSummary,
} from './analytics-types'
export function sortStringValues(values: string[]): string[] {
return [...values].sort((left, right) => left.localeCompare(right))
}
function toAnalyticsVersionMetadata(
version: Labrinth.Versions.v3.Version,
): AnalyticsVersionMetadata {
return {
id: version.id,
versionNumber: version.version_number,
datePublished: version.date_published,
projectId: version.project_id,
downloads: version.downloads,
gameVersions: [...version.game_versions],
loaders:
version.mrpack_loaders && version.mrpack_loaders.length > 0
? [...version.mrpack_loaders]
: [...version.loaders],
}
}
export function getProjectVersionFilterOptionSummary(
versions: AnalyticsVersionMetadata[],
): ProjectVersionFilterOptionSummary {
const gameVersions = new Set<string>()
const loaders = new Set<string>()
const versionIds = new Set<string>()
for (const version of versions) {
versionIds.add(version.id)
for (const gameVersion of version.gameVersions) {
const normalizedGameVersion = gameVersion.trim()
if (normalizedGameVersion.length > 0) {
gameVersions.add(normalizedGameVersion)
}
}
for (const loader of version.loaders) {
const normalizedLoader = loader.trim().toLowerCase()
if (normalizedLoader.length > 0 && normalizedLoader !== 'mrpack') {
loaders.add(normalizedLoader)
}
}
}
return {
gameVersions: sortStringValues([...gameVersions]),
loaderTypes: sortStringValues([...loaders]),
versionIds: sortStringValues([...versionIds]),
}
}
export async function fetchAnalyticsVersionMetadataByIds(
versionIds: string[],
getVersions: (ids: string[]) => Promise<Labrinth.Versions.v3.Version[]>,
): Promise<AnalyticsVersionMetadata[]> {
const metadata: AnalyticsVersionMetadata[] = []
const segmentSize = 800
for (let index = 0; index < versionIds.length; index += segmentSize) {
const versions = await getVersions(versionIds.slice(index, index + segmentSize))
metadata.push(...versions.map(toAnalyticsVersionMetadata))
}
return metadata
}
export function getAnalyticsVersionIdsFromProjects(
projects: readonly AnalyticsProjectVersionSource[],
projectIds: readonly string[],
): string[] {
const selectedProjectIds = new Set(projectIds)
const versionIds = new Set<string>()
for (const project of projects) {
if (!selectedProjectIds.has(project.id)) {
continue
}
for (const versionId of project.versions ?? []) {
const normalizedVersionId = versionId.trim()
if (normalizedVersionId.length > 0) {
versionIds.add(normalizedVersionId)
}
}
}
return sortStringValues([...versionIds])
}
function retainAvailableSelectedFilterValues(
values: string[],
availableValues: string[],
): string[] {
const availableValueSet = new Set(availableValues)
return values.filter((value) => availableValueSet.has(value))
}
export function sanitizeAnalyticsSelectedFiltersForAvailableOptions(
filters: AnalyticsSelectedFilters,
filterOptions: AnalyticsDashboardFilterOptions,
): AnalyticsSelectedFilters {
return {
...filters,
download_reason: retainAvailableSelectedFilterValues(
filters.download_reason,
filterOptions.downloadReasons,
),
game_version: retainAvailableSelectedFilterValues(
filters.game_version,
filterOptions.gameVersions,
),
loader_type: retainAvailableSelectedFilterValues(
filters.loader_type,
filterOptions.loaderTypes,
),
}
}
export function cloneAnalyticsSelectedFilters(
filters: AnalyticsSelectedFilters,
): AnalyticsSelectedFilters {
return {
project: [...filters.project],
project_status: [...filters.project_status],
country: [...filters.country],
monetization: [...filters.monetization],
user_agent: [...filters.user_agent],
download_reason: [...filters.download_reason],
version_id: [...filters.version_id],
game_version: [...filters.game_version],
loader_type: [...filters.loader_type],
}
}
export function cloneAnalyticsFilterOptions(
filterOptions: AnalyticsDashboardFilterOptions,
): AnalyticsDashboardFilterOptions {
return {
countries: [...filterOptions.countries],
downloadSources: [...filterOptions.downloadSources],
downloadReasons: [...filterOptions.downloadReasons],
gameVersions: [...filterOptions.gameVersions],
loaderTypes: [...filterOptions.loaderTypes],
versionIds: [...filterOptions.versionIds],
}
}
function getEmptyAnalyticsFacetsFilterOptionSummary(): AnalyticsFacetsFilterOptionSummary {
return {
countries: [],
downloadSources: [],
downloadReasons: [],
gameVersions: [],
loaderTypes: [],
versionIds: [],
projectDownloadsById: new Map(),
projectVersionDownloadsById: new Map(),
gameVersionDownloadsByVersion: new Map(),
countryDownloadsByCode: new Map(),
}
}
function getAnalyticsFacetValues<T>(
facets: Labrinth.Analytics.v3.AnalyticsFacet<T>[] | null | undefined,
): T[] {
return facets?.map((facet) => facet.value) ?? []
}
function getAnalyticsFacetDownloadsByValue<T>(
facets: Labrinth.Analytics.v3.AnalyticsFacet<T>[] | null | undefined,
getKey: (value: T) => string,
): Map<string, number> {
const downloadsByValue = new Map<string, number>()
for (const facet of facets ?? []) {
const key = getKey(facet.value)
if (key.length === 0) {
continue
}
const downloads = Number.isFinite(facet.downloads) ? facet.downloads : 0
downloadsByValue.set(key, (downloadsByValue.get(key) ?? 0) + downloads)
}
return downloadsByValue
}
export function getAnalyticsFacetsFilterOptionSummary(
facets: Labrinth.Analytics.v3.AnalyticsFacets | null | undefined,
): AnalyticsFacetsFilterOptionSummary {
if (!facets) {
return getEmptyAnalyticsFacetsFilterOptionSummary()
}
const downloadCountries = getAnalyticsFacetValues(facets.project_downloads.country)
const downloadGameVersions = getAnalyticsFacetValues(facets.project_downloads.game_version)
const downloadLoaders = getAnalyticsFacetValues(facets.project_downloads.loader)
const downloadVersionIds = getAnalyticsFacetValues(facets.project_downloads.version_id)
const viewCountries = getAnalyticsFacetValues(facets.project_views.country)
const playtimeCountries = getAnalyticsFacetValues(facets.project_playtime.country)
const playtimeGameVersions = getAnalyticsFacetValues(facets.project_playtime.game_version)
const playtimeLoaders = getAnalyticsFacetValues(facets.project_playtime.loader)
const playtimeVersionIds = getAnalyticsFacetValues(facets.project_playtime.version_id)
const countries = new Set([...viewCountries, ...downloadCountries, ...playtimeCountries])
const gameVersions = new Set([...downloadGameVersions, ...playtimeGameVersions])
const loaderTypes = new Set<string>()
for (const loader of [...downloadLoaders, ...playtimeLoaders]) {
const normalizedLoader = loader.trim().toLowerCase()
if (normalizedLoader.length > 0 && normalizedLoader !== 'mrpack') {
loaderTypes.add(normalizedLoader)
}
}
return {
countries: sortStringValues(
[...countries]
.map((country) => country.trim().toUpperCase())
.filter((country) => country.length > 0),
),
downloadSources: sortStringValues(getAnalyticsFacetValues(facets.project_downloads.user_agent)),
downloadReasons: sortStringValues(getAnalyticsFacetValues(facets.project_downloads.reason)),
gameVersions: sortStringValues(
[...gameVersions]
.map((gameVersion) => gameVersion.trim())
.filter((gameVersion) => gameVersion.length > 0),
),
loaderTypes: sortStringValues([...loaderTypes]),
versionIds: sortStringValues([...new Set([...downloadVersionIds, ...playtimeVersionIds])]),
projectDownloadsById: getAnalyticsFacetDownloadsByValue(
facets.project_downloads.project_id,
(projectId) => projectId.trim(),
),
projectVersionDownloadsById: getAnalyticsFacetDownloadsByValue(
facets.project_downloads.version_id,
(versionId) => versionId.trim(),
),
gameVersionDownloadsByVersion: getAnalyticsFacetDownloadsByValue(
facets.project_downloads.game_version,
(gameVersion) => gameVersion.trim(),
),
countryDownloadsByCode: getAnalyticsFacetDownloadsByValue(
facets.project_downloads.country,
(country) => country.trim().toUpperCase(),
),
}
}
export function doesAnalyticsPointMatchFilters(
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
filters: AnalyticsSelectedFilters,
): boolean {
return doesAnalyticsPointMatchNormalizedFilters(
dataPoint,
normalizeAnalyticsSelectedFilters(filters),
)
}
export function normalizeAnalyticsSelectedFilters(
filters: AnalyticsSelectedFilters,
): NormalizedAnalyticsSelectedFilters {
return {
country: normalizeAnalyticsFilterValues(filters.country),
monetization: normalizeAnalyticsFilterValues(filters.monetization),
userAgent: normalizeAnalyticsFilterValues(filters.user_agent),
downloadReason: normalizeAnalyticsFilterValues(filters.download_reason),
versionId: normalizeAnalyticsFilterValues(filters.version_id),
gameVersion: normalizeAnalyticsFilterValues(filters.game_version),
loaderType: normalizeAnalyticsFilterValues(filters.loader_type),
}
}
function normalizeAnalyticsFilterValues(values: string[]): ReadonlySet<string> {
const normalizedValues = new Set<string>()
for (const value of values) {
const normalizedValue = value.trim().toLowerCase()
if (normalizedValue.length > 0) {
normalizedValues.add(normalizedValue)
}
}
return normalizedValues
}
export function doesAnalyticsPointMatchNormalizedFilters(
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
filters: NormalizedAnalyticsSelectedFilters,
): boolean {
switch (dataPoint.metric_kind) {
case 'views':
return (
doesAnalyticsPointMatchNormalizedFilter(
dataPoint,
filters.country,
getCountryFilterValue,
) &&
doesAnalyticsPointMatchNormalizedFilter(
dataPoint,
filters.monetization,
getMonetizationFilterValue,
)
)
case 'downloads':
return (
doesAnalyticsPointMatchNormalizedFilter(
dataPoint,
filters.country,
getCountryFilterValue,
) &&
doesAnalyticsPointMatchNormalizedFilter(
dataPoint,
filters.monetization,
getMonetizationFilterValue,
) &&
doesAnalyticsPointMatchNormalizedFilter(
dataPoint,
filters.userAgent,
getDownloadSourceFilterValue,
) &&
doesAnalyticsPointMatchNormalizedFilter(
dataPoint,
filters.downloadReason,
getDownloadReasonFilterValue,
) &&
doesAnalyticsPointMatchNormalizedFilter(
dataPoint,
filters.versionId,
getVersionFilterValue,
) &&
doesAnalyticsPointMatchNormalizedFilter(
dataPoint,
filters.gameVersion,
getGameVersionFilterValue,
) &&
doesAnalyticsPointMatchNormalizedFilter(dataPoint, filters.loaderType, getLoaderFilterValue)
)
case 'playtime':
return (
doesAnalyticsPointMatchNormalizedFilter(
dataPoint,
filters.country,
getCountryFilterValue,
) &&
doesAnalyticsPointMatchNormalizedFilter(
dataPoint,
filters.versionId,
getVersionFilterValue,
) &&
doesAnalyticsPointMatchNormalizedFilter(
dataPoint,
filters.gameVersion,
getGameVersionFilterValue,
) &&
doesAnalyticsPointMatchNormalizedFilter(dataPoint, filters.loaderType, getLoaderFilterValue)
)
case 'revenue':
return true
default:
return true
}
}
function doesAnalyticsPointMatchNormalizedFilter(
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
filterValues: ReadonlySet<string>,
getPointValue: (dataPoint: Labrinth.Analytics.v3.ProjectAnalytics) => string | null | undefined,
): boolean {
if (filterValues.size === 0) {
return true
}
const pointValue = getPointValue(dataPoint)
if (pointValue === undefined) {
return true
}
if (pointValue === null) {
return false
}
const normalizedPointValue = pointValue.trim().toLowerCase()
return filterValues.has(normalizedPointValue)
}
function getCountryFilterValue(
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
): string | null | undefined {
if (
dataPoint.metric_kind !== 'views' &&
dataPoint.metric_kind !== 'downloads' &&
dataPoint.metric_kind !== 'playtime'
) {
return undefined
}
return dataPoint.country ?? null
}
function getMonetizationFilterValue(
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
): string | null | undefined {
if (dataPoint.metric_kind !== 'views' && dataPoint.metric_kind !== 'downloads') {
return undefined
}
if (typeof dataPoint.monetized !== 'boolean') {
return null
}
return dataPoint.monetized ? 'monetized' : 'unmonetized'
}
function getDownloadSourceFilterValue(
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
): string | null | undefined {
if (dataPoint.metric_kind !== 'downloads') {
return undefined
}
return dataPoint.user_agent ?? null
}
function getDownloadReasonFilterValue(
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
): string | null | undefined {
if (dataPoint.metric_kind !== 'downloads') {
return undefined
}
return dataPoint.reason ?? null
}
function getVersionFilterValue(
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
): string | null | undefined {
if (dataPoint.metric_kind !== 'downloads' && dataPoint.metric_kind !== 'playtime') {
return undefined
}
return dataPoint.version_id ?? null
}
function getGameVersionFilterValue(
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
): string | null | undefined {
if (dataPoint.metric_kind !== 'downloads' && dataPoint.metric_kind !== 'playtime') {
return undefined
}
return dataPoint.game_version ?? null
}
function getLoaderFilterValue(
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
): string | null | undefined {
if (dataPoint.metric_kind !== 'downloads' && dataPoint.metric_kind !== 'playtime') {
return undefined
}
return dataPoint.loader ?? null
}
@@ -0,0 +1,101 @@
import {
getProjectStatusFilterValue,
type ProjectStatusFilterValue,
} from '~/components/analytics-dashboard/query-builder/query-filter'
import type {
AnalyticsDashboardProject,
AnalyticsDashboardProjectSource,
AnalyticsSelectedFilters,
ProjectTypeMetadata,
} from './analytics-types'
const MINECRAFT_JAVA_SERVER_PROJECT_TYPE = 'minecraft_java_server'
export const UNKNOWN_ORGANIZATION_NAME = 'Organization'
function isServerProject(project: ProjectTypeMetadata): boolean {
if (project.project_type === MINECRAFT_JAVA_SERVER_PROJECT_TYPE) {
return true
}
return project.project_types?.includes(MINECRAFT_JAVA_SERVER_PROJECT_TYPE) ?? false
}
export function isAnalyticsEligibleProject(
project: ProjectTypeMetadata & { status?: string | null },
): boolean {
return !isServerProject(project) && getProjectStatusFilterValue(project.status) !== 'draft'
}
export function getSingleQueryValue(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined
}
const normalizedValue = value.trim()
return normalizedValue.length > 0 ? normalizedValue : undefined
}
export function toAnalyticsDashboardProject(
project: AnalyticsDashboardProjectSource,
): AnalyticsDashboardProject {
return {
id: project.id,
name: project.name ?? project.title ?? project.id,
iconUrl: project.icon_url ?? undefined,
downloads: project.downloads ?? 0,
status: getProjectStatusFilterValue(project.status),
}
}
export function getUniqueAnalyticsDashboardProjects(
projects: AnalyticsDashboardProjectSource[],
seenProjectIds: Set<string>,
): AnalyticsDashboardProject[] {
const analyticsProjects: AnalyticsDashboardProject[] = []
for (const project of projects) {
if (seenProjectIds.has(project.id) || !isAnalyticsEligibleProject(project)) {
continue
}
seenProjectIds.add(project.id)
analyticsProjects.push(toAnalyticsDashboardProject(project))
}
return analyticsProjects
}
export function getProjectOrganizationId(
project: AnalyticsDashboardProjectSource,
): string | undefined {
return typeof project.organization === 'string' && project.organization.trim().length > 0
? project.organization
: undefined
}
export function doesProjectStatusMatchFilters(
status: string | null | undefined,
filters: AnalyticsSelectedFilters,
): boolean {
if (filters.project_status.length === 0) {
return true
}
return filters.project_status.includes(getProjectStatusFilterValue(status))
}
export function getProjectIdsMatchingStatusFilter(
projectIds: string[],
projectStatusById: Map<string, ProjectStatusFilterValue>,
filters: AnalyticsSelectedFilters,
): string[] {
if (filters.project_status.length === 0) {
return projectIds
}
return projectIds.filter((projectId) =>
doesProjectStatusMatchFilters(projectStatusById.get(projectId), filters),
)
}
@@ -0,0 +1,202 @@
import type { Labrinth } from '@modrinth/api-client'
import type { LocationQueryValueRaw } from 'vue-router'
import type { ProjectStatusFilterValue } from '~/components/analytics-dashboard/query-builder/query-filter'
export type AnalyticsQueryFilterCategory =
| 'project'
| 'project_status'
| 'country'
| 'monetization'
| 'user_agent'
| 'download_reason'
| 'version_id'
| 'game_version'
| 'loader_type'
export type AnalyticsTimeframePreset =
| 'today'
| 'yesterday'
| 'last_7_days'
| 'last_14_days'
| 'last_30_days'
| 'last_90_days'
| 'last_180_days'
| 'year_to_date'
| 'all_time'
export type AnalyticsTimeframeMode = 'preset' | 'last' | 'custom_range' | 'custom_datetime_range'
export type AnalyticsLastTimeframeUnit = 'hours' | 'days' | 'weeks' | 'months'
export type AnalyticsGroupByPreset = '1h' | '6h' | 'day' | 'week' | 'month' | 'year'
export type AnalyticsBreakdownPreset =
| 'none'
| 'project'
| 'country'
| 'monetization'
| 'user_agent'
| 'download_reason'
| 'version_id'
| 'loader'
| 'game_version'
export type AnalyticsSelectedBreakdowns = Exclude<AnalyticsBreakdownPreset, 'none'>[]
export type AnalyticsDashboardStat = 'views' | 'downloads' | 'revenue' | 'playtime'
export type AnalyticsGraphViewMode = 'line' | 'area' | 'bar'
export type AnalyticsTableSortColumn =
| 'date'
| 'project'
| 'breakdown'
| `breakdown_${Exclude<AnalyticsBreakdownPreset, 'none'>}`
| 'views'
| 'downloads'
| 'revenue'
| 'playtime'
export type AnalyticsTableSortDirection = 'asc' | 'desc'
export type AnalyticsSelectedFilters = Record<AnalyticsQueryFilterCategory, string[]>
export type AnalyticsQueryBuilderState = {
selectedProjectIds: string[]
selectedTimeframeMode: AnalyticsTimeframeMode
selectedTimeframe: AnalyticsTimeframePreset
selectedLastTimeframeAmount: number
selectedLastTimeframeUnit: AnalyticsLastTimeframeUnit
selectedCustomTimeframeStartDate: string
selectedCustomTimeframeEndDate: string
selectedGroupBy: AnalyticsGroupByPreset
selectedBreakdowns: AnalyticsSelectedBreakdowns
selectedFilters: AnalyticsSelectedFilters
}
export type AnalyticsGraphState = {
activeStat: AnalyticsDashboardStat
activeGraphViewMode: AnalyticsGraphViewMode
isRatioMode: boolean
showChartEvents: boolean
showProjectEvents: boolean
showPreviousPeriod: boolean
hiddenGraphDatasetIds: string[]
selectedGraphDatasetIds: string[] | null
}
export type AnalyticsTableSortState = {
sortColumn: AnalyticsTableSortColumn | undefined
sortDirection: AnalyticsTableSortDirection
}
export type MutableRouteQuery = Record<
string,
LocationQueryValueRaw | LocationQueryValueRaw[] | undefined
>
export type ProjectTypeMetadata = {
project_type?: string | null
project_types?: readonly string[] | null
}
export type AnalyticsProjectFetchRequest = Labrinth.Analytics.v3.FetchRequest & {
project_ids: string[]
}
export type AnalyticsDashboardProjectSource = ProjectTypeMetadata & {
id: string
name?: string | null
title?: string | null
organization?: string | null
icon_url?: string | null
downloads?: number | null
status?: string | null
}
export type AnalyticsProjectVersionSource = {
id: string
versions?: readonly string[] | null
}
export interface AnalyticsDashboardProject {
id: string
name: string
iconUrl?: string
downloads: number
status: ProjectStatusFilterValue
}
export interface AnalyticsDashboardProjectGroup {
key?: string
title?: string
projects: AnalyticsDashboardProject[]
}
export interface AnalyticsDashboardTotals {
views: number
downloads: number
revenue: number
playtime: number
}
export interface AnalyticsDashboardPercentChanges {
views: number
downloads: number
revenue: number
playtime: number
}
export interface AnalyticsDashboardFilterOptions {
countries: string[]
downloadSources: string[]
downloadReasons: string[]
gameVersions: string[]
loaderTypes: string[]
versionIds: string[]
}
export interface NormalizedAnalyticsSelectedFilters {
country: ReadonlySet<string>
monetization: ReadonlySet<string>
userAgent: ReadonlySet<string>
downloadReason: ReadonlySet<string>
versionId: ReadonlySet<string>
gameVersion: ReadonlySet<string>
loaderType: ReadonlySet<string>
}
export interface AnalyticsFacetsFilterOptionSummary {
countries: string[]
downloadSources: string[]
downloadReasons: string[]
gameVersions: string[]
loaderTypes: string[]
versionIds: string[]
projectDownloadsById: Map<string, number>
projectVersionDownloadsById: Map<string, number>
gameVersionDownloadsByVersion: Map<string, number>
countryDownloadsByCode: Map<string, number>
}
export interface ProjectVersionFilterOptionSummary {
gameVersions: string[]
loaderTypes: string[]
versionIds: string[]
}
export interface AnalyticsVersionMetadata {
id: string
versionNumber: string
datePublished: string
projectId: string
downloads: number
gameVersions: string[]
loaders: string[]
}
export type AnalyticsTimeSliceSplit = {
currentTimeSlices: Labrinth.Analytics.v3.TimeSlice[]
previousTimeSlices: Labrinth.Analytics.v3.TimeSlice[]
}
export type AnalyticsFetchData = {
metrics: Labrinth.Analytics.v3.TimeSlice[]
project_events: Labrinth.Analytics.v3.ProjectAnalyticsEvent[]
}
File diff suppressed because it is too large Load Diff
-493
View File
@@ -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,
}
}
+4
View File
@@ -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',
},
)
}
}
+14
View File
@@ -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
+16
View File
@@ -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

+16
View File
@@ -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

+17
View File
@@ -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

+16
View File
@@ -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

+16
View File
@@ -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

+16
View File
@@ -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

+7
View File
@@ -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.
+1
View File
@@ -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,
+3 -3
View File
@@ -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>
+3 -4
View File
@@ -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>
+282 -88
View File
@@ -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>
+240 -39
View File
@@ -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) {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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"
+184 -109
View File
@@ -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>
+3 -3
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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',
+14 -2
View File
@@ -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'
+26 -7
View File
@@ -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,
}
}
+87
View File
@@ -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."
},
+184 -41
View File
@@ -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.',
},
},
},
}
+111 -1
View File
@@ -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
+25 -6
View File
@@ -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