// DEPLOY_CONFIG: {"proxy": [{"name": "currency_conversion_proxy", "path": "/api/currency/convert", "method": "GET", "target": {"type": "external_api", "url": "https://api.exchangerate.host/convert"}, "auth": {"type": "secret", "key_name": "EXCHANGE_RATE_API_KEY"}, "request_mapping": {"from": "from", "to": "to", "amount": "amount"}, "response_mapping": {"normalize": "{ success: true, from: data.query.from, to: data.query.to, amount: data.query.amount, result: data.result, rate: data.info.rate, raw: null }"}, "security": {"internal_only": true, "strip_headers": ["authorization", "cookie"]}}], "triggers": [{"name": "budget_spend_threshold_notification", "on": "collection.add", "collection": "spending", "actions": [{"type": "email", "to": "finance@yourcompany.com", "subject": "Budget alert: spending exceeded 80%", "body": "Spending has exceeded 80% of the budget. Please review current expenses and budget allocation."}]}, {"name": "trip_expense_summary_email", "on": "collection.add", "collection": "trip_expenses", "actions": [{"type": "email", "to": "finance@yourcompany.com", "subject": "Trip expenses summary", "body": "A new trip expense was added. Please review the latest trip expenses and current budget status."}]}, {"name": "budget_status_summary_email", "on": "collection.add", "collection": "budget_status", "actions": [{"type": "email", "to": "finance@yourcompany.com", "subject": "Budget status summary", "body": "A new budget status update was recorded. Please review the latest budget summary and spending trends."}]}]}
import { useMemo, useState } from 'react';
import { useAuth, useCollection, renderChart } from '@deplixo/sdk';
import { useEffect, useRef } from 'react';
import { Trips } from './components/Trips.jsx';
import { TripDetail } from './components/TripDetail.jsx';
import { Dashboard } from './components/Dashboard.jsx';
function AnalyticsChart({ type, data, options, className, height = 260 }) {
const canvasRef = useRef(null);
useEffect(() => {
if (!canvasRef.current) return;
if (!data || !data.labels || !data.labels.length) return;
renderChart(canvasRef.current, {
type,
data,
options,
});
}, [type, data, options]);
return (
);
}
function App() {
const { user, loading, login, logout } = useAuth();
const {
items: trips,
loading: tripsLoading,
add: addTrip,
update: updateTrip,
remove: removeTrip,
} = useCollection('trips', { personal: true });
const {
items: expenses,
loading: expensesLoading,
add: addExpense,
update: updateExpense,
remove: removeExpense,
} = useCollection('expenses', { personal: true });
const [activeTab, setActiveTab] = useState('trips');
const [selectedTripId, setSelectedTripId] = useState(null);
const normalizeShareEntries = (value) => {
if (!value) return [];
if (Array.isArray(value)) {
return value
.map((entry) => {
if (!entry) return null;
if (typeof entry === 'string') {
return { email: entry.trim().toLowerCase(), role: 'companion' };
}
const email = String(entry.email || '').trim().toLowerCase();
if (!email) return null;
return {
email,
role: entry.role || 'companion',
invitedAt: entry.invitedAt || null,
acceptedAt: entry.acceptedAt || null,
};
})
.filter(Boolean);
}
if (typeof value === 'object') {
return normalizeShareEntries(Object.values(value));
}
return [];
};
const tripsWithStats = useMemo(() => {
return (trips || []).map((trip) => {
const sharedWith = normalizeShareEntries(trip.sharedWith);
const companionEmails = sharedWith.map((entry) => entry.email);
const tripExpenses = (expenses || []).filter((expense) => expense.tripId === trip.id);
const spent = tripExpenses.reduce((sum, expense) => sum + Number(expense.amount || 0), 0);
const budget = Number(trip.budget || 0);
const remaining = budget - spent;
const percentSpent = budget > 0 ? Math.min((spent / budget) * 100, 999) : 0;
return {
...trip,
sharedWith,
companionEmails,
spent,
remaining,
percentSpent,
expensesCount: tripExpenses.length,
};
});
}, [trips, expenses]);
const selectedTrip = useMemo(() => {
return tripsWithStats.find((trip) => trip.id === selectedTripId) || null;
}, [tripsWithStats, selectedTripId]);
const selectedTripExpenses = useMemo(() => {
return (expenses || [])
.filter((expense) => expense.tripId === selectedTripId)
.sort((a, b) => new Date(b.date || b.createdAt || 0) - new Date(a.date || a.createdAt || 0));
}, [expenses, selectedTripId]);
const analytics = useMemo(() => {
const allTrips = tripsWithStats || [];
const allExpenses = (expenses || []).slice();
const categoryMap = new Map();
const dailyMap = new Map();
const burnDownMap = new Map();
const getExpenseDate = (expense) => {
const raw = expense?.date || expense?.createdAt || null;
const date = raw ? new Date(raw) : null;
return date && !Number.isNaN(date.getTime()) ? date : null;
};
const getTripForExpense = (expense) => allTrips.find((trip) => trip.id === expense.tripId) || null;
allExpenses.forEach((expense) => {
const amount = Number(expense.amount || 0);
const trip = getTripForExpense(expense);
const category = String(expense.category || expense.type || 'Other');
categoryMap.set(category, (categoryMap.get(category) || 0) + amount);
const expenseDate = getExpenseDate(expense);
if (expenseDate) {
const key = expenseDate.toISOString().slice(0, 10);
dailyMap.set(key, (dailyMap.get(key) || 0) + amount);
}
if (trip) {
const start = new Date(trip.startDate || trip.createdAt || expense.date || expense.createdAt || Date.now());
const end = new Date(trip.endDate || trip.createdAt || expense.date || expense.createdAt || Date.now());
const tripStart = !Number.isNaN(start.getTime()) ? start : new Date();
const tripEnd = !Number.isNaN(end.getTime()) ? end : tripStart;
const validExpenseDate = expenseDate || tripStart;
const dayKey = validExpenseDate.toISOString().slice(0, 10);
const totalBudget = Number(trip.budget || 0);
const tripSpent = Number(trip.spent || 0);
const durationDays = Math.max(1, Math.ceil((tripEnd - tripStart) / 86400000) + 1);
const elapsedDays = Math.min(durationDays, Math.max(1, Math.ceil((validExpenseDate - tripStart) / 86400000) + 1));
const expectedBurn = totalBudget > 0 ? (totalBudget / durationDays) * elapsedDays : tripSpent;
const currentBurn = totalBudget > 0 ? Math.min(totalBudget, tripSpent) : tripSpent;
if (!burnDownMap.has(trip.id)) {
burnDownMap.set(trip.id, {
tripId: trip.id,
tripName: trip.destination || trip.name || 'Trip',
points: [],
});
}
const item = burnDownMap.get(trip.id);
item.points.push({
date: dayKey,
expected: expectedBurn,
actual: currentBurn,
budget: totalBudget,
});
}
});
const categoryData = {
labels: Array.from(categoryMap.entries())
.sort((a, b) => b[1] - a[1])
.map(([label]) => label),
datasets: [
{
label: 'Spending by Category',
data: Array.from(categoryMap.entries())
.sort((a, b) => b[1] - a[1])
.map(([, value]) => value),
},
],
};
const dailyEntries = Array.from(dailyMap.entries()).sort((a, b) => new Date(a[0]) - new Date(b[0]));
const dailyData = {
labels: dailyEntries.map(([date]) => date),
datasets: [
{
label: 'Daily Spend',
data: dailyEntries.map(([, value]) => value),
},
],
};
const burnDownData = {
labels: [],
datasets: [
{
label: 'Actual Spend',
data: [],
},
{
label: 'Expected Burn',
data: [],
},
],
};
const selectedBurnTrip = allTrips.find((trip) => trip.id === selectedTripId) || allTrips[0] || null;
if (selectedBurnTrip) {
const tripExpenses = allExpenses
.filter((expense) => expense.tripId === selectedBurnTrip.id)
.sort((a, b) => new Date(a.date || a.createdAt || 0) - new Date(b.date || b.createdAt || 0));
const start = new Date(selectedBurnTrip.startDate || selectedBurnTrip.createdAt || Date.now());
const end = new Date(selectedBurnTrip.endDate || selectedBurnTrip.createdAt || Date.now());
const tripStart = !Number.isNaN(start.getTime()) ? start : new Date();
const tripEnd = !Number.isNaN(end.getTime()) ? end : tripStart;
const durationDays = Math.max(1, Math.ceil((tripEnd - tripStart) / 86400000) + 1);
const budget = Number(selectedBurnTrip.budget || 0);
let runningActual = 0;
const burnPoints = [];
for (let i = 0; i < durationDays; i += 1) {
const currentDate = new Date(tripStart);
currentDate.setDate(currentDate.getDate() + i);
const dayKey = currentDate.toISOString().slice(0, 10);
const daySpend = tripExpenses
.filter((expense) => (expense.date || expense.createdAt || '').slice(0, 10) === dayKey)
.reduce((sum, expense) => sum + Number(expense.amount || 0), 0);
runningActual += daySpend;
const expected = budget > 0 ? Math.min(budget, (budget / durationDays) * (i + 1)) : runningActual;
burnPoints.push({ label: dayKey, actual: runningActual, expected });
}
burnDownData.labels = burnPoints.map((p) => p.label);
burnDownData.datasets[0].data = burnPoints.map((p) => p.actual);
burnDownData.datasets[1].data = burnPoints.map((p) => p.expected);
}
return {
categoryData,
dailyData,
burnDownData,
};
}, [tripsWithStats, expenses, selectedTripId]);
const handleSelectTrip = (tripId) => {
setSelectedTripId(tripId);
setActiveTab('detail');
};
const handleBack = () => {
setSelectedTripId(null);
setActiveTab('trips');
};
const handleSignIn = async () => {
if (login) {
await login('google');
}
};
const collectionActions = {
addTrip,
updateTrip,
removeTrip,
addExpense,
updateExpense,
removeExpense,
};
if (loading) {
return (
βοΈ
Signing you in...
Please wait while we connect your account.
);
}
if (!user) {
return (
βοΈ Travel Budget
Plan smart, travel more
π
Sign in to manage your trips
Use Google sign-in to keep your trips private and accessible across devices.
);
}
return (
βοΈ Travel Budget
Plan smart, travel more
{user.avatar ?

:
{user.name?.[0] || 'U'}
}
{user.name}
{user.email}
{activeTab !== 'detail' && (
)}
{activeTab === 'trips' && (
)}
{activeTab === 'detail' && (
)}
{activeTab === 'dashboard' && (
Spending Analytics
Spending by Category
{analytics.categoryData.labels.length > 0 ? (
) : (
No category spending yet
Add expenses to see category breakdown.
)}
Daily Spend Rate
{analytics.dailyData.labels.length > 0 ? (
) : (
No daily spending data
Your expenses will appear here over time.
)}
Budget Burn-down
{analytics.burnDownData.labels.length > 0 ? (
) : (
No trip selected for burn-down
The chart uses the first trip with a budget, including shared trip history where applicable.
)}
)}
);
}
// PROGRESS:sc_001:complete:Setting up travel budget planner
ReactDOM.createRoot(document.getElementById("root")).render();