This commit is contained in:
@@ -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
16
pnpm-lock.yaml
generated
@@ -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
|
||||
|
205
src/lib/components/BarChart.svelte
Normal file
205
src/lib/components/BarChart.svelte
Normal 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>
|
145
src/routes/api/cospend/monthly-expenses/+server.ts
Normal file
145
src/routes/api/cospend/monthly-expenses/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
@@ -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>
|
||||
|
Reference in New Issue
Block a user