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