You've already forked AstralRinth
forked from didirus/AstralRinth
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:
1
assets/images/utils/calendar-clock.svg
Normal file
1
assets/images/utils/calendar-clock.svg
Normal 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 |
530
components/ui/charts/Chart.client.vue
Normal file
530
components/ui/charts/Chart.client.vue
Normal 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>
|
||||||
474
components/ui/charts/ChartDisplay.vue
Normal file
474
components/ui/charts/ChartDisplay.vue
Normal 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>
|
||||||
280
components/ui/charts/CompactChart.client.vue
Normal file
280
components/ui/charts/CompactChart.client.vue
Normal 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>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@formatjs/cli": "^6.1.2",
|
"@formatjs/cli": "^6.1.2",
|
||||||
"@nuxt/devtools": "^0.7.0",
|
"@nuxt/devtools": "=0.7.0",
|
||||||
"@nuxtjs/eslint-config-typescript": "^12.0.0",
|
"@nuxtjs/eslint-config-typescript": "^12.0.0",
|
||||||
"@nuxtjs/turnstile": "^0.5.0",
|
"@nuxtjs/turnstile": "^0.5.0",
|
||||||
"@types/node": "^20.1.0",
|
"@types/node": "^20.1.0",
|
||||||
@@ -51,6 +51,7 @@
|
|||||||
"qrcode.vue": "^3.4.0",
|
"qrcode.vue": "^3.4.0",
|
||||||
"semver": "^7.5.4",
|
"semver": "^7.5.4",
|
||||||
"vue-multiselect": "^3.0.0-alpha.2",
|
"vue-multiselect": "^3.0.0-alpha.2",
|
||||||
|
"vue3-apexcharts": "^1.4.4",
|
||||||
"xss": "^1.0.14"
|
"xss": "^1.0.14"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@8.6.1",
|
"packageManager": "pnpm@8.6.1",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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">
|
<div class="normal-page__sidebar">
|
||||||
<aside class="universal-card">
|
<aside class="universal-card">
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
@@ -75,6 +75,16 @@
|
|||||||
>
|
>
|
||||||
<UsersIcon />
|
<UsersIcon />
|
||||||
</NavStackItem>
|
</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>
|
<h3>Upload</h3>
|
||||||
<NavStackItem
|
<NavStackItem
|
||||||
:link="`/${project.project_type}/${project.slug ? project.slug : project.id}/gallery`"
|
:link="`/${project.project_type}/${project.slug ? project.slug : project.id}/gallery`"
|
||||||
@@ -99,8 +109,8 @@
|
|||||||
:project="project"
|
:project="project"
|
||||||
:versions="versions"
|
:versions="versions"
|
||||||
:current-member="currentMember"
|
:current-member="currentMember"
|
||||||
:is-settings="$route.name.startsWith('type-id-settings')"
|
:is-settings="route.name.startsWith('type-id-settings')"
|
||||||
:route-name="$route.name"
|
:route-name="route.name"
|
||||||
:set-processing="setProcessing"
|
:set-processing="setProcessing"
|
||||||
:collapsed="collapsedChecklist"
|
:collapsed="collapsedChecklist"
|
||||||
:toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
|
:toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
|
||||||
@@ -324,8 +334,8 @@
|
|||||||
:project="project"
|
:project="project"
|
||||||
:versions="versions"
|
:versions="versions"
|
||||||
:current-member="currentMember"
|
:current-member="currentMember"
|
||||||
:is-settings="$route.name.startsWith('type-id-settings')"
|
:is-settings="route.name.startsWith('type-id-settings')"
|
||||||
:route-name="$route.name"
|
:route-name="route.name"
|
||||||
:set-processing="setProcessing"
|
:set-processing="setProcessing"
|
||||||
:collapsed="collapsedChecklist"
|
:collapsed="collapsedChecklist"
|
||||||
:toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
|
:toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
|
||||||
@@ -490,7 +500,7 @@
|
|||||||
<div class="featured-header">
|
<div class="featured-header">
|
||||||
<h2 class="card-header">Featured versions</h2>
|
<h2 class="card-header">Featured versions</h2>
|
||||||
<nuxt-link
|
<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}/${
|
:to="`/${project.project_type}/${
|
||||||
project.slug ? project.slug : project.id
|
project.slug ? project.slug : project.id
|
||||||
}/versions#all-versions`"
|
}/versions#all-versions`"
|
||||||
@@ -658,7 +668,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Promotion } from 'omorphia'
|
import { Promotion, ChartIcon } from 'omorphia'
|
||||||
import CalendarIcon from '~/assets/images/utils/calendar.svg'
|
import CalendarIcon from '~/assets/images/utils/calendar.svg'
|
||||||
import ClearIcon from '~/assets/images/utils/clear.svg'
|
import ClearIcon from '~/assets/images/utils/clear.svg'
|
||||||
import DownloadIcon from '~/assets/images/utils/download.svg'
|
import DownloadIcon from '~/assets/images/utils/download.svg'
|
||||||
|
|||||||
34
pages/[type]/[id]/settings/analytics.vue
Normal file
34
pages/[type]/[id]/settings/analytics.vue
Normal 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>
|
||||||
@@ -16,6 +16,9 @@
|
|||||||
<NavStackItem link="/dashboard/reports" label="Active reports">
|
<NavStackItem link="/dashboard/reports" label="Active reports">
|
||||||
<ReportIcon />
|
<ReportIcon />
|
||||||
</NavStackItem>
|
</NavStackItem>
|
||||||
|
<NavStackItem link="/dashboard/analytics" label="Analytics">
|
||||||
|
<ChartIcon />
|
||||||
|
</NavStackItem>
|
||||||
|
|
||||||
<h3>Manage</h3>
|
<h3>Manage</h3>
|
||||||
<NavStackItem v-if="true" link="/dashboard/projects" label="Projects">
|
<NavStackItem v-if="true" link="/dashboard/projects" label="Projects">
|
||||||
@@ -33,6 +36,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { ChartIcon } from 'omorphia'
|
||||||
import NavStack from '~/components/ui/NavStack.vue'
|
import NavStack from '~/components/ui/NavStack.vue'
|
||||||
import NavStackItem from '~/components/ui/NavStackItem.vue'
|
import NavStackItem from '~/components/ui/NavStackItem.vue'
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<section class="universal-card">
|
<ChartDisplay :projects="projects ?? undefined" />
|
||||||
<h2>Analytics</h2>
|
|
||||||
<p>You found a secret!</p>
|
|
||||||
<nuxt-link to="/frog" class="goto-link"> Click here for fancy graphs! </nuxt-link>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
export default defineNuxtComponent({
|
import ChartDisplay from '~/components/ui/charts/ChartDisplay.vue'
|
||||||
head: {
|
|
||||||
title: 'Analytics - Modrinth',
|
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>
|
</script>
|
||||||
|
|||||||
84
pnpm-lock.yaml
generated
84
pnpm-lock.yaml
generated
@@ -49,6 +49,9 @@ dependencies:
|
|||||||
vue-multiselect:
|
vue-multiselect:
|
||||||
specifier: ^3.0.0-alpha.2
|
specifier: ^3.0.0-alpha.2
|
||||||
version: 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:
|
xss:
|
||||||
specifier: ^1.0.14
|
specifier: ^1.0.14
|
||||||
version: 1.0.14
|
version: 1.0.14
|
||||||
@@ -58,7 +61,7 @@ devDependencies:
|
|||||||
specifier: ^6.1.2
|
specifier: ^6.1.2
|
||||||
version: 6.1.2
|
version: 6.1.2
|
||||||
'@nuxt/devtools':
|
'@nuxt/devtools':
|
||||||
specifier: ^0.7.0
|
specifier: '=0.7.0'
|
||||||
version: 0.7.0(nuxt@3.5.3)(vite@4.3.9)
|
version: 0.7.0(nuxt@3.5.3)(vite@4.3.9)
|
||||||
'@nuxtjs/eslint-config-typescript':
|
'@nuxtjs/eslint-config-typescript':
|
||||||
specifier: ^12.0.0
|
specifier: ^12.0.0
|
||||||
@@ -1442,14 +1445,14 @@ packages:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@nuxt/devtools-kit': 0.7.0(nuxt@3.5.3)(vite@4.3.9)
|
'@nuxt/devtools-kit': 0.7.0(nuxt@3.5.3)(vite@4.3.9)
|
||||||
'@nuxt/devtools-wizard': 0.7.0
|
'@nuxt/devtools-wizard': 0.7.0
|
||||||
'@nuxt/kit': 3.6.5
|
'@nuxt/kit': 3.8.0
|
||||||
birpc: 0.2.12
|
birpc: 0.2.12
|
||||||
boxen: 7.1.1
|
boxen: 7.1.1
|
||||||
consola: 3.2.3
|
consola: 3.2.3
|
||||||
error-stack-parser-es: 0.1.0
|
error-stack-parser-es: 0.1.0
|
||||||
execa: 7.1.1
|
execa: 7.1.1
|
||||||
fast-folder-size: 2.1.0
|
fast-folder-size: 2.1.0
|
||||||
fast-glob: 3.3.0
|
fast-glob: 3.3.1
|
||||||
get-port-please: 3.0.1
|
get-port-please: 3.0.1
|
||||||
global-dirs: 3.0.1
|
global-dirs: 3.0.1
|
||||||
h3: 1.7.1
|
h3: 1.7.1
|
||||||
@@ -1469,7 +1472,7 @@ packages:
|
|||||||
rc9: 2.1.1
|
rc9: 2.1.1
|
||||||
semver: 7.5.4
|
semver: 7.5.4
|
||||||
sirv: 2.0.3
|
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: 4.3.9(@types/node@20.1.0)(sass@1.58.0)
|
||||||
vite-plugin-inspect: 0.7.33(vite@4.3.9)
|
vite-plugin-inspect: 0.7.33(vite@4.3.9)
|
||||||
vite-plugin-vue-inspector: 3.4.2(vite@4.3.9)
|
vite-plugin-vue-inspector: 3.4.2(vite@4.3.9)
|
||||||
@@ -1537,32 +1540,6 @@ packages:
|
|||||||
- supports-color
|
- supports-color
|
||||||
dev: true
|
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:
|
/@nuxt/kit@3.8.0:
|
||||||
resolution: {integrity: sha512-oIthQxeMIVs4ESVP5FqLYn8tj0S1sLd+eYreh+dNYgnJ2pTi7+THR12ONBNHjk668jqEe7ErUJ8UlGwqBzgezg==}
|
resolution: {integrity: sha512-oIthQxeMIVs4ESVP5FqLYn8tj0S1sLd+eYreh+dNYgnJ2pTi7+THR12ONBNHjk668jqEe7ErUJ8UlGwqBzgezg==}
|
||||||
engines: {node: ^14.18.0 || >=16.10.0}
|
engines: {node: ^14.18.0 || >=16.10.0}
|
||||||
@@ -1626,24 +1603,6 @@ packages:
|
|||||||
- supports-color
|
- supports-color
|
||||||
dev: true
|
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:
|
/@nuxt/schema@3.8.0:
|
||||||
resolution: {integrity: sha512-VEDVeCjdVowhoY5vIBSz94+SSwmM204jN6TNe/ShBJ2d/vZiy9EtLbhOwqaPNFHwnN1fl/XFHThwJiexdB9D1w==}
|
resolution: {integrity: sha512-VEDVeCjdVowhoY5vIBSz94+SSwmM204jN6TNe/ShBJ2d/vZiy9EtLbhOwqaPNFHwnN1fl/XFHThwJiexdB9D1w==}
|
||||||
engines: {node: ^14.18.0 || >=16.10.0}
|
engines: {node: ^14.18.0 || >=16.10.0}
|
||||||
@@ -4493,17 +4452,6 @@ packages:
|
|||||||
- supports-color
|
- supports-color
|
||||||
dev: true
|
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:
|
/fast-glob@3.3.1:
|
||||||
resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
|
resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
|
||||||
engines: {node: '>=8.6.0'}
|
engines: {node: '>=8.6.0'}
|
||||||
@@ -8540,24 +8488,6 @@ packages:
|
|||||||
- rollup
|
- rollup
|
||||||
dev: true
|
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):
|
/unimport@3.4.0(rollup@3.26.0):
|
||||||
resolution: {integrity: sha512-M/lfFEgufIT156QAr/jWHLUn55kEmxBBiQsMxvRSIbquwmeJEyQYgshHDEvQDWlSJrVOOTAgnJ3FvlsrpGkanA==}
|
resolution: {integrity: sha512-M/lfFEgufIT156QAr/jWHLUn55kEmxBBiQsMxvRSIbquwmeJEyQYgshHDEvQDWlSJrVOOTAgnJ3FvlsrpGkanA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
331
utils/analytics.js
Normal file
331
utils/analytics.js
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user