add MVP monthly expenses graph
Some checks failed
CI / update (push) Failing after 5s

This commit is contained in:
2025-09-12 20:07:37 +02:00
parent e773a90f1d
commit db3de29e48
5 changed files with 437 additions and 2 deletions

View File

@@ -27,6 +27,7 @@
"dependencies": {
"@auth/sveltekit": "^1.10.0",
"@sveltejs/adapter-node": "^5.0.0",
"chart.js": "^4.5.0",
"cheerio": "1.0.0-rc.12",
"mongoose": "^8.0.0",
"node-cron": "^4.2.1",

16
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
'@sveltejs/adapter-node':
specifier: ^5.0.0
version: 5.3.1(@sveltejs/kit@2.37.0(@sveltejs/vite-plugin-svelte@6.1.3(svelte@5.38.6)(vite@7.1.3(@types/node@22.18.0)))(svelte@5.38.6)(vite@7.1.3(@types/node@22.18.0)))
chart.js:
specifier: ^4.5.0
version: 4.5.0
cheerio:
specifier: 1.0.0-rc.12
version: 1.0.0-rc.12
@@ -376,6 +379,9 @@ packages:
'@jridgewell/trace-mapping@0.3.30':
resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
'@kurkle/color@0.3.4':
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
'@mongodb-js/saslprep@1.3.0':
resolution: {integrity: sha512-zlayKCsIjYb7/IdfqxorK5+xUMyi4vOKcFy10wKJYc63NSdKI8mNME+uJqfatkPmOSMMUiojrL58IePKBm3gvQ==}
@@ -625,6 +631,10 @@ packages:
resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==}
engines: {node: '>=16.20.1'}
chart.js@4.5.0:
resolution: {integrity: sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==}
engines: {pnpm: '>=8'}
cheerio-select@2.1.0:
resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
@@ -1251,6 +1261,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.0
'@jridgewell/sourcemap-codec': 1.4.15
'@kurkle/color@0.3.4': {}
'@mongodb-js/saslprep@1.3.0':
dependencies:
sparse-bitfield: 3.0.3
@@ -1455,6 +1467,10 @@ snapshots:
bson@6.10.4: {}
chart.js@4.5.0:
dependencies:
'@kurkle/color': 0.3.4
cheerio-select@2.1.0:
dependencies:
boolbase: 1.0.0

View File

@@ -0,0 +1,205 @@
<script>
import { onMount } from 'svelte';
import { Chart, registerables } from 'chart.js';
export let data = { labels: [], datasets: [] };
export let title = '';
export let height = '400px';
let canvas;
let chart;
// Register Chart.js components
Chart.register(...registerables);
// Nord theme colors for categories
const nordColors = [
'#5E81AC', // Nord Blue
'#88C0D0', // Nord Light Blue
'#81A1C1', // Nord Lighter Blue
'#A3BE8C', // Nord Green
'#EBCB8B', // Nord Yellow
'#D08770', // Nord Orange
'#BF616A', // Nord Red
'#B48EAD', // Nord Purple
'#8FBCBB', // Nord Cyan
'#ECEFF4', // Nord Light Gray
];
function getCategoryColor(category, index) {
const categoryColorMap = {
'groceries': '#A3BE8C', // Green
'restaurant': '#D08770', // Orange
'transport': '#5E81AC', // Blue
'entertainment': '#B48EAD', // Purple
'shopping': '#EBCB8B', // Yellow
'utilities': '#81A1C1', // Light Blue
'healthcare': '#BF616A', // Red
'education': '#88C0D0', // Cyan
'travel': '#8FBCBB', // Light Cyan
'other': '#4C566A' // Dark Gray
};
return categoryColorMap[category] || nordColors[index % nordColors.length];
}
function createChart() {
if (!canvas || !data.datasets) return;
// Destroy existing chart
if (chart) {
chart.destroy();
}
const ctx = canvas.getContext('2d');
// Process datasets with colors
const processedDatasets = data.datasets.map((dataset, index) => ({
...dataset,
backgroundColor: getCategoryColor(dataset.label, index),
borderColor: getCategoryColor(dataset.label, index),
borderWidth: 1
}));
chart = new Chart(ctx, {
type: 'bar',
data: {
labels: data.labels,
datasets: processedDatasets
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true,
grid: {
display: false
},
ticks: {
color: 'var(--nord3)',
font: {
family: 'Inter, system-ui, sans-serif'
}
}
},
y: {
stacked: true,
beginAtZero: true,
grid: {
color: 'var(--nord4)',
borderDash: [2, 2]
},
ticks: {
color: 'var(--nord3)',
font: {
family: 'Inter, system-ui, sans-serif'
},
callback: function(value) {
return 'CHF ' + value.toFixed(0);
}
}
}
},
plugins: {
legend: {
position: 'bottom',
labels: {
padding: 20,
usePointStyle: true,
color: 'var(--nord1)',
font: {
family: 'Inter, system-ui, sans-serif',
size: 12
}
}
},
title: {
display: !!title,
text: title,
color: 'var(--nord0)',
font: {
family: 'Inter, system-ui, sans-serif',
size: 16,
weight: 'bold'
},
padding: 20
},
tooltip: {
backgroundColor: 'var(--nord1)',
titleColor: 'var(--nord6)',
bodyColor: 'var(--nord6)',
borderColor: 'var(--nord3)',
borderWidth: 1,
cornerRadius: 8,
titleFont: {
family: 'Inter, system-ui, sans-serif'
},
bodyFont: {
family: 'Inter, system-ui, sans-serif'
},
callbacks: {
label: function(context) {
return context.dataset.label + ': CHF ' + context.parsed.y.toFixed(2);
}
}
}
},
interaction: {
intersect: false,
mode: 'index'
}
}
});
}
onMount(() => {
createChart();
// Watch for theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleThemeChange = () => {
setTimeout(createChart, 100); // Small delay to let CSS variables update
};
mediaQuery.addEventListener('change', handleThemeChange);
return () => {
mediaQuery.removeEventListener('change', handleThemeChange);
if (chart) {
chart.destroy();
}
};
});
// Recreate chart when data changes
$: if (canvas && data) {
createChart();
}
</script>
<div class="chart-container" style="height: {height}">
<canvas bind:this={canvas}></canvas>
</div>
<style>
.chart-container {
background: var(--nord6);
border-radius: 0.75rem;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid var(--nord4);
}
@media (prefers-color-scheme: dark) {
.chart-container {
background: var(--nord1);
border-color: var(--nord2);
}
}
canvas {
max-width: 100%;
height: 100% !important;
}
</style>

View File

@@ -0,0 +1,145 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { Payment } from '../../../../models/Payment';
import { dbConnect } from '../../../../utils/db';
export const GET: RequestHandler = async ({ url, locals }) => {
const session = await locals.auth();
if (!session || !session.user?.nickname) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
await dbConnect();
try {
// Get query parameters for date range (default to last 12 months)
const monthsBack = parseInt(url.searchParams.get('months') || '12');
const endDate = new Date();
const startDate = new Date();
startDate.setMonth(startDate.getMonth() - monthsBack);
// First, let's get all payments and see what we have
console.log('Searching for payments for user:', session.user.nickname);
console.log('Date range:', startDate.toISOString(), 'to', endDate.toISOString());
const totalPayments = await Payment.countDocuments();
const paymentsInRange = await Payment.countDocuments({
date: {
$gte: startDate,
$lte: endDate
}
});
const expensePayments = await Payment.countDocuments({
date: {
$gte: startDate,
$lte: endDate
},
category: { $ne: 'settlement' },
$or: [
{ paidBy: session.user.nickname },
{ createdBy: session.user.nickname }
]
});
console.log('Total payments:', totalPayments, 'In date range:', paymentsInRange, 'User expenses:', expensePayments);
// Aggregate payments by month and category
const pipeline = [
{
$match: {
date: {
$gte: startDate,
$lte: endDate
},
// Exclude settlements - only show actual expenses
category: { $ne: 'settlement' },
// Only include payments where current user is involved
$or: [
{ paidBy: session.user.nickname },
{ createdBy: session.user.nickname }
]
}
},
{
$addFields: {
// Extract year-month from date
yearMonth: {
$dateToString: {
format: '%Y-%m',
date: '$date'
}
}
}
},
{
$group: {
_id: {
yearMonth: '$yearMonth',
category: '$category'
},
totalAmount: { $sum: '$amount' },
count: { $sum: 1 }
}
},
{
$sort: {
'_id.yearMonth': 1,
'_id.category': 1
}
}
];
const results = await Payment.aggregate(pipeline);
console.log('Aggregation results:', results);
// Transform data into chart-friendly format
const monthsMap = new Map();
const categories = new Set();
// Initialize months
for (let i = 0; i < monthsBack; i++) {
const date = new Date();
date.setMonth(date.getMonth() - monthsBack + i + 1);
const yearMonth = date.toISOString().substring(0, 7);
monthsMap.set(yearMonth, {});
}
// Populate data
results.forEach((result: any) => {
const { yearMonth, category } = result._id;
const amount = result.totalAmount;
categories.add(category);
if (!monthsMap.has(yearMonth)) {
monthsMap.set(yearMonth, {});
}
monthsMap.get(yearMonth)[category] = amount;
});
// Convert to arrays for Chart.js
const months = Array.from(monthsMap.keys()).sort();
const categoryList = Array.from(categories).sort();
const datasets = categoryList.map((category: string) => ({
label: category,
data: months.map(month => monthsMap.get(month)[category] || 0)
}));
return json({
labels: months.map(month => {
const [year, monthNum] = month.split('-');
const date = new Date(parseInt(year), parseInt(monthNum) - 1);
return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
}),
datasets,
categories: categoryList
});
} catch (error) {
console.error('Error fetching monthly expenses:', error);
return json({ error: 'Failed to fetch monthly expenses' }, { status: 500 });
}
};

View File

@@ -5,6 +5,7 @@
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
import EnhancedBalance from '$lib/components/EnhancedBalance.svelte';
import DebtBreakdown from '$lib/components/DebtBreakdown.svelte';
import BarChart from '$lib/components/BarChart.svelte';
import { getCategoryEmoji, getCategoryName } from '$lib/utils/categories';
import { isSettlementPayment, getSettlementIcon, getSettlementClasses, getSettlementReceiver } from '$lib/utils/settlements';
import AddButton from '$lib/components/AddButton.svelte';
@@ -18,6 +19,8 @@
};
let loading = false; // Start as false since we have server data
let error = null;
let monthlyExpensesData = { labels: [], datasets: [] };
let expensesLoading = false;
// Component references for refreshing
let enhancedBalanceComponent;
@@ -27,7 +30,10 @@
onMount(async () => {
// Mark that JavaScript is loaded for progressive enhancement
document.body.classList.add('js-loaded');
await fetchBalance();
await Promise.all([
fetchBalance(),
fetchMonthlyExpenses()
]);
// Listen for dashboard refresh events from the layout
const handleDashboardRefresh = () => {
@@ -57,10 +63,31 @@
}
}
async function fetchMonthlyExpenses() {
try {
expensesLoading = true;
console.log('Fetching monthly expenses...');
const response = await fetch('/api/cospend/monthly-expenses');
if (!response.ok) {
throw new Error('Failed to fetch monthly expenses');
}
monthlyExpensesData = await response.json();
console.log('Monthly expenses data:', monthlyExpensesData);
} catch (err) {
console.error('Error fetching monthly expenses:', err);
// Don't show this error in the main error state
} finally {
expensesLoading = false;
}
}
// Function to refresh all dashboard components after payment deletion
async function refreshAllComponents() {
// Refresh the main balance and recent activity
await fetchBalance();
await Promise.all([
fetchBalance(),
fetchMonthlyExpenses()
]);
// Refresh the enhanced balance component if it exists and has a refresh method
if (enhancedBalanceComponent && enhancedBalanceComponent.refresh) {
@@ -138,6 +165,25 @@
<DebtBreakdown bind:this={debtBreakdownComponent} />
<!-- Monthly Expenses Chart -->
<div class="chart-section">
{#if expensesLoading}
<div class="loading">Loading monthly expenses chart...</div>
{:else if monthlyExpensesData.datasets && monthlyExpensesData.datasets.length > 0}
<BarChart
data={monthlyExpensesData}
title="Monthly Expenses by Category"
height="400px"
/>
{:else}
<div class="loading">
Debug: expensesLoading={expensesLoading},
datasets={monthlyExpensesData.datasets?.length || 0},
data={JSON.stringify(monthlyExpensesData)}
</div>
{/if}
</div>
{#if loading}
<div class="loading">Loading recent activity...</div>
{:else if error}
@@ -660,4 +706,26 @@
font-size: 1.3rem;
}
}
.chart-section {
margin-bottom: 2rem;
}
.chart-section .loading {
background: var(--nord6);
border-radius: 0.75rem;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid var(--nord4);
text-align: center;
color: var(--nord2);
}
@media (prefers-color-scheme: dark) {
.chart-section .loading {
background: var(--nord1);
border-color: var(--nord2);
color: var(--nord4);
}
}
</style>