feat: add countries and devices charts (#783)

Co-authored-by: orig <oriorigranot@gamil.com>
This commit is contained in:
orig
2024-05-18 00:35:16 +03:00
committed by GitHub
parent fc5b231bbf
commit 04ea480e82
28 changed files with 400 additions and 24 deletions

View File

@ -12,8 +12,46 @@ export class AnalyticsController {
@Get(':key')
async getAnalytics(@Param('key') key: string, @Query('days') days: number, @UserCtx() user: UserContext) {
const link = await this.findLink(key, user.id);
const data = await this.analyticsService.getClicksOverTime(link.id, days);
return { url: link.url, clicksOverTime: data };
}
@Get(':key/devices')
async getDevices(@Param('key') key: string, @Query('days') days: number, @UserCtx() user: UserContext) {
return this.getGroupedData(key, user.id, days, 'device');
}
@Get(':key/os')
async getOs(@Param('key') key: string, @Query('days') days: number, @UserCtx() user: UserContext) {
return this.getGroupedData(key, user.id, days, 'os');
}
@Get(':key/browsers')
async getBrowsers(@Param('key') key: string, @Query('days') days: number, @UserCtx() user: UserContext) {
return this.getGroupedData(key, user.id, days, 'browser');
}
@Get(':key/countries')
async getCountries(@Param('key') key: string, @Query('days') days: number, @UserCtx() user: UserContext) {
return this.getGroupedData(key, user.id, days, 'country');
}
@Get(':key/regions')
async getRegions(@Param('key') key: string, @Query('days') days: number, @UserCtx() user: UserContext) {
return this.getGroupedData(key, user.id, days, 'region');
}
@Get(':key/cities')
async getCities(@Param('key') key: string, @Query('days') days: number, @UserCtx() user: UserContext) {
return this.getGroupedData(key, user.id, days, 'city', {
country: true,
});
}
private async findLink(key: string, userId: string) {
const link = await this.prismaService.link.findUnique({
where: { key, userId: user.id },
where: { key, userId },
select: { id: true, url: true },
});
@ -21,11 +59,12 @@ export class AnalyticsController {
throw new NotFoundException('Link not found');
}
const data = await this.analyticsService.getClicksOverTime(link.id, days);
return link;
}
return {
url: link.url,
clicksOverTime: data,
};
private async getGroupedData(key: string, userId: string, days: number, field: string, include?: Record<string, boolean>) {
const link = await this.findLink(key, userId);
const data = await this.analyticsService.getGroupedByField(link.id, field, days, include);
return { url: link.url, data };
}
}

View File

@ -18,11 +18,11 @@ export class AnalyticsService {
});
}
async getClicksOverTime(linkId: string, durationDays = 30): Promise<any[]> {
async getClicksOverTime(linkId: string, durationDays = 30): Promise<{ day: string; count: string }[]> {
const fromDate = sub(new Date(), { days: durationDays });
const trunc = durationDays === 1 ? 'hour' : 'day';
return this.prismaService.$queryRaw`
return this.prismaService.$queryRaw<{ day: string; count: string }[]>`
SELECT
date_trunc(${trunc}, "createdAt")::text AS day,
COUNT(*)::text AS count
@ -37,4 +37,37 @@ export class AnalyticsService {
day;
`;
}
async getGroupedByField(
linkId: string,
field: string,
durationDays = 30,
include?: Record<string, boolean>
): Promise<{ field: string; count: string }[]> {
const fromDate = sub(new Date(), { days: durationDays });
const includeFields = include
? Object.keys(include)
.map((k) => `"${k}"`)
.join(', ')
: '';
const query = `
SELECT
"${field}" AS field,
${includeFields ? `${includeFields},` : ''}
COUNT(*)::text AS count
FROM
"Visit"
WHERE
"linkId" = $1
AND "createdAt" >= $2
GROUP BY
"${field}" ${includeFields ? `, ${includeFields}` : ''}
ORDER BY
COUNT(*) DESC;
`;
return this.prismaService.$queryRawUnsafe<{ field: string; count: string }[]>(query, linkId, fromDate);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,15 @@
/* Hide scrollbar for WebKit-based browsers */
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge, and Firefox */
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/* Ensure scrollbar is hidden for WebKit-based browsers */
.scrollbar-hide::-webkit-scrollbar {
display: none;
}

View File

@ -51,7 +51,8 @@ export const ClicksChart = component$((props: ClicksChartProps) => {
},
yaxis: {
title: { text: 'Clicks' },
stepSize: 1,
min: 0,
forceNiceScale: true,
},
tooltip: { x: { format: 'dd MMM yyyy HH:mm' } },
dataLabels: { enabled: false },
@ -73,17 +74,20 @@ export const ClicksChart = component$((props: ClicksChartProps) => {
theme: { mode: isDarkMode() ? 'dark' : 'light', palette: 'palette1' },
};
chartInstance.value = noSerialize(new ApexCharts(chartRef.value!, options));
chartInstance.value!.render();
window.addEventListener('theme-toggled', (ev) => {
const theme = (ev as CustomEvent).detail.theme;
chartInstance.value!.updateOptions({
theme: { mode: theme, palette: 'palette1' },
grid: {
borderColor: isDarkMode() ? '#374151' : '#bfc4cf',
},
// Ensure the chartRef is available before rendering the chart
if (chartRef.value) {
chartInstance.value = noSerialize(new ApexCharts(chartRef.value, options));
await chartInstance.value!.render();
window.addEventListener('theme-toggled', (ev) => {
const theme = (ev as CustomEvent).detail.theme;
chartInstance.value!.updateOptions({
theme: { mode: theme, palette: 'palette1' },
grid: {
borderColor: isDarkMode() ? '#374151' : '#bfc4cf',
},
});
});
});
}
isInitialized.value = true;
} else {
chartInstance.value.updateOptions({
@ -105,6 +109,11 @@ export const ClicksChart = component$((props: ClicksChartProps) => {
<p class="text-base font-normal text-gray-500 dark:text-gray-400">{`Clicks for the last ${chartDescription.value}`}</p>
</div>
</div>
{!isInitialized.value ? (
<div class="flex items-center justify-center min-h-[365px]">
<span class="loading loading-spinner loading-lg" />
</div>
) : null}
<div ref={chartRef} class="chart-container" />
</div>
</>
@ -128,7 +137,6 @@ const updateChartDescription = (chartDescription: Signal<string>, daysDuration:
}
};
// Utility function to fetch chart data
async function fetchChartData(key: string, days: number) {
try {
const response = await authorizedFetch(`${process.env.CLIENTSIDE_API_DOMAIN}/api/v1/analytics/${key}?days=${days}`);

View File

@ -0,0 +1,106 @@
import { component$, useSignal, useStylesScoped$, useVisibleTask$ } from '@builder.io/qwik';
import { NoData } from '../../empty-data/no-data';
import countryLookup from 'country-code-lookup';
import styles from '../analytics-chart.css?inline';
import { fetchAnalyticsChartData } from '../utils';
interface Location {
field: string;
country?: string;
count: number;
}
interface CountriesChartProps {
urlKey: string;
daysDuration: number;
initialData: Location[];
}
export const CountriesChart = component$((props: CountriesChartProps) => {
useStylesScoped$(styles);
const selectedCategory = useSignal<'countries' | 'cities'>('countries');
const searchQuery = useSignal('');
const locations = useSignal<Location[]>(props.initialData);
useVisibleTask$(async ({ track }) => {
track(() => selectedCategory.value);
track(() => props.daysDuration);
const data = await fetchAnalyticsChartData(props.urlKey, selectedCategory.value, props.daysDuration);
locations.value = data;
});
const getCountryCode = (item: Location) => {
return item.country ? item.country : item.field;
};
const filteredData = locations.value.filter((item) => {
const country = countryLookup.byIso(getCountryCode(item));
return country && country.country.toLowerCase().includes(searchQuery.value.toLowerCase());
});
return (
<div class="relative z-0 h-[400px] dark:bg-slate-800 bg-white px-5 py-5 rounded-lg shadow overflow-hidden">
<div class="mb-3 flex justify-between">
<h1 class="text-lg font-semibold">Locations</h1>
<div class="relative inline-flex items-center space-x-1">
<button
class={`btn btn-sm btn-ghost ${selectedCategory.value === 'countries' ? 'btn-active' : ''}`}
onClick$={() => {
selectedCategory.value = 'countries';
}}
>
Countries
</button>
<button
class={`btn btn-sm btn-ghost ${selectedCategory.value === 'cities' ? 'btn-active' : ''}`}
onClick$={() => {
selectedCategory.value = 'cities';
}}
>
Cities
</button>
</div>
</div>
{/* Need to think about how to design this input field */}
{/* <input
type="text"
placeholder="Search country..."
class="input input-bordered w-full mb-3"
onInput$={(e) => (searchQuery.value = (e.target as HTMLInputElement).value)}
/> */}
<div class="flex flex-col gap-1 overflow-y-auto h-[300px] pb-4 scrollbar-hide relative">
{filteredData.length === 0 ? (
<div class="pt-12">
<NoData title="No Data Available" description="No locations to display." />
</div>
) : (
filteredData.map((item) => {
const countryCode = getCountryCode(item);
const country = countryLookup.byIso(countryCode);
const displayName = selectedCategory.value === 'cities' ? item.field : country?.country || item.field;
return (
<div key={item.field} class="group flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-500/10">
<div class="relative z-10 flex h-8 w-full max-w-[calc(100%-2rem)] items-center">
<div class="z-10 flex items-center space-x-2 px-2">
<img alt={countryCode} src={`https://flag.vercel.app/m/${countryCode}.svg`} class="h-3 w-5" />
<div class="truncate text-sm text-gray-800 dark:text-gray-200 underline-offset-4 group-hover:underline">
{displayName}
</div>
</div>
<div
class="absolute h-full origin-left rounded-sm bg-green-100 dark:bg-green-500/30"
style={{ width: `${(item.count / filteredData[0].count) * 100}%`, transform: 'scaleX(1)' }}
></div>
</div>
<p class="z-10 px-2 text-sm text-gray-600 dark:text-gray-200">{item.count}</p>
</div>
);
})
)}
</div>
</div>
);
});

View File

@ -0,0 +1,108 @@
import { component$, useSignal, useStylesScoped$, useVisibleTask$ } from '@builder.io/qwik';
import { NoData } from '../../empty-data/no-data';
import { fetchAnalyticsChartData } from '../utils';
import styles from '../analytics-chart.css?inline';
import { browserIcons, deviceIcons, osIcons } from './icons';
import { UNKNOWN_FAVICON_SMALL } from '../../../temporary-links/utils';
interface Device {
field: string;
count: number;
}
interface DevicesChartProps {
urlKey: string;
daysDuration: number;
initialData: Device[];
}
export const DevicesChart = component$((props: DevicesChartProps) => {
useStylesScoped$(styles);
const selectedCategory = useSignal<'devices' | 'os' | 'browsers'>('devices');
const devices = useSignal<Device[]>(props.initialData);
useVisibleTask$(async ({ track }) => {
track(() => selectedCategory.value);
track(() => props.daysDuration);
const data = await fetchAnalyticsChartData(props.urlKey, selectedCategory.value, props.daysDuration);
devices.value = data;
});
const getIcon = (item: Device) => {
let icon: string | undefined = undefined;
const name = item.field.toLowerCase();
switch (selectedCategory.value) {
case 'devices':
icon = deviceIcons[name];
break;
case 'os':
icon = osIcons[name];
break;
case 'browsers':
icon = browserIcons[name];
break;
}
return icon || UNKNOWN_FAVICON_SMALL;
};
return (
<div class="relative z-0 h-[400px] dark:bg-slate-800 bg-white px-5 py-5 rounded-lg shadow overflow-hidden">
<div class="mb-3 flex justify-between">
<h1 class="text-lg font-semibold">Devices</h1>
<div class="relative inline-flex items-center space-x-1">
<button
class={`btn btn-sm btn-ghost ${selectedCategory.value === 'devices' ? 'btn-active' : ''}`}
onClick$={() => {
selectedCategory.value = 'devices';
}}
>
Type
</button>
<button
class={`btn btn-sm btn-ghost ${selectedCategory.value === 'os' ? 'btn-active' : ''}`}
onClick$={() => {
selectedCategory.value = 'os';
}}
>
OS
</button>
<button
class={`btn btn-sm btn-ghost ${selectedCategory.value === 'browsers' ? 'btn-active' : ''}`}
onClick$={() => {
selectedCategory.value = 'browsers';
}}
>
Browsers
</button>
</div>
</div>
<div class="flex flex-col gap-1 overflow-y-auto h-[300px] pb-4 scrollbar-hide relative">
{devices.value.length === 0 ? (
<div class="pt-12">
<NoData title="No Data Available" description="No devices to display." />
</div>
) : (
devices.value.map((item) => (
<div key={item.field} class="group flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-500/10">
<div class="relative z-10 flex h-8 w-full max-w-[calc(100%-2rem)] items-center">
<div class="z-10 flex items-center space-x-2 px-2">
<img alt={item.field} src={getIcon(item)} class="h-4 w-4" />
<div class="truncate text-sm text-gray-800 dark:text-gray-200 underline-offset-4 group-hover:underline">{item.field}</div>
</div>
<div
class="absolute h-full origin-left rounded-sm bg-orange-100 dark:bg-orange-500/30"
style={{ width: `${(item.count / devices.value[0].count) * 100}%`, transform: 'scaleX(1)' }}
></div>
</div>
<p class="z-10 px-2 text-sm text-gray-600 dark:text-gray-200">{item.count}</p>
</div>
))
)}
</div>
</div>
);
});

View File

@ -0,0 +1,30 @@
export const deviceIcons: Record<string, string> = {
desktop: 'https://faisalman.github.io/ua-parser-js/images/types/default.png',
mobile: 'https://faisalman.github.io/ua-parser-js/images/types/mobile.png',
};
export const osIcons: Record<string, string> = {
windows: '/images/icons/os/windows.png',
'mac os': '/images/icons/os/macos.png',
ios: '/images/icons/os/apple.png',
android: '/images/icons/os/android.png',
linux: '/images/icons/os/linux.png',
chromeoS: 'https://faisalman.github.io/ua-parser-js/images/os/chromeos.png',
windowsphone: 'https://faisalman.github.io/ua-parser-js/images/os/windowsphone.png',
blacknerry: 'https://faisalman.github.io/ua-parser-js/images/os/blackberry.png',
firefoxos: 'https://faisalman.github.io/ua-parser-js/images/os/firefoxos.png',
};
export const browserIcons: Record<string, string> = {
chrome: '/images/icons/browsers/chrome.png',
firefox: '/images/icons/browsers/firefox.png',
facebook: '/images/icons/browsers/facebook.png',
safari: '/images/icons/browsers/safari.png',
'mobile safari': '/images/icons/browsers/safari.png',
ie: '/images/icons/browsers/ie.png',
edge: '/images/icons/browsers/edge.png',
opera: '/images/icons/browsers/opera.png',
yandex: '/images/icons/browsers/yandex.png',
instagram: '/images/icons/browsers/instagram.png',
ucbrowser: '/images/icons/browsers/ucbrowser.png',
};

View File

@ -0,0 +1,12 @@
import { authorizedFetch } from '../../../shared/auth.service';
export const fetchAnalyticsChartData = async (key: string, category: string, days: number) => {
try {
const response = await authorizedFetch(`${process.env.CLIENTSIDE_API_DOMAIN}/api/v1/analytics/${key}/${category}?days=${days}`);
const data = await response.json();
return data.data;
} catch (err) {
console.error(`Could not fetch ${category} chart data`, err);
return [];
}
};

View File

@ -1,4 +1,5 @@
export const UNKNOWN_FAVICON = '/images/unkown-favicon.png';
export const UNKNOWN_FAVICON_SMALL = '/images/unkown-favicon-small.png';
export const getFavicon = async (url: string) => {
const faviconUrl = `https://www.google.com/s2/favicons?sz=128&domain_url=${url}`;

View File

@ -2,18 +2,31 @@ import { DocumentHead, routeLoader$ } from '@builder.io/qwik-city';
import { serverSideFetch } from '../../../../shared/auth.service';
import { component$, useSignal } from '@builder.io/qwik';
import { ClicksChart } from '../../../../components/dashboard/analytics/clicks-chart/clicks-chart';
import { CountriesChart } from '../../../../components/dashboard/analytics/contries-chart/countries-chart';
import { DevicesChart } from '../../../../components/dashboard/analytics/devices-chart/devices-chart';
export const useGetAnalytics = routeLoader$(async ({ params: { key }, cookie, redirect }) => {
const res = await serverSideFetch(`${process.env.API_DOMAIN}/api/v1/analytics/${key}?days=7`, cookie);
const [clicksResponse, countriesResponse, devicesResponse] = await Promise.all([
serverSideFetch(`${process.env.API_DOMAIN}/api/v1/analytics/${key}?days=7`, cookie),
serverSideFetch(`${process.env.API_DOMAIN}/api/v1/analytics/${key}/countries?days=7`, cookie),
serverSideFetch(`${process.env.API_DOMAIN}/api/v1/analytics/${key}/devices?days=7`, cookie),
]);
if (res.status !== 200) {
if (clicksResponse.status !== 200 || countriesResponse.status !== 200 || devicesResponse.status !== 200) {
throw redirect(302, '/unknown');
}
const data = await res.json();
const [clicks, countries, devices] = await Promise.all([clicksResponse.json(), countriesResponse.json(), devicesResponse.json()]);
const data = { clicksOverTime: clicks, countries, devices };
return {
key,
data,
data: {
clicksOverTime: clicks.clicksOverTime,
countries: countries.data,
devices: devices.data,
url: clicks.url,
},
};
});
@ -54,6 +67,10 @@ export default component$(() => {
initialData={analytics.value.data.clicksOverTime}
url={analytics.value.data.url}
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 pt-4">
<CountriesChart urlKey={analytics.value.key} daysDuration={daysDuration.value} initialData={analytics.value.data.countries} />
<DevicesChart urlKey={analytics.value.key} daysDuration={daysDuration.value} initialData={analytics.value.data.devices} />
</div>
</>
);
});

6
package-lock.json generated
View File

@ -38,6 +38,7 @@
"class-validator": "^0.14.1",
"clsx": "^2.1.1",
"cookie-parser": "^1.4.6",
"country-code-lookup": "^0.1.3",
"geoip-lite": "^1.4.10",
"isbot": "^5.1.5",
"jwt-decode": "^3.1.2",
@ -14746,6 +14747,11 @@
"node": ">=10"
}
},
"node_modules/country-code-lookup": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/country-code-lookup/-/country-code-lookup-0.1.3.tgz",
"integrity": "sha512-gLu+AQKHUnkSQNTxShKgi/4tYd0vEEait3JMrLNZgYlmIZ9DJLkHUjzXE9qcs7dy3xY/kUx2/nOxZ0Z3D9JE+A=="
},
"node_modules/create-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",

View File

@ -93,6 +93,7 @@
"class-validator": "^0.14.1",
"clsx": "^2.1.1",
"cookie-parser": "^1.4.6",
"country-code-lookup": "^0.1.3",
"geoip-lite": "^1.4.10",
"isbot": "^5.1.5",
"jwt-decode": "^3.1.2",