fix pdf + admin panel

This commit is contained in:
Emily
2024-09-10 16:59:34 +02:00
parent 5172ad4f4d
commit 4134d33dc4
7 changed files with 145 additions and 132 deletions

View File

@@ -71,7 +71,11 @@ async function generatePDF() {
try { try {
const res = await $fetch<Blob>('/api/project/generate_pdf', { const res = await $fetch<Blob>('/api/project/generate_pdf', {
...signHeaders(), ...signHeaders({
'x-snapshot-name': snapshot.value.name,
'x-from': snapshot.value.from.toISOString(),
'x-to': snapshot.value.to.toISOString(),
}),
responseType: 'blob' responseType: 'blob'
}); });

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Slice } from '@services/DateService';
import { onMounted } from 'vue'; import { onMounted } from 'vue';
import DateService, { type Slice } from '@services/DateService';
const props = defineProps<{ slice: Slice }>(); const props = defineProps<{ slice: Slice }>();
const slice = computed(() => props.slice); const slice = computed(() => props.slice);
@@ -22,7 +22,7 @@ function transformResponse(input: { _id: string, name: string, count: number }[]
const fixed = fixMetrics({ const fixed = fixMetrics({
data: input, data: input,
from: safeSnapshotDates.value.from, from: input[0]._id,
to: safeSnapshotDates.value.to to: safeSnapshotDates.value.to
}, slice.value, { }, slice.value, {
advanced: true, advanced: true,
@@ -68,7 +68,8 @@ onMounted(async () => {
<div v-if="eventsStackedData.pending.value" class="flex justify-center py-40"> <div v-if="eventsStackedData.pending.value" class="flex justify-center py-40">
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i> <i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
</div> </div>
<AdvancedStackedBarChart v-if="!eventsStackedData.pending.value" :datasets="eventsStackedData.data.value?.datasets || []" <AdvancedStackedBarChart v-if="!eventsStackedData.pending.value"
:datasets="eventsStackedData.data.value?.datasets || []"
:labels="eventsStackedData.data.value?.labels || []"> :labels="eventsStackedData.data.value?.labels || []">
</AdvancedStackedBarChart> </AdvancedStackedBarChart>
</div> </div>

View File

@@ -5,6 +5,8 @@ import type { AdminProjectsList } from '~/server/api/admin/projects';
definePageMeta({ layout: 'dashboard' }); definePageMeta({ layout: 'dashboard' });
const { data: projects } = await useFetch<AdminProjectsList[]>('/api/admin/projects', signHeaders()); const { data: projects } = await useFetch<AdminProjectsList[]>('/api/admin/projects', signHeaders());
const { data: counts } = await useFetch('/api/admin/counts', signHeaders());
type TProjectsGrouped = { type TProjectsGrouped = {
user: { user: {
@@ -88,11 +90,6 @@ function onHideClicked() {
isAdminHidden.value = true; isAdminHidden.value = true;
} }
const projectsCount = computed(() => {
return projects.value?.length || 0;
});
const premiumCount = computed(() => { const premiumCount = computed(() => {
let premiums = 0; let premiums = 0;
projects.value?.forEach(e => { projects.value?.forEach(e => {
@@ -102,12 +99,6 @@ const premiumCount = computed(() => {
}) })
const usersCount = computed(() => {
const uniqueUsers = new Set<string>();
projects.value?.forEach(e => uniqueUsers.add(e.user.email));
return uniqueUsers.size;
});
const totalVisits = computed(() => { const totalVisits = computed(() => {
return projects.value?.reduce((a, e) => a + e.total_visits, 0) || 0; return projects.value?.reduce((a, e) => a + e.total_visits, 0) || 0;
@@ -155,10 +146,10 @@ async function resetCount(project_id: string) {
<div class="grid grid-cols-2"> <div class="grid grid-cols-2">
<div> <div>
Users: {{ usersCount }} Users: {{ counts?.users }}
</div> </div>
<div> <div>
Projects: {{ projectsCount }} ( {{ premiumCount }} premium ) Projects: {{ counts?.projects }} ( {{ premiumCount }} premium )
</div> </div>
<div> <div>
Total visits: {{ formatNumberK(totalVisits) }} Total visits: {{ formatNumberK(totalVisits) }}

View File

@@ -2,6 +2,7 @@
definePageMeta({ layout: 'none' }); definePageMeta({ layout: 'none' });
const { snapshot, snapshots } = useSnapshot();
const { data: project } = useLiveDemo(); const { data: project } = useLiveDemo();
@@ -9,7 +10,7 @@ let interval: any;
onMounted(async () => { onMounted(async () => {
await getOnlineUsers(); await getOnlineUsers();
snapshot.value = snapshots.value[0];
interval = setInterval(async () => { interval = setInterval(async () => {
await getOnlineUsers(); await getOnlineUsers();
}, 5000); }, 5000);
@@ -46,7 +47,7 @@ const selectLabelsEvents = [
{ label: 'Month', value: 'month' }, { label: 'Month', value: 'month' },
]; ];
const { snapshot } = useSnapshot();
</script> </script>
@@ -71,7 +72,8 @@ const { snapshot } = useSnapshot();
</div> </div>
<div class="grow"></div> <div class="grow"></div>
<div class="flex gap-2 md:pt-0 pt-4"> <div class="flex gap-2 md:pt-0 pt-4">
<LyxUiButton link="/" type="primary" class="poppins font-semibold text-[.9rem] lg:text-[1.2rem] flex items-center !px-14 py-4"> <LyxUiButton link="/" type="primary"
class="poppins font-semibold text-[.9rem] lg:text-[1.2rem] flex items-center !px-14 py-4">
Get started for free Get started for free
</LyxUiButton> </LyxUiButton>
</div> </div>
@@ -85,7 +87,7 @@ const { snapshot } = useSnapshot();
<div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row"> <div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row">
<CardTitled class="p-4 flex-1" title="Visits trends" sub="Shows trends in page visits."> <CardTitled class="p-4 flex-1 w-full" title="Visits trends" sub="Shows trends in page visits.">
<template #header> <template #header>
<SelectButton @changeIndex="mainChartSelectIndex = $event" :currentIndex="mainChartSelectIndex" <SelectButton @changeIndex="mainChartSelectIndex = $event" :currentIndex="mainChartSelectIndex"
:options="selectLabels"> :options="selectLabels">
@@ -196,7 +198,8 @@ const { snapshot } = useSnapshot();
</div> </div>
<div class="flex gap-2 flex-col md:flex-row"> <div class="flex gap-2 flex-col md:flex-row">
<LyxUiButton link="/" type="primary" class="poppins font-semibold text-[1.1rem] lg:text-[1.6rem] flex items-center !px-14"> <LyxUiButton link="/" type="primary"
class="poppins font-semibold text-[1.1rem] lg:text-[1.6rem] flex items-center !px-14">
Get started Get started
</LyxUiButton> </LyxUiButton>
<NuxtLink target="_blank" to="https://cal.com/litlyx/30min" <NuxtLink target="_blank" to="https://cal.com/litlyx/30min"

Binary file not shown.

View File

@@ -0,0 +1,17 @@
import { ProjectModel } from "@schema/ProjectSchema";
import { UserModel } from "@schema/UserSchema";
export default defineEventHandler(async event => {
const userData = getRequestUser(event);
if (!userData?.logged) return;
if (!userData.user.roles.includes('ADMIN')) return;
const projectsCount = await ProjectModel.countDocuments({});
const usersCount = await UserModel.countDocuments({});
return { users: usersCount, projects: projectsCount }
});

View File

@@ -2,19 +2,25 @@
import pdfkit from 'pdfkit'; import pdfkit from 'pdfkit';
import { PassThrough } from 'node:stream'; import { PassThrough } from 'node:stream';
import fs from 'fs';
import { ProjectModel, TProject } from "@schema/ProjectSchema"; import { ProjectModel } from "@schema/ProjectSchema";
import { UserSettingsModel } from "@schema/UserSettings"; import { UserSettingsModel } from "@schema/UserSettings";
import { VisitModel } from '@schema/metrics/VisitSchema'; import { VisitModel } from '@schema/metrics/VisitSchema';
import { EventModel } from '@schema/metrics/EventSchema'; import { EventModel } from '@schema/metrics/EventSchema';
import { ProjectSnapshotModel } from '@schema/ProjectSnapshot';
type PDF_Data = { type PDFGenerationData = {
pageVisits: number, customEvents: number, projectName: string,
visitsDay: number, eventsDay: number, visitsSessions: number, snapshotName: string,
visitsSessionsDay: number totalVisits: string,
avgVisitsDay: string,
totalEvents: string,
topDomain: string,
topDevice: string,
topCountries: string[],
topReferrers: string[],
avgGrowthText: string,
} }
function formatNumberK(value: string | number, decimals: number = 1) { function formatNumberK(value: string | number, decimals: number = 1) {
@@ -26,82 +32,54 @@ function formatNumberK(value: string | number, decimals: number = 1) {
} }
function createPdf(projectName: string, data: PDF_Data) { const LINE_SPACING = 0.5;
const pdf = new pdfkit({
size: 'A4', function createPdf(data: PDFGenerationData) {
margins: { top: 50, bottom: 50, left: 50, right: 50 },
const pdf = new pdfkit({ size: 'A4', margins: { top: 50, bottom: 50, left: 50, right: 50 }, });
pdf.fillColor('#ffffff').rect(0, 0, pdf.page.width, pdf.page.height).fill('#000000');
pdf.font('pdf_fonts/Poppins-Bold.ttf').fontSize(16).fillColor('#ffffff');
pdf.text(`Project name: ${data.projectName}`, { align: 'left' }).moveDown(LINE_SPACING);
pdf.text(`Snapshot name: ${data.snapshotName}`, { align: 'left' }).moveDown(LINE_SPACING);
pdf.font('pdf_fonts/Poppins-Regular.ttf').fontSize(12).fillColor('#ffffff')
pdf.text(`Total visits: ${data.totalVisits}`, { align: 'left' }).moveDown(LINE_SPACING);
pdf.text(`Average visits per day: ${data.avgVisitsDay}`, { align: 'left' }).moveDown(LINE_SPACING);
pdf.text(`Total events: ${data.totalEvents}`, { align: 'left' }).moveDown(LINE_SPACING);
pdf.text(`Top domain: ${data.topDomain}`, { align: 'left' }).moveDown(LINE_SPACING);
pdf.text(`Top device: ${data.topDevice}`, { align: 'left' }).moveDown(LINE_SPACING);
pdf.text('Top 3 countries:', { align: 'left' }).moveDown(LINE_SPACING);
data.topCountries.forEach((country: any) => {
pdf.text(`${country}`, { align: 'left' }).moveDown(LINE_SPACING);
}); });
pdf.pipe(fs.createWriteStream('out.pdf')); pdf.text('Top 3 best acquisition channels (referrers):', { align: 'left' }).moveDown(LINE_SPACING);
data.topReferrers.forEach((channel: any) => {
pdf.text(`${channel}`, { align: 'left' }).moveDown(LINE_SPACING);
});
// Set up fonts and colors pdf.text('Average growth:', { align: 'left' }).moveDown(LINE_SPACING);
pdf pdf.text(`${data.avgGrowthText}`, { align: 'left' }).moveDown(LINE_SPACING);
pdf.font('pdf_fonts/Poppins-Italic.ttf')
.text('This gives you an idea of the average growth your website is experiencing over time.', { align: 'left' })
.moveDown(LINE_SPACING);
pdf.font('pdf_fonts/Poppins-Regular.ttf')
.fontSize(10)
.fillColor('#ffffff') .fillColor('#ffffff')
.rect(0, 0, pdf.page.width, pdf.page.height) .text('Created with Litlyx.com', 50, 760, { align: 'center' });
.fill('#000000');
// Title pdf.image('pdf_images/logo.png', 460, 700, { width: 100 });
pdf
.font('pdf_fonts/Poppins-Bold.ttf')
.fontSize(26)
.fillColor('#ffffff')
.text(`Report of: ${projectName}`, 50, 50);
// Section 1
pdf
.font('pdf_fonts/Poppins-SemiBold.ttf')
.fontSize(20)
.fillColor('#ffffff')
.text('-> This month has seen a lot of visits!', 50, 120);
pdf
.image('pdf_images/d.png', 50, 160, { width: 300 })
.font('pdf_fonts/Poppins-Bold.ttf')
.fontSize(28)
.fillColor('#ffffff')
.text(`${formatNumberK(data.pageVisits, 2)}`, 400, 180)
.text('WOW!', 400, 210);
// Section 2
pdf
.font('pdf_fonts/Poppins-SemiBold.ttf')
.fontSize(20)
.fillColor('#ffffff')
.text('-> There are also many recorded events!', 50, 350);
pdf
.image('pdf_images/c.png', 50, 390, { width: 300 })
.font('pdf_fonts/Poppins-Bold.ttf')
.fontSize(28)
.fillColor('#ffffff')
.text(`${formatNumberK(data.customEvents, 2)}`, 400, 420)
.text('Let\'s go!', 400, 450);
// Final section
pdf
.font('pdf_fonts/Poppins-SemiBold.ttf')
.fontSize(20)
.fillColor('#ffffff')
.text('This report is not final, it only serves to demonstrate the potential of this tool. LitLyx will improve soon! Stay tuned!', 50, 600);
pdf
.font('pdf_fonts/Poppins-Regular.ttf')
.fontSize(14)
.fillColor('#ffffff')
.text('Generated on litlyx.com', 50, 760);
pdf
.image('pdf_images/logo.png', 460, 700, { width: 100 }) // replace with the correct path to your Unsplash image
// End PDF creation and save to file
pdf.end(); pdf.end();
return pdf; return pdf;
} }
export default defineEventHandler(async event => { export default defineEventHandler(async event => {
const userData = getRequestUser(event); const userData = getRequestUser(event);
@@ -115,54 +93,73 @@ export default defineEventHandler(async event => {
const project = await ProjectModel.findById(project_id); const project = await ProjectModel.findById(project_id);
if (!project) return setResponseStatus(event, 400, 'Project not found'); if (!project) return setResponseStatus(event, 400, 'Project not found');
const snapshotHeader = getHeader(event, 'x-snapshot-name');
const fromHeader = getHeader(event, 'x-from'); const fromHeader = getHeader(event, 'x-from');
const toHeader = getHeader(event, 'x-from'); const toHeader = getHeader(event, 'x-to');
const from = fromHeader; const from = fromHeader ? new Date(fromHeader) : new Date(2020, 0);
const to = toHeader; const to = toHeader ? new Date(toHeader) : new Date(3001, 0);
const eventsCount = await EventModel.countDocuments({ project_id: project._id }); const eventsCount = await EventModel.countDocuments({
const visitsCount = await VisitModel.countDocuments({ project_id: project._id }); project_id: project._id,
created_at: { $gte: from, $lte: to }
});
const sessionsVisitsCount: any[] = await VisitModel.aggregate([ const visitsCount = await VisitModel.countDocuments({
{ $match: { project_id: project._id } }, project_id: project._id,
{ $group: { _id: "$session" } }, created_at: { $gte: from, $lte: to }
{ $count: "count" } });
]);
const firstEventDate = await EventModel.findOne({ project_id: project._id }, { created_at: 1 }, { sort: { created_at: 1 } });
const firstViewDate = await VisitModel.findOne({ project_id: project._id }, { created_at: 1 }, { sort: { created_at: 1 } });
// if (!firstEventDate || !firstViewDate) {
// return setResponseStatus(event, 400, 'Not enough data to generate report');
// }
const avgEventsDay = () => {
const days = (Date.now() - (firstEventDate?.created_at.getTime() || 0)) / 1000 / 60 / 60 / 24;
const avg = eventsCount / Math.max(days, 1);
return avg;
};
const avgVisitDay = () => { const avgVisitDay = () => {
const days = (Date.now() - (firstViewDate?.created_at.getTime() || 0)) / 1000 / 60 / 60 / 24; const days = (Date.now() - (from.getTime())) / 1000 / 60 / 60 / 24;
const avg = visitsCount / Math.max(days, 1); const avg = visitsCount / Math.max(days, 1);
return avg; return avg;
}; };
const avgVisitsSessionsDay = () => { const topDevices = await VisitModel.aggregate([
const days = (Date.now() - (firstViewDate?.created_at.getTime() || 0)) / 1000 / 60 / 60 / 24; { $match: { project_id: project._id, created_at: { $gte: from, $lte: to } } },
const avg = sessionsVisitsCount[0].count / Math.max(days, 1); { $group: { _id: "$device", count: { $sum: 1 } } },
return avg; { $match: { _id: { $ne: null } } },
}; { $sort: { count: -1 } },
{ $limit: 1 }
]);
const pdf = createPdf( const topDevice = topDevices?.[0]?._id || 'Not enough data';
project.name, {
customEvents: eventsCount, const topDomains = await VisitModel.aggregate([
eventsDay: avgEventsDay(), { $match: { project_id: project._id, created_at: { $gte: from, $lte: to } } },
pageVisits: visitsCount, { $group: { _id: "$website", count: { $sum: 1 } } },
visitsDay: avgVisitDay(), { $sort: { count: -1 } },
visitsSessions: sessionsVisitsCount[0].count, { $limit: 1 }
visitsSessionsDay: avgVisitsSessionsDay() ]);
const topDomain = topDomains?.[0]?._id || 'Not enough data';
const topCountries = await VisitModel.aggregate([
{ $match: { project_id: project._id, created_at: { $gte: from, $lte: to } } },
{ $group: { _id: "$country", count: { $sum: 1 } } },
{ $sort: { count: -1 } },
{ $limit: 3 }
]);
const topReferrers = await VisitModel.aggregate([
{ $match: { project_id: project._id, created_at: { $gte: from, $lte: to } } },
{ $group: { _id: "$referrer", count: { $sum: 1 } } },
{ $sort: { count: -1 } },
{ $limit: 3 }
]);
const pdf = createPdf({
projectName: project.name,
snapshotName: snapshotHeader || 'NO_NAME',
totalVisits: formatNumberK(visitsCount),
avgVisitsDay: formatNumberK(avgVisitDay()) + '/day',
totalEvents: formatNumberK(eventsCount),
avgGrowthText: 'Insufficient Data (Requires at least 2 months of tracking)',
topDevice: topDevice,
topDomain: topDomain,
topCountries: topCountries.map(e => e._id),
topReferrers: topReferrers.map(e => e._id)
}); });
const passThrough = new PassThrough(); const passThrough = new PassThrough();