diff --git a/assets/images/utils/calendar-clock.svg b/assets/images/utils/calendar-clock.svg new file mode 100644 index 000000000..13cd832da --- /dev/null +++ b/assets/images/utils/calendar-clock.svg @@ -0,0 +1 @@ + diff --git a/components/ui/charts/Chart.client.vue b/components/ui/charts/Chart.client.vue new file mode 100644 index 000000000..4052fc91a --- /dev/null +++ b/components/ui/charts/Chart.client.vue @@ -0,0 +1,530 @@ + + + + + diff --git a/components/ui/charts/ChartDisplay.vue b/components/ui/charts/ChartDisplay.vue new file mode 100644 index 000000000..896d8e0a6 --- /dev/null +++ b/components/ui/charts/ChartDisplay.vue @@ -0,0 +1,474 @@ + + + + + + + diff --git a/components/ui/charts/CompactChart.client.vue b/components/ui/charts/CompactChart.client.vue new file mode 100644 index 000000000..0aadc5ee4 --- /dev/null +++ b/components/ui/charts/CompactChart.client.vue @@ -0,0 +1,280 @@ + + + + + diff --git a/package.json b/package.json index fb98944fb..99d4c30df 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ }, "devDependencies": { "@formatjs/cli": "^6.1.2", - "@nuxt/devtools": "^0.7.0", + "@nuxt/devtools": "=0.7.0", "@nuxtjs/eslint-config-typescript": "^12.0.0", "@nuxtjs/turnstile": "^0.5.0", "@types/node": "^20.1.0", @@ -51,6 +51,7 @@ "qrcode.vue": "^3.4.0", "semver": "^7.5.4", "vue-multiselect": "^3.0.0-alpha.2", + "vue3-apexcharts": "^1.4.4", "xss": "^1.0.14" }, "packageManager": "pnpm@8.6.1", diff --git a/pages/[type]/[id].vue b/pages/[type]/[id].vue index 16d3916c9..4ee073da9 100644 --- a/pages/[type]/[id].vue +++ b/pages/[type]/[id].vue @@ -1,5 +1,5 @@ + + diff --git a/pages/dashboard.vue b/pages/dashboard.vue index b213c9fa5..c85b47e43 100644 --- a/pages/dashboard.vue +++ b/pages/dashboard.vue @@ -16,6 +16,9 @@ + + +

Manage

@@ -33,6 +36,7 @@ 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, + } +}