From 0301f4a8becf773d308ecd2a7a9c891f74db918b Mon Sep 17 00:00:00 2001 From: tdgao Date: Tue, 21 Apr 2026 11:46:15 -0600 Subject: [PATCH 01/20] feat: implement analytics route in api client --- packages/api-client/src/modules/index.ts | 2 + .../src/modules/labrinth/analytics/v3.ts | 40 ++++++ .../api-client/src/modules/labrinth/index.ts | 1 + .../api-client/src/modules/labrinth/types.ts | 119 ++++++++++++++++++ 4 files changed, 162 insertions(+) create mode 100644 packages/api-client/src/modules/labrinth/analytics/v3.ts 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 = { From 1db663ba775fc74270361212f73a5197e3f078be Mon Sep 17 00:00:00 2001 From: tdgao Date: Tue, 21 Apr 2026 21:28:02 -0600 Subject: [PATCH 02/20] remove: delete current analytics implementation --- .../src/components/ui/charts/Chart.client.vue | 495 -------- .../src/components/ui/charts/ChartDisplay.vue | 1009 ----------------- .../ui/charts/CompactChart.client.vue | 281 ----- .../pages/[type]/[id]/settings/analytics.vue | 29 +- .../src/pages/dashboard/analytics.vue | 27 +- .../organization/[id]/settings/analytics.vue | 27 +- apps/frontend/src/utils/analytics.js | 491 -------- 7 files changed, 3 insertions(+), 2356 deletions(-) delete mode 100644 apps/frontend/src/components/ui/charts/Chart.client.vue delete mode 100644 apps/frontend/src/components/ui/charts/ChartDisplay.vue delete mode 100644 apps/frontend/src/components/ui/charts/CompactChart.client.vue delete mode 100644 apps/frontend/src/utils/analytics.js 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/pages/[type]/[id]/settings/analytics.vue b/apps/frontend/src/pages/[type]/[id]/settings/analytics.vue index ce57caf281..4df61b9e0d 100644 --- a/apps/frontend/src/pages/[type]/[id]/settings/analytics.vue +++ b/apps/frontend/src/pages/[type]/[id]/settings/analytics.vue @@ -1,30 +1,3 @@ - - - - diff --git a/apps/frontend/src/pages/dashboard/analytics.vue b/apps/frontend/src/pages/dashboard/analytics.vue index 25d8376438..33d98f998e 100644 --- a/apps/frontend/src/pages/dashboard/analytics.vue +++ b/apps/frontend/src/pages/dashboard/analytics.vue @@ -1,23 +1,8 @@ diff --git a/apps/frontend/src/pages/organization/[id]/settings/analytics.vue b/apps/frontend/src/pages/organization/[id]/settings/analytics.vue index 081bc89587..b81364f879 100644 --- a/apps/frontend/src/pages/organization/[id]/settings/analytics.vue +++ b/apps/frontend/src/pages/organization/[id]/settings/analytics.vue @@ -1,28 +1,3 @@ - - - - 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, - } -} From e09845b0b84bfff893f5f6bf88dbe3c0e92c0472 Mon Sep 17 00:00:00 2001 From: tdgao Date: Tue, 21 Apr 2026 22:22:55 -0600 Subject: [PATCH 03/20] feat: wire up shared analytics dashboard page --- .../src/components/analytics/AnalyticsDashboard.vue | 7 +++++++ apps/frontend/src/layouts/default.vue | 4 ++++ .../frontend/src/pages/[type]/[id]/settings/analytics.vue | 6 +++++- apps/frontend/src/pages/dashboard/analytics.vue | 6 ++++-- .../src/pages/organization/[id]/settings/analytics.vue | 8 +++++++- 5 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 apps/frontend/src/components/analytics/AnalyticsDashboard.vue diff --git a/apps/frontend/src/components/analytics/AnalyticsDashboard.vue b/apps/frontend/src/components/analytics/AnalyticsDashboard.vue new file mode 100644 index 0000000000..a7f8b58e52 --- /dev/null +++ b/apps/frontend/src/components/analytics/AnalyticsDashboard.vue @@ -0,0 +1,7 @@ + + + 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 4df61b9e0d..447eb7eae7 100644 --- a/apps/frontend/src/pages/[type]/[id]/settings/analytics.vue +++ b/apps/frontend/src/pages/[type]/[id]/settings/analytics.vue @@ -1,3 +1,7 @@ + + diff --git a/apps/frontend/src/pages/dashboard/analytics.vue b/apps/frontend/src/pages/dashboard/analytics.vue index 33d98f998e..c410a86725 100644 --- a/apps/frontend/src/pages/dashboard/analytics.vue +++ b/apps/frontend/src/pages/dashboard/analytics.vue @@ -1,8 +1,10 @@ - From 1c368d8755e464874577dc8f41430cf3c31f01dd Mon Sep 17 00:00:00 2001 From: tdgao Date: Wed, 22 Apr 2026 14:14:19 -0600 Subject: [PATCH 04/20] feat: initial implementation of analytics DI, query builder component, and stat cards component --- .../analytics/AnalyticsDashboard.vue | 23 +- .../analytics/query-builder/QueryBuilder.vue | 660 ++++++++++++++++++ .../analytics/stat-cards/StatCard.vue | 116 +++ .../analytics/stat-cards/StatCards.vue | 88 +++ apps/frontend/src/providers/analytics.ts | 282 ++++++++ 5 files changed, 1168 insertions(+), 1 deletion(-) create mode 100644 apps/frontend/src/components/analytics/query-builder/QueryBuilder.vue create mode 100644 apps/frontend/src/components/analytics/stat-cards/StatCard.vue create mode 100644 apps/frontend/src/components/analytics/stat-cards/StatCards.vue create mode 100644 apps/frontend/src/providers/analytics.ts diff --git a/apps/frontend/src/components/analytics/AnalyticsDashboard.vue b/apps/frontend/src/components/analytics/AnalyticsDashboard.vue index a7f8b58e52..c2e46d932d 100644 --- a/apps/frontend/src/components/analytics/AnalyticsDashboard.vue +++ b/apps/frontend/src/components/analytics/AnalyticsDashboard.vue @@ -1,7 +1,28 @@ 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..cc102f4dcd --- /dev/null +++ b/apps/frontend/src/components/analytics/query-builder/QueryBuilder.vue @@ -0,0 +1,660 @@ + + + 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..9c4cec00cb --- /dev/null +++ b/apps/frontend/src/components/analytics/stat-cards/StatCard.vue @@ -0,0 +1,116 @@ + + + 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..d431ef9641 --- /dev/null +++ b/apps/frontend/src/components/analytics/stat-cards/StatCards.vue @@ -0,0 +1,88 @@ + + + diff --git a/apps/frontend/src/providers/analytics.ts b/apps/frontend/src/providers/analytics.ts new file mode 100644 index 0000000000..61f477ea1c --- /dev/null +++ b/apps/frontend/src/providers/analytics.ts @@ -0,0 +1,282 @@ +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' + +export type AnalyticsDashboardStat = 'views' | 'downloads' | 'revenue' | '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 + fetchRequest: Ref + timeSlices: Ref + previousTimeSlices: Ref + isLoading: ComputedRef + isRefetching: ComputedRef + activeStat: Ref + currentTotals: ComputedRef + previousTotals: ComputedRef + percentChanges: ComputedRef + 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, +): AnalyticsDashboardTotals { + const totals: AnalyticsDashboardTotals = { + views: 0, + downloads: 0, + revenue: 0, + playtime: 0, + } + + for (const timeSlice of timeSlices) { + for (const dataPoint of timeSlice) { + if (!('source_project' in dataPoint)) { + continue + } + + if (selectedProjectIds.size > 0 && !selectedProjectIds.has(dataPoint.source_project)) { + continue + } + + switch (dataPoint.metric_kind) { + case 'views': + totals.views += dataPoint.views + break + case 'downloads': + totals.downloads += dataPoint.downloads + break + case 'playtime': + totals.playtime += dataPoint.seconds + break + case 'revenue': { + const value = Number.parseFloat(dataPoint.revenue) + totals.revenue += Number.isFinite(value) ? value : 0 + break + } + } + } + } + + return totals +} + +export function createAnalyticsDashboardContext( + options: CreateAnalyticsDashboardContextOptions, +): AnalyticsDashboardContextValue { + const client = injectModrinthClient() + const activeStat = ref('views') + const selectedProjectIds = ref([]) + 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 ? [{ id: project.id, name: project.title }] : [] + } + + if (hasOrganizationContext.value && options.organizationContext?.projects.value) { + return options.organizationContext.projects.value.map((project) => ({ + id: project.id, + name: project.name, + })) + } + + return (userProjects.value ?? []).map((project) => ({ + id: project.id, + name: project.title, + })) + }) + + watch( + projects, + (nextProjects) => { + if (nextProjects.length === 0) { + selectedProjectIds.value = [] + 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 }, + ) + + 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 currentTotals = computed(() => + computeTotals(timeSlices.value, selectedProjectIdSet.value), + ) + const previousTotals = computed(() => + computeTotals(previousTimeSlices.value, selectedProjectIdSet.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) { + activeStat.value = nextStat + } + + return { + projects, + selectedProjectIds, + fetchRequest, + timeSlices, + previousTimeSlices, + isLoading, + isRefetching, + activeStat, + currentTotals, + previousTotals, + percentChanges, + setFetchRequest, + setActiveStat, + } +} From 661765d9029968e8ace5a16d29542f4a79b58a5d Mon Sep 17 00:00:00 2001 From: tdgao Date: Wed, 22 Apr 2026 14:37:09 -0600 Subject: [PATCH 05/20] feat: style consistency updates --- .../src/components/analytics/query-builder/QueryBuilder.vue | 2 +- .../frontend/src/components/analytics/stat-cards/StatCard.vue | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/frontend/src/components/analytics/query-builder/QueryBuilder.vue b/apps/frontend/src/components/analytics/query-builder/QueryBuilder.vue index cc102f4dcd..b05a97549b 100644 --- a/apps/frontend/src/components/analytics/query-builder/QueryBuilder.vue +++ b/apps/frontend/src/components/analytics/query-builder/QueryBuilder.vue @@ -1,6 +1,6 @@ @@ -11,6 +12,7 @@ import { injectProjectPageContext } from '@modrinth/ui' import { createAnalyticsDashboardContext, provideAnalyticsDashboardContext } from '~/providers/analytics' import { injectOrganizationContext } from '~/providers/organization-context' +import AnalyticsGraph from './graph/AnalyticsGraph.vue' import QueryBuilder from './query-builder/QueryBuilder.vue' import StatCards from './stat-cards/StatCards.vue' 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..3bdcc82176 --- /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..f0a43b1f55 --- /dev/null +++ b/apps/frontend/src/components/analytics/graph/AnalyticsGraph.vue @@ -0,0 +1,259 @@ + + + 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..3554893252 --- /dev/null +++ b/apps/frontend/src/components/analytics/graph/utils.ts @@ -0,0 +1,194 @@ +import type { Labrinth } from '@modrinth/api-client' + +import type { AnalyticsDashboardProject, AnalyticsDashboardStat } from '~/providers/analytics' + +export type ChartDataset = { + projectId: string + label: string + data: number[] + borderColor: string + backgroundColor: string +} + +const DAY_MS = 24 * 60 * 60 * 1000 + +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[], +): ChartDataset[] { + const selectedProjectIds = new Set(selectedProjects.map((project) => project.id)) + + 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, +): 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 = getTickFormatter(totalMs) + + const labels: string[] = [] + for (let i = 0; i < sliceCount; i++) { + labels.push(formatter.format(new Date(startMs + i * bucketMs))) + } + return labels +} + +function getTickFormatter(totalMs: number): Intl.DateTimeFormat { + if (totalMs <= 2 * DAY_MS) { + return new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }) + } + if (totalMs < 31 * DAY_MS) { + 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 formatBucketRange(start: Date, end: Date): string { + const timeFormatter = new Intl.DateTimeFormat(undefined, { + hour: 'numeric', + minute: '2-digit', + }) + const dateTimeFormatter = new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }) + + const sameDay = + start.getFullYear() === end.getFullYear() && + start.getMonth() === end.getMonth() && + start.getDate() === end.getDate() + + if (sameDay) { + return `${timeFormatter.format(start)} – ${timeFormatter.format(end)}` + } + return `${dateTimeFormatter.format(start)} – ${dateTimeFormatter.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 index b05a97549b..efdebcd076 100644 --- a/apps/frontend/src/components/analytics/query-builder/QueryBuilder.vue +++ b/apps/frontend/src/components/analytics/query-builder/QueryBuilder.vue @@ -107,16 +107,6 @@ - -
- - {{ filterLabel }} - -
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 From 31f13a20aed9d4746e1edddc3fc99cbf0dee51a7 Mon Sep 17 00:00:00 2001 From: tdgao Date: Wed, 22 Apr 2026 17:40:55 -0600 Subject: [PATCH 07/20] feat: improve query builder styles --- .../analytics/query-builder/QueryBuilder.vue | 162 +++++++++--------- 1 file changed, 83 insertions(+), 79 deletions(-) diff --git a/apps/frontend/src/components/analytics/query-builder/QueryBuilder.vue b/apps/frontend/src/components/analytics/query-builder/QueryBuilder.vue index efdebcd076..b8856d7b13 100644 --- a/apps/frontend/src/components/analytics/query-builder/QueryBuilder.vue +++ b/apps/frontend/src/components/analytics/query-builder/QueryBuilder.vue @@ -3,9 +3,9 @@ class="flex flex-col gap-3 rounded-2xl border border-solid border-surface-5 bg-surface-3 p-4" >
-
- - Projects: +
+ + Projects:
-
-
- - Timeframe: -
-
-
+
+
+
+ + Timeframe: +
+
- Grouped by -
+
+
+ Grouped by +
-
-
- - Breakdown: -
-
-
-
- +
+
+
+ + Breakdown: +
+
+
+
+ +
- Filtered by - - - - +
From e52d6420de5e85aa3e655a2aa3c961297201bcaa Mon Sep 17 00:00:00 2001 From: tdgao Date: Wed, 22 Apr 2026 17:57:33 -0600 Subject: [PATCH 08/20] feat: add query to url params for query builder --- .../analytics/AnalyticsDashboard.vue | 2 +- .../analytics/graph/AnalyticsChart.client.vue | 2 +- .../analytics/graph/AnalyticsGraph.vue | 4 +- .../src/components/analytics/graph/utils.ts | 2 +- .../analytics/query-builder/QueryBuilder.vue | 135 ++------ .../analytics/stat-cards/StatCards.vue | 2 +- .../providers/{ => analytics}/analytics.ts | 106 +++++- .../providers/analytics/query-builder-url.ts | 313 ++++++++++++++++++ 8 files changed, 446 insertions(+), 120 deletions(-) rename apps/frontend/src/providers/{ => analytics}/analytics.ts (72%) create mode 100644 apps/frontend/src/providers/analytics/query-builder-url.ts diff --git a/apps/frontend/src/components/analytics/AnalyticsDashboard.vue b/apps/frontend/src/components/analytics/AnalyticsDashboard.vue index 2945a89f4a..4e6b7422a4 100644 --- a/apps/frontend/src/components/analytics/AnalyticsDashboard.vue +++ b/apps/frontend/src/components/analytics/AnalyticsDashboard.vue @@ -9,7 +9,7 @@ From 5297b173dc54e6a192b00e62edbf4b9fb536e9da Mon Sep 17 00:00:00 2001 From: tdgao Date: Thu, 23 Apr 2026 11:15:57 -0600 Subject: [PATCH 10/20] fix: date display to show time conditionally --- .../analytics/graph/AnalyticsGraph.vue | 11 ++-- .../src/components/analytics/graph/utils.ts | 50 ++++++++----------- .../analytics/table/AnalyticsTable.vue | 26 ++++++---- 3 files changed, 47 insertions(+), 40 deletions(-) diff --git a/apps/frontend/src/components/analytics/graph/AnalyticsGraph.vue b/apps/frontend/src/components/analytics/graph/AnalyticsGraph.vue index c2d3edd720..97eec57bc7 100644 --- a/apps/frontend/src/components/analytics/graph/AnalyticsGraph.vue +++ b/apps/frontend/src/components/analytics/graph/AnalyticsGraph.vue @@ -101,10 +101,11 @@ import AnalyticsChartTooltip, { type AnalyticsChartTooltipEntry } from './Analyt import { buildChartDatasets, buildTimeAxisLabels, - formatBucketRange, + formatBucketEndLabel, formatMetricValue, getSliceBucketRange, getSliceCount, + isTimeRelevantForGroupBy, } from './utils' type DataMode = 'events' @@ -162,10 +163,14 @@ const sliceCount = computed(() => { return getSliceCount(fetchRequest.time_range, fallback) }) +const showTimeInBucketLabel = computed(() => + isTimeRelevantForGroupBy(analyticsDashboardContext.selectedGroupBy.value), +) + const chartLabels = computed(() => { const fetchRequest = analyticsDashboardContext.fetchRequest.value if (!fetchRequest) return [] - return buildTimeAxisLabels(fetchRequest.time_range, sliceCount.value) + return buildTimeAxisLabels(fetchRequest.time_range, sliceCount.value, showTimeInBucketLabel.value) }) const chartDatasets = computed(() => @@ -240,7 +245,7 @@ const hoverBucketRange = computed(() => { const hoverRangeLabel = computed(() => { if (!hoverBucketRange.value) return '' - return formatBucketRange(hoverBucketRange.value.start, hoverBucketRange.value.end) + return formatBucketEndLabel(hoverBucketRange.value.end, showTimeInBucketLabel.value) }) const hoverEntries = computed(() => { diff --git a/apps/frontend/src/components/analytics/graph/utils.ts b/apps/frontend/src/components/analytics/graph/utils.ts index 15e8d1a6ef..544a6cc0d7 100644 --- a/apps/frontend/src/components/analytics/graph/utils.ts +++ b/apps/frontend/src/components/analytics/graph/utils.ts @@ -4,6 +4,7 @@ import type { AnalyticsBreakdownPreset, AnalyticsDashboardProject, AnalyticsDashboardStat, + AnalyticsGroupByPreset, } from '~/providers/analytics/analytics' import { getAnalyticsBreakdownValue } from '../breakdown' @@ -16,8 +17,6 @@ export type ChartDataset = { backgroundColor: string } -const DAY_MS = 24 * 60 * 60 * 1000 - export function getMetricValue( point: Labrinth.Analytics.v3.ProjectAnalytics, activeStat: AnalyticsDashboardStat, @@ -148,25 +147,23 @@ export function getSliceBucketRange( 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 = getTickFormatter(totalMs) + const formatter = getBucketEndFormatter(includeTime) const labels: string[] = [] for (let i = 0; i < sliceCount; i++) { - labels.push(formatter.format(new Date(startMs + i * bucketMs))) + labels.push(formatter.format(new Date(startMs + (i + 1) * bucketMs))) } return labels } -function getTickFormatter(totalMs: number): Intl.DateTimeFormat { - if (totalMs <= 2 * DAY_MS) { - return new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }) - } - if (totalMs < 31 * DAY_MS) { +function getBucketEndFormatter(includeTime: boolean): Intl.DateTimeFormat { + if (includeTime) { return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', @@ -177,27 +174,24 @@ function getTickFormatter(totalMs: number): Intl.DateTimeFormat { return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }) } -export function formatBucketRange(start: Date, end: Date): string { - const timeFormatter = new Intl.DateTimeFormat(undefined, { - hour: 'numeric', - minute: '2-digit', - }) - const dateTimeFormatter = new Intl.DateTimeFormat(undefined, { - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - }) - - const sameDay = - start.getFullYear() === end.getFullYear() && - start.getMonth() === end.getMonth() && - start.getDate() === end.getDate() +export function isTimeRelevantForGroupBy(groupBy: AnalyticsGroupByPreset): boolean { + return groupBy === '1h' || groupBy === '6h' +} - if (sameDay) { - return `${timeFormatter.format(start)} – ${timeFormatter.format(end)}` +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 `${dateTimeFormatter.format(start)} – ${dateTimeFormatter.format(end)}` + + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + }).format(end) } export function formatMetricValue( diff --git a/apps/frontend/src/components/analytics/table/AnalyticsTable.vue b/apps/frontend/src/components/analytics/table/AnalyticsTable.vue index cf46b9bbf2..00d4595d89 100644 --- a/apps/frontend/src/components/analytics/table/AnalyticsTable.vue +++ b/apps/frontend/src/components/analytics/table/AnalyticsTable.vue @@ -86,7 +86,12 @@ import type { AnalyticsBreakdownPreset } from '~/providers/analytics/analytics' import { injectAnalyticsDashboardContext } from '~/providers/analytics/analytics' import { getAnalyticsBreakdownValue } from '../breakdown' -import { formatBucketRange, getSliceBucketRange, getSliceCount } from '../graph/utils' +import { + formatBucketEndLabel, + getSliceBucketRange, + getSliceCount, + isTimeRelevantForGroupBy, +} from '../graph/utils' type TableMode = 'date_breakdown' | 'breakdown_only' type SortDirection = 'asc' | 'desc' @@ -95,7 +100,7 @@ type TableColumnKey = 'date' | 'breakdown' | 'views' | 'downloads' | 'revenue' | type AnalyticsTableRow = { id: string date: string - dateStartMs: number + dateMs: number breakdown: string views: number downloads: number @@ -135,6 +140,10 @@ const selectedProjectIdSet = computed( () => new Set(analyticsDashboardContext.selectedProjectIds.value), ) +const showTimeInBucketLabel = computed(() => + isTimeRelevantForGroupBy(analyticsDashboardContext.selectedGroupBy.value), +) + const tableRows = computed(() => { const fetchRequest = analyticsDashboardContext.fetchRequest.value const timeSlices = analyticsDashboardContext.timeSlices.value @@ -149,8 +158,8 @@ const tableRows = computed(() => { timeSlices.forEach((slice, sliceIndex) => { const bucketRange = getSliceBucketRange(fetchRequest.time_range, sliceCount, sliceIndex) - const dateStartMs = bucketRange.start.getTime() - const dateLabel = formatBucketRange(bucketRange.start, bucketRange.end) + const dateMs = bucketRange.end.getTime() + const dateLabel = formatBucketEndLabel(bucketRange.end, showTimeInBucketLabel.value) for (const point of slice) { if (!('source_project' in point)) { @@ -162,8 +171,7 @@ const tableRows = computed(() => { } const breakdown = getBreakdownValue(point, selectedBreakdown) - const rowId = - tableMode.value === 'date_breakdown' ? `${dateStartMs}::${breakdown}` : breakdown + const rowId = tableMode.value === 'date_breakdown' ? `${dateMs}::${breakdown}` : breakdown let row = nextRows.get(rowId) @@ -171,7 +179,7 @@ const tableRows = computed(() => { row = { id: rowId, date: tableMode.value === 'date_breakdown' ? dateLabel : '', - dateStartMs: tableMode.value === 'date_breakdown' ? dateStartMs : 0, + dateMs: tableMode.value === 'date_breakdown' ? dateMs : 0, breakdown, views: 0, downloads: 0, @@ -250,7 +258,7 @@ const sortedRows = computed(() => { return primaryResult * directionFactor } - const dateResult = left.dateStartMs - right.dateStartMs + const dateResult = left.dateMs - right.dateMs if (dateResult !== 0) { return dateResult * directionFactor } @@ -314,7 +322,7 @@ function getSortComparison( ): number { switch (column) { case 'date': - return left.dateStartMs - right.dateStartMs + return left.dateMs - right.dateMs case 'breakdown': return left.breakdown.localeCompare(right.breakdown, undefined, { sensitivity: 'base' }) case 'views': From fbc996b7b230ee0258f520d3fbd69d740ac6e98a Mon Sep 17 00:00:00 2001 From: tdgao Date: Thu, 23 Apr 2026 11:19:38 -0600 Subject: [PATCH 11/20] fix: query builder disable group-by options if not relavant --- .../analytics/query-builder/QueryBuilder.vue | 55 ++++++++++++++++--- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/apps/frontend/src/components/analytics/query-builder/QueryBuilder.vue b/apps/frontend/src/components/analytics/query-builder/QueryBuilder.vue index 0f0d220fb5..df3b0fb1e3 100644 --- a/apps/frontend/src/components/analytics/query-builder/QueryBuilder.vue +++ b/apps/frontend/src/components/analytics/query-builder/QueryBuilder.vue @@ -178,13 +178,17 @@ const timeframeOptions: ComboboxOption[] = [ { value: 'all_time', label: 'All time' }, ] -const groupByOptions: ComboboxOption[] = [ - { value: '1h', label: '1h' }, - { value: '6h', label: '6h' }, - { value: 'day', label: 'Day' }, - { value: 'week', label: 'Week' }, - { value: 'month', label: 'Month' }, - { value: 'year', label: 'Year' }, +const groupByPresetOptions: Array<{ + value: AnalyticsGroupByPreset + label: string + minutes: number +}> = [ + { value: '1h', label: '1h', minutes: 60 }, + { value: '6h', label: '6h', minutes: 360 }, + { value: 'day', label: 'Day', minutes: 24 * 60 }, + { value: 'week', label: 'Week', minutes: 7 * 24 * 60 }, + { value: 'month', label: 'Month', minutes: 30 * 24 * 60 }, + { value: 'year', label: 'Year', minutes: 365 * 24 * 60 }, ] const breakdownOptions: ComboboxOption[] = [ @@ -439,6 +443,43 @@ function ensureMinimumRange(start: Date, end: Date): { start: Date; end: Date } return { start, end } } +const selectedTimeframeDurationMinutes = computed(() => { + const rawRange = getTimeRangeForPreset(selectedTimeframe.value) + const { start, end } = ensureMinimumRange(rawRange.start, rawRange.end) + const durationMs = end.getTime() - start.getTime() + return Math.max(1, Math.floor(durationMs / (60 * 1000))) +}) + +const groupByOptions = computed[]>(() => { + const options = groupByPresetOptions.map((option) => ({ + value: option.value, + label: option.label, + disabled: option.minutes >= selectedTimeframeDurationMinutes.value, + })) + + if (options.every((option) => option.disabled)) { + options[0].disabled = false + } + + return options +}) + +watch( + groupByOptions, + (nextOptions) => { + const selectedOption = nextOptions.find((option) => option.value === selectedGroupBy.value) + if (selectedOption && !selectedOption.disabled) { + return + } + + const fallbackOption = [...nextOptions].reverse().find((option) => !option.disabled) ?? nextOptions[0] + if (fallbackOption && selectedGroupBy.value !== fallbackOption.value) { + selectedGroupBy.value = fallbackOption.value + } + }, + { immediate: true }, +) + function unique(values: T[]): T[] { return Array.from(new Set(values)) } From 6ce8be3b5795cf1325cee62d17ea54c25044bbdc Mon Sep 17 00:00:00 2001 From: tdgao Date: Thu, 23 Apr 2026 11:25:20 -0600 Subject: [PATCH 12/20] feat: style improvements --- .../src/components/analytics/AnalyticsDashboard.vue | 7 +++++-- .../src/components/analytics/stat-cards/StatCard.vue | 6 +++--- .../src/components/analytics/table/AnalyticsTable.vue | 3 ++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/frontend/src/components/analytics/AnalyticsDashboard.vue b/apps/frontend/src/components/analytics/AnalyticsDashboard.vue index c8c1315d6c..e3f1d6bf21 100644 --- a/apps/frontend/src/components/analytics/AnalyticsDashboard.vue +++ b/apps/frontend/src/components/analytics/AnalyticsDashboard.vue @@ -1,5 +1,5 @@ @@ -24,7 +24,14 @@ import { import StatCard from './StatCard.vue' -const analyticsDashboardContext = injectAnalyticsDashboardContext() +const { + activeStat, + setActiveStat, + currentTotals, + percentChanges, + selectedBreakdown, + isAnalyticsDashboardStatRelevant, +} = injectAnalyticsDashboardContext() const formatNumber = useFormatNumber() const compactNumberFormatter = computed( @@ -64,46 +71,34 @@ const statCards = computed< { key: 'views', label: 'Views', - statLabel: formatStatNumber(analyticsDashboardContext.currentTotals.value.views), - vsPrevPeriodPercent: formatPercent(analyticsDashboardContext.percentChanges.value.views), + statLabel: formatStatNumber(currentTotals.value.views), + vsPrevPeriodPercent: formatPercent(percentChanges.value.views), icon: 'eye', - disabled: !analyticsDashboardContext.isAnalyticsDashboardStatRelevant( - 'views', - analyticsDashboardContext.selectedBreakdown.value, - ), + disabled: !isAnalyticsDashboardStatRelevant('views', selectedBreakdown.value), }, { key: 'downloads', label: 'Downloads', - statLabel: formatStatNumber(analyticsDashboardContext.currentTotals.value.downloads), - vsPrevPeriodPercent: formatPercent(analyticsDashboardContext.percentChanges.value.downloads), + statLabel: formatStatNumber(currentTotals.value.downloads), + vsPrevPeriodPercent: formatPercent(percentChanges.value.downloads), icon: 'download', - disabled: !analyticsDashboardContext.isAnalyticsDashboardStatRelevant( - 'downloads', - analyticsDashboardContext.selectedBreakdown.value, - ), + disabled: !isAnalyticsDashboardStatRelevant('downloads', selectedBreakdown.value), }, { key: 'revenue', label: 'Revenue', - statLabel: `$${formatStatNumber(analyticsDashboardContext.currentTotals.value.revenue)}`, - vsPrevPeriodPercent: formatPercent(analyticsDashboardContext.percentChanges.value.revenue), + statLabel: `$${formatStatNumber(currentTotals.value.revenue)}`, + vsPrevPeriodPercent: formatPercent(percentChanges.value.revenue), icon: 'dollar', - disabled: !analyticsDashboardContext.isAnalyticsDashboardStatRelevant( - 'revenue', - analyticsDashboardContext.selectedBreakdown.value, - ), + disabled: !isAnalyticsDashboardStatRelevant('revenue', selectedBreakdown.value), }, { key: 'playtime', label: 'Playtime', - statLabel: `${formatStatNumber(analyticsDashboardContext.currentTotals.value.playtime / 3600)} hrs`, - vsPrevPeriodPercent: formatPercent(analyticsDashboardContext.percentChanges.value.playtime), + statLabel: `${formatStatNumber(currentTotals.value.playtime / 3600)} hrs`, + vsPrevPeriodPercent: formatPercent(percentChanges.value.playtime), icon: 'clock', - disabled: !analyticsDashboardContext.isAnalyticsDashboardStatRelevant( - 'playtime', - analyticsDashboardContext.selectedBreakdown.value, - ), + disabled: !isAnalyticsDashboardStatRelevant('playtime', selectedBreakdown.value), }, ]) diff --git a/apps/frontend/src/components/analytics/table/AnalyticsTable.vue b/apps/frontend/src/components/analytics/table/AnalyticsTable.vue index f98b850da3..f808efd387 100644 --- a/apps/frontend/src/components/analytics/table/AnalyticsTable.vue +++ b/apps/frontend/src/components/analytics/table/AnalyticsTable.vue @@ -59,7 +59,13 @@ {{ value }}