Modrinth/utils/analytics.js
Geometrically e319d19a54
New analytics (#1483)
* [WIP] Transfer analytics to own branch

* code style changes

* Refactor country name conversion

* Clean up api and ssr for settings page

* refactor analytics into reusables

* Refactor chart tooltip and reset functionality

* Update dayjs import and formatTimestamp function

* Fix bug in login functionality

* Abstract data fetching

* Refactor analytics data formatting

* Refactor useFetchAllAnalytics function signature

* Refactor analytics processing functions

* Fix chart data in ChartDisplay.vue

* Refactor analytics pages

* Refactor delete labrinth.ts test types

* Fix import statement for dayjs and update usage of
unix function

* Fix dropdown select in ChartDisplay.vue and add
Analytics link in creations.vue

* Update chart colors in ChartDisplay.vue and
analytics.js

* Update defaultRanges in ChartDisplay.vue

* Add colors to chart

* Update legend position in ChartDisplay.vue

* Refactor color conversion functions in
analytics.js

* Bug fixes

* Use softer colors

* Import dayjs unix module for analytics.js

* Refactor chart tooltip generation

* Fix calculation of total value in generateTooltip
function

* Fix button-base styling in ChartDisplay.vue

* Adopt intl standard rather than iso-3166-1

* Add support for potential flags

* Analytics rebased

* fix cf pages

* now?

* try now

* now?

* Fix this time

* address rev

* Finish analytics

* fix api url

---------

Co-authored-by: Carter <safe@fea.st>
2023-12-26 14:46:32 -05:00

332 lines
8.9 KiB
JavaScript

import dayjs from 'dayjs'
// note: build step can miss unix import for some reason, so
// we have to import it like this
// eslint-disable-next-line import/no-named-as-default-member
const { unix } = dayjs
export function useCountryNames(style = 'long') {
const formattingOptions = { type: 'region', style }
const { formats } = useVIntl()
return function formatCountryName(code) {
return formats.displayName(code, formattingOptions)
}
}
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 intToRgba = (color, projectId = 'Unknown', theme) => {
// 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
const hash = projectId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % 30
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}, 1)`
}
const emptyAnalytics = {
sum: 0,
len: 0,
chart: {
labels: [],
data: [],
sumData: [
{
name: '',
data: [],
},
],
colors: [],
},
}
export const processAnalytics = (category, projects, labelFn, sortFn, mapFn, chartName) => {
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
// Sort, label&map
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)
)
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 theme = useTheme()
const project = chartData[i]
return project.color
? intToRgba(project.color, project.id, theme.value.value)
: '--color-brand'
}),
},
}
}
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 current = countrySums.get(country) || 0
countrySums.set(country, 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, a], [_b, 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) =>
processAnalytics(c, projects, formatTimestamp, sortTimestamp, null, 'Downloads')
const processRevAnalytics = (c, projects) =>
processAnalytics(c, projects, formatTimestamp, sortTimestamp, roundValue, 'Revenue')
const useFetchAnalytics = (
url,
baseOptions = {
apiVersion: 3,
}
) => {
return useBaseFetch(url, baseOptions)
}
/**
* @param {any} projects
* @param {undefined | () => any} onDataRefresh
*/
export const useFetchAllAnalytics = (onDataRefresh, projects = undefined) => {
const timeResolution = ref(1440) // 1 day
const timeRange = ref(43200) // 30 days
const startDate = ref(Date.now() - timeRange.value * 60 * 1000)
const endDate = ref(Date.now())
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, projects),
views: processNumberAnalytics(viewData.value, projects),
revenue: processRevAnalytics(revenueData.value, projects),
downloadsByCountry: processCountryAnalytics(downloadsByCountry.value, projects),
viewsByCountry: processCountryAnalytics(viewsByCountry.value, projects),
}))
const fetchData = async (query) => {
const normalQuery = new URLSearchParams(query)
const revQuery = new URLSearchParams(query)
const qs = normalQuery.toString()
const revQs = revQuery.toString()
try {
loading.value = true
error.value = null
const responses = await Promise.all([
useFetchAnalytics(`analytics/downloads?${qs}`),
useFetchAnalytics(`analytics/views?${qs}`),
useFetchAnalytics(`analytics/revenue?${revQs}`),
useFetchAnalytics(`analytics/countries/downloads?${qs}`),
useFetchAnalytics(`analytics/countries/views?${qs}`),
])
downloadData.value = responses[0] || {}
viewData.value = responses[1] || {}
revenueData.value = responses[2] || {}
downloadsByCountry.value = responses[3] || {}
viewsByCountry.value = responses[4] || {}
} catch (e) {
error.value = e
} finally {
loading.value = false
}
}
watch(
[() => startDate.value, () => endDate.value, () => timeResolution.value, () => projects],
async () => {
const q = {
start_date: dayjs(startDate.value).toISOString(),
end_date: dayjs(endDate.value).toISOString(),
resolution_minutes: timeResolution.value,
}
if (projects?.length) {
q.project_ids = JSON.stringify(projects.map((p) => p.id))
}
await fetchData(q)
if (onDataRefresh) {
onDataRefresh()
}
},
{
immediate: true,
}
)
return {
// Configuration
timeResolution,
timeRange,
startDate,
endDate,
// Data
downloadData,
viewData,
revenueData,
downloadsByCountry,
viewsByCountry,
// Computed state
formattedData,
loading,
error,
}
}