diff --git a/src/lib/components/DebtBreakdown.svelte b/src/lib/components/DebtBreakdown.svelte
index 1885c46..6ab2d32 100644
--- a/src/lib/components/DebtBreakdown.svelte
+++ b/src/lib/components/DebtBreakdown.svelte
@@ -43,6 +43,11 @@
currency: 'CHF'
}).format(amount);
}
+
+ // Export refresh method for parent components to call
+ export async function refresh() {
+ await fetchDebtBreakdown();
+ }
{#if !shouldHide}
diff --git a/src/lib/components/EnhancedBalance.svelte b/src/lib/components/EnhancedBalance.svelte
index c00ca9c..68a8224 100644
--- a/src/lib/components/EnhancedBalance.svelte
+++ b/src/lib/components/EnhancedBalance.svelte
@@ -43,7 +43,8 @@
}
$: {
- // Recalculate when debtData changes
+ // Recalculate when debtData changes - trigger on the arrays specifically
+ const totalUsers = debtData.whoOwesMe.length + debtData.whoIOwe.length;
singleDebtUser = getSingleDebtUser();
shouldShowIntegratedView = singleDebtUser !== null;
}
@@ -67,7 +68,12 @@
if (!response.ok) {
throw new Error('Failed to fetch balance');
}
- balance = await response.json();
+ const newBalance = await response.json();
+ // Force reactivity by creating new object with spread arrays
+ balance = {
+ netBalance: newBalance.netBalance || 0,
+ recentSplits: [...(newBalance.recentSplits || [])]
+ };
} catch (err) {
error = err.message;
}
@@ -79,7 +85,14 @@
if (!response.ok) {
throw new Error('Failed to fetch debt breakdown');
}
- debtData = await response.json();
+ const newDebtData = await response.json();
+ // Force reactivity by creating new object with spread arrays
+ debtData = {
+ whoOwesMe: [...(newDebtData.whoOwesMe || [])],
+ whoIOwe: [...(newDebtData.whoIOwe || [])],
+ totalOwedToMe: newDebtData.totalOwedToMe || 0,
+ totalIOwe: newDebtData.totalIOwe || 0
+ };
} catch (err) {
error = err.message;
} finally {
@@ -94,6 +107,12 @@
}).format(Math.abs(amount));
}
+ // Export refresh method for parent components to call
+ export async function refresh() {
+ loading = true;
+ await Promise.all([fetchBalance(), fetchDebtBreakdown()]);
+ }
+
diff --git a/src/routes/api/cospend/balance/+server.ts b/src/routes/api/cospend/balance/+server.ts
index db558ec..843ae81 100644
--- a/src/routes/api/cospend/balance/+server.ts
+++ b/src/routes/api/cospend/balance/+server.ts
@@ -66,7 +66,7 @@ export const GET: RequestHandler = async ({ locals, url }) => {
}
},
{ $unwind: '$paymentId' },
- { $sort: { 'paymentId.date': -1 } },
+ { $sort: { 'paymentId.date': -1, 'paymentId.createdAt': -1 } },
{ $limit: 10 }
]);
diff --git a/src/routes/api/cospend/payments/+server.ts b/src/routes/api/cospend/payments/+server.ts
index a00cbba..6c71f5f 100644
--- a/src/routes/api/cospend/payments/+server.ts
+++ b/src/routes/api/cospend/payments/+server.ts
@@ -18,7 +18,7 @@ export const GET: RequestHandler = async ({ locals, url }) => {
try {
const payments = await Payment.find()
.populate('splits')
- .sort({ date: -1 })
+ .sort({ date: -1, createdAt: -1 })
.limit(limit)
.skip(offset)
.lean();
diff --git a/src/routes/cospend/+layout.svelte b/src/routes/cospend/+layout.svelte
index c5d6cce..6573c24 100644
--- a/src/routes/cospend/+layout.svelte
+++ b/src/routes/cospend/+layout.svelte
@@ -24,6 +24,17 @@
paymentId = null;
}
}
+
+ async function handlePaymentDeleted() {
+ // Close the modal
+ showModal = false;
+ paymentId = null;
+
+ // Dispatch a custom event to trigger dashboard refresh
+ if ($page.route.id === '/cospend') {
+ window.dispatchEvent(new CustomEvent('dashboardRefresh'));
+ }
+ }
@@ -36,7 +47,7 @@
{#key paymentId}
-
showModal = false} />
+ showModal = false} on:paymentDeleted={handlePaymentDeleted} />
{/key}
diff --git a/src/routes/cospend/+page.svelte b/src/routes/cospend/+page.svelte
index 4bbc4be..05b07d2 100644
--- a/src/routes/cospend/+page.svelte
+++ b/src/routes/cospend/+page.svelte
@@ -17,12 +17,28 @@
};
let loading = false; // Start as false since we have server data
let error = null;
+
+ // Component references for refreshing
+ let enhancedBalanceComponent;
+ let debtBreakdownComponent;
// Progressive enhancement: refresh data if JavaScript is available
onMount(async () => {
// Mark that JavaScript is loaded for progressive enhancement
document.body.classList.add('js-loaded');
await fetchBalance();
+
+ // Listen for dashboard refresh events from the layout
+ const handleDashboardRefresh = () => {
+ refreshAllComponents();
+ };
+
+ window.addEventListener('dashboardRefresh', handleDashboardRefresh);
+
+ // Cleanup
+ return () => {
+ window.removeEventListener('dashboardRefresh', handleDashboardRefresh);
+ };
});
async function fetchBalance() {
@@ -40,6 +56,22 @@
}
}
+ // Function to refresh all dashboard components after payment deletion
+ async function refreshAllComponents() {
+ // Refresh the main balance and recent activity
+ await fetchBalance();
+
+ // Refresh the enhanced balance component if it exists and has a refresh method
+ if (enhancedBalanceComponent && enhancedBalanceComponent.refresh) {
+ await enhancedBalanceComponent.refresh();
+ }
+
+ // Refresh the debt breakdown component if it exists and has a refresh method
+ if (debtBreakdownComponent && debtBreakdownComponent.refresh) {
+ await debtBreakdownComponent.refresh();
+ }
+ }
+
function formatCurrency(amount) {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
@@ -98,18 +130,17 @@
Track and split expenses with your friends and family
-
+
-
+
{#if loading}
Loading recent activity...
@@ -175,7 +206,7 @@
{:else if split.amount < 0}
+{formatCurrency(split.amount)}
{:else}
- even
+ {formatCurrency(split.amount)}
{/if}
@@ -282,16 +313,6 @@
background-color: #e8e8e8;
}
- .btn-recurring {
- background: linear-gradient(135deg, #9c27b0, #673ab7);
- color: white;
- border: none;
- }
-
- .btn-recurring:hover {
- background: linear-gradient(135deg, #8e24aa, #5e35b1);
- }
-
.btn-settlement {
background: linear-gradient(135deg, #28a745, #20c997);
color: white;
diff --git a/src/routes/cospend/payments/+page.svelte b/src/routes/cospend/payments/+page.svelte
index 4817303..faf82c9 100644
--- a/src/routes/cospend/payments/+page.svelte
+++ b/src/routes/cospend/payments/+page.svelte
@@ -122,6 +122,7 @@
@@ -214,7 +215,7 @@
{:else if split.amount < 0}
owed {formatCurrency(Math.abs(split.amount))}
{:else}
- even
+ owes {formatCurrency(split.amount)}
{/if}
@@ -335,6 +336,16 @@
background-color: #e8e8e8;
}
+ .btn-recurring {
+ background: linear-gradient(135deg, #9c27b0, #673ab7);
+ color: white;
+ border: none;
+ }
+
+ .btn-recurring:hover {
+ background: linear-gradient(135deg, #8e24aa, #5e35b1);
+ }
+
.loading, .error {
text-align: center;
padding: 2rem;
diff --git a/src/routes/cospend/payments/add/+page.server.ts b/src/routes/cospend/payments/add/+page.server.ts
index ff4e8ce..7eabc20 100644
--- a/src/routes/cospend/payments/add/+page.server.ts
+++ b/src/routes/cospend/payments/add/+page.server.ts
@@ -32,6 +32,13 @@ export const actions: Actions = {
const date = formData.get('date')?.toString();
const category = formData.get('category')?.toString() || 'groceries';
const splitMethod = formData.get('splitMethod')?.toString() || 'equal';
+
+ // Recurring payment data
+ const isRecurring = formData.get('isRecurring') === 'true';
+ const recurringFrequency = formData.get('recurringFrequency')?.toString() || 'monthly';
+ const recurringCronExpression = formData.get('recurringCronExpression')?.toString() || '';
+ const recurringStartDate = formData.get('recurringStartDate')?.toString() || '';
+ const recurringEndDate = formData.get('recurringEndDate')?.toString() || '';
// Basic validation
if (!title || amount <= 0 || !paidBy) {
@@ -41,6 +48,16 @@ export const actions: Actions = {
});
}
+ // Recurring payment validation
+ if (isRecurring) {
+ if (recurringFrequency === 'custom' && !recurringCronExpression) {
+ return fail(400, {
+ error: 'Please provide a cron expression for custom recurring payments',
+ values: Object.fromEntries(formData)
+ });
+ }
+ }
+
try {
// Get users from form - either predefined or manual
const users = [];
@@ -83,10 +100,13 @@ export const actions: Actions = {
amount: user === paidBy ? paidByAmount : splitAmount
}));
} else if (splitMethod === 'full') {
- // Payer pays everything, others owe nothing
+ // Payer pays everything, others owe their share of the full amount
+ const otherUsers = users.filter(user => user !== paidBy);
+ const amountPerOtherUser = otherUsers.length > 0 ? amount / otherUsers.length : 0;
+
splits = users.map(user => ({
username: user,
- amount: user === paidBy ? -amount : 0
+ amount: user === paidBy ? -amount : amountPerOtherUser
}));
} else if (splitMethod === 'personal_equal') {
// Get personal amounts from form
@@ -158,8 +178,43 @@ export const actions: Actions = {
});
}
- // Success - redirect to payments list
- throw redirect(303, '/cospend/payments');
+ const paymentResult = await response.json();
+
+ // If this is a recurring payment, create the recurring payment record
+ if (isRecurring) {
+ const recurringPayload = {
+ title,
+ description,
+ amount,
+ paidBy,
+ category,
+ splitMethod,
+ splits,
+ frequency: recurringFrequency,
+ cronExpression: recurringFrequency === 'custom' ? recurringCronExpression : undefined,
+ startDate: recurringStartDate ? new Date(recurringStartDate).toISOString() : new Date().toISOString(),
+ endDate: recurringEndDate ? new Date(recurringEndDate).toISOString() : null,
+ isActive: true,
+ nextExecutionDate: recurringStartDate ? new Date(recurringStartDate).toISOString() : new Date().toISOString()
+ };
+
+ const recurringResponse = await fetch('/api/cospend/recurring-payments', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(recurringPayload)
+ });
+
+ if (!recurringResponse.ok) {
+ // Log the error but don't fail the entire operation since the payment was created
+ console.error('Failed to create recurring payment:', await recurringResponse.text());
+ // Could optionally return a warning to the user
+ }
+ }
+
+ // Success - redirect to dashboard
+ throw redirect(303, '/cospend');
} catch (error) {
if (error.status === 303) throw error; // Re-throw redirect
diff --git a/src/routes/cospend/payments/add/+page.svelte b/src/routes/cospend/payments/add/+page.svelte
index 372d2dd..c78120b 100644
--- a/src/routes/cospend/payments/add/+page.svelte
+++ b/src/routes/cospend/payments/add/+page.svelte
@@ -4,6 +4,7 @@
import { enhance } from '$app/forms';
import { getCategoryOptions } from '$lib/utils/categories';
import { PREDEFINED_USERS, isPredefinedUsersMode } from '$lib/config/users';
+ import { validateCronExpression, getFrequencyDescription, calculateNextExecutionDate } from '$lib/utils/recurring';
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
export let data;
@@ -18,7 +19,16 @@
date: form?.values?.date || new Date().toISOString().split('T')[0],
category: form?.values?.category || 'groceries',
splitMethod: form?.values?.splitMethod || 'equal',
- splits: []
+ splits: [],
+ isRecurring: form?.values?.isRecurring === 'true' || false
+ };
+
+ // Recurring payment settings
+ let recurringData = {
+ frequency: form?.values?.recurringFrequency || 'monthly',
+ cronExpression: form?.values?.recurringCronExpression || '',
+ startDate: form?.values?.recurringStartDate || new Date().toISOString().split('T')[0],
+ endDate: form?.values?.recurringEndDate || ''
};
let imageFile = null;
@@ -31,6 +41,8 @@
let personalTotalError = false;
let predefinedMode = data.predefinedUsers.length > 0;
let jsEnhanced = false;
+ let cronError = false;
+ let nextExecutionPreview = '';
// Initialize users from server data for no-JS support
let users = predefinedMode ? [...data.predefinedUsers] : (data.currentUser ? [data.currentUser] : []);
@@ -172,12 +184,14 @@
if (!formData.amount) return;
const amountNum = parseFloat(formData.amount);
+ const otherUsers = users.filter(user => user !== formData.paidBy);
+ const amountPerOtherUser = otherUsers.length > 0 ? amountNum / otherUsers.length : 0;
users.forEach(user => {
if (user === formData.paidBy) {
splitAmounts[user] = -amountNum; // They paid it all, so they're owed the full amount
} else {
- splitAmounts[user] = 0; // Others don't owe anything
+ splitAmounts[user] = amountPerOtherUser; // Others owe their share of the full amount
}
});
splitAmounts = { ...splitAmounts };
@@ -336,6 +350,46 @@
calculatePersonalEqualSplit();
}
}
+
+ function validateCron() {
+ if (recurringData.frequency !== 'custom') {
+ cronError = false;
+ return;
+ }
+ cronError = !validateCronExpression(recurringData.cronExpression);
+ }
+
+ function updateNextExecutionPreview() {
+ try {
+ if (recurringData.frequency && recurringData.startDate && formData.isRecurring) {
+ const recurringPayment = {
+ ...recurringData,
+ startDate: new Date(recurringData.startDate)
+ };
+ const nextDate = calculateNextExecutionDate(recurringPayment, new Date(recurringData.startDate));
+ nextExecutionPreview = nextDate.toLocaleString('de-CH', {
+ weekday: 'long',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit'
+ });
+ } else {
+ nextExecutionPreview = '';
+ }
+ } catch (e) {
+ nextExecutionPreview = 'Invalid configuration';
+ }
+ }
+
+ $: if (recurringData.cronExpression) {
+ validateCron();
+ }
+
+ $: if (recurringData.frequency || recurringData.cronExpression || recurringData.startDate || formData.isRecurring) {
+ updateNextExecutionPreview();
+ }