// 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} :
{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();