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>
This commit is contained in:
Geometrically
2023-12-26 14:46:32 -05:00
committed by GitHub
parent 5f075e4936
commit e319d19a54
11 changed files with 1697 additions and 95 deletions

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-calendar-clock"><path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5"/><path d="M16 2v4"/><path d="M8 2v4"/><path d="M3 10h5"/><path d="M17.5 17.5 16 16.25V14"/><path d="M22 16a6 6 0 1 1-12 0 6 6 0 0 1 12 0Z"/></svg>

After

Width:  |  Height:  |  Size: 436 B

View File

@@ -0,0 +1,530 @@
<script setup>
import dayjs from 'dayjs'
import { Button, DownloadIcon, UpdatedIcon, formatNumber, formatMoney } from 'omorphia'
import VueApexCharts from 'vue3-apexcharts'
// let VueApexCharts
// if (process.client) {
// VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
// }
const props = defineProps({
name: {
type: String,
required: true,
},
labels: {
type: Array,
required: true,
},
data: {
type: Array,
required: true,
},
formatLabels: {
type: Function,
default: (label) => dayjs(label).format('MMM D'),
},
colors: {
type: Array,
default: () => [],
},
prefix: {
type: String,
default: '',
},
suffix: {
type: String,
default: '',
},
hideToolbar: {
type: Boolean,
default: false,
},
hideLegend: {
type: Boolean,
default: false,
},
stacked: {
type: Boolean,
default: false,
},
type: {
type: String,
default: 'bar',
},
hideTotal: {
type: Boolean,
default: false,
},
isMoney: {
type: Boolean,
default: false,
},
legendPosition: {
type: String,
default: 'right',
},
xAxisType: {
type: String,
default: 'datetime',
},
percentStacked: {
type: Boolean,
default: false,
},
horizontalBar: {
type: Boolean,
default: false,
},
disableAnimations: {
type: Boolean,
default: false,
},
})
function formatTooltipValue(value, props) {
return props.isMoney ? formatMoney(value, false) : formatNumber(value, false)
}
function generateListEntry(value, index, _, w, props) {
const color = props.colors[index % props.colors.length]
return `<div class="list-entry">
<span class="circle" style="background-color: ${color}"></span>
<div class="label">
${w.globals.seriesNames[index]}
</div>
<div class="value">
${props.prefix}${formatTooltipValue(value, props)}${props.suffix}
</div>
</div>`
}
function generateTooltip({ series, seriesIndex, dataPointIndex, w }, props) {
const label = w.globals.lastXAxis.categories[dataPointIndex]
const formattedLabel = props.formatLabels(label)
let tooltip = `<div class="bar-tooltip">
<div class="seperated-entry title">
<div class="label">${formattedLabel}</div>`
// Logic for total and percent stacked
if (!props.hideTotal) {
if (props.percentStacked) {
const total = series.reduce((a, b) => a + (b?.[dataPointIndex] || 0), 0)
const percentValue = (100 * series[seriesIndex][dataPointIndex]) / total
tooltip += `<div class="value">${props.prefix}${formatNumber(percentValue)}%${
props.suffix
}</div>`
} else {
const totalValue = series.reduce((a, b) => a + (b?.[dataPointIndex] || 0), 0)
tooltip += `<div class="value">${props.prefix}${formatTooltipValue(totalValue, props)}${
props.suffix
}</div>`
}
}
tooltip += '</div><hr class="card-divider" />'
// Logic for generating list entries
if (props.percentStacked) {
tooltip += generateListEntry(
series[seriesIndex][dataPointIndex],
seriesIndex,
seriesIndex,
w,
props
)
} else {
const listEntries = series
.map((value, index) => [
value[dataPointIndex],
generateListEntry(value[dataPointIndex], index, seriesIndex, w, props),
])
.filter((value) => value[0] > 0)
.sort((a, b) => b[0] - a[0])
.map((value) => value[1])
.join('')
tooltip += listEntries
}
tooltip += '</div>'
return tooltip
}
const chartOptions = ref({
chart: {
id: props.name,
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
foreColor: 'var(--color-base)',
selection: {
enabled: true,
fill: {
color: 'var(--color-brand)',
},
},
toolbar: {
show: false,
},
stacked: props.stacked,
stackType: props.percentStacked ? '100%' : 'normal',
zoom: {
autoScaleYaxis: true,
},
animations: {
enabled: props.disableAnimations,
},
},
xaxis: {
type: props.xAxisType,
categories: props.labels,
labels: {
style: {
borderRadius: 'var(--radius-sm)',
},
},
axisTicks: {
show: false,
},
tooltip: {
enabled: false,
},
},
yaxis: {
tooltip: {
enabled: false,
},
},
colors: props.colors,
dataLabels: {
enabled: false,
background: {
enabled: true,
borderRadius: 20,
},
},
grid: {
borderColor: 'var(--color-button-bg)',
tickColor: 'var(--color-button-bg)',
},
legend: {
show: !props.hideLegend,
position: props.legendPosition,
showForZeroSeries: false,
showForSingleSeries: false,
showForNullSeries: false,
fontSize: 'var(--font-size-nm)',
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
onItemClick: {
toggleDataSeries: true,
},
},
markers: {
size: 0,
strokeColor: 'var(--color-contrast)',
strokeWidth: 3,
strokeOpacity: 1,
fillOpacity: 1,
hover: {
size: 6,
},
},
plotOptions: {
bar: {
horizontal: props.horizontalBar,
columnWidth: '80%',
endingShape: 'rounded',
borderRadius: 5,
borderRadiusApplication: 'end',
borderRadiusWhenStacked: 'last',
},
},
stroke: {
curve: 'smooth',
width: 2,
},
tooltip: {
custom: (d) => generateTooltip(d, props),
},
})
const fillOptions = {
colors: props.colors,
type: 'gradient',
opacity: 1,
gradient: {
shade: 'light',
type: 'vertical',
shadeIntensity: 0,
gradientToColors: props.colors,
inverseColors: true,
opacityFrom: 0.5,
opacityTo: 0,
stops: [0, 100],
colorStops: [],
},
}
const chart = ref(null)
const legendValues = ref(
[...props.data].map((project, index) => {
return { name: project.name, visible: true, color: props.colors[index] }
})
)
const flipLegend = (legend, newVal) => {
legend.visible = newVal
chart.value.toggleSeries(legend.name)
}
const downloadCSV = () => {
const csvContent =
'data:text/csv;charset=utf-8,' +
'Date,' +
props.labels.join(',') +
'\n' +
props.data
.map((project) => project.name.replace(',', '-') + ',' + project.data.join(','))
.reduce((a, b) => a + '\n' + b)
const encodedUri = encodeURI(csvContent)
const link = document.createElement('a')
link.setAttribute('href', encodedUri)
link.setAttribute('download', `${props.name}.csv`)
document.body.appendChild(link) // Required for FF
link.click()
}
const resetChart = () => {
chart.value.updateSeries([...props.data])
chart.value.updateOptions({
xaxis: {
categories: props.labels,
},
colors: props.colors,
})
chart.value.resetSeries()
legendValues.value.forEach((legend) => {
legend.visible = true
})
}
defineExpose({
resetChart,
downloadCSV,
flipLegend,
})
</script>
<template>
<div class="bar-chart">
<div class="title-bar">
<slot />
<div v-if="!hideToolbar" class="toolbar">
<Button v-tooltip="'Download data as CSV'" icon-only @click="downloadCSV">
<DownloadIcon />
</Button>
<Button v-tooltip="'Reset chart'" icon-only @click="resetChart">
<UpdatedIcon />
</Button>
<slot name="toolbar" />
</div>
</div>
<VueApexCharts
ref="chart"
:type="type"
:options="{
...chartOptions,
fill: type === 'area' ? fillOptions : {},
}"
:series="data"
class="chart"
/>
</div>
</template>
<style scoped lang="scss">
.chart {
width: 100%;
height: 100%;
}
svg {
width: 100%;
height: 100%;
}
.btn {
svg {
width: 1.25rem;
height: 1.25rem;
}
}
.bar-chart {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
}
.title-bar {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-xs);
}
.toolbar {
display: flex;
flex-direction: row;
gap: var(--gap-xs);
z-index: 1;
margin-left: auto;
}
:deep(.apexcharts-menu),
:deep(.apexcharts-tooltip),
:deep(.apexcharts-yaxistooltip) {
background: var(--color-raised-bg) !important;
border-radius: var(--radius-sm) !important;
border: 1px solid var(--color-button-bg) !important;
box-shadow: var(--shadow-floating) !important;
font-size: var(--font-size-nm) !important;
}
:deep(.apexcharts-grid-borders) {
line {
stroke: var(--color-button-bg) !important;
}
}
:deep(.apexcharts-yaxistooltip),
:deep(.apexcharts-xaxistooltip) {
background: var(--color-raised-bg) !important;
border-radius: var(--radius-sm) !important;
border: 1px solid var(--color-button-bg) !important;
font-size: var(--font-size-nm) !important;
color: var(--color-base) !important;
.apexcharts-xaxistooltip-text {
font-size: var(--font-size-nm) !important;
color: var(--color-base) !important;
}
}
:deep(.apexcharts-yaxistooltip-left:after) {
border-left-color: var(--color-raised-bg) !important;
}
:deep(.apexcharts-yaxistooltip-left:before) {
border-left-color: var(--color-button-bg) !important;
}
:deep(.apexcharts-xaxistooltip-bottom:after) {
border-bottom-color: var(--color-raised-bg) !important;
}
:deep(.apexcharts-xaxistooltip-bottom:before) {
border-bottom-color: var(--color-button-bg) !important;
}
:deep(.apexcharts-menu-item) {
border-radius: var(--radius-sm) !important;
padding: var(--gap-xs) var(--gap-sm) !important;
&:hover {
transition: all 0.3s !important;
color: var(--color-accent-contrast) !important;
background: var(--color-brand) !important;
}
}
:deep(.apexcharts-tooltip) {
.bar-tooltip {
min-width: 10rem;
display: flex;
flex-direction: column;
gap: var(--gap-xs);
padding: var(--gap-sm);
.card-divider {
margin: var(--gap-xs) 0;
}
.seperated-entry {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.title {
font-weight: bolder;
}
.label {
margin-right: var(--gap-xl);
color: var(--color-contrast);
}
.value {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-xs);
color: var(--color-base);
}
.list-entry {
display: flex;
flex-direction: row;
align-items: center;
font-size: var(--font-size-sm);
.value {
margin-left: auto;
}
}
.circle {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
display: inline-block;
margin-right: var(--gap-sm);
border: 2px solid var(--color-base);
}
svg {
height: 1em;
width: 1em;
}
}
}
.legend {
display: flex;
flex-wrap: wrap;
flex-direction: row;
align-items: center;
gap: var(--gap-lg);
justify-content: center;
}
:deep(.checkbox) {
white-space: nowrap;
}
.legend-checkbox :deep(.checkbox.checked) {
background-color: var(--color);
}
</style>

View File

@@ -0,0 +1,474 @@
<template>
<div>
<div v-if="analytics.error.value">
{{ analytics.error.value }}
</div>
<div v-else class="graphs">
<div class="graphs__vertical-bar">
<client-only>
<CompactChart
v-if="analytics.formattedData.value.downloads"
ref="tinyDownloadChart"
:title="`Downloads since ${dayjs(startDate).format('MMM D, YYYY')}`"
color="var(--color-brand)"
:value="formatNumber(analytics.formattedData.value.downloads.sum, false)"
:data="analytics.formattedData.value.downloads.chart.sumData"
:labels="analytics.formattedData.value.downloads.chart.labels"
suffix="<svg xmlns='http://www.w3.org/2000/svg' class='h-6 w-6' fill='none' viewBox='0 0 24 24' stroke='currentColor' stroke-width='2'><path stroke-linecap='round' stroke-linejoin='round' d='M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4' /></svg>"
:class="`clickable button-base ${
selectedChart === 'downloads' ? 'button-base__selected' : ''
}`"
:onclick="() => (selectedChart = 'downloads')"
role="button"
/>
</client-only>
<client-only>
<CompactChart
v-if="analytics.formattedData.value.views"
ref="tinyViewChart"
:title="`Page views since ${dayjs(startDate).format('MMM D, YYYY')}`"
color="var(--color-blue)"
:value="formatNumber(analytics.formattedData.value.views.sum, false)"
:data="analytics.formattedData.value.views.chart.sumData"
:labels="analytics.formattedData.value.views.chart.labels"
suffix="<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z'/><circle cx='12' cy='12' r='3'/></svg>"
:class="`clickable button-base ${
selectedChart === 'views' ? 'button-base__selected' : ''
}`"
:onclick="() => (selectedChart = 'views')"
role="button"
/>
</client-only>
<client-only>
<CompactChart
v-if="analytics.formattedData.value.revenue"
ref="tinyRevenueChart"
:title="`Revenue since ${dayjs(startDate).format('MMM D, YYYY')}`"
color="var(--color-purple)"
:value="formatMoney(analytics.formattedData.value.revenue.sum, false)"
:data="analytics.formattedData.value.revenue.chart.sumData"
:labels="analytics.formattedData.value.revenue.chart.labels"
is-money
:class="`clickable button-base ${
selectedChart === 'revenue' ? 'button-base__selected' : ''
}`"
:onclick="() => (selectedChart = 'revenue')"
role="button"
/>
</client-only>
</div>
<div class="graphs__main-graph">
<Card>
<div class="graphs__main-graph-control">
<DropdownSelect
v-model="selectedRange"
:options="selectableRanges"
name="Time range"
:display-name="(o: typeof selectableRanges[number] | undefined) => o?.label || 'Custom'"
/>
<!-- <DropdownSelect
v-model="selectedResolution"
:options="selectableResoloutions"
:display-name="(o: typeof selectableResoloutions[number] | undefined) => o?.label || 'Custom'"
/> -->
</div>
<client-only>
<Chart
v-if="analytics.formattedData.value.downloads && selectedChart === 'downloads'"
ref="downloadsChart"
type="line"
name="Download data"
legend-position="right"
:data="analytics.formattedData.value.downloads.chart.data"
:labels="analytics.formattedData.value.downloads.chart.labels"
suffix="<svg xmlns='http://www.w3.org/2000/svg' class='h-6 w-6' fill='none' viewBox='0 0 24 24' stroke='currentColor' stroke-width='2'><path stroke-linecap='round' stroke-linejoin='round' d='M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4' /></svg>"
:colors="analytics.formattedData.value.downloads.chart.colors"
>
<h2>Downloads</h2>
</Chart>
<Chart
v-if="analytics.formattedData.value.views && selectedChart === 'views'"
ref="viewsChart"
type="line"
name="View data"
legend-position="right"
:data="analytics.formattedData.value.views.chart.data"
:labels="analytics.formattedData.value.views.chart.labels"
suffix="<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z'/><circle cx='12' cy='12' r='3'/></svg>"
:colors="analytics.formattedData.value.views.chart.colors"
>
<h2 class="">Views</h2>
</Chart>
<Chart
v-if="analytics.formattedData.value.revenue && selectedChart === 'revenue'"
ref="revenueChart"
type="line"
name="Revenue data"
legend-position="right"
:data="analytics.formattedData.value.revenue.chart.data"
:labels="analytics.formattedData.value.revenue.chart.labels"
is-money
suffix="<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><line x1='12' y1='2' x2='12' y2='22'></line><path d='M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6'></path></svg>"
:colors="analytics.formattedData.value.revenue.chart.colors"
>
<h2 class="">Revenue</h2>
</Chart>
</client-only>
</Card>
<div class="country-data">
<Card
v-if="
analytics.formattedData.value?.downloadsByCountry && selectedChart === 'downloads'
"
class="country-downloads"
>
<label>
<span class="label__title">Downloads by country</span>
</label>
<div class="country-values">
<div
v-for="[name, count] in analytics.formattedData.value.downloadsByCountry.data"
:key="name"
class="country-value"
>
<div class="country-flag-container">
<img
:src="
name.toLowerCase() === 'xx' || !name
? 'https://cdn.modrinth.com/placeholder-banner.svg'
: countryCodeToFlag(name)
"
alt="Hidden country"
class="country-flag"
/>
</div>
<div class="country-text">
<strong class="country-name"
><template v-if="name.toLowerCase() === 'xx' || !name">Hidden</template>
<template v-else>{{ countryCodeToName(name) }}</template>
</strong>
<span class="data-point">{{ formatNumber(count) }}</span>
</div>
<div
v-tooltip="
formatPercent(count, analytics.formattedData.value.downloadsByCountry.sum)
"
class="percentage-bar"
>
<span
:style="{
width: formatPercent(
count,
analytics.formattedData.value.downloadsByCountry.sum
),
backgroundColor: 'var(--color-brand)',
}"
></span>
</div>
</div>
</div>
</Card>
<Card
v-if="analytics.formattedData.value?.viewsByCountry && selectedChart === 'views'"
class="country-downloads"
>
<label>
<span class="label__title">Page views by country</span>
</label>
<div class="country-values">
<div
v-for="[name, count] in analytics.formattedData.value.viewsByCountry.data"
:key="name"
class="country-value"
>
<div class="country-flag-container">
<img
:src="`https://flagcdn.com/h240/${name.toLowerCase()}.png`"
:alt="name"
class="country-flag"
/>
</div>
<div class="country-text">
<strong class="country-name">{{ countryCodeToName(name) }}</strong>
<span class="data-point">{{ formatNumber(count) }}</span>
</div>
<div
v-tooltip="
`${
Math.round(
(count / analytics.formattedData.value.viewsByCountry.sum) * 10000
) / 100
}%`
"
class="percentage-bar"
>
<span
:style="{
width: `${(count / analytics.formattedData.value.viewsByCountry.sum) * 100}%`,
backgroundColor: 'var(--color-blue)',
}"
></span>
</div>
</div>
</div>
</Card>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Card, formatMoney, formatNumber, DropdownSelect } from 'omorphia'
import dayjs from 'dayjs'
import { defineProps, ref, computed } from 'vue'
import { UiChartsCompactChart as CompactChart, UiChartsChart as Chart } from '#components'
const props = withDefaults(
defineProps<{
projects?: any[]
/**
* @deprecated Use `ranges` instead
*/
resoloutions?: Record<string, number>
ranges?: Record<number, [string, number] | string>
}>(),
{
projects: undefined,
resoloutions: () => defaultResoloutions,
ranges: () => defaultRanges,
}
)
const selectableRanges = Object.entries(props.ranges).map(([duration, extra]) => ({
label: typeof extra === 'string' ? extra : extra[0],
value: Number(duration),
res: typeof extra === 'string' ? Number(duration) : extra[1],
}))
const selectedChart = ref('downloads')
// Chart refs
const downloadsChart = ref()
const viewsChart = ref()
const revenueChart = ref()
const tinyDownloadChart = ref()
const tinyViewChart = ref()
const tinyRevenueChart = ref()
const analytics = useFetchAllAnalytics(() => {
downloadsChart.value?.resetChart()
viewsChart.value?.resetChart()
revenueChart.value?.resetChart()
tinyDownloadChart.value?.resetChart()
tinyViewChart.value?.resetChart()
tinyRevenueChart.value?.resetChart()
}, props.projects)
const { startDate, endDate, timeRange, timeResolution } = analytics
const selectedRange = computed({
get: () => {
return (
selectableRanges.find((option) => option.value === timeRange.value) || {
label: 'Custom',
value: timeRange.value,
}
)
},
set: (newRange: { label: string; value: number; res?: number }) => {
timeRange.value = newRange.value
startDate.value = Date.now() - timeRange.value * 60 * 1000
endDate.value = Date.now()
if (newRange?.res) {
timeResolution.value = newRange.res
}
},
})
</script>
<script lang="ts">
const defaultResoloutions: Record<string, number> = {
'5 minutes': 5,
'30 minutes': 30,
'An hour': 60,
'12 hours': 720,
'A day': 1440,
'A week': 10080,
}
const defaultRanges: Record<number, [string, number] | string> = {
30: ['Last 30 minutes', 1],
60: ['Last hour', 5],
720: ['Last 12 hours', 15],
1440: ['Last day', 60],
10080: ['Last week', 720],
43200: ['Last month', 1440],
129600: ['Last quarter', 10080],
525600: ['Last year', 20160],
1051200: ['Last two years', 40320],
}
</script>
<style scoped lang="scss">
.button-base {
overflow: hidden;
}
.button-base__selected {
color: var(--color-contrast);
background-color: var(--color-brand-highlight);
box-shadow: inset 0 0 0 transparent, 0 0 0 2px var(--color-brand);
&:hover {
background-color: var(--color-brand-highlight);
}
}
.graphs {
// Pages clip so we need to add a margin
margin-left: 0.25rem;
margin-top: 0.25rem;
display: flex;
flex-direction: column;
.graphs__vertical-bar {
flex-grow: 0;
flex-shrink: 0;
gap: 0.75rem;
display: flex;
margin-right: 0.1rem;
}
.graphs__main-graph {
// Take up the rest of the width
flex-grow: 1;
display: grid;
grid-template-columns: 1fr;
.graphs__main-graph-control {
display: flex;
flex-direction: row;
align-items: flex-end;
justify-content: end;
margin-bottom: var(--gap-md);
gap: var(--gap-md);
.animated-dropdown {
width: auto;
}
}
}
}
// Mobile
@media (max-width: 768px) {
.graphs {
flex-direction: column;
gap: var(--gap-md);
.graphs__vertical-bar {
display: block;
width: 100%;
max-width: none;
}
.graphs__main-graph {
display: block;
overflow: hidden;
}
}
}
.country-flag-container {
width: 40px;
height: 27px;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
border: 1px solid var(--color-divider);
border-radius: var(--radius-xs);
}
.country-flag {
object-fit: cover;
min-width: 100%;
min-height: 100%;
}
.spark-data {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: var(--gap-md);
}
.country-data {
display: grid;
grid-template-columns: 1fr;
gap: var(--gap-md);
}
.country-values {
display: flex;
flex-direction: column;
background-color: var(--color-bg);
border-radius: var(--radius-sm);
border: 1px solid var(--color-button-bg);
gap: var(--gap-md);
padding: var(--gap-md);
margin-top: var(--gap-md);
overflow-y: auto;
max-height: 24rem;
}
.country-value {
display: grid;
grid-template-areas: 'flag text bar';
grid-template-columns: auto 1fr 10rem;
align-items: center;
justify-content: space-between;
width: 100%;
gap: var(--gap-sm);
.country-text {
grid-area: text;
display: flex;
flex-direction: column;
gap: var(--gap-xs);
}
.percentage-bar {
grid-area: bar;
width: 100%;
height: 1rem;
background-color: var(--color-raised-bg);
border: 1px solid var(--color-button-bg);
border-radius: var(--radius-sm);
overflow: hidden;
span {
display: block;
height: 100%;
}
}
}
@media (max-width: 768px) {
.country-data {
display: block;
}
.country-value {
grid-template-columns: auto 1fr 5rem;
}
}
</style>

View File

@@ -0,0 +1,280 @@
<script setup>
import { Card } from 'omorphia'
import VueApexCharts from 'vue3-apexcharts'
// let VueApexCharts
// if (process.client) {
// VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
// }
const props = defineProps({
value: {
type: String,
default: '',
},
title: {
type: String,
default: '',
},
data: {
type: Array,
default: () => [],
},
labels: {
type: Array,
default: () => [],
},
prefix: {
type: String,
default: '',
},
suffix: {
type: String,
default: '',
},
isMoney: {
type: Boolean,
default: false,
},
color: {
type: String,
default: 'var(--color-brand)',
},
})
// no grid lines, no toolbar, no legend, no data labels
const chartOptions = {
chart: {
id: props.title,
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
foreColor: 'var(--color-base)',
toolbar: {
show: false,
},
zoom: {
enabled: false,
},
sparkline: {
enabled: true,
},
parentHeightOffset: 0,
},
stroke: {
curve: 'smooth',
width: 2,
},
fill: {
colors: [props.color],
type: 'gradient',
opacity: 1,
gradient: {
shade: 'light',
type: 'vertical',
shadeIntensity: 0,
gradientToColors: [props.color],
inverseColors: true,
opacityFrom: 0.5,
opacityTo: 0,
stops: [0, 100],
colorStops: [],
},
},
grid: {
show: false,
},
legend: {
show: false,
},
colors: [props.color],
dataLabels: {
enabled: false,
},
xaxis: {
type: 'datetime',
categories: props.labels,
labels: {
show: false,
},
axisTicks: {
show: false,
},
tooltip: {
enabled: false,
},
},
yaxis: {
labels: {
show: false,
},
axisBorder: {
show: false,
},
axisTicks: {
show: false,
},
tooltip: {
enabled: false,
},
},
tooltip: {
enabled: false,
},
}
const chart = ref(null)
const resetChart = () => {
chart.value?.updateSeries([...props.data])
chart.value?.updateOptions({
xaxis: {
categories: props.labels,
},
})
chart.value?.resetSeries()
}
defineExpose({
resetChart,
})
</script>
<template>
<Card class="compact-chart">
<h1 class="value">
{{ value }}
</h1>
<div class="subtitle">
{{ title }}
</div>
<div class="chart">
<VueApexCharts ref="chart" type="area" :options="chartOptions" :series="data" height="70" />
</div>
</Card>
</template>
<style scoped lang="scss">
.compact-chart {
display: flex;
flex-direction: column;
gap: var(--gap-xs);
border: 1px solid var(--color-button-bg);
border-radius: var(--radius-md);
background-color: var(--color-raised-bg);
box-shadow: var(--shadow-floating);
color: var(--color-base);
font-size: var(--font-size-nm);
width: 100%;
padding-top: var(--gap-xl);
padding-bottom: 0;
.value {
margin: 0;
}
}
.chart {
// width: calc(100% + 3rem);
margin: 0 -1.5rem 0.25rem -1.5rem;
}
svg {
width: 100%;
height: 100%;
}
:deep(.apexcharts-menu),
:deep(.apexcharts-tooltip),
:deep(.apexcharts-yaxistooltip) {
background: var(--color-raised-bg) !important;
border-radius: var(--radius-sm) !important;
border: 1px solid var(--color-button-bg) !important;
box-shadow: var(--shadow-floating) !important;
font-size: var(--font-size-nm) !important;
}
:deep(.apexcharts-graphical) {
width: 100%;
}
:deep(.apexcharts-tooltip) {
.bar-tooltip {
display: flex;
flex-direction: column;
gap: var(--gap-xs);
padding: var(--gap-sm);
.card-divider {
margin: var(--gap-xs) 0;
}
.label {
display: flex;
flex-direction: row;
align-items: center;
}
.value {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-xs);
color: var(--color-base);
}
.list-entry {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: var(--gap-md);
}
.circle {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
display: inline-block;
margin-right: var(--gap-sm);
}
svg {
height: 1em;
width: 1em;
}
.divider {
font-size: var(--font-size-lg);
font-weight: 400;
}
}
}
.legend {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-md);
justify-content: center;
}
:deep(.apexcharts-grid-borders) {
line {
stroke: var(--color-button-bg) !important;
}
}
:deep(.apexcharts-xaxis) {
line {
stroke: none;
}
}
.legend-checkbox :deep(.checkbox.checked) {
background-color: var(--color);
}
</style>

View File

@@ -13,7 +13,7 @@
},
"devDependencies": {
"@formatjs/cli": "^6.1.2",
"@nuxt/devtools": "^0.7.0",
"@nuxt/devtools": "=0.7.0",
"@nuxtjs/eslint-config-typescript": "^12.0.0",
"@nuxtjs/turnstile": "^0.5.0",
"@types/node": "^20.1.0",
@@ -51,6 +51,7 @@
"qrcode.vue": "^3.4.0",
"semver": "^7.5.4",
"vue-multiselect": "^3.0.0-alpha.2",
"vue3-apexcharts": "^1.4.4",
"xss": "^1.0.14"
},
"packageManager": "pnpm@8.6.1",

View File

@@ -1,5 +1,5 @@
<template>
<div v-if="$route.name.startsWith('type-id-settings')" class="normal-page">
<div v-if="route.name.startsWith('type-id-settings')" class="normal-page">
<div class="normal-page__sidebar">
<aside class="universal-card">
<Breadcrumbs
@@ -75,6 +75,16 @@
>
<UsersIcon />
</NavStackItem>
<h3>View</h3>
<NavStackItem
:link="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/settings/analytics`"
label="Analytics"
chevron
>
<ChartIcon />
</NavStackItem>
<h3>Upload</h3>
<NavStackItem
:link="`/${project.project_type}/${project.slug ? project.slug : project.id}/gallery`"
@@ -99,8 +109,8 @@
:project="project"
:versions="versions"
:current-member="currentMember"
:is-settings="$route.name.startsWith('type-id-settings')"
:route-name="$route.name"
:is-settings="route.name.startsWith('type-id-settings')"
:route-name="route.name"
:set-processing="setProcessing"
:collapsed="collapsedChecklist"
:toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
@@ -324,8 +334,8 @@
:project="project"
:versions="versions"
:current-member="currentMember"
:is-settings="$route.name.startsWith('type-id-settings')"
:route-name="$route.name"
:is-settings="route.name.startsWith('type-id-settings')"
:route-name="route.name"
:set-processing="setProcessing"
:collapsed="collapsedChecklist"
:toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
@@ -490,7 +500,7 @@
<div class="featured-header">
<h2 class="card-header">Featured versions</h2>
<nuxt-link
v-if="$route.name !== 'type-id-versions' && (versions.length > 0 || currentMember)"
v-if="route.name !== 'type-id-versions' && (versions.length > 0 || currentMember)"
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/versions#all-versions`"
@@ -658,7 +668,7 @@
</div>
</template>
<script setup>
import { Promotion } from 'omorphia'
import { Promotion, ChartIcon } from 'omorphia'
import CalendarIcon from '~/assets/images/utils/calendar.svg'
import ClearIcon from '~/assets/images/utils/clear.svg'
import DownloadIcon from '~/assets/images/utils/download.svg'

View File

@@ -0,0 +1,34 @@
<template>
<div>
<div class="universal-card">
<h2>Analytics</h2>
<p>
This page shows you the analytics for your project, <strong>{{ project.title }}</strong
>. You can see the number of downloads, page views and revenue earned for your project, as
well as the total downloads and page views for {{ project.title }} by country.
</p>
</div>
<ChartDisplay :projects="[props.project]" />
</div>
</template>
<script setup>
import ChartDisplay from '~/components/ui/charts/ChartDisplay.vue'
const props = defineProps({
project: {
type: Object,
default() {
return {}
},
},
})
</script>
<style scoped lang="scss">
.markdown-body {
margin-bottom: var(--gap-md);
}
</style>

View File

@@ -16,6 +16,9 @@
<NavStackItem link="/dashboard/reports" label="Active reports">
<ReportIcon />
</NavStackItem>
<NavStackItem link="/dashboard/analytics" label="Analytics">
<ChartIcon />
</NavStackItem>
<h3>Manage</h3>
<NavStackItem v-if="true" link="/dashboard/projects" label="Projects">
@@ -33,6 +36,7 @@
</div>
</template>
<script setup>
import { ChartIcon } from 'omorphia'
import NavStack from '~/components/ui/NavStack.vue'
import NavStackItem from '~/components/ui/NavStackItem.vue'

View File

@@ -1,17 +1,24 @@
<template>
<div>
<section class="universal-card">
<h2>Analytics</h2>
<p>You found a secret!</p>
<nuxt-link to="/frog" class="goto-link"> Click here for fancy graphs! </nuxt-link>
</section>
<ChartDisplay :projects="projects ?? undefined" />
</div>
</template>
<script>
export default defineNuxtComponent({
head: {
title: 'Analytics - Modrinth',
},
<script setup>
import ChartDisplay from '~/components/ui/charts/ChartDisplay.vue'
definePageMeta({
middleware: 'auth',
})
useHead({
title: 'Analytics - Modrinth',
})
const auth = await useAuth()
const id = auth.value?.user?.id
const { data: projects } = await useAsyncData(`user/${id}/projects`, () =>
useBaseFetch(`user/${id}/projects`)
)
</script>

84
pnpm-lock.yaml generated
View File

@@ -49,6 +49,9 @@ dependencies:
vue-multiselect:
specifier: ^3.0.0-alpha.2
version: 3.0.0-alpha.2
vue3-apexcharts:
specifier: ^1.4.4
version: 1.4.4(apexcharts@3.44.0)(vue@3.3.4)
xss:
specifier: ^1.0.14
version: 1.0.14
@@ -58,7 +61,7 @@ devDependencies:
specifier: ^6.1.2
version: 6.1.2
'@nuxt/devtools':
specifier: ^0.7.0
specifier: '=0.7.0'
version: 0.7.0(nuxt@3.5.3)(vite@4.3.9)
'@nuxtjs/eslint-config-typescript':
specifier: ^12.0.0
@@ -1442,14 +1445,14 @@ packages:
dependencies:
'@nuxt/devtools-kit': 0.7.0(nuxt@3.5.3)(vite@4.3.9)
'@nuxt/devtools-wizard': 0.7.0
'@nuxt/kit': 3.6.5
'@nuxt/kit': 3.8.0
birpc: 0.2.12
boxen: 7.1.1
consola: 3.2.3
error-stack-parser-es: 0.1.0
execa: 7.1.1
fast-folder-size: 2.1.0
fast-glob: 3.3.0
fast-glob: 3.3.1
get-port-please: 3.0.1
global-dirs: 3.0.1
h3: 1.7.1
@@ -1469,7 +1472,7 @@ packages:
rc9: 2.1.1
semver: 7.5.4
sirv: 2.0.3
unimport: 3.1.0
unimport: 3.4.0(rollup@3.26.0)
vite: 4.3.9(@types/node@20.1.0)(sass@1.58.0)
vite-plugin-inspect: 0.7.33(vite@4.3.9)
vite-plugin-vue-inspector: 3.4.2(vite@4.3.9)
@@ -1537,32 +1540,6 @@ packages:
- supports-color
dev: true
/@nuxt/kit@3.6.5:
resolution: {integrity: sha512-uBI5I2Zx6sk+vRHU+nBmifwxg/nyXCGZ1g5hUKrUfgv1ZfiKB8JkN5T9iRoduDOaqbwM6XSnEl1ja73iloDcrw==}
engines: {node: ^14.18.0 || >=16.10.0}
dependencies:
'@nuxt/schema': 3.6.5
c12: 1.5.1
consola: 3.2.3
defu: 6.1.2
globby: 13.2.2
hash-sum: 2.0.0
ignore: 5.2.4
jiti: 1.20.0
knitwork: 1.0.0
mlly: 1.4.2
pathe: 1.1.1
pkg-types: 1.0.3
scule: 1.0.0
semver: 7.5.4
unctx: 2.3.1
unimport: 3.4.0(rollup@3.26.0)
untyped: 1.4.0
transitivePeerDependencies:
- rollup
- supports-color
dev: true
/@nuxt/kit@3.8.0:
resolution: {integrity: sha512-oIthQxeMIVs4ESVP5FqLYn8tj0S1sLd+eYreh+dNYgnJ2pTi7+THR12ONBNHjk668jqEe7ErUJ8UlGwqBzgezg==}
engines: {node: ^14.18.0 || >=16.10.0}
@@ -1626,24 +1603,6 @@ packages:
- supports-color
dev: true
/@nuxt/schema@3.6.5:
resolution: {integrity: sha512-UPUnMB0W5TZ/Pi1fiF71EqIsPlj8LGZqzhSf8wOeh538KHwxbA9r7cuvEUU92eXRksOZaylbea3fJxZWhOITVw==}
engines: {node: ^14.18.0 || >=16.10.0}
dependencies:
defu: 6.1.2
hookable: 5.5.3
pathe: 1.1.1
pkg-types: 1.0.3
postcss-import-resolver: 2.0.0
std-env: 3.4.3
ufo: 1.3.1
unimport: 3.4.0(rollup@3.26.0)
untyped: 1.4.0
transitivePeerDependencies:
- rollup
- supports-color
dev: true
/@nuxt/schema@3.8.0:
resolution: {integrity: sha512-VEDVeCjdVowhoY5vIBSz94+SSwmM204jN6TNe/ShBJ2d/vZiy9EtLbhOwqaPNFHwnN1fl/XFHThwJiexdB9D1w==}
engines: {node: ^14.18.0 || >=16.10.0}
@@ -4493,17 +4452,6 @@ packages:
- supports-color
dev: true
/fast-glob@3.3.0:
resolution: {integrity: sha512-ChDuvbOypPuNjO8yIDf36x7BlZX1smcUMTTcyoIjycexOxd6DFsKsg21qVBzEmr3G7fUKIRy2/psii+CIUt7FA==}
engines: {node: '>=8.6.0'}
dependencies:
'@nodelib/fs.stat': 2.0.5
'@nodelib/fs.walk': 1.2.8
glob-parent: 5.1.2
merge2: 1.4.1
micromatch: 4.0.5
dev: true
/fast-glob@3.3.1:
resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
engines: {node: '>=8.6.0'}
@@ -8540,24 +8488,6 @@ packages:
- rollup
dev: true
/unimport@3.1.0:
resolution: {integrity: sha512-ybK3NVWh30MdiqSyqakrrQOeiXyu5507tDA0tUf7VJHrsq4DM6S43gR7oAsZaFojM32hzX982Lqw02D3yf2aiA==}
dependencies:
'@rollup/pluginutils': 5.0.5(rollup@3.26.0)
escape-string-regexp: 5.0.0
fast-glob: 3.3.1
local-pkg: 0.4.3
magic-string: 0.30.5
mlly: 1.4.2
pathe: 1.1.1
pkg-types: 1.0.3
scule: 1.0.0
strip-literal: 1.3.0
unplugin: 1.5.0
transitivePeerDependencies:
- rollup
dev: true
/unimport@3.4.0(rollup@3.26.0):
resolution: {integrity: sha512-M/lfFEgufIT156QAr/jWHLUn55kEmxBBiQsMxvRSIbquwmeJEyQYgshHDEvQDWlSJrVOOTAgnJ3FvlsrpGkanA==}
dependencies:

331
utils/analytics.js Normal file
View File

@@ -0,0 +1,331 @@
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,
}
}