diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 22012b479f..2bca56665a 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -56,6 +56,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", diff --git a/apps/frontend/src/components/analytics/AnalyticsDashboard.vue b/apps/frontend/src/components/analytics/AnalyticsDashboard.vue new file mode 100644 index 0000000000..e3f1d6bf21 --- /dev/null +++ b/apps/frontend/src/components/analytics/AnalyticsDashboard.vue @@ -0,0 +1,35 @@ + + + diff --git a/apps/frontend/src/components/analytics/breakdown.ts b/apps/frontend/src/components/analytics/breakdown.ts new file mode 100644 index 0000000000..2596ec46c0 --- /dev/null +++ b/apps/frontend/src/components/analytics/breakdown.ts @@ -0,0 +1,38 @@ +import type { Labrinth } from '@modrinth/api-client' + +import type { AnalyticsBreakdownPreset } from '~/providers/analytics/analytics' + +export const ALL_BREAKDOWN_VALUE = 'All' + +export function getAnalyticsBreakdownValue( + point: Labrinth.Analytics.v3.ProjectAnalytics, + selectedBreakdown: AnalyticsBreakdownPreset, +): string { + switch (selectedBreakdown) { + case 'none': + return ALL_BREAKDOWN_VALUE + case 'country': + return normalizeBreakdownValue('country' in point ? point.country : undefined) + case 'monetization': { + if ('monetized' in point && typeof point.monetized === 'boolean') { + return point.monetized ? 'monetized' : 'unmonetized' + } + return ALL_BREAKDOWN_VALUE + } + case 'download_source': + return normalizeBreakdownValue('domain' in point ? point.domain : undefined) + case 'download_type': + return normalizeBreakdownValue('version_id' in point ? point.version_id : undefined) + case 'loader': + return normalizeBreakdownValue('loader' in point ? point.loader : undefined) + case 'game_version': + return normalizeBreakdownValue('game_version' in point ? point.game_version : undefined) + default: + return ALL_BREAKDOWN_VALUE + } +} + +function normalizeBreakdownValue(value: string | undefined): string { + const normalized = value?.trim() + return normalized && normalized.length > 0 ? normalized : ALL_BREAKDOWN_VALUE +} diff --git a/apps/frontend/src/components/analytics/graph/AnalyticsChart.client.vue b/apps/frontend/src/components/analytics/graph/AnalyticsChart.client.vue new file mode 100644 index 0000000000..a149f907aa --- /dev/null +++ b/apps/frontend/src/components/analytics/graph/AnalyticsChart.client.vue @@ -0,0 +1,225 @@ + + + diff --git a/apps/frontend/src/components/analytics/graph/AnalyticsChartTooltip.vue b/apps/frontend/src/components/analytics/graph/AnalyticsChartTooltip.vue new file mode 100644 index 0000000000..c83e06ae00 --- /dev/null +++ b/apps/frontend/src/components/analytics/graph/AnalyticsChartTooltip.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/apps/frontend/src/components/analytics/graph/AnalyticsGraph.vue b/apps/frontend/src/components/analytics/graph/AnalyticsGraph.vue new file mode 100644 index 0000000000..e7324f3fe6 --- /dev/null +++ b/apps/frontend/src/components/analytics/graph/AnalyticsGraph.vue @@ -0,0 +1,252 @@ + + + diff --git a/apps/frontend/src/components/analytics/graph/utils.ts b/apps/frontend/src/components/analytics/graph/utils.ts new file mode 100644 index 0000000000..544a6cc0d7 --- /dev/null +++ b/apps/frontend/src/components/analytics/graph/utils.ts @@ -0,0 +1,233 @@ +import type { Labrinth } from '@modrinth/api-client' + +import type { + AnalyticsBreakdownPreset, + AnalyticsDashboardProject, + AnalyticsDashboardStat, + AnalyticsGroupByPreset, +} from '~/providers/analytics/analytics' + +import { getAnalyticsBreakdownValue } from '../breakdown' + +export type ChartDataset = { + projectId: string + label: string + data: number[] + borderColor: string + backgroundColor: string +} + +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 + } + } +} + +export function buildChartDatasets( + timeSlices: Labrinth.Analytics.v3.TimeSlice[], + selectedProjects: AnalyticsDashboardProject[], + activeStat: AnalyticsDashboardStat, + palette: string[], + selectedBreakdown: AnalyticsBreakdownPreset, +): ChartDataset[] { + const selectedProjectIds = new Set(selectedProjects.map((project) => project.id)) + if (selectedProjectIds.size === 0) { + return [] + } + + if (selectedBreakdown !== 'none') { + const dataByBreakdown = new Map() + + timeSlices.forEach((slice, sliceIndex) => { + for (const point of slice) { + if (!('source_project' in point)) continue + if (!selectedProjectIds.has(point.source_project)) continue + + const value = getMetricValue(point, activeStat) + if (value === 0) continue + + const breakdownValue = getAnalyticsBreakdownValue(point, selectedBreakdown) + + let breakdownData = dataByBreakdown.get(breakdownValue) + if (!breakdownData) { + breakdownData = new Array(timeSlices.length).fill(0) + dataByBreakdown.set(breakdownValue, breakdownData) + } + + breakdownData[sliceIndex] += value + } + }) + + return Array.from(dataByBreakdown.entries()).map(([breakdownValue, data], index) => { + const color = palette[index % palette.length] + return { + projectId: `breakdown:${breakdownValue}`, + label: breakdownValue, + data, + borderColor: color, + backgroundColor: color, + } + }) + } + + const dataByProjectId = new Map() + for (const project of selectedProjects) { + dataByProjectId.set(project.id, new Array(timeSlices.length).fill(0)) + } + + timeSlices.forEach((slice, sliceIndex) => { + for (const point of slice) { + if (!('source_project' in point)) continue + if (!selectedProjectIds.has(point.source_project)) continue + + const projectData = dataByProjectId.get(point.source_project) + if (!projectData) continue + + projectData[sliceIndex] += getMetricValue(point, activeStat) + } + }) + + return selectedProjects.map((project, index) => { + const color = palette[index % palette.length] + return { + projectId: project.id, + label: project.name, + data: dataByProjectId.get(project.id) ?? [], + 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), + } +} + +export function buildTimeAxisLabels( + timeRange: Labrinth.Analytics.v3.TimeRange, + sliceCount: number, + includeTime: boolean, +): 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 formatter = getBucketEndFormatter(includeTime) + + const labels: string[] = [] + for (let i = 0; i < sliceCount; i++) { + labels.push(formatter.format(new Date(startMs + (i + 1) * bucketMs))) + } + return labels +} + +function getBucketEndFormatter(includeTime: boolean): Intl.DateTimeFormat { + if (includeTime) { + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }) + } + return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }) +} + +export function isTimeRelevantForGroupBy(groupBy: AnalyticsGroupByPreset): boolean { + return groupBy === '1h' || groupBy === '6h' +} + +export function formatBucketEndLabel(end: Date, includeTime: boolean): string { + if (includeTime) { + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(end) + } + + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + }).format(end) +} + +export function formatMetricValue( + value: number, + activeStat: AnalyticsDashboardStat, + formatNumber: (value: number) => string, +): string { + switch (activeStat) { + case 'revenue': { + const amount = Math.round(value * 100) / 100 + return `$${formatNumber(amount)}` + } + case 'playtime': { + const hours = value / 3600 + return `${hours.toFixed(1)} hrs` + } + case 'views': + case 'downloads': + default: + return formatNumber(Math.round(value)) + } +} + +export function formatAxisValue( + value: number, + activeStat: AnalyticsDashboardStat, + formatCompact: (value: number) => string, +): string { + switch (activeStat) { + case 'revenue': + return `$${formatCompact(Math.round(value * 100) / 100)}` + case 'playtime': + return `${(value / 3600).toFixed(1)}h` + case 'views': + case 'downloads': + default: + return formatCompact(Math.round(value)) + } +} diff --git a/apps/frontend/src/components/analytics/query-builder/QueryBuilder.vue b/apps/frontend/src/components/analytics/query-builder/QueryBuilder.vue new file mode 100644 index 0000000000..e95dfa9476 --- /dev/null +++ b/apps/frontend/src/components/analytics/query-builder/QueryBuilder.vue @@ -0,0 +1,613 @@ + + + diff --git a/apps/frontend/src/components/analytics/stat-cards/StatCard.vue b/apps/frontend/src/components/analytics/stat-cards/StatCard.vue new file mode 100644 index 0000000000..93c0d14393 --- /dev/null +++ b/apps/frontend/src/components/analytics/stat-cards/StatCard.vue @@ -0,0 +1,148 @@ + + + diff --git a/apps/frontend/src/components/analytics/stat-cards/StatCards.vue b/apps/frontend/src/components/analytics/stat-cards/StatCards.vue new file mode 100644 index 0000000000..fec09526c0 --- /dev/null +++ b/apps/frontend/src/components/analytics/stat-cards/StatCards.vue @@ -0,0 +1,104 @@ + + + diff --git a/apps/frontend/src/components/analytics/table/AnalyticsTable.vue b/apps/frontend/src/components/analytics/table/AnalyticsTable.vue new file mode 100644 index 0000000000..f808efd387 --- /dev/null +++ b/apps/frontend/src/components/analytics/table/AnalyticsTable.vue @@ -0,0 +1,407 @@ + + + diff --git a/apps/frontend/src/components/ui/charts/Chart.client.vue b/apps/frontend/src/components/ui/charts/Chart.client.vue deleted file mode 100644 index 16ea42aaea..0000000000 --- a/apps/frontend/src/components/ui/charts/Chart.client.vue +++ /dev/null @@ -1,495 +0,0 @@ - - - - - diff --git a/apps/frontend/src/components/ui/charts/ChartDisplay.vue b/apps/frontend/src/components/ui/charts/ChartDisplay.vue deleted file mode 100644 index 5dbb2dbdf6..0000000000 --- a/apps/frontend/src/components/ui/charts/ChartDisplay.vue +++ /dev/null @@ -1,1009 +0,0 @@ - - - - - - - diff --git a/apps/frontend/src/components/ui/charts/CompactChart.client.vue b/apps/frontend/src/components/ui/charts/CompactChart.client.vue deleted file mode 100644 index 1a76864196..0000000000 --- a/apps/frontend/src/components/ui/charts/CompactChart.client.vue +++ /dev/null @@ -1,281 +0,0 @@ - - - - - diff --git a/apps/frontend/src/layouts/default.vue b/apps/frontend/src/layouts/default.vue index f0bf302991..3168b7fec1 100644 --- a/apps/frontend/src/layouts/default.vue +++ b/apps/frontend/src/layouts/default.vue @@ -1199,6 +1199,10 @@ async function logoutUser() { } function runAnalytics() { + if (import.meta.dev) { + return + } + const config = useRuntimeConfig() const replacedUrl = config.public.apiBaseUrl.replace('v2/', '') diff --git a/apps/frontend/src/pages/[type]/[id]/settings/analytics.vue b/apps/frontend/src/pages/[type]/[id]/settings/analytics.vue index ce57caf281..447eb7eae7 100644 --- a/apps/frontend/src/pages/[type]/[id]/settings/analytics.vue +++ b/apps/frontend/src/pages/[type]/[id]/settings/analytics.vue @@ -1,30 +1,7 @@ - - - diff --git a/apps/frontend/src/pages/dashboard/analytics.vue b/apps/frontend/src/pages/dashboard/analytics.vue index 25d8376438..c410a86725 100644 --- a/apps/frontend/src/pages/dashboard/analytics.vue +++ b/apps/frontend/src/pages/dashboard/analytics.vue @@ -1,22 +1,9 @@ - diff --git a/apps/frontend/src/pages/organization/[id]/settings/analytics.vue b/apps/frontend/src/pages/organization/[id]/settings/analytics.vue index 081bc89587..dce239756c 100644 --- a/apps/frontend/src/pages/organization/[id]/settings/analytics.vue +++ b/apps/frontend/src/pages/organization/[id]/settings/analytics.vue @@ -1,28 +1,9 @@ - - - diff --git a/apps/frontend/src/providers/analytics/analytics.ts b/apps/frontend/src/providers/analytics/analytics.ts new file mode 100644 index 0000000000..4e8ddc2d19 --- /dev/null +++ b/apps/frontend/src/providers/analytics/analytics.ts @@ -0,0 +1,478 @@ +import type { Labrinth } from '@modrinth/api-client' +import { createContext, injectModrinthClient, type ProjectPageContext } from '@modrinth/ui' +import { useQuery } from '@tanstack/vue-query' +import type { ComputedRef, Ref } from 'vue' + +import type { OrganizationContext } from '../organization-context' +import { + type AnalyticsBreakdownPreset, + type AnalyticsGroupByPreset, + type AnalyticsSelectedFilters, + type AnalyticsTimeframePreset, + areSelectedFiltersEqual, + areStringArraysEqual, + buildAnalyticsQueryBuilderRouteQuery, + hasAnalyticsQueryBuilderRouteChange, + readAnalyticsQueryBuilderState, +} from './query-builder-url' + +export type { + AnalyticsBreakdownPreset, + AnalyticsGroupByPreset, + AnalyticsQueryFilterCategory, + AnalyticsSelectedFilters, + AnalyticsTimeframePreset, +} from './query-builder-url' + +export type AnalyticsDashboardStat = 'views' | 'downloads' | 'revenue' | 'playtime' + +const MINECRAFT_JAVA_SERVER_PROJECT_TYPE = 'minecraft_java_server' + +type ProjectTypeMetadata = { + project_type?: string | null + project_types?: readonly string[] | null +} + +const ANALYTICS_DASHBOARD_STAT_ORDER: AnalyticsDashboardStat[] = [ + 'views', + 'downloads', + 'revenue', + 'playtime', +] + +const ANALYTICS_RELEVANT_STATS_BY_BREAKDOWN: Record< + AnalyticsBreakdownPreset, + readonly AnalyticsDashboardStat[] +> = { + none: ANALYTICS_DASHBOARD_STAT_ORDER, + country: ['views', 'downloads'], + monetization: ['views'], + download_source: ['downloads'], + download_type: ['downloads', 'playtime'], + loader: ['playtime'], + game_version: ['playtime'], +} + +export interface AnalyticsDashboardProject { + id: string + name: string +} + +export interface AnalyticsDashboardTotals { + views: number + downloads: number + revenue: number + playtime: number +} + +export interface AnalyticsDashboardPercentChanges { + views: number + downloads: number + revenue: number + playtime: number +} + +export interface AnalyticsDashboardContextValue { + projects: ComputedRef + selectedProjectIds: Ref + selectedTimeframe: Ref + selectedGroupBy: Ref + selectedBreakdown: Ref + selectedFilters: Ref + fetchRequest: Ref + timeSlices: Ref + previousTimeSlices: Ref + isLoading: ComputedRef + isRefetching: ComputedRef + activeStat: Ref + currentTotals: ComputedRef + previousTotals: ComputedRef + percentChanges: ComputedRef + getRelevantAnalyticsDashboardStats: ( + breakdown: AnalyticsBreakdownPreset, + ) => readonly AnalyticsDashboardStat[] + isAnalyticsDashboardStatRelevant: ( + stat: AnalyticsDashboardStat, + breakdown: AnalyticsBreakdownPreset, + ) => boolean + setFetchRequest: (fetchRequest: Labrinth.Analytics.v3.FetchRequest) => void + setActiveStat: (stat: AnalyticsDashboardStat) => void +} + +export type CreateAnalyticsDashboardContextOptions = { + auth: Ref<{ user?: { id?: string } | null }> + projectPageContext?: ProjectPageContext | null + organizationContext?: OrganizationContext | null +} + +export const [injectAnalyticsDashboardContext, provideAnalyticsDashboardContext] = + createContext('AnalyticsDashboard') + +function buildPreviousFetchRequest( + fetchRequest: Labrinth.Analytics.v3.FetchRequest | null, +): Labrinth.Analytics.v3.FetchRequest | null { + if (!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 previousEnd = new Date(startTimestamp) + const previousStart = new Date(startTimestamp - duration) + + return { + time_range: { + start: previousStart.toISOString(), + end: previousEnd.toISOString(), + resolution: fetchRequest.time_range.resolution, + }, + return_metrics: fetchRequest.return_metrics, + } +} + +function getPercentChange(currentValue: number, previousValue: number): number { + if (previousValue === 0) { + if (currentValue === 0) { + return 0 + } + return 100 + } + + return ((currentValue - previousValue) / previousValue) * 100 +} + +function computeTotals( + timeSlices: Labrinth.Analytics.v3.TimeSlice[], + selectedProjectIds: Set, + availableProjectIds: Set, +): 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 + + for (const timeSlice of timeSlices) { + for (const dataPoint of timeSlice) { + if (!('source_project' in dataPoint)) { + continue + } + + if (!effectiveProjectIds.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 +} + +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 createAnalyticsDashboardContext( + options: CreateAnalyticsDashboardContextOptions, +): AnalyticsDashboardContextValue { + const client = injectModrinthClient() + const route = useRoute() + const router = useRouter() + const initialQueryState = readAnalyticsQueryBuilderState(route.query, []) + + const activeStat = ref('views') + const selectedProjectIds = ref(initialQueryState.selectedProjectIds) + const selectedTimeframe = ref(initialQueryState.selectedTimeframe) + const selectedGroupBy = ref(initialQueryState.selectedGroupBy) + const selectedBreakdown = ref(initialQueryState.selectedBreakdown) + const selectedFilters = ref(initialQueryState.selectedFilters) + const fetchRequest = ref(null) + + const hasProjectContext = computed(() => Boolean(options.projectPageContext)) + const hasOrganizationContext = computed( + () => !hasProjectContext.value && Boolean(options.organizationContext), + ) + + const { data: userProjects } = useQuery({ + queryKey: computed(() => ['analytics', 'dashboard', options.auth.value?.user?.id, 'projects']), + queryFn: () => client.labrinth.users_v2.getProjects(options.auth.value.user?.id ?? ''), + enabled: computed( + () => + Boolean(options.auth.value.user?.id) && + !hasProjectContext.value && + !hasOrganizationContext.value, + ), + placeholderData: [], + }) + + const projects = computed(() => { + if (hasProjectContext.value && options.projectPageContext) { + const project = options.projectPageContext.projectV2.value + return project && !isServerProject(project) ? [{ id: project.id, name: project.title }] : [] + } + + if (hasOrganizationContext.value && options.organizationContext?.projects.value) { + return options.organizationContext.projects.value + .filter((project) => !isServerProject(project)) + .map((project) => ({ + id: project.id, + name: project.name, + })) + } + + return (userProjects.value ?? []) + .filter((project) => !isServerProject(project)) + .map((project) => ({ + id: project.id, + name: project.title, + })) + }) + + const availableProjectIds = computed(() => projects.value.map((project) => project.id)) + + function getRelevantAnalyticsDashboardStats( + breakdown: AnalyticsBreakdownPreset, + ): readonly AnalyticsDashboardStat[] { + return ANALYTICS_RELEVANT_STATS_BY_BREAKDOWN[breakdown] ?? ANALYTICS_DASHBOARD_STAT_ORDER + } + + function isAnalyticsDashboardStatRelevant( + stat: AnalyticsDashboardStat, + breakdown: AnalyticsBreakdownPreset, + ): boolean { + return getRelevantAnalyticsDashboardStats(breakdown).includes(stat) + } + + watch( + [selectedBreakdown, activeStat], + ([nextBreakdown, nextActiveStat]) => { + if (isAnalyticsDashboardStatRelevant(nextActiveStat, nextBreakdown)) { + return + } + + const fallbackStat = getRelevantAnalyticsDashboardStats(nextBreakdown)[0] + if (fallbackStat && fallbackStat !== nextActiveStat) { + activeStat.value = fallbackStat + } + }, + { immediate: true }, + ) + + watch( + projects, + (nextProjects) => { + if (nextProjects.length === 0) { + return + } + + const availableProjectIds = new Set(nextProjects.map((project) => project.id)) + const retainedSelection = selectedProjectIds.value.filter((id) => availableProjectIds.has(id)) + + selectedProjectIds.value = + retainedSelection.length > 0 ? retainedSelection : nextProjects.map((project) => project.id) + }, + { immediate: true }, + ) + + watch( + () => route.query, + (nextQuery) => { + const nextQueryState = readAnalyticsQueryBuilderState(nextQuery, availableProjectIds.value) + + if (!areStringArraysEqual(selectedProjectIds.value, nextQueryState.selectedProjectIds)) { + selectedProjectIds.value = nextQueryState.selectedProjectIds + } + if (selectedTimeframe.value !== nextQueryState.selectedTimeframe) { + selectedTimeframe.value = nextQueryState.selectedTimeframe + } + if (selectedGroupBy.value !== nextQueryState.selectedGroupBy) { + selectedGroupBy.value = nextQueryState.selectedGroupBy + } + if (selectedBreakdown.value !== nextQueryState.selectedBreakdown) { + selectedBreakdown.value = nextQueryState.selectedBreakdown + } + if (!areSelectedFiltersEqual(selectedFilters.value, nextQueryState.selectedFilters)) { + selectedFilters.value = nextQueryState.selectedFilters + } + }, + ) + + watch( + [ + selectedProjectIds, + selectedTimeframe, + selectedGroupBy, + selectedBreakdown, + selectedFilters, + availableProjectIds, + ], + () => { + if (import.meta.server) { + return + } + + const nextRouteQuery = buildAnalyticsQueryBuilderRouteQuery( + route.query, + { + selectedProjectIds: selectedProjectIds.value, + selectedTimeframe: selectedTimeframe.value, + selectedGroupBy: selectedGroupBy.value, + selectedBreakdown: selectedBreakdown.value, + selectedFilters: selectedFilters.value, + }, + availableProjectIds.value, + ) + + const hasAnalyticsQueryChange = hasAnalyticsQueryBuilderRouteChange( + route.query, + nextRouteQuery, + ) + + if (!hasAnalyticsQueryChange) return + + router.replace({ + path: route.path, + query: nextRouteQuery, + }) + }, + { deep: true, immediate: true }, + ) + + const { + data: currentTimeSliceData, + isPending: currentTimeSlicePending, + isFetching: currentFetching, + } = useQuery({ + queryKey: computed(() => ['analytics', 'dashboard', 'current', fetchRequest.value]), + queryFn: () => + client.labrinth.analytics_v3.fetch(fetchRequest.value as Labrinth.Analytics.v3.FetchRequest), + enabled: computed(() => fetchRequest.value !== null), + placeholderData: [], + }) + + const previousFetchRequest = computed(() => buildPreviousFetchRequest(fetchRequest.value)) + + const { + data: previousTimeSliceData, + isPending: previousTimeSlicePending, + isFetching: previousFetching, + } = useQuery({ + queryKey: computed(() => ['analytics', 'dashboard', 'previous', previousFetchRequest.value]), + queryFn: () => + client.labrinth.analytics_v3.fetch( + previousFetchRequest.value as Labrinth.Analytics.v3.FetchRequest, + ), + enabled: computed(() => previousFetchRequest.value !== null), + placeholderData: [], + }) + + const timeSlices = ref([]) + const previousTimeSlices = ref([]) + + watch( + currentTimeSliceData, + (nextTimeSlices) => { + timeSlices.value = nextTimeSlices ?? [] + }, + { immediate: true }, + ) + + watch( + previousTimeSliceData, + (nextTimeSlices) => { + previousTimeSlices.value = nextTimeSlices ?? [] + }, + { immediate: true }, + ) + + const selectedProjectIdSet = computed(() => new Set(selectedProjectIds.value)) + const availableProjectIdSet = computed(() => new Set(availableProjectIds.value)) + + const currentTotals = computed(() => + computeTotals(timeSlices.value, selectedProjectIdSet.value, availableProjectIdSet.value), + ) + const previousTotals = computed(() => + computeTotals( + previousTimeSlices.value, + selectedProjectIdSet.value, + availableProjectIdSet.value, + ), + ) + + const percentChanges = computed(() => ({ + views: getPercentChange(currentTotals.value.views, previousTotals.value.views), + downloads: getPercentChange(currentTotals.value.downloads, previousTotals.value.downloads), + revenue: getPercentChange(currentTotals.value.revenue, previousTotals.value.revenue), + playtime: getPercentChange(currentTotals.value.playtime, previousTotals.value.playtime), + })) + + const isLoading = computed(() => currentTimeSlicePending.value || previousTimeSlicePending.value) + const isRefetching = computed(() => currentFetching.value || previousFetching.value) + + function setFetchRequest(nextFetchRequest: Labrinth.Analytics.v3.FetchRequest) { + fetchRequest.value = nextFetchRequest + } + + function setActiveStat(nextStat: AnalyticsDashboardStat) { + if (!isAnalyticsDashboardStatRelevant(nextStat, selectedBreakdown.value)) { + return + } + + activeStat.value = nextStat + } + + return { + projects, + selectedProjectIds, + selectedTimeframe, + selectedGroupBy, + selectedBreakdown, + selectedFilters, + fetchRequest, + timeSlices, + previousTimeSlices, + isLoading, + isRefetching, + activeStat, + currentTotals, + previousTotals, + percentChanges, + getRelevantAnalyticsDashboardStats, + isAnalyticsDashboardStatRelevant, + setFetchRequest, + setActiveStat, + } +} diff --git a/apps/frontend/src/providers/analytics/query-builder-url.ts b/apps/frontend/src/providers/analytics/query-builder-url.ts new file mode 100644 index 0000000000..4a9991ad8e --- /dev/null +++ b/apps/frontend/src/providers/analytics/query-builder-url.ts @@ -0,0 +1,316 @@ +import type { LocationQuery, LocationQueryValue, LocationQueryValueRaw } from 'vue-router' + +export type AnalyticsQueryFilterCategory = + | 'project' + | 'country' + | 'monetization' + | 'download_source' + | 'download_type' + | '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 AnalyticsGroupByPreset = '1h' | '6h' | 'day' | 'week' | 'month' | 'year' + +export type AnalyticsBreakdownPreset = + | 'none' + | 'country' + | 'monetization' + | 'download_source' + | 'download_type' + | 'loader' + | 'game_version' + +export type AnalyticsSelectedFilters = Record + +export type AnalyticsQueryBuilderState = { + selectedProjectIds: string[] + selectedTimeframe: AnalyticsTimeframePreset + selectedGroupBy: AnalyticsGroupByPreset + selectedBreakdown: AnalyticsBreakdownPreset + selectedFilters: AnalyticsSelectedFilters +} + +type MutableRouteQuery = Record + +export const DEFAULT_TIMEFRAME_PRESET: AnalyticsTimeframePreset = 'yesterday' +export const DEFAULT_GROUP_BY_PRESET: AnalyticsGroupByPreset = '1h' +export const DEFAULT_BREAKDOWN_PRESET: AnalyticsBreakdownPreset = 'none' + +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 GROUP_BY_PRESET_VALUES: AnalyticsGroupByPreset[] = [ + '1h', + '6h', + 'day', + 'week', + 'month', + 'year', +] + +const BREAKDOWN_PRESET_VALUES: AnalyticsBreakdownPreset[] = [ + 'none', + 'country', + 'monetization', + 'download_source', + 'download_type', + 'loader', + 'game_version', +] + +const QUERY_KEY_PROJECT_IDS = 'a_projects' +const QUERY_KEY_TIMEFRAME = 'a_timeframe' +const QUERY_KEY_GROUP_BY = 'a_group_by' +const QUERY_KEY_BREAKDOWN = 'a_breakdown' +const QUERY_KEY_FILTER_COUNTRY = 'a_country' +const QUERY_KEY_FILTER_MONETIZATION = 'a_monetization' +const QUERY_KEY_FILTER_DOWNLOAD_SOURCE = 'a_download_source' +const QUERY_KEY_FILTER_DOWNLOAD_TYPE = 'a_download_type' +const QUERY_KEY_FILTER_GAME_VERSION = 'a_game_version' +const QUERY_KEY_FILTER_LOADER_TYPE = 'a_loader_type' + +const URL_FILTER_CATEGORIES: Exclude[] = [ + 'country', + 'monetization', + 'download_source', + 'download_type', + 'game_version', + 'loader_type', +] + +const FILTER_QUERY_KEY_BY_CATEGORY: Record< + Exclude, + string +> = { + country: QUERY_KEY_FILTER_COUNTRY, + monetization: QUERY_KEY_FILTER_MONETIZATION, + download_source: QUERY_KEY_FILTER_DOWNLOAD_SOURCE, + download_type: QUERY_KEY_FILTER_DOWNLOAD_TYPE, + 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, + QUERY_KEY_GROUP_BY, + QUERY_KEY_BREAKDOWN, + QUERY_KEY_FILTER_COUNTRY, + QUERY_KEY_FILTER_MONETIZATION, + QUERY_KEY_FILTER_DOWNLOAD_SOURCE, + QUERY_KEY_FILTER_DOWNLOAD_TYPE, + QUERY_KEY_FILTER_GAME_VERSION, + QUERY_KEY_FILTER_LOADER_TYPE, +] + +export function buildEmptySelectedFilters(): AnalyticsSelectedFilters { + return { + project: [], + country: [], + monetization: [], + download_source: [], + download_type: [], + 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 parsePresetQueryValue( + 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 serializeListQueryValue(values: string[]): string | undefined { + if (values.length === 0) return undefined + return values.join(',') +} + +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 readAnalyticsQueryBuilderState( + query: LocationQuery, + availableProjectIds: string[], +): AnalyticsQueryBuilderState { + const selectedProjectIdsFromQuery = parseListQueryValue(query[QUERY_KEY_PROJECT_IDS]) + const selectedProjectIds = + selectedProjectIdsFromQuery.length > 0 ? selectedProjectIdsFromQuery : availableProjectIds + + const selectedFilters = buildEmptySelectedFilters() + for (const category of URL_FILTER_CATEGORIES) { + selectedFilters[category] = parseListQueryValue(query[FILTER_QUERY_KEY_BY_CATEGORY[category]]) + } + + return { + selectedProjectIds, + selectedTimeframe: parsePresetQueryValue( + query[QUERY_KEY_TIMEFRAME], + TIMEFRAME_PRESET_VALUES, + DEFAULT_TIMEFRAME_PRESET, + ), + selectedGroupBy: parsePresetQueryValue( + query[QUERY_KEY_GROUP_BY], + GROUP_BY_PRESET_VALUES, + DEFAULT_GROUP_BY_PRESET, + ), + selectedBreakdown: parsePresetQueryValue( + query[QUERY_KEY_BREAKDOWN], + BREAKDOWN_PRESET_VALUES, + DEFAULT_BREAKDOWN_PRESET, + ), + selectedFilters, + } +} + +export function buildAnalyticsQueryBuilderRouteQuery( + currentRouteQuery: LocationQuery, + state: AnalyticsQueryBuilderState, + availableProjectIds: string[], +): MutableRouteQuery { + const nextRouteQuery = { + ...currentRouteQuery, + } as MutableRouteQuery + + const projectIdsQueryValue = areAllProjectsSelected(state.selectedProjectIds, availableProjectIds) + ? undefined + : serializeListQueryValue(state.selectedProjectIds) + + nextRouteQuery[QUERY_KEY_PROJECT_IDS] = projectIdsQueryValue + nextRouteQuery[QUERY_KEY_TIMEFRAME] = + state.selectedTimeframe !== DEFAULT_TIMEFRAME_PRESET ? state.selectedTimeframe : undefined + nextRouteQuery[QUERY_KEY_GROUP_BY] = + state.selectedGroupBy !== DEFAULT_GROUP_BY_PRESET ? state.selectedGroupBy : undefined + nextRouteQuery[QUERY_KEY_BREAKDOWN] = + state.selectedBreakdown !== DEFAULT_BREAKDOWN_PRESET ? state.selectedBreakdown : undefined + + for (const category of URL_FILTER_CATEGORIES) { + const categoryQueryKey = FILTER_QUERY_KEY_BY_CATEGORY[category] + nextRouteQuery[categoryQueryKey] = serializeListQueryValue(state.selectedFilters[category]) + } + + return nextRouteQuery +} + +export function hasAnalyticsQueryBuilderRouteChange( + currentRouteQuery: LocationQuery, + nextRouteQuery: MutableRouteQuery, +): boolean { + return ANALYTICS_QUERY_KEYS.some( + (key) => !areQueryValuesEqual(currentRouteQuery[key], nextRouteQuery[key]), + ) +} diff --git a/apps/frontend/src/utils/analytics.js b/apps/frontend/src/utils/analytics.js deleted file mode 100644 index c516de1287..0000000000 --- a/apps/frontend/src/utils/analytics.js +++ /dev/null @@ -1,491 +0,0 @@ -import { injectI18n, useDebugLogger } from '@modrinth/ui' -import dayjs from 'dayjs' -import { computed, ref, watch } from 'vue' - -// 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} 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, - } -} diff --git a/packages/api-client/src/modules/index.ts b/packages/api-client/src/modules/index.ts index 9eed22bc72..83908e706e 100644 --- a/packages/api-client/src/modules/index.ts +++ b/packages/api-client/src/modules/index.ts @@ -12,6 +12,7 @@ import { KyrosFilesV0Module } from './kyros/files/v0' import { KyrosLogsV1Module } from './kyros/logs/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' @@ -68,6 +69,7 @@ export const MODULE_REGISTRY = { kyros_files_v0: KyrosFilesV0Module, kyros_logs_v1: KyrosLogsV1Module, labrinth_affiliate_internal: LabrinthAffiliateInternalModule, + labrinth_analytics_v3: LabrinthAnalyticsV3Module, labrinth_auth_internal: LabrinthAuthInternalModule, labrinth_auth_v2: LabrinthAuthV2Module, labrinth_billing_internal: LabrinthBillingInternalModule, diff --git a/packages/api-client/src/modules/labrinth/analytics/v3.ts b/packages/api-client/src/modules/labrinth/analytics/v3.ts new file mode 100644 index 0000000000..1ec43c99df --- /dev/null +++ b/packages/api-client/src/modules/labrinth/analytics/v3.ts @@ -0,0 +1,40 @@ +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 analytics time slices + * + * @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 }, + * }, + * return_metrics: { + * project_views: { bucket_by: ['project_id'] }, + * }, + * }) + * ``` + */ + public async fetch( + data: Labrinth.Analytics.v3.FetchRequest, + ): Promise { + return this.client.request('/analytics', { + api: 'labrinth', + version: 3, + method: 'POST', + body: data, + }) + } +} diff --git a/packages/api-client/src/modules/labrinth/index.ts b/packages/api-client/src/modules/labrinth/index.ts index a1e25783a7..f2a2a8a243 100644 --- a/packages/api-client/src/modules/labrinth/index.ts +++ b/packages/api-client/src/modules/labrinth/index.ts @@ -1,5 +1,6 @@ export * from './auth/internal' export * from './auth/v2' +export * from './analytics/v3' export * from './billing/internal' export * from './collections' export * from './globals/internal' diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index 2426d700d9..45b48e74f0 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -238,6 +238,125 @@ export namespace Labrinth { } } + export namespace Analytics { + export namespace v3 { + export type FetchRequest = { + time_range: TimeRange + return_metrics: ReturnMetrics + } + + export type TimeRange = { + start: string + end: string + resolution: TimeRangeResolution + } + + export type TimeRangeResolution = { slices: number } | { minutes: number } + + export type ReturnMetrics = { + project_views?: Metrics + project_downloads?: Metrics + project_playtime?: Metrics + project_revenue?: Metrics + affiliate_code_clicks?: Metrics + affiliate_code_conversions?: Metrics + affiliate_code_revenue?: Metrics + } + + export type Metrics = { + bucket_by?: F[] + } + + export type ProjectViewsField = + | 'project_id' + | 'domain' + | 'site_path' + | 'monetized' + | 'country' + + export type ProjectDownloadsField = + | 'project_id' + | 'version_id' + | 'domain' + | 'site_path' + | 'country' + + export type ProjectPlaytimeField = 'project_id' | 'version_id' | 'loader' | 'game_version' + + export type ProjectRevenueField = 'project_id' + + export type AffiliateCodeClicksField = 'affiliate_code_id' + + export type AffiliateCodeConversionsField = 'affiliate_code_id' + + export type AffiliateCodeRevenueField = 'affiliate_code_id' + + export type FetchResponse = TimeSlice[] + + export type TimeSlice = AnalyticsData[] + + 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 = { + domain?: string + site_path?: string + version_id?: string + country?: string + downloads: number + } + + export type ProjectPlaytime = { + version_id?: string + loader?: string + game_version?: 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 = { diff --git a/packages/ui/src/components/base/Table.vue b/packages/ui/src/components/base/Table.vue index d952a5337b..d977ac03f6 100644 --- a/packages/ui/src/components/base/Table.vue +++ b/packages/ui/src/components/base/Table.vue @@ -45,36 +45,50 @@ - - - - + - - {{ row[column.key] ?? '' }} + +
+ No data available. +
+ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 540560cec9..aca1ec31f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -167,7 +167,7 @@ importers: devDependencies: '@eslint/compat': specifier: ^1.1.1 - version: 1.4.1(eslint@9.39.2(jiti@1.21.7)) + version: 1.4.1(eslint@9.39.2(jiti@2.6.1)) '@formatjs/cli': specifier: ^6.2.12 version: 6.12.2(@vue/compiler-core@3.5.27)(vue@3.5.27(typescript@5.9.3)) @@ -176,22 +176,22 @@ importers: version: link:../../packages/tooling-config '@nuxt/eslint-config': specifier: ^0.5.6 - version: 0.5.7(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + version: 0.5.7(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@taijased/vue-render-tracker': specifier: ^1.0.7 version: 1.0.7(vue@3.5.27(typescript@5.9.3)) '@vitejs/plugin-vue': specifier: ^6.0.3 - version: 6.0.4(vite@8.0.3(@types/node@20.19.31)(esbuild@0.27.3)(jiti@1.21.7)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3)) + version: 6.0.4(vite@8.0.3(@types/node@20.19.31)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3)) autoprefixer: specifier: ^10.4.19 version: 10.4.24(postcss@8.5.6) eslint: specifier: ^9.9.1 - version: 9.39.2(jiti@1.21.7) + version: 9.39.2(jiti@2.6.1) eslint-plugin-turbo: specifier: ^2.5.4 - version: 2.8.2(eslint@9.39.2(jiti@1.21.7))(turbo@2.8.2) + version: 2.8.2(eslint@9.39.2(jiti@2.6.1))(turbo@2.8.2) postcss: specifier: ^8.4.39 version: 8.5.6 @@ -209,7 +209,7 @@ importers: version: 5.9.3 vite: specifier: ^8.0.0 - version: 8.0.3(@types/node@20.19.31)(esbuild@0.27.3)(jiti@1.21.7)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) + version: 8.0.3(@types/node@20.19.31)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) vue-component-type-helpers: specifier: ^3.1.8 version: 3.2.4 @@ -283,7 +283,7 @@ importers: version: 0.11.3(magicast@0.5.1)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3))) '@sentry/nuxt': specifier: ^10.33.0 - version: 10.38.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.211.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0)(magicast@0.5.1)(nuxt@3.21.0(@parcel/watcher@2.5.6)(@types/node@20.19.31)(@vue/compiler-sfc@3.5.27)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.2(jiti@2.6.1))(ioredis@5.9.2)(lightningcss@1.32.0)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-rc.12)(rollup@4.57.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2))(pinia@3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)))(rollup@4.57.1)(vue@3.5.27(typescript@5.9.3)) + version: 10.38.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.211.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0)(magicast@0.5.1)(nuxt@3.21.0(@parcel/watcher@2.5.6)(@types/node@20.19.31)(@vue/compiler-sfc@3.5.27)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.2(jiti@1.21.7))(ioredis@5.9.2)(lightningcss@1.32.0)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-rc.12)(rollup@4.57.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2))(pinia@3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)))(rollup@4.57.1)(vue@3.5.27(typescript@5.9.3)) '@tanstack/vue-query': specifier: ^5.90.7 version: 5.92.9(vue@3.5.27(typescript@5.9.3)) @@ -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 @@ -413,7 +416,7 @@ importers: version: 10.5.0 nuxt: specifier: ^3.20.2 - version: 3.21.0(@parcel/watcher@2.5.6)(@types/node@20.19.31)(@vue/compiler-sfc@3.5.27)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.2(jiti@2.6.1))(ioredis@5.9.2)(lightningcss@1.32.0)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-rc.12)(rollup@4.57.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2) + version: 3.21.0(@parcel/watcher@2.5.6)(@types/node@20.19.31)(@vue/compiler-sfc@3.5.27)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.2(jiti@1.21.7))(ioredis@5.9.2)(lightningcss@1.32.0)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-rc.12)(rollup@4.57.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2) postcss: specifier: ^8.4.39 version: 8.5.6 @@ -2455,6 +2458,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==} @@ -5301,6 +5307,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'} @@ -9711,8 +9721,8 @@ packages: vue-component-type-helpers@3.2.4: resolution: {integrity: sha512-05lR16HeZDcDpB23ku5b5f1fBOoHqFnMiKRr2CiEvbG5Ux4Yi0McmQBOET0dR0nxDXosxyVqv67q6CzS3AK8rw==} - vue-component-type-helpers@3.2.6: - resolution: {integrity: sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==} + vue-component-type-helpers@3.2.7: + resolution: {integrity: sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==} vue-confetti-explosion@1.0.2: resolution: {integrity: sha512-80OboM3/6BItIoZ6DpNcZFqGpF607kjIVc5af56oKgtFmt5yWehvJeoYhkzYlqxrqdBe0Ko4Ie3bWrmLau+dJw==} @@ -10987,6 +10997,7 @@ snapshots: dependencies: eslint: 9.39.2(jiti@1.21.7) eslint-visitor-keys: 3.4.3 + optional: true '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': dependencies: @@ -10995,11 +11006,11 @@ snapshots: '@eslint-community/regexpp@4.12.2': {} - '@eslint/compat@1.4.1(eslint@9.39.2(jiti@1.21.7))': + '@eslint/compat@1.4.1(eslint@9.39.2(jiti@2.6.1))': dependencies: '@eslint/core': 0.17.0 optionalDependencies: - eslint: 9.39.2(jiti@1.21.7) + eslint: 9.39.2(jiti@2.6.1) '@eslint/config-array@0.21.1': dependencies: @@ -11409,6 +11420,8 @@ snapshots: '@jsdevtools/ono@7.1.3': {} + '@kurkle/color@0.3.4': {} + '@kwsites/file-exists@1.1.1': dependencies: debug: 4.4.3 @@ -11564,7 +11577,7 @@ snapshots: dependencies: '@nuxt/kit': 4.3.0(magicast@0.5.1) execa: 8.0.1 - vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) transitivePeerDependencies: - magicast @@ -11609,7 +11622,7 @@ snapshots: sirv: 3.0.2 structured-clone-es: 1.0.0 tinyglobby: 0.2.15 - vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) vite-plugin-inspect: 11.3.3(@nuxt/kit@4.3.0(magicast@0.5.1))(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2)) vite-plugin-vue-tracer: 1.2.0(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3)) which: 5.0.0 @@ -11620,36 +11633,36 @@ snapshots: - utf-8-validate - vue - '@nuxt/eslint-config@0.5.7(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@nuxt/eslint-config@0.5.7(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint/js': 9.39.2 - '@nuxt/eslint-plugin': 0.5.7(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@stylistic/eslint-plugin': 2.13.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - eslint: 9.39.2(jiti@1.21.7) - eslint-config-flat-gitignore: 0.3.0(eslint@9.39.2(jiti@1.21.7)) + '@nuxt/eslint-plugin': 0.5.7(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@stylistic/eslint-plugin': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.54.0(@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))(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) + eslint-config-flat-gitignore: 0.3.0(eslint@9.39.2(jiti@2.6.1)) eslint-flat-config-utils: 0.4.0 - eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)) - eslint-plugin-jsdoc: 50.8.0(eslint@9.39.2(jiti@1.21.7)) - eslint-plugin-regexp: 2.10.0(eslint@9.39.2(jiti@1.21.7)) - eslint-plugin-unicorn: 55.0.0(eslint@9.39.2(jiti@1.21.7)) - eslint-plugin-vue: 9.33.0(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-jsdoc: 50.8.0(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-regexp: 2.10.0(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-unicorn: 55.0.0(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-vue: 9.33.0(eslint@9.39.2(jiti@2.6.1)) globals: 15.15.0 local-pkg: 0.5.1 pathe: 1.1.2 - vue-eslint-parser: 9.4.3(eslint@9.39.2(jiti@1.21.7)) + vue-eslint-parser: 9.4.3(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - '@typescript-eslint/utils' - eslint-import-resolver-node - supports-color - typescript - '@nuxt/eslint-plugin@0.5.7(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@nuxt/eslint-plugin@0.5.7(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - eslint: 9.39.2(jiti@1.21.7) + '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) transitivePeerDependencies: - supports-color - typescript @@ -11705,7 +11718,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/nitro-server@3.21.0(db0@0.3.4)(ioredis@5.9.2)(magicast@0.5.1)(nuxt@3.21.0(@parcel/watcher@2.5.6)(@types/node@20.19.31)(@vue/compiler-sfc@3.5.27)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.2(jiti@2.6.1))(ioredis@5.9.2)(lightningcss@1.32.0)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-rc.12)(rollup@4.57.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2))(rolldown@1.0.0-rc.12)(typescript@5.9.3)(xml2js@0.6.2)': + '@nuxt/nitro-server@3.21.0(db0@0.3.4)(ioredis@5.9.2)(magicast@0.5.1)(nuxt@3.21.0(@parcel/watcher@2.5.6)(@types/node@20.19.31)(@vue/compiler-sfc@3.5.27)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.2(jiti@1.21.7))(ioredis@5.9.2)(lightningcss@1.32.0)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-rc.12)(rollup@4.57.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2))(rolldown@1.0.0-rc.12)(typescript@5.9.3)(xml2js@0.6.2)': dependencies: '@nuxt/devalue': 2.0.2 '@nuxt/kit': 3.21.0(magicast@0.5.1) @@ -11723,7 +11736,7 @@ snapshots: klona: 2.0.6 mocked-exports: 0.1.1 nitropack: 2.13.1(rolldown@1.0.0-rc.12)(xml2js@0.6.2) - nuxt: 3.21.0(@parcel/watcher@2.5.6)(@types/node@20.19.31)(@vue/compiler-sfc@3.5.27)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.2(jiti@2.6.1))(ioredis@5.9.2)(lightningcss@1.32.0)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-rc.12)(rollup@4.57.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2) + nuxt: 3.21.0(@parcel/watcher@2.5.6)(@types/node@20.19.31)(@vue/compiler-sfc@3.5.27)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.2(jiti@1.21.7))(ioredis@5.9.2)(lightningcss@1.32.0)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-rc.12)(rollup@4.57.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2) ohash: 2.0.11 pathe: 2.0.3 pkg-types: 2.3.0 @@ -11795,7 +11808,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/vite-builder@3.21.0(@types/node@20.19.31)(eslint@9.39.2(jiti@2.6.1))(lightningcss@1.32.0)(magicast@0.5.1)(nuxt@3.21.0(@parcel/watcher@2.5.6)(@types/node@20.19.31)(@vue/compiler-sfc@3.5.27)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.2(jiti@2.6.1))(ioredis@5.9.2)(lightningcss@1.32.0)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-rc.12)(rollup@4.57.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2))(optionator@0.9.4)(rolldown@1.0.0-rc.12)(rollup@4.57.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3))(vue@3.5.27(typescript@5.9.3))(yaml@2.8.2)': + '@nuxt/vite-builder@3.21.0(@types/node@20.19.31)(eslint@9.39.2(jiti@1.21.7))(lightningcss@1.32.0)(magicast@0.5.1)(nuxt@3.21.0(@parcel/watcher@2.5.6)(@types/node@20.19.31)(@vue/compiler-sfc@3.5.27)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.2(jiti@1.21.7))(ioredis@5.9.2)(lightningcss@1.32.0)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-rc.12)(rollup@4.57.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2))(optionator@0.9.4)(rolldown@1.0.0-rc.12)(rollup@4.57.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3))(vue@3.5.27(typescript@5.9.3))(yaml@2.8.2)': dependencies: '@nuxt/kit': 3.21.0(magicast@0.5.1) '@rollup/plugin-replace': 6.0.3(rollup@4.57.1) @@ -11815,7 +11828,7 @@ snapshots: magic-string: 0.30.21 mlly: 1.8.0 mocked-exports: 0.1.1 - nuxt: 3.21.0(@parcel/watcher@2.5.6)(@types/node@20.19.31)(@vue/compiler-sfc@3.5.27)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.2(jiti@2.6.1))(ioredis@5.9.2)(lightningcss@1.32.0)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-rc.12)(rollup@4.57.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2) + nuxt: 3.21.0(@parcel/watcher@2.5.6)(@types/node@20.19.31)(@vue/compiler-sfc@3.5.27)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.2(jiti@1.21.7))(ioredis@5.9.2)(lightningcss@1.32.0)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-rc.12)(rollup@4.57.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2) ohash: 2.0.11 pathe: 2.0.3 perfect-debounce: 2.1.0 @@ -11826,9 +11839,9 @@ snapshots: std-env: 3.10.0 ufo: 1.6.3 unenv: 2.0.0-rc.24 - vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) vite-node: 5.3.0(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) - vite-plugin-checker: 0.12.0(eslint@9.39.2(jiti@2.6.1))(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.9.3)) + vite-plugin-checker: 0.12.0(eslint@9.39.2(jiti@1.21.7))(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.9.3)) vue: 3.5.27(typescript@5.9.3) vue-bundle-renderer: 2.2.0 optionalDependencies: @@ -12917,7 +12930,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@sentry/nuxt@10.38.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.211.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0)(magicast@0.5.1)(nuxt@3.21.0(@parcel/watcher@2.5.6)(@types/node@20.19.31)(@vue/compiler-sfc@3.5.27)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.2(jiti@2.6.1))(ioredis@5.9.2)(lightningcss@1.32.0)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-rc.12)(rollup@4.57.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2))(pinia@3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)))(rollup@4.57.1)(vue@3.5.27(typescript@5.9.3))': + '@sentry/nuxt@10.38.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.211.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0)(magicast@0.5.1)(nuxt@3.21.0(@parcel/watcher@2.5.6)(@types/node@20.19.31)(@vue/compiler-sfc@3.5.27)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.2(jiti@1.21.7))(ioredis@5.9.2)(lightningcss@1.32.0)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-rc.12)(rollup@4.57.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2))(pinia@3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)))(rollup@4.57.1)(vue@3.5.27(typescript@5.9.3))': dependencies: '@nuxt/kit': 3.21.0(magicast@0.5.1) '@sentry/browser': 10.38.0 @@ -12928,7 +12941,7 @@ snapshots: '@sentry/rollup-plugin': 4.9.0(rollup@4.57.1) '@sentry/vite-plugin': 4.9.0 '@sentry/vue': 10.38.0(pinia@3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3)) - nuxt: 3.21.0(@parcel/watcher@2.5.6)(@types/node@20.19.31)(@vue/compiler-sfc@3.5.27)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.2(jiti@2.6.1))(ioredis@5.9.2)(lightningcss@1.32.0)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-rc.12)(rollup@4.57.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2) + nuxt: 3.21.0(@parcel/watcher@2.5.6)(@types/node@20.19.31)(@vue/compiler-sfc@3.5.27)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.2(jiti@1.21.7))(ioredis@5.9.2)(lightningcss@1.32.0)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-rc.12)(rollup@4.57.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -13122,22 +13135,10 @@ snapshots: storybook: 10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) type-fest: 2.19.0 vue: 3.5.27(typescript@5.9.3) - vue-component-type-helpers: 3.2.6 + vue-component-type-helpers: 3.2.7 '@stripe/stripe-js@7.9.0': {} - '@stylistic/eslint-plugin@2.13.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - eslint: 9.39.2(jiti@1.21.7) - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - estraverse: 5.3.0 - picomatch: 4.0.3 - transitivePeerDependencies: - - supports-color - - typescript - '@stylistic/eslint-plugin@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) @@ -13149,7 +13150,6 @@ snapshots: transitivePeerDependencies: - supports-color - typescript - optional: true '@svgdotjs/svg.draggable.js@3.0.6(@svgdotjs/svg.js@3.2.5)': dependencies: @@ -13551,22 +13551,6 @@ snapshots: dependencies: '@types/node': 20.19.31 - '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': - dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.54.0 - eslint: 9.39.2(jiti@1.21.7) - ignore: 7.0.5 - natural-compare: 1.4.0 - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/eslint-plugin@8.54.0(@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))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -13583,18 +13567,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.54.0 - debug: 4.4.3 - eslint: 9.39.2(jiti@1.21.7) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.54.0 @@ -13625,18 +13597,6 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - debug: 4.4.3 - eslint: 9.39.2(jiti@1.21.7) - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.54.0 @@ -13666,17 +13626,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - eslint: 9.39.2(jiti@1.21.7) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) @@ -13791,7 +13740,7 @@ snapshots: '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) '@rolldown/pluginutils': 1.0.0-rc.2 '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.0) - vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) vue: 3.5.27(typescript@5.9.3) transitivePeerDependencies: - supports-color @@ -13804,13 +13753,13 @@ snapshots: '@vitejs/plugin-vue@6.0.4(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 - vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) vue: 3.5.27(typescript@5.9.3) - '@vitejs/plugin-vue@6.0.4(vite@8.0.3(@types/node@20.19.31)(esbuild@0.27.3)(jiti@1.21.7)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3))': + '@vitejs/plugin-vue@6.0.4(vite@8.0.3(@types/node@20.19.31)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 - vite: 8.0.3(@types/node@20.19.31)(esbuild@0.27.3)(jiti@1.21.7)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) + vite: 8.0.3(@types/node@20.19.31)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) vue: 3.5.27(typescript@5.9.3) '@vitest/expect@3.2.4': @@ -14707,6 +14656,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: @@ -15336,10 +15289,10 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-flat-gitignore@0.3.0(eslint@9.39.2(jiti@1.21.7)): + eslint-config-flat-gitignore@0.3.0(eslint@9.39.2(jiti@2.6.1)): dependencies: - '@eslint/compat': 1.4.1(eslint@9.39.2(jiti@1.21.7)) - eslint: 9.39.2(jiti@1.21.7) + '@eslint/compat': 1.4.1(eslint@9.39.2(jiti@2.6.1)) + eslint: 9.39.2(jiti@2.6.1) find-up-simple: 1.0.1 eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)): @@ -15357,12 +15310,12 @@ snapshots: optionalDependencies: unrs-resolver: 1.11.1 - eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)): + eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@typescript-eslint/types': 8.54.0 comment-parser: 1.4.5 debug: 4.4.3 - eslint: 9.39.2(jiti@1.21.7) + eslint: 9.39.2(jiti@2.6.1) eslint-import-context: 0.1.9(unrs-resolver@1.11.1) is-glob: 4.0.3 minimatch: 10.1.2 @@ -15370,18 +15323,18 @@ snapshots: stable-hash-x: 0.2.0 unrs-resolver: 1.11.1 optionalDependencies: - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) transitivePeerDependencies: - supports-color - eslint-plugin-jsdoc@50.8.0(eslint@9.39.2(jiti@1.21.7)): + eslint-plugin-jsdoc@50.8.0(eslint@9.39.2(jiti@2.6.1)): dependencies: '@es-joy/jsdoccomment': 0.50.2 are-docs-informative: 0.0.2 comment-parser: 1.4.1 debug: 4.4.3 escape-string-regexp: 4.0.0 - eslint: 9.39.2(jiti@1.21.7) + eslint: 9.39.2(jiti@2.6.1) espree: 10.4.0 esquery: 1.7.0 parse-imports-exports: 0.2.4 @@ -15399,12 +15352,12 @@ snapshots: optionalDependencies: eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-regexp@2.10.0(eslint@9.39.2(jiti@1.21.7)): + eslint-plugin-regexp@2.10.0(eslint@9.39.2(jiti@2.6.1)): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 comment-parser: 1.4.5 - eslint: 9.39.2(jiti@1.21.7) + eslint: 9.39.2(jiti@2.6.1) jsdoc-type-pratt-parser: 4.8.0 refa: 0.12.1 regexp-ast-analysis: 0.7.1 @@ -15423,20 +15376,20 @@ snapshots: - supports-color - typescript - eslint-plugin-turbo@2.8.2(eslint@9.39.2(jiti@1.21.7))(turbo@2.8.2): + eslint-plugin-turbo@2.8.2(eslint@9.39.2(jiti@2.6.1))(turbo@2.8.2): dependencies: dotenv: 16.0.3 - eslint: 9.39.2(jiti@1.21.7) + eslint: 9.39.2(jiti@2.6.1) turbo: 2.8.2 - eslint-plugin-unicorn@55.0.0(eslint@9.39.2(jiti@1.21.7)): + eslint-plugin-unicorn@55.0.0(eslint@9.39.2(jiti@2.6.1)): dependencies: '@babel/helper-validator-identifier': 7.28.5 - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) ci-info: 4.4.0 clean-regexp: 1.0.0 core-js-compat: 3.48.0 - eslint: 9.39.2(jiti@1.21.7) + eslint: 9.39.2(jiti@2.6.1) esquery: 1.7.0 globals: 15.15.0 indent-string: 4.0.0 @@ -15463,16 +15416,16 @@ snapshots: '@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-plugin-vue@9.33.0(eslint@9.39.2(jiti@1.21.7)): + eslint-plugin-vue@9.33.0(eslint@9.39.2(jiti@2.6.1)): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) - eslint: 9.39.2(jiti@1.21.7) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + eslint: 9.39.2(jiti@2.6.1) globals: 13.24.0 natural-compare: 1.4.0 nth-check: 2.1.1 postcss-selector-parser: 6.1.2 semver: 7.7.3 - vue-eslint-parser: 9.4.3(eslint@9.39.2(jiti@1.21.7)) + vue-eslint-parser: 9.4.3(eslint@9.39.2(jiti@2.6.1)) xml-name-validator: 4.0.0 transitivePeerDependencies: - supports-color @@ -15531,6 +15484,7 @@ snapshots: jiti: 1.21.7 transitivePeerDependencies: - supports-color + optional: true eslint@9.39.2(jiti@2.6.1): dependencies: @@ -17526,16 +17480,16 @@ snapshots: dependencies: boolbase: 1.0.0 - nuxt@3.21.0(@parcel/watcher@2.5.6)(@types/node@20.19.31)(@vue/compiler-sfc@3.5.27)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.2(jiti@2.6.1))(ioredis@5.9.2)(lightningcss@1.32.0)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-rc.12)(rollup@4.57.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2): + nuxt@3.21.0(@parcel/watcher@2.5.6)(@types/node@20.19.31)(@vue/compiler-sfc@3.5.27)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.2(jiti@1.21.7))(ioredis@5.9.2)(lightningcss@1.32.0)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-rc.12)(rollup@4.57.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2): dependencies: '@dxup/nuxt': 0.3.2(magicast@0.5.1) '@nuxt/cli': 3.32.0(cac@6.7.14)(magicast@0.5.1) '@nuxt/devtools': 3.1.1(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3)) '@nuxt/kit': 3.21.0(magicast@0.5.1) - '@nuxt/nitro-server': 3.21.0(db0@0.3.4)(ioredis@5.9.2)(magicast@0.5.1)(nuxt@3.21.0(@parcel/watcher@2.5.6)(@types/node@20.19.31)(@vue/compiler-sfc@3.5.27)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.2(jiti@2.6.1))(ioredis@5.9.2)(lightningcss@1.32.0)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-rc.12)(rollup@4.57.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2))(rolldown@1.0.0-rc.12)(typescript@5.9.3)(xml2js@0.6.2) + '@nuxt/nitro-server': 3.21.0(db0@0.3.4)(ioredis@5.9.2)(magicast@0.5.1)(nuxt@3.21.0(@parcel/watcher@2.5.6)(@types/node@20.19.31)(@vue/compiler-sfc@3.5.27)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.2(jiti@1.21.7))(ioredis@5.9.2)(lightningcss@1.32.0)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-rc.12)(rollup@4.57.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2))(rolldown@1.0.0-rc.12)(typescript@5.9.3)(xml2js@0.6.2) '@nuxt/schema': 3.21.0 '@nuxt/telemetry': 2.6.6(magicast@0.5.1) - '@nuxt/vite-builder': 3.21.0(@types/node@20.19.31)(eslint@9.39.2(jiti@2.6.1))(lightningcss@1.32.0)(magicast@0.5.1)(nuxt@3.21.0(@parcel/watcher@2.5.6)(@types/node@20.19.31)(@vue/compiler-sfc@3.5.27)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.2(jiti@2.6.1))(ioredis@5.9.2)(lightningcss@1.32.0)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-rc.12)(rollup@4.57.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2))(optionator@0.9.4)(rolldown@1.0.0-rc.12)(rollup@4.57.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3))(vue@3.5.27(typescript@5.9.3))(yaml@2.8.2) + '@nuxt/vite-builder': 3.21.0(@types/node@20.19.31)(eslint@9.39.2(jiti@1.21.7))(lightningcss@1.32.0)(magicast@0.5.1)(nuxt@3.21.0(@parcel/watcher@2.5.6)(@types/node@20.19.31)(@vue/compiler-sfc@3.5.27)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.2(jiti@1.21.7))(ioredis@5.9.2)(lightningcss@1.32.0)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-rc.12)(rollup@4.57.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2))(optionator@0.9.4)(rolldown@1.0.0-rc.12)(rollup@4.57.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3))(vue@3.5.27(typescript@5.9.3))(yaml@2.8.2) '@unhead/vue': 2.1.2(vue@3.5.27(typescript@5.9.3)) '@vue/shared': 3.5.27 c12: 3.3.3(magicast@0.5.1) @@ -19738,12 +19692,12 @@ snapshots: vite-dev-rpc@1.1.0(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2)): dependencies: birpc: 2.9.0 - vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) vite-hot-client: 2.1.0(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2)) vite-hot-client@2.1.0(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2)): dependencies: - vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) vite-node@5.3.0(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2): dependencies: @@ -19765,7 +19719,7 @@ snapshots: - tsx - yaml - vite-plugin-checker@0.12.0(eslint@9.39.2(jiti@2.6.1))(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.9.3)): + vite-plugin-checker@0.12.0(eslint@9.39.2(jiti@1.21.7))(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.9.3)): dependencies: '@babel/code-frame': 7.29.0 chokidar: 4.0.3 @@ -19774,10 +19728,10 @@ snapshots: picomatch: 4.0.3 tiny-invariant: 1.3.3 tinyglobby: 0.2.15 - vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) vscode-uri: 3.1.0 optionalDependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(jiti@1.21.7) optionator: 0.9.4 typescript: 5.9.3 vue-tsc: 2.2.12(typescript@5.9.3) @@ -19792,7 +19746,7 @@ snapshots: perfect-debounce: 2.1.0 sirv: 3.0.2 unplugin-utils: 0.3.1 - vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) vite-dev-rpc: 1.1.0(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2)) optionalDependencies: '@nuxt/kit': 4.3.0(magicast@0.5.1) @@ -19806,7 +19760,7 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 source-map-js: 1.2.1 - vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) vue: 3.5.27(typescript@5.9.3) vite-svg-loader@5.1.0(vue@3.5.27(typescript@5.9.3)): @@ -19843,6 +19797,23 @@ snapshots: terser: 5.46.0 yaml: 2.8.2 + vite@7.3.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.31 + fsevents: 2.3.3 + jiti: 1.21.7 + lightningcss: 1.32.0 + sass: 1.97.3 + terser: 5.46.0 + yaml: 2.8.2 + vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2): dependencies: esbuild: 0.27.2 @@ -19860,7 +19831,7 @@ snapshots: terser: 5.46.0 yaml: 2.8.2 - vite@8.0.3(@types/node@20.19.31)(esbuild@0.27.3)(jiti@1.21.7)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2): + vite@8.0.3(@types/node@20.19.31)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -19871,7 +19842,7 @@ snapshots: '@types/node': 20.19.31 esbuild: 0.27.3 fsevents: 2.3.3 - jiti: 1.21.7 + jiti: 2.6.1 sass: 1.97.3 terser: 5.46.0 yaml: 2.8.2 @@ -19996,7 +19967,7 @@ snapshots: vue-component-type-helpers@3.2.4: {} - vue-component-type-helpers@3.2.6: {} + vue-component-type-helpers@3.2.7: {} vue-confetti-explosion@1.0.2(vue@3.5.27(typescript@5.9.3)): dependencies: @@ -20036,10 +20007,10 @@ snapshots: transitivePeerDependencies: - supports-color - vue-eslint-parser@9.4.3(eslint@9.39.2(jiti@1.21.7)): + vue-eslint-parser@9.4.3(eslint@9.39.2(jiti@2.6.1)): dependencies: debug: 4.4.3 - eslint: 9.39.2(jiti@1.21.7) + eslint: 9.39.2(jiti@2.6.1) eslint-scope: 7.2.2 eslint-visitor-keys: 3.4.3 espree: 9.6.1