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); + } + }