Creator update frontend hotfixes (#1538)

* Fix donation link submission

* Refactor Charts to fit edgecase bugs in design

* edge and mobile bug fixes

* remove dead code

* fix width on mobile

* Update omorphia version to 0.7.3

* Refactor legend item styling in ChartDisplay.vue

* Update package dependencies
This commit is contained in:
Carter
2024-01-10 12:50:21 -08:00
committed by GitHub
parent 5924154a62
commit 81948a5c29
6 changed files with 1086 additions and 270 deletions

View File

@@ -1,6 +1,6 @@
<script setup> <script setup>
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Button, DownloadIcon, UpdatedIcon, formatNumber, formatMoney } from 'omorphia' import { formatNumber, formatMoney } from 'omorphia'
import VueApexCharts from 'vue3-apexcharts' import VueApexCharts from 'vue3-apexcharts'
// let VueApexCharts // let VueApexCharts
@@ -138,6 +138,8 @@ function generateTooltip({ series, seriesIndex, dataPointIndex, w }, props) {
props props
) )
} else { } else {
const returnTopN = 5
const listEntries = series const listEntries = series
.map((value, index) => [ .map((value, index) => [
value[dataPointIndex], value[dataPointIndex],
@@ -145,6 +147,7 @@ function generateTooltip({ series, seriesIndex, dataPointIndex, w }, props) {
]) ])
.filter((value) => value[0] > 0) .filter((value) => value[0] > 0)
.sort((a, b) => b[0] - a[0]) .sort((a, b) => b[0] - a[0])
.slice(0, returnTopN) // Return only the top X entries
.map((value) => value[1]) .map((value) => value[1])
.join('') .join('')
@@ -283,25 +286,6 @@ const flipLegend = (legend, newVal) => {
chart.value.toggleSeries(legend.name) 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 = () => { const resetChart = () => {
chart.value.updateSeries([...props.data]) chart.value.updateSeries([...props.data])
chart.value.updateOptions({ chart.value.updateOptions({
@@ -318,36 +302,21 @@ const resetChart = () => {
defineExpose({ defineExpose({
resetChart, resetChart,
downloadCSV,
flipLegend, flipLegend,
}) })
</script> </script>
<template> <template>
<div class="bar-chart"> <VueApexCharts
<div class="title-bar"> ref="chart"
<slot /> :type="type"
<div v-if="!hideToolbar" class="toolbar"> :options="{
<Button v-tooltip="'Download data as CSV'" icon-only @click="downloadCSV"> ...chartOptions,
<DownloadIcon /> fill: type === 'area' ? fillOptions : {},
</Button> }"
<Button v-tooltip="'Reset chart'" icon-only @click="resetChart"> :series="data"
<UpdatedIcon /> class="chart"
</Button> />
<slot name="toolbar" />
</div>
</div>
<VueApexCharts
ref="chart"
:type="type"
:options="{
...chartOptions,
fill: type === 'area' ? fillOptions : {},
}"
:series="data"
class="chart"
/>
</div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -20,8 +20,10 @@
:data="analytics.formattedData.value.downloads.chart.sumData" :data="analytics.formattedData.value.downloads.chart.sumData"
:labels="analytics.formattedData.value.downloads.chart.labels" :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>" 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 ${ :class="`clickable chart-button-base button-base ${
selectedChart === 'downloads' ? 'button-base__selected' : '' selectedChart === 'downloads'
? 'chart-button-base__selected button-base__selected'
: ''
}`" }`"
:onclick="() => setSelectedChart('downloads')" :onclick="() => setSelectedChart('downloads')"
role="button" role="button"
@@ -37,8 +39,8 @@
:data="analytics.formattedData.value.views.chart.sumData" :data="analytics.formattedData.value.views.chart.sumData"
:labels="analytics.formattedData.value.views.chart.labels" :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>" 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 ${ :class="`clickable chart-button-base button-base ${
selectedChart === 'views' ? 'button-base__selected' : '' selectedChart === 'views' ? 'chart-button-base__selected button-base__selected' : ''
}`" }`"
:onclick="() => setSelectedChart('views')" :onclick="() => setSelectedChart('views')"
role="button" role="button"
@@ -54,8 +56,8 @@
:data="analytics.formattedData.value.revenue.chart.sumData" :data="analytics.formattedData.value.revenue.chart.sumData"
:labels="analytics.formattedData.value.revenue.chart.labels" :labels="analytics.formattedData.value.revenue.chart.labels"
is-money is-money
:class="`clickable button-base ${ :class="`clickable chart-button-base button-base ${
selectedChart === 'revenue' ? 'button-base__selected' : '' selectedChart === 'revenue' ? 'chart-button-base__selected button-base__selected' : ''
}`" }`"
:onclick="() => setSelectedChart('revenue')" :onclick="() => setSelectedChart('revenue')"
role="button" role="button"
@@ -63,63 +65,96 @@
</client-only> </client-only>
</div> </div>
<div class="graphs__main-graph"> <div class="graphs__main-graph">
<Card> <div class="universal-card">
<div class="graphs__main-graph-control"> <div class="chart-controls">
<DropdownSelect <h2>
v-model="selectedRange" <span class="label__title">
:options="selectableRanges" {{ formatCategoryHeader(selectedChart) }}
name="Time range" </span>
:display-name="(o: typeof selectableRanges[number] | undefined) => o?.label || 'Custom'" </h2>
/> <div class="chart-controls__buttons">
<!-- <DropdownSelect <Button v-tooltip="'Download this data as CSV'" icon-only @click="onDownloadSetAsCSV">
v-model="selectedResolution" <DownloadIcon />
:options="selectableResoloutions" </Button>
:display-name="(o: typeof selectableResoloutions[number] | undefined) => o?.label || 'Custom'" <Button v-tooltip="'Refresh the chart'" icon-only @click="resetCharts">
/> --> <UpdatedIcon />
</Button>
<DropdownSelect
v-model="selectedRange"
:options="selectableRanges"
name="Time range"
:display-name="(o: typeof selectableRanges[number] | undefined) => o?.label || 'Custom'"
/>
</div>
</div> </div>
<client-only> <div class="chart-area">
<Chart <div class="chart">
v-if="analytics.formattedData.value.downloads && selectedChart === 'downloads'" <client-only>
ref="downloadsChart" <Chart
type="line" v-if="analytics.formattedData.value.downloads && selectedChart === 'downloads'"
name="Download data" ref="downloadsChart"
legend-position="right" type="line"
:data="analytics.formattedData.value.downloads.chart.data" name="Download data"
:labels="analytics.formattedData.value.downloads.chart.labels" :hide-legend="true"
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>" :data="analytics.formattedData.value.downloads.chart.data"
:colors="analytics.formattedData.value.downloads.chart.colors" :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>"
<h2>Downloads</h2> :colors="analytics.formattedData.value.downloads.chart.colors"
</Chart> />
<Chart <Chart
v-if="analytics.formattedData.value.views && selectedChart === 'views'" v-if="analytics.formattedData.value.views && selectedChart === 'views'"
ref="viewsChart" ref="viewsChart"
type="line" type="line"
name="View data" name="View data"
legend-position="right" :hide-legend="true"
:data="analytics.formattedData.value.views.chart.data" :data="analytics.formattedData.value.views.chart.data"
:labels="analytics.formattedData.value.views.chart.labels" :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>" 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" :colors="analytics.formattedData.value.views.chart.colors"
> />
<h2 class="">Views</h2> <Chart
</Chart> v-if="analytics.formattedData.value.revenue && selectedChart === 'revenue'"
<Chart ref="revenueChart"
v-if="analytics.formattedData.value.revenue && selectedChart === 'revenue'" type="line"
ref="revenueChart" name="Revenue data"
type="line" :hide-legend="true"
name="Revenue data" :data="analytics.formattedData.value.revenue.chart.data"
legend-position="right" :labels="analytics.formattedData.value.revenue.chart.labels"
:data="analytics.formattedData.value.revenue.chart.data" is-money
:labels="analytics.formattedData.value.revenue.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'><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>"
is-money :colors="analytics.formattedData.value.revenue.chart.colors"
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" </client-only>
> </div>
<h2 class="">Revenue</h2> <div class="legend">
</Chart> <div class="legend__items">
</client-only> <template v-for="project in props.projects" :key="project.id">
</Card> <button
v-if="analytics.validProjectIds.value.includes(project.id)"
v-tooltip="project.title"
:class="`legend__item button-base btn-transparent ${
!projectIsOnDisplay(project.id) ? 'btn-dimmed' : ''
}`"
@click="
() =>
projectIsOnDisplay(project.id)
? removeProjectFromDisplay(project.id)
: addProjectToDisplay(project.id)
"
>
<div
:style="{
'--color-brand': intToRgba(project.color, project.id, theme || 'dark'),
}"
class="legend__item__color"
></div>
<div class="legend__item__text">{{ project.title }}</div>
</button>
</template>
</div>
</div>
</div>
</div>
<div class="country-data"> <div class="country-data">
<Card <Card
v-if=" v-if="
@@ -237,12 +272,25 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Card, formatMoney, formatNumber, DropdownSelect } from 'omorphia' import {
Button,
UpdatedIcon,
DownloadIcon,
Card,
formatMoney,
formatNumber,
DropdownSelect,
formatCategoryHeader,
} from 'omorphia'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { defineProps, ref, computed } from 'vue' import { defineProps, ref, computed } from 'vue'
import { analyticsSetToCSVString, intToRgba } from '~/utils/analytics.js'
import { UiChartsCompactChart as CompactChart, UiChartsChart as Chart } from '#components' import { UiChartsCompactChart as CompactChart, UiChartsChart as Chart } from '#components'
const router = useRouter() const router = useRouter()
const theme = useTheme()
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -268,7 +316,12 @@ const selectableRanges = Object.entries(props.ranges).map(([duration, extra]) =>
// const selectedChart = ref('downloads') // const selectedChart = ref('downloads')
const selectedChart = computed(() => { const selectedChart = computed(() => {
return (router.currentRoute.value.query?.chart as string | undefined) || 'downloads' const id = (router.currentRoute.value.query?.chart as string | undefined) || 'downloads'
// if the id is anything but the 3 charts we have or undefined, throw an error
if (!['downloads', 'views', 'revenue'].includes(id)) {
throw new Error(`Unknown chart ${id}`)
}
return id
}) })
const setSelectedChart = (chart: string) => { const setSelectedChart = (chart: string) => {
router.push({ router.push({
@@ -287,7 +340,24 @@ const tinyDownloadChart = ref()
const tinyViewChart = ref() const tinyViewChart = ref()
const tinyRevenueChart = ref() const tinyRevenueChart = ref()
const analytics = useFetchAllAnalytics(() => { const selectedDisplayProjects = ref(props.projects || [])
const removeProjectFromDisplay = (id: string) => {
selectedDisplayProjects.value = selectedDisplayProjects.value.filter((p) => p.id !== id)
}
const addProjectToDisplay = (id: string) => {
selectedDisplayProjects.value = [
...selectedDisplayProjects.value,
props.projects?.find((p) => p.id === id),
].filter(Boolean)
}
const projectIsOnDisplay = (id: string) => {
return selectedDisplayProjects.value.some((p) => p.id === id)
}
const resetCharts = () => {
downloadsChart.value?.resetChart() downloadsChart.value?.resetChart()
viewsChart.value?.resetChart() viewsChart.value?.resetChart()
revenueChart.value?.resetChart() revenueChart.value?.resetChart()
@@ -295,7 +365,9 @@ const analytics = useFetchAllAnalytics(() => {
tinyDownloadChart.value?.resetChart() tinyDownloadChart.value?.resetChart()
tinyViewChart.value?.resetChart() tinyViewChart.value?.resetChart()
tinyRevenueChart.value?.resetChart() tinyRevenueChart.value?.resetChart()
}, props.projects) }
const analytics = useFetchAllAnalytics(resetCharts, selectedDisplayProjects)
const { startDate, endDate, timeRange, timeResolution } = analytics const { startDate, endDate, timeRange, timeResolution } = analytics
@@ -318,6 +390,41 @@ const selectedRange = computed({
} }
}, },
}) })
const downloadSelectedSetAsCSV = () => {
const selectedChartName = selectedChart.value
let downloadsDataSet
switch (selectedChartName) {
case 'downloads':
downloadsDataSet = analytics.formattedData.value.downloads
break
case 'views':
downloadsDataSet = analytics.formattedData.value.views
break
case 'revenue':
downloadsDataSet = analytics.formattedData.value.revenue
break
default:
throw new Error(`Unknown chart ${selectedChartName}`)
}
const csv = analyticsSetToCSVString(downloadsDataSet)
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `${selectedChartName}-data.csv`)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
}
const onDownloadSetAsCSV = useClientTry(async () => await downloadSelectedSetAsCSV())
</script> </script>
<script lang="ts"> <script lang="ts">
@@ -344,11 +451,98 @@ const defaultRanges: Record<number, [string, number] | string> = {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.button-base { .chart-controls {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: start;
gap: var(--gap-md);
.chart-controls__buttons {
display: flex;
flex-direction: row;
gap: var(--gap-xs);
* {
width: auto;
min-height: auto;
}
}
}
.chart-area {
display: flex;
flex-direction: row;
gap: var(--gap-md);
height: 100%;
.chart {
flex-grow: 1;
flex-shrink: 1;
display: flex;
flex-direction: column;
gap: var(--gap-md);
}
.legend {
margin-top: 24px;
overflow: hidden;
max-width: 26ch;
width: fit-content;
.legend__items {
display: flex;
flex-direction: column;
gap: var(--gap-xs);
.legend__item {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-xs);
font-size: var(--font-size-sm);
width: 100%;
.legend__item__text {
white-space: nowrap;
text-overflow: ellipsis;
}
.legend__item__color {
height: var(--font-size-xs);
width: var(--font-size-xs);
border-radius: var(--radius-sm);
background-color: var(--color-brand);
flex-grow: 0;
flex-shrink: 0;
}
}
}
}
}
.btn-transparent {
background-color: transparent;
border: none;
cursor: pointer;
color: var(--text-color);
font-weight: var(--font-weight-regular);
}
.btn-dimmed {
opacity: 0.5;
}
.chart-button-base {
overflow: hidden; overflow: hidden;
} }
.button-base__selected { .chart-button-base__selected {
color: var(--color-contrast); color: var(--color-contrast);
background-color: var(--color-brand-highlight); background-color: var(--color-brand-highlight);
box-shadow: inset 0 0 0 transparent, 0 0 0 2px var(--color-brand); box-shadow: inset 0 0 0 transparent, 0 0 0 2px var(--color-brand);
@@ -373,47 +567,6 @@ const defaultRanges: Record<number, [string, number] | string> = {
display: flex; display: flex;
margin-right: 0.1rem; 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 { .country-flag-container {
@@ -493,6 +646,38 @@ const defaultRanges: Record<number, [string, number] | string> = {
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.chart-area {
flex-direction: column;
gap: var(--gap-md);
}
.chart-controls {
flex-direction: column;
gap: var(--gap-md);
}
.chart {
flex-direction: column;
gap: var(--gap-md);
}
.legend {
margin-top: 0px;
width: 100%;
max-width: 100%;
}
.graphs {
margin-left: 0px;
margin-top: 0px;
.graphs__vertical-bar {
flex-direction: column;
gap: 0;
margin-right: 0px;
}
}
.country-data { .country-data {
display: block; display: block;
} }

View File

@@ -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",
@@ -40,17 +40,17 @@
"dependencies": { "dependencies": {
"@ltd/j-toml": "^1.38.0", "@ltd/j-toml": "^1.38.0",
"dayjs": "^1.11.7", "dayjs": "^1.11.7",
"floating-vue": "^2.0.0-beta.20", "floating-vue": "2.0.0-beta.20",
"fuse.js": "^6.6.2", "fuse.js": "^6.6.2",
"highlight.js": "^11.7.0", "highlight.js": "^11.7.0",
"iso-3166-1": "^2.1.1", "iso-3166-1": "^2.1.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"markdown-it": "^13.0.1", "markdown-it": "^13.0.1",
"omorphia": "=0.7.2", "omorphia": "^0.7.3",
"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", "vue3-apexcharts": "^1.4.4",
"xss": "^1.0.14" "xss": "^1.0.14"
}, },

View File

@@ -210,6 +210,12 @@ export default defineNuxtComponent({
data.donation_urls = donationLinks data.donation_urls = donationLinks
} }
if (data.donation_urls) {
data.donation_urls.forEach((link) => {
link.id = link.platform.toLowerCase()
})
}
return data return data
}, },
hasChanges() { hasChanges() {

782
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -34,14 +34,41 @@ export const formatPercent = (value, sum) => {
return `${((value / sum) * 100).toFixed(2)}%` return `${((value / sum) * 100).toFixed(2)}%`
} }
const intToRgba = (color, projectId = 'Unknown', theme) => { const hashProjectId = (projectId) => {
return projectId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % 30
}
const defaultColors = ['#ff496e', '#ffa347', '#1bd96a', '#4f9cff', '#c78aff']
/**
* @param {string | number} value
* @returns {string} color
*/
export const getDefaultColor = (value) => {
if (typeof value === 'string') {
value = hashProjectId(value)
}
return defaultColors[value % defaultColors.length]
}
export const intToRgba = (color, projectId = 'Unknown', theme) => {
const hash = hashProjectId(projectId)
if (!color || color === 0) {
return getDefaultColor(hash)
}
// if color is a string, return that instead
if (typeof color === 'string') {
return color
}
// Extract RGB values // Extract RGB values
let r = (color >> 16) & 255 let r = (color >> 16) & 255
let g = (color >> 8) & 255 let g = (color >> 8) & 255
let b = color & 255 let b = color & 255
// Hash function to alter color slightly based on project_id // 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 r = (r + hash) % 256
g = (g + hash) % 256 g = (g + hash) % 256
b = (b + hash) % 256 b = (b + hash) % 256
@@ -85,6 +112,27 @@ const emptyAnalytics = {
}, },
} }
export const analyticsSetToCSVString = (analytics) => {
if (!analytics) {
return ''
}
const newline = '\n'
const labels = analytics.chart.labels
const projects = analytics.chart.data
const projectNames = projects.map((p) => p.name)
const header = ['Date', ...projectNames].join(',')
const data = labels.map((label, i) => {
const values = projects.map((p) => p.data?.[i] || '')
return [label, ...values].join(',')
})
return [header, ...data].join(newline)
}
export const processAnalytics = (category, projects, labelFn, sortFn, mapFn, chartName) => { export const processAnalytics = (category, projects, labelFn, sortFn, mapFn, chartName) => {
if (!category || !projects) { if (!category || !projects) {
return emptyAnalytics return emptyAnalytics
@@ -154,9 +202,7 @@ export const processAnalytics = (category, projects, labelFn, sortFn, mapFn, cha
const theme = useTheme() const theme = useTheme()
const project = chartData[i] const project = chartData[i]
return project.color return intToRgba(project.color, project.id, theme.value)
? intToRgba(project.color, project.id, theme.value.value)
: '--color-brand'
}), }),
}, },
} }
@@ -226,10 +272,10 @@ const useFetchAnalytics = (
} }
/** /**
* @param {any} projects * @param {Ref<any[]>} projects
* @param {undefined | () => any} onDataRefresh * @param {undefined | () => any} onDataRefresh
*/ */
export const useFetchAllAnalytics = (onDataRefresh, projects = undefined) => { export const useFetchAllAnalytics = (onDataRefresh, projects) => {
const timeResolution = ref(1440) // 1 day const timeResolution = ref(1440) // 1 day
const timeRange = ref(43200) // 30 days const timeRange = ref(43200) // 30 days
@@ -245,11 +291,11 @@ export const useFetchAllAnalytics = (onDataRefresh, projects = undefined) => {
const error = ref(null) const error = ref(null)
const formattedData = computed(() => ({ const formattedData = computed(() => ({
downloads: processNumberAnalytics(downloadData.value, projects), downloads: processNumberAnalytics(downloadData.value, projects.value),
views: processNumberAnalytics(viewData.value, projects), views: processNumberAnalytics(viewData.value, projects.value),
revenue: processRevAnalytics(revenueData.value, projects), revenue: processRevAnalytics(revenueData.value, projects.value),
downloadsByCountry: processCountryAnalytics(downloadsByCountry.value, projects), downloadsByCountry: processCountryAnalytics(downloadsByCountry.value, projects.value),
viewsByCountry: processCountryAnalytics(viewsByCountry.value, projects), viewsByCountry: processCountryAnalytics(viewsByCountry.value, projects.value),
})) }))
const fetchData = async (query) => { const fetchData = async (query) => {
@@ -285,7 +331,7 @@ export const useFetchAllAnalytics = (onDataRefresh, projects = undefined) => {
} }
watch( watch(
[() => startDate.value, () => endDate.value, () => timeResolution.value, () => projects], [() => startDate.value, () => endDate.value, () => timeResolution.value, () => projects.value],
async () => { async () => {
const q = { const q = {
start_date: dayjs(startDate.value).toISOString(), start_date: dayjs(startDate.value).toISOString(),
@@ -294,7 +340,7 @@ export const useFetchAllAnalytics = (onDataRefresh, projects = undefined) => {
} }
if (projects?.length) { if (projects?.length) {
q.project_ids = JSON.stringify(projects.map((p) => p.id)) q.project_ids = JSON.stringify(projects.value.map((p) => p.id))
} }
await fetchData(q) await fetchData(q)
@@ -308,6 +354,29 @@ export const useFetchAllAnalytics = (onDataRefresh, projects = undefined) => {
} }
) )
const validProjectIds = computed(() => {
const ids = new Set()
if (downloadData.value) {
Object.keys(downloadData.value).forEach((id) => ids.add(id))
}
if (viewData.value) {
Object.keys(viewData.value).forEach((id) => ids.add(id))
}
if (revenueData.value) {
// revenue will always have all project ids, but the ids may have an empty object as value.
Object.entries(revenueData.value).forEach(([id, data]) => {
if (Object.keys(data).length) {
ids.add(id)
}
})
}
return Array.from(ids)
})
return { return {
// Configuration // Configuration
timeResolution, timeResolution,
@@ -324,6 +393,7 @@ export const useFetchAllAnalytics = (onDataRefresh, projects = undefined) => {
viewsByCountry, viewsByCountry,
// Computed state // Computed state
validProjectIds,
formattedData, formattedData,
loading, loading,
error, error,