Adds in dashboard page showing basic Mantis statistics

This commit is contained in:
Cameron Redmore 2025-04-26 09:32:59 +01:00
parent 7564937faa
commit 92230f8a07
13 changed files with 595 additions and 19 deletions

8
src/boot/apexcharts.js Normal file
View file

@ -0,0 +1,8 @@
import { defineBoot } from '#q-app/wrappers';
import VueApexCharts from 'vue3-apexcharts';
export default defineBoot(async({app}) =>
{
app.use(VueApexCharts);
});

View file

@ -1,14 +1,30 @@
import { boot } from 'quasar/wrappers';
import axios from 'axios';
// Be careful when using SSR for cross-request state pollution
// due to creating a Singleton instance here;
// If any client changes this (global) instance, it might be a
// good idea to move this instance creation inside of the
// "export default () => {}" function below (which runs individually
// for each client)
import SuperJSON from 'superjson';
axios.defaults.withCredentials = true; // Enable sending cookies with requests
// Export the API instance so you can import it easily elsewhere, e.g. stores
axios.interceptors.response.use(
(response) =>
{
//If the response content type is application/json, we want to parse it with SuperJSON
if (response.headers['content-type'] && response.headers['content-type'].includes('application/json'))
{
try
{
response.data = SuperJSON.parse(JSON.stringify(response.data));
}
catch (error)
{
console.error('Error deserializing response data:', error);
}
}
return response;
},
(error) =>
{
// Handle errors here if needed
return Promise.reject(error);
}
);
export default axios;

View file

@ -21,4 +21,8 @@ body {
.bg-blurred {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(10px);
}
.drop-shadow {
filter: drop-shadow(0 0 25px rgba(0, 0, 0, 0.5));
}

366
src/pages/DashboardPage.vue Normal file
View file

@ -0,0 +1,366 @@
<template>
<q-page padding>
<div class="q-pa-md">
<div class="text-h4 q-mb-md">
Dashboard
</div>
<div class="row q-col-gutter-md">
<!-- Mantis Issues Chart -->
<div class="col-12 col-md-6">
<q-card class="dashboard-card">
<q-card-section>
<div class="text-h6">
New Mantis Issues
</div>
<div class="text-subtitle2">
Last 7 days
</div>
</q-card-section>
<q-card-section>
<div
v-if="loadingIssues"
class="flex flex-center q-pa-xl"
>
<q-spinner
color="primary"
size="3em"
/>
</div>
<div
v-else-if="issueChartData.series[0].data.length === 0"
class="text-center q-pa-md"
>
<q-icon
name="info"
color="grey"
size="3em"
/>
<div class="text-grey q-mt-md">
No data available for this period
</div>
</div>
<div v-else>
<apexchart
type="bar"
height="300"
:options="issueChartOptions"
:series="issueChartData.series"
/>
</div>
</q-card-section>
</q-card>
</div>
<!-- Mantis Comments Chart -->
<div class="col-12 col-md-6">
<q-card class="dashboard-card">
<q-card-section>
<div class="text-h6">
New Mantis Comments
</div>
<div class="text-subtitle2">
Last 7 days
</div>
</q-card-section>
<q-card-section>
<div
v-if="loadingComments"
class="flex flex-center q-pa-xl"
>
<q-spinner
color="primary"
size="3em"
/>
</div>
<div
v-else-if="commentChartData.series[0].data.length === 0"
class="text-center q-pa-md"
>
<q-icon
name="info"
color="grey"
size="3em"
/>
<div class="text-grey q-mt-md">
No data available for this period
</div>
</div>
<div v-else>
<apexchart
type="bar"
height="300"
:options="commentChartOptions"
:series="commentChartData.series"
/>
</div>
</q-card-section>
</q-card>
</div>
</div>
</div>
</q-page>
</template>
<script>
import { defineComponent, ref, computed, onMounted, watch } from 'vue';
import { useQuasar } from 'quasar';
import { date } from 'quasar';
import axios from 'boot/axios';
export default defineComponent({
name: 'DashboardPage',
setup()
{
const $q = useQuasar();
const loadingIssues = ref(true);
const loadingComments = ref(true);
const issuesData = ref([]);
const commentsData = ref([]);
// Format date as "Monday 25th"
const formatDate = (dateStr) =>
{
const dateObj = new Date(dateStr);
const day = date.formatDate(dateObj, 'dddd');
const dayNum = dateObj.getDate();
// Add ordinal suffix (st, nd, rd, th)
let suffix = 'th';
if (dayNum % 10 === 1 && dayNum !== 11) suffix = 'st';
else if (dayNum % 10 === 2 && dayNum !== 12) suffix = 'nd';
else if (dayNum % 10 === 3 && dayNum !== 13) suffix = 'rd';
return `${day} ${dayNum}${suffix}`;
};
// Generate an array of dates for the last 7 days
const getLast7Days = () =>
{
const result = [];
for (let i = 6; i >= 0; i--)
{
const d = new Date();
d.setDate(d.getDate() - i);
result.push(date.formatDate(d, 'YYYY-MM-DD'));
}
return result;
};
// Prepare data with 0 counts for days with no data
const prepareChartData = (data) =>
{
const last7Days = getLast7Days();
const countMap = {};
// Initialize with 0 counts
last7Days.forEach(day =>
{
countMap[day] = 0;
});
// Fill in actual counts
data.forEach(item =>
{
console.log(item);
const dateStr = item.date.toISOString().split('T')[0];
if (countMap[dateStr] !== undefined)
{
countMap[dateStr] = Number(item.count);
}
});
// Convert to arrays for chart
const categories = Object.keys(countMap).map(formatDate);
const counts = Object.values(countMap);
return {
categories,
series: [{
name: 'Count',
data: counts
}]
};
};
// Computed properties for chart data
const issueChartData = computed(() =>
{
return prepareChartData(issuesData.value);
});
const commentChartData = computed(() =>
{
return prepareChartData(commentsData.value);
});
// Chart options
const getChartOptions = (title, isDark) =>
{
return {
chart: {
type: 'bar',
height: 300,
toolbar: {
show: false
},
foreColor: isDark ? '#ccc' : '#333'
},
plotOptions: {
bar: {
borderRadius: 6,
columnWidth: '50%',
dataLabels: {
position: 'top'
}
}
},
colors: [isDark ? '#3498db' : '#1976D2'],
dataLabels: {
enabled: true,
offsetY: -20,
style: {
fontSize: '12px',
colors: [isDark ? '#fff' : '#000']
}
},
grid: {
borderColor: isDark ? '#555' : '#e0e0e0'
},
xaxis: {
categories: issueChartData.value.categories,
position: 'bottom',
axisBorder: {
show: false
},
axisTicks: {
show: false
}
},
yaxis: {
axisBorder: {
show: false
},
axisTicks: {
show: false
},
labels: {
show: true
},
min: 0,
forceNiceScale: true
},
title: {
text: title,
align: 'center',
style: {
fontSize: '16px',
color: isDark ? '#fff' : '#333'
},
floating: false
},
theme: {
mode: isDark ? 'dark' : 'light'
}
};
};
// Computed options that change with dark mode
const issueChartOptions = computed(() =>
{
return getChartOptions('New Mantis Issues', $q.dark.isActive);
});
const commentChartOptions = computed(() =>
{
return getChartOptions('New Mantis Comments', $q.dark.isActive);
});
// Fetch data from API
const fetchIssuesData = async() =>
{
loadingIssues.value = true;
try
{
const response = await axios.get('/api/mantis/stats/issues');
issuesData.value = response.data;
}
catch (error)
{
console.error('Error fetching issues data:', error);
$q.notify({
color: 'negative',
message: 'Failed to load issues data',
icon: 'warning'
});
}
finally
{
loadingIssues.value = false;
}
};
const fetchCommentsData = async() =>
{
loadingComments.value = true;
try
{
const response = await axios.get('/api/mantis/stats/comments');
console.log(response.data);
commentsData.value = response.data;
}
catch (error)
{
console.error('Error fetching comments data:', error);
$q.notify({
color: 'negative',
message: 'Failed to load comments data',
icon: 'warning'
});
}
finally
{
loadingComments.value = false;
}
};
// Watch for dark mode changes
const refreshCharts = () =>
{
issueChartOptions.value = getChartOptions('New Mantis Issues', $q.dark.isActive);
commentChartOptions.value = getChartOptions('New Mantis Comments', $q.dark.isActive);
};
watch(
() => $q.dark.isActive,
refreshCharts
);
// Fetch data on component mount
onMounted(() =>
{
fetchIssuesData();
fetchCommentsData();
});
return {
loadingIssues,
loadingComments,
issueChartData,
commentChartData,
issueChartOptions,
commentChartOptions
};
}
});
</script>
<style scoped>
.dashboard-card {
height: 100%;
}
</style>

View file

@ -4,7 +4,7 @@
<img
src="/stylepoint.png"
alt="StylePoint Logo"
class="logo q-mb-md"
class="drop-shadow q-mb-md"
style="max-width: 300px; width: 100%;"
>
<h1 class="text-h3 text-weight-bold text-yellow q-mb-sm">
@ -63,10 +63,6 @@ const features = ref([
text-shadow: 0 0 10px rgba(0, 0, 0, 0.75);
}
.logo {
filter: drop-shadow(0 0 25px rgba(0, 0, 0, 0.5));
}
.features {
background-color: rgba(0, 0, 0, 0.25);
border-radius: 10px;

View file

@ -3,7 +3,7 @@
<img
src="/stylepoint.png"
alt="StylePoint Logo"
class="logo q-mb-md absolute"
class="drop-shadow q-mb-md absolute"
style="max-width: 300px; width: 100%; top: 75px;"
>
<q-card

View file

@ -3,7 +3,7 @@
<img
src="/stylepoint.png"
alt="StylePoint Logo"
class="logo q-mb-md absolute"
class="drop-shadow q-mb-md absolute"
style="max-width: 300px; width: 100%; top: 75px;"
>
<q-card

View file

@ -33,7 +33,18 @@ const routes = [
caption: 'Create an account'
}
},
// Add a new route specifically for managing passkeys when logged in
{
path: '/dashboard',
name: 'dashboard',
component: () => import('pages/DashboardPage.vue'),
meta: {
requiresAuth: true,
navGroup: 'auth',
icon: 'dashboard',
title: 'Dashboard',
caption: 'Overview and stats'
}
},
{
path: '/passkeys',
name: 'passkeys',