2026-03-19 15:26:15 +00:00
import { injectI18n , useDebugLogger } from '@modrinth/ui'
2025-08-14 21:48:38 +01:00
import dayjs from 'dayjs'
import { computed , ref , watch } from 'vue'
2024-07-03 13:05:14 -07:00
2023-12-26 14:46:32 -05:00
// note: build step can miss unix import for some reason, so
// we have to import it like this
2024-03-29 22:09:45 -07:00
2025-08-14 21:48:38 +01:00
const { unix } = dayjs
2023-12-26 14:46:32 -05:00
2025-08-14 21:48:38 +01:00
export function useCountryNames ( style = 'long' ) {
2026-01-16 09:07:40 +00:00
const { locale } = injectI18n ( )
2026-01-06 00:42:13 +00:00
const displayNames = computed (
( ) => new Intl . DisplayNames ( [ locale . value ] , { type : 'region' , style } ) ,
)
2025-08-14 21:48:38 +01:00
return function formatCountryName ( code ) {
2026-01-06 00:42:13 +00:00
try {
return displayNames . value . of ( code ) ? ? code
} catch {
return code
}
2025-08-14 21:48:38 +01:00
}
2023-12-26 14:46:32 -05:00
}
export const countryCodeToName = ( code ) => {
2025-08-14 21:48:38 +01:00
const formatCountryName = useCountryNames ( )
2023-12-26 14:46:32 -05:00
2025-08-14 21:48:38 +01:00
return formatCountryName ( code )
}
2023-12-26 14:46:32 -05:00
export const countryCodeToFlag = ( code ) => {
2025-08-14 21:48:38 +01:00
if ( code === 'XX' ) {
return undefined
}
return ` https://flagcdn.com/h240/ ${ code . toLowerCase ( ) } .png `
}
2023-12-26 14:46:32 -05:00
export const formatTimestamp = ( timestamp ) => {
2025-08-14 21:48:38 +01:00
return unix ( timestamp ) . format ( )
}
2023-12-26 14:46:32 -05:00
export const formatPercent = ( value , sum ) => {
2025-08-14 21:48:38 +01:00
return ` ${ ( ( value / sum ) * 100 ) . toFixed ( 2 ) } % `
}
2023-12-26 14:46:32 -05:00
2024-01-10 12:50:21 -08:00
const hashProjectId = ( projectId ) => {
2025-08-14 21:48:38 +01:00
return projectId . split ( '' ) . reduce ( ( acc , char ) => acc + char . charCodeAt ( 0 ) , 0 ) % 30
}
2024-01-10 12:50:21 -08:00
2024-01-12 14:51:03 -08:00
export const defaultColors = [
2025-08-14 21:48:38 +01:00
'#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
]
2024-01-10 12:50:21 -08:00
/ * *
* @ param { string | number } value
* @ returns { string } color
* /
export const getDefaultColor = ( value ) => {
2025-08-14 21:48:38 +01:00
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 } ) `
}
2023-12-26 14:46:32 -05:00
const emptyAnalytics = {
2025-08-14 21:48:38 +01:00
sum : 0 ,
len : 0 ,
chart : {
labels : [ ] ,
data : [ ] ,
sumData : [
{
name : '' ,
data : [ ] ,
} ,
] ,
colors : [ ] ,
defaultColors : [ ] ,
} ,
projectIds : [ ] ,
}
2023-12-26 14:46:32 -05:00
2024-01-10 12:50:21 -08:00
export const analyticsSetToCSVString = ( analytics ) => {
2025-08-14 21:48:38 +01:00
if ( ! analytics ) {
return ''
}
2024-01-10 12:50:21 -08:00
2025-08-14 21:48:38 +01:00
const newline = '\n'
const labels = analytics . chart . labels
const projects = analytics . chart . data
2024-01-10 12:50:21 -08:00
2025-08-14 21:48:38 +01:00
const projectNames = projects . map ( ( p ) => p . name )
2024-01-10 12:50:21 -08:00
2025-08-14 21:48:38 +01:00
const header = [ 'Date' , ... projectNames ] . join ( ',' )
2024-01-10 12:50:21 -08:00
2025-08-14 21:48:38 +01:00
const data = labels . map ( ( label , i ) => {
const values = projects . map ( ( p ) => p . data ? . [ i ] || '' )
return [ label , ... values ] . join ( ',' )
} )
2024-01-10 12:50:21 -08:00
2025-08-14 21:48:38 +01:00
return [ header , ... data ] . join ( newline )
}
2024-01-10 12:50:21 -08:00
2024-07-31 21:42:55 +02:00
export const processAnalytics = ( category , projects , labelFn , sortFn , mapFn , chartName , theme ) => {
2025-08-14 21:48:38 +01:00
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 ,
}
}
2023-12-26 14:46:32 -05:00
export const processAnalyticsByCountry = ( category , projects , sortFn ) => {
2025-08-14 21:48:38 +01:00
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 )
2024-07-31 21:42:55 +02:00
const processNumberAnalytics = ( c , projects , theme ) =>
2025-08-14 21:48:38 +01:00
processAnalytics ( c , projects , formatTimestamp , sortTimestamp , null , 'Downloads' , theme )
2024-07-31 21:42:55 +02:00
const processRevAnalytics = ( c , projects , theme ) =>
2025-08-14 21:48:38 +01:00
processAnalytics ( c , projects , formatTimestamp , sortTimestamp , roundValue , 'Revenue' , theme )
2023-12-26 14:46:32 -05:00
const useFetchAnalytics = (
2025-08-14 21:48:38 +01:00
url ,
baseOptions = {
apiVersion : 3 ,
} ,
2023-12-26 14:46:32 -05:00
) => {
2025-08-14 21:48:38 +01:00
return useBaseFetch ( url , baseOptions )
}
2023-12-26 14:46:32 -05:00
/ * *
2024-01-10 12:50:21 -08:00
* @ param { Ref < any [ ] > } projects
2023-12-26 14:46:32 -05:00
* @ param { undefined | ( ) => any } onDataRefresh
* /
2024-01-12 14:51:03 -08:00
export const useFetchAllAnalytics = (
2025-08-14 21:48:38 +01:00
onDataRefresh ,
projects ,
selectedProjects ,
personalRevenue = false ,
startDate = ref ( dayjs ( ) . subtract ( 30 , 'days' ) ) ,
endDate = ref ( dayjs ( ) ) ,
timeResolution = ref ( 1440 ) ,
2024-01-12 14:51:03 -08:00
) => {
2026-03-19 15:26:15 +00:00
const debug = useDebugLogger ( 'useFetchAllAnalytics' )
debug ( 'init' , {
projectCount : projects . value ? . length ,
personalRevenue ,
startDate : startDate . value ? . toISOString ( ) ,
endDate : endDate . value ? . toISOString ( ) ,
} )
2025-08-14 21:48:38 +01:00
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 ) ,
} ) )
2026-03-19 15:26:15 +00:00
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
}
2025-08-14 21:48:38 +01:00
const fetchData = async ( query ) => {
2026-03-19 15:26:15 +00:00
debug ( 'fetchData called' , { query } )
2025-08-14 21:48:38 +01:00
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
2026-03-19 15:26:15 +00:00
debug ( 'fetching all 5 endpoints...' )
2025-08-14 21:48:38 +01:00
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 } ` ) ,
] )
2026-03-19 15:26:15 +00:00
debug ( 'all 5 endpoints resolved' , {
downloads : Object . keys ( responses [ 0 ] || { } ) . length ,
views : Object . keys ( responses [ 1 ] || { } ) . length ,
revenue : Object . keys ( responses [ 2 ] || { } ) . length ,
} )
2025-08-14 21:48:38 +01:00
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 ) )
}
2026-03-19 15:26:15 +00:00
debug ( 'filtering to projectIds' , { count : projectIds . size } )
2025-08-14 21:48:38 +01:00
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 ) {
2026-03-19 15:26:15 +00:00
debug ( 'fetchData error' , e )
2025-08-14 21:48:38 +01:00
error . value = e
} finally {
loading . value = false
2026-03-19 15:26:15 +00:00
debug ( 'fetchData done, loading=false' )
2025-08-14 21:48:38 +01:00
}
}
2026-03-19 15:26:15 +00:00
const fetch = async ( ) => {
debug ( 'fetch() called' , { projectCount : projects . value ? . length } )
await fetchData ( buildQuery ( ) )
if ( onDataRefresh ) {
onDataRefresh ( )
}
}
2025-08-14 21:48:38 +01:00
2026-03-19 15:26:15 +00:00
watch (
[ ( ) => startDate . value , ( ) => endDate . value , ( ) => timeResolution . value , ( ) => projects . value ] ,
( newVals , oldVals ) => {
debug ( 'watch triggered' , { new : newVals , old : oldVals } )
fetch ( )
2025-08-14 21:48:38 +01:00
} ,
)
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 ,
2026-03-19 15:26:15 +00:00
fetch ,
2025-08-14 21:48:38 +01:00
}
}