diff --git a/package.json b/package.json
index d5ee4a1..8ed4669 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d9651fe..4716f81 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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
diff --git a/src/lib/components/BarChart.svelte b/src/lib/components/BarChart.svelte
new file mode 100644
index 0000000..9a4b1b8
--- /dev/null
+++ b/src/lib/components/BarChart.svelte
@@ -0,0 +1,205 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/routes/api/cospend/monthly-expenses/+server.ts b/src/routes/api/cospend/monthly-expenses/+server.ts
new file mode 100644
index 0000000..bf4ff75
--- /dev/null
+++ b/src/routes/api/cospend/monthly-expenses/+server.ts
@@ -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 });
+ }
+};
\ No newline at end of file
diff --git a/src/routes/cospend/+page.svelte b/src/routes/cospend/+page.svelte
index 9fac13a..cb033eb 100644
--- a/src/routes/cospend/+page.svelte
+++ b/src/routes/cospend/+page.svelte
@@ -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 @@
+
+
+ {#if expensesLoading}
+
Loading monthly expenses chart...
+ {:else if monthlyExpensesData.datasets && monthlyExpensesData.datasets.length > 0}
+
+ {:else}
+
+ Debug: expensesLoading={expensesLoading},
+ datasets={monthlyExpensesData.datasets?.length || 0},
+ data={JSON.stringify(monthlyExpensesData)}
+
+ {/if}
+
+
{#if loading}
Loading recent activity...
{: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);
+ }
+ }