diff --git a/.Jules/JULES_PROMPT.md b/.Jules/JULES_PROMPT.md index 7c5b5a19..ea8c50e8 100644 --- a/.Jules/JULES_PROMPT.md +++ b/.Jules/JULES_PROMPT.md @@ -1,27 +1,31 @@ # Jules Scheduled Task - UI/UX Enhancement Agent ## Agent Identity + You are an autonomous UI/UX enhancement agent for the Splitwiser expense-splitting application. Your task is to make **meaningful, focused improvements** to the codebase while maintaining perfect backwards compatibility. Each change should be noticeable to users and align with the project's goal of providing exceptional expense-splitting UX. -**Core Principle:** Quality over quantity. One well-executed, complete feature is better than multiple half-baked changes. +**Core Principle:** Quality over quantity. Before starting, you must ensure you are not duplicating work already present in the repository's open Pull Requests. --- ## Project Context ### Tech Stack + - **Web App** (`/web`): React + Vite + TypeScript + TailwindCSS + Framer Motion - Uses dual theming system: `GLASSMORPHISM` and `NEOBRUTALISM` - Components in `/web/components/ui/` (Button, Card, Input, Modal, Skeleton) - Pages in `/web/pages/` (Auth, Dashboard, Groups, GroupDetails, Friends, Profile) - Context providers for Auth and Theme - - **Mobile App** (`/mobile`): Expo/React Native + React Native Paper - Screens in `/mobile/screens/` - Navigation in `/mobile/navigation/` - API services in `/mobile/api/` +- **Backend** (`/backend`): Python/FastAPI + ### Design Philosophy + - Web uses modern design with two switchable themes (glassmorphism for elegance, neobrutalism for bold) - Mobile uses React Native Paper with Material Design principles - Both prioritize accessibility and responsive design @@ -30,67 +34,72 @@ You are an autonomous UI/UX enhancement agent for the Splitwiser expense-splitti ## Your Task Instructions -### CRITICAL: Before Making ANY Changes +### CRITICAL: Before Making ANY Changes (Anti-Duplicate Protocol) + +1. **Check Live Pull Requests (DO THIS FIRST!)** + You must verify that the task you intend to pick is not already being addressed by another contributor. + + ```bash + # Get a list of all open PRs to check for duplicate topics + gh pr list --state open --limit 50 + ``` + + Alternatively, check the live status at: https://github.com/Devasy/splitwiser/pulls + +2. **Verify Project Tooling** -1. **Verify Project Tooling** (DO THIS FIRST!) ```bash # Check package.json to determine package manager # Look for package-lock.json (npm), yarn.lock (yarn), or pnpm-lock.yaml (pnpm) # This project uses: npm (confirmed by package-lock.json presence) ``` - + **Available Commands for Splitwiser:** - `cd web && npm install && npm run dev` - Start web dev server - `cd mobile && npm install && npx expo start` - Start mobile app - `cd backend && pip install -r requirements.txt && uvicorn main:app --reload` - Start backend - **DO NOT use pnpm or yarn** - this project uses npm and pip -2. **Understand Project Direction** - - Read `README.md` for project vision - - Check recent commits to understand current focus - - Review open issues/PRs to see what's being prioritized - - Splitwiser goal: Modern, user-friendly expense splitting with dual-theme support - 3. **Read the tracking files** in `.Jules/`: - - `knowledge.md` - Understand past learnings and avoid repeating mistakes - - `todo.md` - Check for queued tasks to pick up - - `changelog.md` - Review recent changes for context + - `knowledge.md` - Past learnings + - `todo.md` - Queued tasks + - `changelog.md` - Recent changes + +--- + +## Task Selection Guide -4. **Analyze current state** of `web/` and `mobile/` folders +**Pick ONE complete feature from `todo.md` that meets these criteria:** + +- **NO DUPLICATES:** The task must not have an open PR with a similar title or description on GitHub +- **User Impact:** Users will immediately notice and appreciate the change +- **Completeness:** Represents a complete system (component + integration) + +**If you find a duplicate PR for your intended task:** + +- Do NOT start the task +- Move to the next item in `todo.md` +- If the `todo.md` item is blocked by many duplicates, notify the user or pick a different "Priority Area" ### Priority Areas **Focus on complete systems in these areas:** 🔴 **High-Impact** + - Error boundaries and error handling -- Loading states (skeletons for remaining pages) -- Keyboard navigation (for pages that lack it) +- Loading states (skeletons) +- Keyboard navigation - Confirmation dialogs for destructive actions -🟡 **Polish** +🟡 **Polish** + - Form enhancements (password strength, file upload) - Hover/focus states consistency - Responsive design improvements -- Animation polish - -🟢 **Enhancement** -- Pull-to-refresh (mobile) -- Advanced interactions (swipe gestures, haptics) -- Offline support -- Performance optimizations - ---- - -## Task Selection Guide - -**Pick ONE complete feature from `todo.md` that:** -- Users will immediately notice and appreciate -- Represents a complete system (not a fragment) -- Aligns with expense-splitting UX excellence -- Can be fully implemented and integrated **Examples of complete features:** + - Error boundary system (component + integration + styling) - Confirmation dialog system (component + context + usage) - Complete keyboard navigation for a page @@ -98,6 +107,7 @@ You are an autonomous UI/UX enhancement agent for the Splitwiser expense-splitti - Image upload with preview and crop **Not complete enough:** + - Just adding one ARIA label - Only styling one button - Creating component without using it @@ -109,6 +119,7 @@ You are an autonomous UI/UX enhancement agent for the Splitwiser expense-splitti **Task:** Add error boundary with retry mechanism **1. Plan complete implementation:** + - Create ErrorBoundary component with dual-theme support - Add error state UI with retry button - Wrap App in ErrorBoundary @@ -116,30 +127,32 @@ You are an autonomous UI/UX enhancement agent for the Splitwiser expense-splitti - Ensure accessibility **2. Implement:** + - Create `web/components/ErrorBoundary.tsx` - Update `web/App.tsx` to wrap with ErrorBoundary - Test error scenarios - Verify both themes and dark mode **3. Update tracking:** + - Mark task complete in `todo.md` - Document pattern in `knowledge.md` if needed - Log change in `changelog.md` --- - - ## Core Principles ### ✅ DO + - **Complete systems over fragments** - Implement full features with all integration points - **Use semantic HTML first** - Prefer ` + + + You'll be redirected to Splitwise to authorize access + + + + + + } + /> + + } + /> + } + /> + } + /> + } + /> + + + + + } + /> + + + After authorizing in your browser, please return to the app. + + + The import will start automatically and may take a few minutes. + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + flex: 1, + padding: 16, + }, + card: { + marginBottom: 16, + }, + title: { + marginBottom: 8, + textAlign: "center", + }, + subtitle: { + marginBottom: 24, + textAlign: "center", + opacity: 0.7, + }, + input: { + marginBottom: 8, + }, + helperText: { + marginBottom: 24, + opacity: 0.7, + }, + link: { + color: "#2196F3", + }, + progressContainer: { + marginBottom: 24, + }, + progressHeader: { + flexDirection: "row", + justifyContent: "space-between", + marginBottom: 8, + }, + progressText: { + fontWeight: "bold", + }, + progressBar: { + height: 8, + borderRadius: 4, + }, + button: { + paddingVertical: 8, + }, + infoCard: { + marginBottom: 16, + backgroundColor: "#E3F2FD", + }, + warningCard: { + marginBottom: 16, + backgroundColor: "#FFF3E0", + }, + warningText: { + marginBottom: 4, + }, +}); + +export default SplitwiseImportScreen; diff --git a/web/App.tsx b/web/App.tsx index dfc2041e..7a735389 100644 --- a/web/App.tsx +++ b/web/App.tsx @@ -1,26 +1,34 @@ -import React from 'react'; -import { HashRouter, Navigate, Route, Routes } from 'react-router-dom'; -import { Layout } from './components/layout/Layout'; -import { ThemeWrapper } from './components/layout/ThemeWrapper'; -import { AuthProvider, useAuth } from './contexts/AuthContext'; -import { ThemeProvider } from './contexts/ThemeContext'; -import { ToastProvider } from './contexts/ToastContext'; -import { ConfirmProvider } from './contexts/ConfirmContext'; -import { ToastContainer } from './components/ui/Toast'; -import { ErrorBoundary } from './components/ErrorBoundary'; -import { Auth } from './pages/Auth'; -import { Dashboard } from './pages/Dashboard'; -import { Friends } from './pages/Friends'; -import { GroupDetails } from './pages/GroupDetails'; -import { Groups } from './pages/Groups'; -import { Profile } from './pages/Profile'; +import React from "react"; +import { HashRouter, Navigate, Route, Routes } from "react-router-dom"; +import { ErrorBoundary } from "./components/ErrorBoundary"; +import { Layout } from "./components/layout/Layout"; +import { ThemeWrapper } from "./components/layout/ThemeWrapper"; +import { ToastContainer } from "./components/ui/Toast"; +import { AuthProvider, useAuth } from "./contexts/AuthContext"; +import { ConfirmProvider } from "./contexts/ConfirmContext"; +import { ThemeProvider } from "./contexts/ThemeContext"; +import { ToastProvider } from "./contexts/ToastContext"; +import { Auth } from "./pages/Auth"; +import { Dashboard } from "./pages/Dashboard"; +import { Friends } from "./pages/Friends"; +import { GroupDetails } from "./pages/GroupDetails"; +import { Groups } from "./pages/Groups"; +import { Profile } from "./pages/Profile"; +import { SplitwiseCallback } from "./pages/SplitwiseCallback"; +import { SplitwiseGroupSelection } from "./pages/SplitwiseGroupSelection"; +import { SplitwiseImport } from "./pages/SplitwiseImport"; // Protected Route Wrapper const ProtectedRoute = ({ children }: { children: React.ReactElement }) => { const { isAuthenticated, isLoading } = useAuth(); - - if (isLoading) return
Loading...
; - + + if (isLoading) + return ( +
+ Loading... +
+ ); + if (!isAuthenticated) { return ; } @@ -29,23 +37,107 @@ const ProtectedRoute = ({ children }: { children: React.ReactElement }) => { }; const AppRoutes = () => { - const { isAuthenticated } = useAuth(); - - return ( - - : } /> - : } /> - - } /> - } /> - } /> - } /> - } /> - - } /> - - ); -} + const { isAuthenticated } = useAuth(); + + return ( + + + + + ) : ( + + ) + } + /> + + + + ) : ( + + ) + } + /> + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + } + /> + + ); +}; const App = () => { return ( @@ -54,10 +146,10 @@ const App = () => { - - - - + + + + @@ -66,4 +158,4 @@ const App = () => { ); }; -export default App; \ No newline at end of file +export default App; diff --git a/web/components/AnalyticsContent.tsx b/web/components/AnalyticsContent.tsx new file mode 100644 index 00000000..d672eb8f --- /dev/null +++ b/web/components/AnalyticsContent.tsx @@ -0,0 +1,376 @@ +import { Calendar, PieChart as PieChartIcon, TrendingUp } from 'lucide-react'; +import React from 'react'; +import { + Area, + AreaChart, + Cell, + Legend, + Line, + LineChart, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis +} from 'recharts'; +import { THEMES } from '../constants'; +import { useTheme } from '../contexts/ThemeContext'; +import { GroupAnalytics } from '../types'; +import { formatCurrency } from '../utils/formatters'; +import { Button } from './ui/Button'; +import { Skeleton } from './ui/Skeleton'; + +interface AnalyticsContentProps { + analytics: GroupAnalytics | null; + groupCurrency: string; + timeframe: 'month' | '6months' | 'year'; + onTimeframeChange: (timeframe: 'month' | '6months' | 'year') => void; + selectedYear: number; + selectedMonth: number; + onYearChange: (year: number) => void; + onMonthChange: (month: number) => void; +} + +export const AnalyticsContent: React.FC = ({ + analytics, + groupCurrency, + timeframe, + onTimeframeChange, + selectedYear, + selectedMonth, + onYearChange, + onMonthChange +}) => { + const { style, mode } = useTheme(); + + // Generate year options (last 5 years) + const currentYear = new Date().getFullYear(); + const yearOptions = Array.from({ length: 5 }, (_, i) => currentYear - i); + + // Month options + const monthOptions = [ + { value: 1, label: 'January' }, + { value: 2, label: 'February' }, + { value: 3, label: 'March' }, + { value: 4, label: 'April' }, + { value: 5, label: 'May' }, + { value: 6, label: 'June' }, + { value: 7, label: 'July' }, + { value: 8, label: 'August' }, + { value: 9, label: 'September' }, + { value: 10, label: 'October' }, + { value: 11, label: 'November' }, + { value: 12, label: 'December' }, + ]; + + if (!analytics) { + return ( +
+ + +
+ ); + } + + return ( + <> + {/* Timeframe Filter */} +
+
+
+ + Select Timeframe: +
+ + {/* Timeframe Type Selector */} +
+ + + +
+ + {/* Date Selectors */} +
+ {timeframe === 'month' && ( + <> + + + + )} + + {timeframe === 'year' && ( + + )} + + {timeframe === '6months' && ( +

+ Showing last 6 months from today +

+ )} +
+
+
+ + {/* Summary Stats */} +
+
+

Total Expenses

+

{formatCurrency(analytics.totalExpenses, groupCurrency)}

+

{analytics.expenseCount} transactions

+
+
+

Average Expense

+

{formatCurrency(analytics.avgExpenseAmount, groupCurrency)}

+
+
+

Period

+

{analytics.period}

+
+
+ + {/* Charts Row */} +
+ {/* Spending by Category - Pie Chart */} +
+

+ + Spending by Category +

+ {analytics.topCategories.length > 0 ? ( +
+ + + `${category}: ${percentage.toFixed(1)}%`} + outerRadius={80} + fill="#8884d8" + dataKey="amount" + nameKey="category" + stroke={style === THEMES.NEOBRUTALISM ? 'black' : 'none'} + strokeWidth={style === THEMES.NEOBRUTALISM ? 2 : 0} + > + {analytics.topCategories.map((entry, index) => ( + + ))} + + formatCurrency(value, groupCurrency)} + contentStyle={{ + backgroundColor: mode === 'dark' ? '#333' : '#fff', + borderRadius: style === THEMES.GLASSMORPHISM ? '12px' : '0px', + border: style === THEMES.NEOBRUTALISM ? '2px solid black' : 'none', + }} + /> + `${value} (${entry.payload.count})`} + wrapperStyle={{ fontSize: '12px' }} + /> + + +
+ ) : ( +
+

No category data available

+
+ )} +
+ + {/* Spending Trends - Area Chart */} +
+

+ + Spending Trends +

+ {analytics.expenseTrends.length > 0 ? ( +
+ + + + + + + + + new Date(value).getDate().toString()} + /> + + [formatCurrency(value, groupCurrency), 'Amount']} + labelFormatter={(label) => new Date(label).toLocaleDateString()} + contentStyle={{ + backgroundColor: mode === 'dark' ? '#333' : '#fff', + borderRadius: style === THEMES.GLASSMORPHISM ? '12px' : '0px', + border: style === THEMES.NEOBRUTALISM ? '2px solid black' : 'none', + }} + /> + + + +
+ ) : ( +
+

No trend data available

+
+ )} +
+
+ + {/* Member Contributions Timeline */} +
+

+ + Member Contributions Over Time +

+ {analytics.contributionTimeline && analytics.contributionTimeline.length > 0 ? ( +
+ + + { + const date = new Date(value); + return `${date.getMonth() + 1}/${date.getDate()}`; + }} + /> + `${groupCurrency} ${value.toLocaleString()}`} + /> + [formatCurrency(value, groupCurrency), name]} + labelFormatter={(label) => new Date(label).toLocaleDateString()} + contentStyle={{ + backgroundColor: mode === 'dark' ? '#333' : '#fff', + borderRadius: style === THEMES.GLASSMORPHISM ? '12px' : '0px', + border: style === THEMES.NEOBRUTALISM ? '2px solid black' : 'none', + }} + /> + + + {/* Total Expenses Line - Thicker and distinct */} + + + {/* Individual Member Lines */} + {analytics.memberContributions.map((member, idx) => { + const colors = ['#10b981', '#3b82f6', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4', '#84cc16', '#f97316']; + const color = colors[idx % colors.length]; + return ( + + ); + })} + + +
+ ) : ( +
+

No member contribution data available

+
+ )} +
+ + ); +}; diff --git a/web/constants.ts b/web/constants.ts index 1fe00efd..c6751177 100644 --- a/web/constants.ts +++ b/web/constants.ts @@ -10,3 +10,11 @@ export const COLORS = [ '#1A535C', // Dark Teal '#F7FFF7', // Off White ]; + +export const CURRENCIES = { + USD: { symbol: '$', name: 'US Dollar', code: 'USD' }, + INR: { symbol: '₹', name: 'Indian Rupee', code: 'INR' }, + EUR: { symbol: '€', name: 'Euro', code: 'EUR' }, +} as const; + +export type CurrencyCode = keyof typeof CURRENCIES; diff --git a/web/pages/Dashboard.tsx b/web/pages/Dashboard.tsx index 4ee98c22..fc0b5650 100644 --- a/web/pages/Dashboard.tsx +++ b/web/pages/Dashboard.tsx @@ -1,101 +1,238 @@ -import { DollarSign, TrendingDown, TrendingUp } from 'lucide-react'; -import { useEffect, useState } from 'react'; -import { Bar, BarChart, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; -import { DashboardSkeleton } from '../components/skeletons/DashboardSkeleton'; -import { Card } from '../components/ui/Card'; -import { THEMES } from '../constants'; -import { useTheme } from '../contexts/ThemeContext'; -import { getBalanceSummary } from '../services/api'; -import { BalanceSummary } from '../types'; +import { + ArrowRight, + DollarSign, + PieChart as PieChartIcon, + TrendingDown, + TrendingUp, + Users, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + Bar, + BarChart, + Cell, + Legend, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { DashboardSkeleton } from "../components/skeletons/DashboardSkeleton"; +import { Card } from "../components/ui/Card"; +import { THEMES } from "../constants"; +import { useTheme } from "../contexts/ThemeContext"; +import { + getBalanceSummary, + getFriendsBalance, + getGroups, +} from "../services/api"; +import { BalanceSummary, FriendBalance, Group } from "../types"; + +// Color palette for charts +const CHART_COLORS = [ + "#10b981", // emerald + "#3b82f6", // blue + "#f59e0b", // amber + "#ef4444", // red + "#8b5cf6", // violet + "#ec4899", // pink + "#06b6d4", // cyan + "#84cc16", // lime +]; + +interface GroupWithBalance extends Group { + userBalance?: number; +} export const Dashboard = () => { const [summary, setSummary] = useState(null); + const [friends, setFriends] = useState([]); + const [groups, setGroups] = useState([]); const [loading, setLoading] = useState(true); const { style, mode } = useTheme(); + const navigate = useNavigate(); useEffect(() => { - const fetchSummary = async () => { + const fetchData = async () => { try { - const res = await getBalanceSummary(); - setSummary(res.data); + const [summaryRes, friendsRes, groupsRes] = await Promise.all([ + getBalanceSummary(), + getFriendsBalance(), + getGroups(), + ]); + setSummary(summaryRes.data); + setFriends(friendsRes.data.friendsBalance || []); + + // Merge groups with their balance from summary + const groupsWithBalance = (groupsRes.data.groups || []).map( + (group: Group) => { + const groupBalance = summaryRes.data.groupsSummary?.find( + (g: any) => g.group_id === group._id, + ); + return { + ...group, + userBalance: groupBalance?.yourBalanceInGroup || 0, + }; + }, + ); + setGroups(groupsWithBalance); } catch (err) { console.error(err); } finally { setLoading(false); } }; - fetchSummary(); + fetchData(); }, []); if (loading) return ; - const chartData = [ - { name: 'Owed To You', value: summary?.totalOwedToYou || 0, color: '#10b981' }, // emerald-500 - { name: 'You Owe', value: summary?.totalYouOwe || 0, color: '#ef4444' }, // red-500 + // Prepare data for charts + const balanceChartData = [ + { + name: "Owed To You", + value: summary?.totalOwedToYou || 0, + color: "#10b981", + }, + { name: "You Owe", value: summary?.totalYouOwe || 0, color: "#ef4444" }, ]; + // Group balances for pie chart (only groups with non-zero balance) + const groupBalanceData = groups + .filter((g) => Math.abs(g.userBalance || 0) > 0.01) + .map((group, index) => ({ + name: group.name, + value: Math.abs(group.userBalance || 0), + isPositive: (group.userBalance || 0) >= 0, + color: CHART_COLORS[index % CHART_COLORS.length], + })); + + // Friends balance distribution + const friendsBalanceData = friends + .filter((f) => Math.abs(f.netBalance) > 0.01) + .slice(0, 8) + .map((friend, index) => ({ + name: friend.userName, + value: Math.abs(friend.netBalance), + isPositive: friend.netBalance > 0, + color: friend.netBalance > 0 ? "#10b981" : "#ef4444", + })); + + // Custom tooltip for pie charts + const CustomPieTooltip = ({ active, payload }: any) => { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +
+

{data.name}

+

+ {data.isPositive ? "+" : "-"}${data.value.toFixed(2)} +

+
+ ); + } + return null; + }; + + const cardClasses = + style === THEMES.NEOBRUTALISM + ? "border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]" + : ""; + return (
+ {/* Summary Cards */}
-
+

Owed to You

-

+

${(summary?.totalOwedToYou ?? 0).toFixed(2)}

-
+

You Owe

-

+

${(summary?.totalYouOwe ?? 0).toFixed(2)}

-
+

Net Balance

-

= 0 ? (style === THEMES.NEOBRUTALISM ? 'text-black' : 'text-emerald-500') : (style === THEMES.NEOBRUTALISM ? 'text-black' : 'text-red-500')}`}> +

= 0 ? (style === THEMES.NEOBRUTALISM ? "text-black" : "text-emerald-500") : style === THEMES.NEOBRUTALISM ? "text-black" : "text-red-500"}`} + > ${(summary?.netBalance ?? 0).toFixed(2)}

+ {/* Charts Row 1: Balance Overview + Group Distribution */}
- + - - {chartData.map((entry, index) => ( - + + {balanceChartData.map((entry, index) => ( + ))} @@ -103,13 +240,200 @@ export const Dashboard = () => {
- -
-

No recent activity data available in summary view.

-

Check specific groups for details.

+ + {groupBalanceData.length > 0 ? ( +
+ + + + {groupBalanceData.map((entry, index) => ( + + ))} + + } /> + ( + {value} + )} + wrapperStyle={{ fontSize: "12px" }} + /> + + +
+ ) : ( +
+ +

No group balances to display

+
+ )} +
+
+ + {/* Charts Row 2: Friends Balance + Quick Actions */} +
+ + {friendsBalanceData.length > 0 ? ( +
+ + + + + [ + `$${value.toFixed(2)}`, + props.payload.isPositive ? "Owes You" : "You Owe", + ]} + /> + + {friendsBalanceData.map((entry, index) => ( + + ))} + + + +
+ ) : ( +
+ +

No friend balances to display

+
+ )} +
+ + +
+ {/* Groups with balances */} + {groups + .filter((g) => Math.abs(g.userBalance || 0) > 0.01) + .slice(0, 5) + .map((group) => ( + + ))} + + {groups.filter((g) => Math.abs(g.userBalance || 0) > 0.01) + .length === 0 && ( +
+

All groups are settled up!

+
+ )}
+ + {/* Individual vs Group Totals */} + {summary?.groupsSummary && summary.groupsSummary.length > 0 && ( + +

+ Compare your personal share against total group spending +

+
+ {summary.groupsSummary.map((groupSum: any, index: number) => { + const group = groups.find((g) => g._id === groupSum.group_id); + if (!group) return null; + + const yourBalance = Math.abs(groupSum.yourBalanceInGroup); + const isPositive = groupSum.yourBalanceInGroup >= 0; + + return ( + + ); + })} +
+
+ )}
); -}; \ No newline at end of file +}; diff --git a/web/pages/Friends.tsx b/web/pages/Friends.tsx index 931f187d..039eb5db 100644 --- a/web/pages/Friends.tsx +++ b/web/pages/Friends.tsx @@ -5,6 +5,7 @@ import { EmptyState } from '../components/ui/EmptyState'; import { THEMES } from '../constants'; import { useTheme } from '../contexts/ThemeContext'; import { getFriendsBalance, getGroups } from '../services/api'; +import { formatCurrency } from '../utils/formatters'; interface GroupBreakdown { groupId: string; @@ -91,8 +92,8 @@ export const Friends = () => { const totalOwedToYou = friends.reduce((acc, curr) => curr.netBalance > 0 ? acc + curr.netBalance : acc, 0); const totalYouOwe = friends.reduce((acc, curr) => curr.netBalance < 0 ? acc + Math.abs(curr.netBalance) : acc, 0); - const formatCurrency = (amount: number) => { - return `$${Math.abs(amount).toFixed(2)}`; + const formatPrice = (amount: number) => { + return formatCurrency(Math.abs(amount)); }; const getAvatarContent = (imageUrl: string | undefined, name: string, size: 'sm' | 'lg' = 'lg') => { @@ -149,8 +150,8 @@ export const Friends = () => { value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className={`pl-12 pr-4 py-4 outline-none transition-all w-full md:w-80 font-bold ${isNeo - ? 'bg-white border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] focus:translate-x-[2px] focus:translate-y-[2px] focus:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] rounded-none placeholder:text-black/40' - : 'bg-white/10 border border-white/20 focus:bg-white/20 focus:border-white/30 backdrop-blur-md rounded-2xl text-white placeholder:text-white/40' + ? 'bg-white border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] focus:translate-x-[2px] focus:translate-y-[2px] focus:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] rounded-none placeholder:text-black/40' + : 'bg-white/10 border border-white/20 focus:bg-white/20 focus:border-white/30 backdrop-blur-md rounded-2xl text-white placeholder:text-white/40' }`} />
@@ -164,13 +165,13 @@ export const Friends = () => { animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }} className={`p-6 flex items-center justify-between ${isNeo - ? 'bg-emerald-100 border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] rounded-none' - : 'bg-emerald-500/10 border border-emerald-500/20 rounded-3xl' + ? 'bg-emerald-100 border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] rounded-none' + : 'bg-emerald-500/10 border border-emerald-500/20 rounded-3xl' }`} >

Total Owed to You

-

{formatCurrency(totalOwedToYou)}

+

{formatPrice(totalOwedToYou)}

@@ -182,13 +183,13 @@ export const Friends = () => { animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} className={`p-6 flex items-center justify-between ${isNeo - ? 'bg-orange-100 border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] rounded-none' - : 'bg-orange-500/10 border border-orange-500/20 rounded-3xl' + ? 'bg-orange-100 border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] rounded-none' + : 'bg-orange-500/10 border border-orange-500/20 rounded-3xl' }`} >

Total You Owe

-

{formatCurrency(totalYouOwe)}

+

{formatPrice(totalYouOwe)}

@@ -204,7 +205,7 @@ export const Friends = () => { className={`p-4 flex items-center justify-between ${isNeo ? 'bg-red-100 border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] rounded-none' : 'bg-red-500/10 border border-red-500/20 rounded-2xl' - }`} + }`} >

{error}

@@ -243,24 +244,24 @@ export const Friends = () => { exit={{ opacity: 0, scale: 0.9 }} transition={{ delay: index * 0.05 }} className={`group relative overflow-hidden flex flex-col transition-all duration-300 ${isNeo - ? 'bg-white border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:-translate-y-1 hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] rounded-none' - : 'bg-white/5 border border-white/10 hover:bg-white/10 hover:border-white/20 backdrop-blur-sm rounded-3xl' + ? 'bg-white border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:-translate-y-1 hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] rounded-none' + : 'bg-white/5 border border-white/10 hover:bg-white/10 hover:border-white/20 backdrop-blur-sm rounded-3xl' }`} > @@ -294,7 +295,7 @@ export const Friends = () => { {g.groupName}
0 ? 'text-emerald-500' : g.balance < 0 ? 'text-orange-500' : 'opacity-50'}`}> - {g.balance > 0 ? '+' : g.balance < 0 ? '-' : ''}{formatCurrency(g.balance)} + {g.balance > 0 ? '+' : g.balance < 0 ? '-' : ''}{formatPrice(g.balance)}
))} @@ -302,8 +303,8 @@ export const Friends = () => {

No active groups

)} diff --git a/web/pages/GroupDetails.tsx b/web/pages/GroupDetails.tsx index 1d2da335..b2b76c25 100644 --- a/web/pages/GroupDetails.tsx +++ b/web/pages/GroupDetails.tsx @@ -1,12 +1,13 @@ import { AnimatePresence, motion } from 'framer-motion'; -import { ArrowRight, Banknote, Check, Copy, DollarSign, Hash, Layers, LogOut, PieChart, Plus, Receipt, Settings, Share2, Trash2, UserMinus } from 'lucide-react'; +import { ArrowRight, Banknote, Check, Copy, DollarSign, Hash, Layers, LogOut, PieChart, Plus, Receipt, Search, Settings, Share2, Trash2, UserMinus } from 'lucide-react'; import React, { useEffect, useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; +import { AnalyticsContent } from '../components/AnalyticsContent'; import { Button } from '../components/ui/Button'; import { Input } from '../components/ui/Input'; import { Modal } from '../components/ui/Modal'; import { Skeleton } from '../components/ui/Skeleton'; -import { THEMES } from '../constants'; +import { CURRENCIES, THEMES } from '../constants'; import { useAuth } from '../contexts/AuthContext'; import { useConfirm } from '../contexts/ConfirmContext'; import { useTheme } from '../contexts/ThemeContext'; @@ -17,6 +18,7 @@ import { deleteExpense, deleteGroup, getExpenses, + getGroupAnalytics, getGroupDetails, getGroupMembers, getOptimizedSettlements, @@ -24,7 +26,8 @@ import { updateExpense, updateGroup } from '../services/api'; -import { Expense, Group, GroupMember, SplitType } from '../types'; +import { Expense, Group, GroupAnalytics, GroupMember, SplitType } from '../types'; +import { formatCurrency } from '../utils/formatters'; type UnequalMode = 'amount' | 'percentage' | 'shares'; @@ -46,10 +49,23 @@ export const GroupDetails = () => { const [group, setGroup] = useState(null); const [expenses, setExpenses] = useState([]); + const [totalSummary, setTotalSummary] = useState(null); // Summary for ALL expenses in group const [members, setMembers] = useState([]); const [settlements, setSettlements] = useState([]); const [loading, setLoading] = useState(true); - const [activeTab, setActiveTab] = useState<'expenses' | 'settlements'>('expenses'); + const [activeTab, setActiveTab] = useState<'expenses' | 'settlements' | 'analytics'>('expenses'); + + // Search and Filter State + const [searchQuery, setSearchQuery] = useState(''); + const [analytics, setAnalytics] = useState(null); + const [timeframe, setTimeframe] = useState<'month' | '6months' | 'year'>('month'); + const [selectedYear, setSelectedYear] = useState(new Date().getFullYear()); + const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth() + 1); + + // Pagination State + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); // Modals const [isExpenseModalOpen, setIsExpenseModalOpen] = useState(false); @@ -74,6 +90,7 @@ export const GroupDetails = () => { // Group Settings State const [editGroupName, setEditGroupName] = useState(''); + const [editGroupCurrency, setEditGroupCurrency] = useState('USD'); const [settingsTab, setSettingsTab] = useState<'info' | 'members' | 'danger'>('info'); const [copied, setCopied] = useState(false); @@ -83,6 +100,39 @@ export const GroupDetails = () => { return me?.role === 'admin'; }, [members, user?._id]); + // Calculate group totals using totalSummary (for ALL expenses, not filtered) + const groupTotals = useMemo(() => { + if (!totalSummary) { + // Fallback to calculating from current expenses if totalSummary not available yet + const totalSpent = expenses.reduce((sum, e) => sum + e.amount, 0); + const myContribution = expenses + .filter(e => e.paidBy === user?._id) + .reduce((sum, e) => sum + e.amount, 0); + const myShare = expenses.reduce((sum, e) => { + const mySplit = e.splits.find(s => s.userId === user?._id); + return sum + (mySplit?.amount || 0); + }, 0); + const netBalance = myContribution - myShare; + + return { + totalSpent, + myContribution, + myShare, + netBalance, + expenseCount: expenses.length + }; + } + + // Use totalSummary from backend (includes ALL expenses) + return { + totalSpent: totalSummary.totalAmount || 0, + myContribution: 0, // This would need to be added to backend summary + myShare: 0, // This would need to be added to backend summary + netBalance: 0, // This would need to be added to backend summary + expenseCount: totalSummary.expenseCount || 0 + }; + }, [totalSummary, expenses, user?._id]); + useEffect(() => { if (id) fetchData(); }, [id]); @@ -102,7 +152,25 @@ export const GroupDetails = () => { if (other) setPaymentPayeeId(other.userId); } } - }, [members, group, user, editingExpenseId]); + }, [members, group, user, editingExpenseId, payerId, paymentPayerId, paymentPayeeId]); + + // Search with debounce + const [debouncedSearch, setDebouncedSearch] = useState(''); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedSearch(searchQuery); + }, 300); // 300ms debounce + + return () => clearTimeout(handler); + }, [searchQuery]); + + // Refetch when search changes + useEffect(() => { + if (id && !loading) { + fetchData(); + } + }, [debouncedSearch]); const fetchData = async () => { if (!id) return; @@ -110,15 +178,23 @@ export const GroupDetails = () => { try { const [groupRes, expRes, memRes, setRes] = await Promise.all([ getGroupDetails(id), - getExpenses(id), + getExpenses(id, 1, 20, debouncedSearch || undefined), getGroupMembers(id), getOptimizedSettlements(id) ]); setGroup(groupRes.data); setExpenses(expRes.data.expenses); + setPage(1); + setHasMore(expRes.data.pagination?.page < expRes.data.pagination?.totalPages); setMembers(memRes.data); setSettlements(setRes.data.optimizedSettlements); setEditGroupName(groupRes.data.name); + setEditGroupCurrency(groupRes.data.currency || 'USD'); + + // Store totalSummary separately + if (expRes.data.totalSummary) { + setTotalSummary(expRes.data.totalSummary); + } } catch (err) { console.error(err); } finally { @@ -126,6 +202,77 @@ export const GroupDetails = () => { } }; + const loadMoreExpenses = async () => { + if (!id || !hasMore || loadingMore) return; + setLoadingMore(true); + try { + const nextPage = page + 1; + const res = await getExpenses(id, nextPage); + setExpenses(prev => [...prev, ...res.data.expenses]); + setPage(nextPage); + setHasMore(res.data.pagination?.page < res.data.pagination?.totalPages); + } catch (err) { + console.error(err); + addToast("Failed to load more expenses", "error"); + } finally { + setLoadingMore(false); + } + }; + + const fetchAnalytics = async () => { + if (!id) return; + try { + // For 6 months, don't pass month/year as backend handles it + if (timeframe === '6months') { + const res = await getGroupAnalytics(id, '6months', undefined, undefined); + setAnalytics(res.data); + } else if (timeframe === 'year') { + const res = await getGroupAnalytics(id, 'year', selectedYear, undefined); + setAnalytics(res.data); + } else { + // month - use selected year and month + const res = await getGroupAnalytics(id, 'month', selectedYear, selectedMonth); + setAnalytics(res.data); + } + } catch (err) { + console.error(err); + addToast("Failed to load analytics", "error"); + } + }; + + // Fetch analytics when switching to analytics tab + useEffect(() => { + if (activeTab === 'analytics' && !analytics) { + fetchAnalytics(); + } + }, [activeTab]); + + // Refetch analytics when timeframe, year, or month changes + useEffect(() => { + if (activeTab === 'analytics' && analytics) { + fetchAnalytics(); + } + }, [timeframe, selectedYear, selectedMonth]); + + // Fetch settlements + const fetchSettlements = async () => { + if (!id) return; + try { + const res = await getOptimizedSettlements(id); + setSettlements(res.data.optimizedSettlements); + } catch (err) { + console.error(err); + addToast("Failed to load settlements", "error"); + } + }; + + // Fetch settlements when switching to settlements tab + useEffect(() => { + if (activeTab === 'settlements') { + fetchSettlements(); + } + }, [activeTab]); + const copyToClipboard = () => { if (group?.joinCode) { navigator.clipboard.writeText(group.joinCode) @@ -239,6 +386,7 @@ export const GroupDetails = () => { paidBy: payerId, splitType, splits: requestSplits, + currency, }; try { @@ -282,7 +430,7 @@ export const GroupDetails = () => { const handleRecordPayment = async (e: React.FormEvent) => { e.preventDefault(); if (!id) return; - + const numAmount = parseFloat(paymentAmount); if (paymentPayerId === paymentPayeeId) { alert('Payer and payee cannot be the same'); @@ -292,7 +440,7 @@ export const GroupDetails = () => { alert('Please enter a valid amount'); return; } - + try { await createSettlement(id, { payer_id: paymentPayerId, @@ -312,9 +460,12 @@ export const GroupDetails = () => { e.preventDefault(); if (!id) return; try { - await updateGroup(id, { name: editGroupName }); - setIsSettingsModalOpen(false); + await updateGroup(id, { + name: editGroupName, + currency: editGroupCurrency + }); fetchData(); + setIsSettingsModalOpen(false); addToast('Group updated successfully!', 'success'); } catch (err) { addToast("Failed to update group", 'error'); @@ -471,6 +622,34 @@ export const GroupDetails = () => {
+ {/* Group Totals Summary Cards */} + +
+

Total Spent

+

{formatCurrency(groupTotals.totalSpent, group.currency)}

+

{groupTotals.expenseCount} expenses

+
+
+

You Paid

+

{formatCurrency(groupTotals.myContribution, group.currency)}

+
+
+

Your Share

+

{formatCurrency(groupTotals.myShare, group.currency)}

+
+
= 0 ? 'bg-emerald-100' : 'bg-red-100') + ' border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]' : (groupTotals.netBalance >= 0 ? 'bg-emerald-500/10 border-emerald-500/20' : 'bg-red-500/10 border-red-500/20') + ' backdrop-blur-sm rounded-2xl border'}`}> +

Net Balance

+

= 0 ? 'text-emerald-600' : 'text-red-600'}`}> + {groupTotals.netBalance >= 0 ? '+' : ''}{formatCurrency(groupTotals.netBalance, group.currency)} +

+

{groupTotals.netBalance >= 0 ? 'owed to you' : 'you owe'}

+
+
+ {/* Navigation Pills */}
@@ -488,6 +667,13 @@ export const GroupDetails = () => { > Balances +
@@ -501,6 +687,37 @@ export const GroupDetails = () => { exit={{ opacity: 0, y: -20 }} className="space-y-4 max-w-3xl mx-auto" > + {/* Search Bar */} +
+ + setSearchQuery(e.target.value)} + className={`w-full pl-12 pr-4 py-3 font-medium ${style === THEMES.NEOBRUTALISM + ? 'bg-white border-2 border-black focus:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] focus:-translate-y-1 transition-all rounded-none' + : 'bg-white/5 border border-white/10 rounded-2xl backdrop-blur-sm focus:border-white/30 focus:bg-white/10' + } outline-none`} + /> + {searchQuery && ( + + )} +
+ + {/* Search Results Count */} + {searchQuery && ( +

+ Found {expenses.length} expense{expenses.length !== 1 ? 's' : ''} matching "{searchQuery}" +

+ )} + {loading ? Array(3).fill(0).map((_, i) => ) : expenses.map((expense, idx) => ( { onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openEditExpense(expense); } }} tabIndex={0} role="button" - aria-label={`Expense: ${expense.description}, ${group.currency} ${expense.amount.toFixed(2)}`} + aria-label={`Expense: ${expense.description}, ${formatCurrency(expense.amount, group?.currency)}`} className={`p-5 flex items-center gap-5 cursor-pointer group relative overflow-hidden ${style === THEMES.NEOBRUTALISM ? 'bg-white border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] rounded-none' : 'bg-white/5 border border-white/10 rounded-2xl backdrop-blur-sm hover:bg-white/10 transition-all' @@ -532,7 +749,7 @@ export const GroupDetails = () => { {members.find(m => m.userId === expense.paidBy)?.user?.name?.charAt(0)}

- {members.find(m => m.userId === expense.paidBy)?.user?.name || 'Unknown'} paid {group.currency} {expense.amount.toFixed(2)} + {members.find(m => m.userId === expense.paidBy)?.user?.name || 'Unknown'} paid {formatCurrency(expense.amount, group?.currency)}

@@ -553,6 +770,37 @@ export const GroupDetails = () => {

Add your first expense to get started!

)} + + {hasMore && expenses.length > 0 && ( +
+ +
+ )} + + ) : activeTab === 'analytics' ? ( + + ) : ( {
- {group.currency} {s.amount.toFixed(2)} + {formatCurrency(s.amount, group?.currency)}
@@ -841,7 +1089,7 @@ export const GroupDetails = () => {
{ disabled={!isAdmin} required /> +
+ + +
{isAdmin && }
@@ -961,7 +1230,7 @@ export const GroupDetails = () => { )}
- + ); }; diff --git a/web/pages/Groups.tsx b/web/pages/Groups.tsx index 9073ad02..1ccefabb 100644 --- a/web/pages/Groups.tsx +++ b/web/pages/Groups.tsx @@ -7,11 +7,12 @@ import { EmptyState } from '../components/ui/EmptyState'; import { Input } from '../components/ui/Input'; import { Modal } from '../components/ui/Modal'; import { Skeleton } from '../components/ui/Skeleton'; -import { THEMES } from '../constants'; +import { CURRENCIES, THEMES } from '../constants'; import { useTheme } from '../contexts/ThemeContext'; import { useToast } from '../contexts/ToastContext'; import { createGroup, getBalanceSummary, getGroups, joinGroup } from '../services/api'; import { BalanceSummary, Group, GroupBalanceSummary } from '../types'; +import { formatCurrency } from '../utils/formatters'; export const Groups = () => { const [groups, setGroups] = useState([]); @@ -20,6 +21,7 @@ export const Groups = () => { const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isJoinModalOpen, setIsJoinModalOpen] = useState(false); const [newGroupName, setNewGroupName] = useState(''); + const [newGroupCurrency, setNewGroupCurrency] = useState('USD'); const [joinCode, setJoinCode] = useState(''); const [searchTerm, setSearchTerm] = useState(''); @@ -55,8 +57,9 @@ export const Groups = () => { const handleCreateGroup = async (e: React.FormEvent) => { e.preventDefault(); try { - await createGroup({ name: newGroupName }); + await createGroup({ name: newGroupName, currency: newGroupCurrency }); setNewGroupName(''); + setNewGroupCurrency('USD'); setIsCreateModalOpen(false); loadData(); addToast('Group created successfully!', 'success'); @@ -78,7 +81,7 @@ export const Groups = () => { } }; - const filteredGroups = useMemo(() => + const filteredGroups = useMemo(() => groups.filter(g => g.name.toLowerCase().includes(searchTerm.toLowerCase())), [groups, searchTerm] ); @@ -139,8 +142,8 @@ export const Groups = () => { value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className={`pl-12 pr-4 py-3 outline-none transition-all w-full font-bold ${isNeo - ? 'bg-white border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] focus:translate-x-[2px] focus:translate-y-[2px] focus:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] rounded-none placeholder:text-black/40' - : 'bg-white/10 border border-white/20 focus:bg-white/20 focus:border-white/30 backdrop-blur-md rounded-xl text-white placeholder:text-white/40' + ? 'bg-white border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] focus:translate-x-[2px] focus:translate-y-[2px] focus:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] rounded-none placeholder:text-black/40' + : 'bg-white/10 border border-white/20 focus:bg-white/20 focus:border-white/30 backdrop-blur-md rounded-xl text-white placeholder:text-white/40' }`} /> @@ -187,11 +190,11 @@ export const Groups = () => { {balanceAmount !== 0 && (
0 - ? (isNeo ? 'bg-emerald-200 text-black border-2 border-black rounded-none' : 'bg-emerald-500/20 text-emerald-500 border border-emerald-500/30 rounded-full') - : (isNeo ? 'bg-red-200 text-black border-2 border-black rounded-none' : 'bg-red-500/20 text-red-500 border border-red-500/30 rounded-full') + ? (isNeo ? 'bg-emerald-200 text-black border-2 border-black rounded-none' : 'bg-emerald-500/20 text-emerald-500 border border-emerald-500/30 rounded-full') + : (isNeo ? 'bg-red-200 text-black border-2 border-black rounded-none' : 'bg-red-500/20 text-red-500 border border-red-500/30 rounded-full') }`}> {balanceAmount > 0 ? : } - {balanceAmount > 0 ? 'Owed' : 'Owe'} {group.currency} {Math.abs(balanceAmount).toFixed(2)} + {balanceAmount > 0 ? 'Owed' : 'Owe'} {formatCurrency(Math.abs(balanceAmount), group.currency)}
)} @@ -252,6 +255,26 @@ export const Groups = () => { required className={isNeo ? 'rounded-none' : ''} /> +
+ + +
diff --git a/web/pages/Profile.tsx b/web/pages/Profile.tsx index 58190cfd..ff194895 100644 --- a/web/pages/Profile.tsx +++ b/web/pages/Profile.tsx @@ -108,6 +108,12 @@ export const Profile = () => { { label: 'Security', icon: Shield, onClick: handleComingSoon, desc: 'Password and 2FA' }, ] }, + { + title: 'Import', + items: [ + { label: 'Import from Splitwise', icon: Settings, onClick: () => navigate('/import/splitwise'), desc: 'Import all your Splitwise data' }, + ] + }, { title: 'App', items: [ diff --git a/web/pages/SplitwiseCallback.tsx b/web/pages/SplitwiseCallback.tsx new file mode 100644 index 00000000..c43e66e0 --- /dev/null +++ b/web/pages/SplitwiseCallback.tsx @@ -0,0 +1,172 @@ +import { motion } from 'framer-motion'; +import { useEffect, useRef, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { THEMES } from '../constants'; +import { useTheme } from '../contexts/ThemeContext'; +import { useToast } from '../contexts/ToastContext'; +import { getImportStatus, handleSplitwiseCallback } from '../services/api'; + +export const SplitwiseCallback = () => { + const navigate = useNavigate(); + const location = useLocation(); + const { addToast } = useToast(); + const { style } = useTheme(); + const isNeo = style === THEMES.NEOBRUTALISM; + const [status, setStatus] = useState('Processing authorization...'); + const [progress, setProgress] = useState(0); + const [importing, setImporting] = useState(true); + const hasStartedRef = useRef(false); + + useEffect(() => { + // Check if we're in progress tracking mode (skipOAuth from group selection) + const state = location.state as { jobId?: string; skipOAuth?: boolean }; + if (state?.skipOAuth && state?.jobId) { + // Start polling for existing job + startProgressPolling(state.jobId); + return; + } + + // Prevent duplicate execution in React Strict Mode using ref + if (hasStartedRef.current) { + return; + } + hasStartedRef.current = true; + + const handleCallback = async () => { + // Parse query parameters from the full URL (before the hash) + const urlParams = new URLSearchParams(window.location.search); + const code = urlParams.get('code'); + const state = urlParams.get('state'); + + if (!code) { + addToast('Authorization failed - no code received', 'error'); + navigate('/import/splitwise'); + return; + } + + try { + setStatus('Fetching your Splitwise data...'); + + // First, exchange OAuth code for access token and get preview + const tokenResponse = await handleSplitwiseCallback(code, state || ''); + + // Check if we got groups in the response (from preview) + if (tokenResponse.data.groups && tokenResponse.data.groups.length > 0) { + // Navigate to group selection + navigate('/import/splitwise/select-groups', { + state: { + accessToken: tokenResponse.data.accessToken, + groups: tokenResponse.data.groups + } + }); + return; + } + + // If no groups or preview data, start import directly (backward compatibility) + const jobId = tokenResponse.data.import_job_id || tokenResponse.data.importJobId; + + if (!jobId) { + throw new Error('No import job ID received'); + } + addToast('Authorization successful! Starting import...', 'success'); + + startProgressPolling(jobId); + + } catch (error: any) { + addToast( + error.response?.data?.detail || 'Failed to process authorization', + 'error' + ); + setImporting(false); + setTimeout(() => navigate('/import/splitwise'), 2000); + } + }; + + handleCallback(); + }, [navigate, addToast, location.state]); + + const startProgressPolling = (jobId: string) => { + setStatus('Import started...'); + + // Poll for progress + const pollInterval = setInterval(async () => { + try { + const statusResponse = await getImportStatus(jobId); + const statusData = statusResponse.data; + + // Log errors if any (keep for debugging in dev only) + if (process.env.NODE_ENV === 'development' && statusData.errors && statusData.errors.length > 0) { + console.warn('Import errors:', statusData.errors); + } + + const progressPercentage = statusData.progress?.percentage || 0; + const currentStage = statusData.progress?.currentStage || 'Processing...'; + + setProgress(progressPercentage); + setStatus(currentStage); + + if (statusData.status === 'completed') { + clearInterval(pollInterval); + setImporting(false); + addToast('Import completed successfully!', 'success'); + setStatus('Completed! Redirecting to dashboard...'); + setTimeout(() => navigate('/dashboard'), 2000); + } else if (statusData.status === 'failed') { + clearInterval(pollInterval); + setImporting(false); + addToast('Import failed', 'error'); + setStatus(`Failed: ${statusData.errors?.[0]?.message || 'Unknown error'}`); + } + } catch (error) { + // Silently catch polling errors to avoid spamming console + } + }, 2000); + }; + + return ( +
+ +
+
+

+ {importing ? 'Importing Data' : 'Processing'} +

+

{status}

+
+ + {importing && ( +
+
+ Progress + + {progress.toFixed(0)}% + +
+
+
+
+
+ )} + +
+

+ Please don't close this page until the import is complete. +

+
+ +
+ ); +}; diff --git a/web/pages/SplitwiseGroupSelection.tsx b/web/pages/SplitwiseGroupSelection.tsx new file mode 100644 index 00000000..5f5cc000 --- /dev/null +++ b/web/pages/SplitwiseGroupSelection.tsx @@ -0,0 +1,288 @@ +import { motion } from 'framer-motion'; +import { Check, ChevronLeft, Receipt, Users } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { THEMES } from '../constants'; +import { useTheme } from '../contexts/ThemeContext'; +import { useToast } from '../contexts/ToastContext'; +import { handleSplitwiseCallback } from '../services/api'; +import { getCurrencySymbol } from '../utils/formatters'; + +interface PreviewGroup { + splitwiseId: string; + name: string; + currency: string; + memberCount: number; + expenseCount: number; + totalAmount: number; + imageUrl?: string; +} + +export const SplitwiseGroupSelection = () => { + const navigate = useNavigate(); + const location = useLocation(); + const { addToast } = useToast(); + + const [groups, setGroups] = useState([]); + const [selectedGroupIds, setSelectedGroupIds] = useState>(new Set()); + const [loading, setLoading] = useState(true); + const [importing, setImporting] = useState(false); + const [accessToken, setAccessToken] = useState(''); + const { style, mode } = useTheme(); + const isNeo = style === THEMES.NEOBRUTALISM; + + useEffect(() => { + // Get OAuth params from location state (passed from callback) + const state = location.state as { accessToken?: string; groups?: PreviewGroup[] }; + + if (state?.groups) { + setGroups(state.groups); + setAccessToken(state.accessToken || ''); + // Select all groups by default + setSelectedGroupIds(new Set(state.groups.map(g => g.splitwiseId))); + setLoading(false); + } else { + addToast('No group data available', 'error'); + navigate('/import/splitwise'); + } + }, [location.state, addToast, navigate]); + + const toggleGroup = (groupId: string) => { + const newSelected = new Set(selectedGroupIds); + if (newSelected.has(groupId)) { + newSelected.delete(groupId); + } else { + newSelected.add(groupId); + } + setSelectedGroupIds(newSelected); + }; + + const handleSelectAll = () => { + if (selectedGroupIds.size === groups.length) { + setSelectedGroupIds(new Set()); + } else { + setSelectedGroupIds(new Set(groups.map(g => g.splitwiseId))); + } + }; + + const handleStartImport = async () => { + if (selectedGroupIds.size === 0) { + addToast('Please select at least one group', 'error'); + return; + } + + // Check if user is authenticated + const token = localStorage.getItem('access_token'); + if (!token) { + addToast('Authentication required. Please log in again.', 'error'); + navigate('/login'); + return; + } + + setImporting(true); + try { + // Call the import API with selected groups and access token + const response = await handleSplitwiseCallback( + undefined, // no code + undefined, // no state + Array.from(selectedGroupIds), + accessToken // pass stored access token + ); + + const jobId = response.data.import_job_id || response.data.importJobId; + + // Navigate to callback/progress page + navigate('/import/splitwise/callback', { + state: { jobId, skipOAuth: true } + }); + + } catch (error: any) { + console.error('Import start error:', error); + addToast( + error.response?.data?.detail || 'Failed to start import', + 'error' + ); + setImporting(false); + } + }; + + if (loading) { + return ( +
+
+
+

Loading groups...

+
+
+ ); + } + + return ( +
+
+ {/* Back Button */} + + + {/* Header */} + +

+ Select Groups to Import +

+

+ Your Splitwise groups are ready. Choose which ones to bring to Splitwiser. +

+
+ + {/* Selection Controls */} +
+
+ + {selectedGroupIds.size} + of {groups.length} groups selected +
+ +
+ + {/* Groups List */} +
+ {groups.map((group) => { + const isSelected = selectedGroupIds.has(group.splitwiseId); + + return ( + toggleGroup(group.splitwiseId)} + className={`transition-all cursor-pointer p-4 ${isNeo + ? `bg-white border-2 border-black rounded-none shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] ${isSelected ? 'bg-blue-50' : ''}` + : `bg-white dark:bg-gray-800 rounded-xl shadow hover:shadow-md border-2 ${isSelected ? 'border-blue-500' : 'border-transparent'}` + }`} + > +
+ {/* Checkbox */} +
+ {isSelected && } +
+ + {/* Group Image */} +
+
+ {group.imageUrl ? ( + {group.name} + ) : ( + group.name.charAt(0).toUpperCase() + )} +
+
+ + {/* Group Details */} +
+

+ {group.name} +

+ +
+
+ + {group.memberCount} members +
+ +
+ + {group.expenseCount} expenses +
+ +
+ {getCurrencySymbol(group.currency)} + + {new Intl.NumberFormat(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(group.totalAmount)} + +
+
+
+
+
+ ); + })} +
+ + {/* Import Button */} + + + + {selectedGroupIds.size === 0 && ( +

+ Select at least one group to proceed +

+ )} +
+
+
+ ); +}; diff --git a/web/pages/SplitwiseImport.tsx b/web/pages/SplitwiseImport.tsx new file mode 100644 index 00000000..dad64183 --- /dev/null +++ b/web/pages/SplitwiseImport.tsx @@ -0,0 +1,133 @@ +import { motion } from 'framer-motion'; +import { Download } from 'lucide-react'; +import { useState } from 'react'; +import { THEMES } from '../constants'; +import { useTheme } from '../contexts/ThemeContext'; +import { useToast } from '../contexts/ToastContext'; +import { getSplitwiseAuthUrl } from '../services/api'; + +export const SplitwiseImport = () => { + const [loading, setLoading] = useState(false); + const { addToast } = useToast(); + const { style, mode } = useTheme(); + const isNeo = style === THEMES.NEOBRUTALISM; + + const handleOAuthImport = async () => { + setLoading(true); + try { + const response = await getSplitwiseAuthUrl(); + const { authorization_url } = response.data; + + // Redirect to Splitwise OAuth page + window.location.href = authorization_url; + } catch (error: any) { + console.error('OAuth error:', error); + addToast(error.response?.data?.detail || 'Failed to initiate authorization', 'error'); + setLoading(false); + } + }; + + return ( +
+
+ + {/* Header */} +
+
+ +
+

+ Import from Splitwise +

+

+ Seamlessly migrate all your data in just a few clicks +

+
+ + {/* Main Button */} + + +

+ You'll be redirected to Splitwise for authorization +

+ +
+ {/* What will be imported */} +
+

+
+ + + +
+ What's being imported +

+
    + {['All your friends', 'All your groups', 'All expenses & splits', 'All settlements'].map((item, idx) => ( +
  • + + {item} +
  • + ))} +
+
+ + {/* Important Notes */} +
+

+ + + + Good to know +

+
    + {[ + 'Process may take a few minutes', + 'Select specific groups next', + "Existing data won't be affected" + ].map((item, idx) => ( +
  • + + {item} +
  • + ))} +
+
+
+
+
+
+ ); +}; diff --git a/web/services/api.ts b/web/services/api.ts index 8efb4dc6..3bd6a88d 100644 --- a/web/services/api.ts +++ b/web/services/api.ts @@ -1,56 +1,201 @@ -import axios from 'axios'; +import axios from "axios"; -const API_URL = 'https://splitwiser-production.up.railway.app'; +// Use localhost for development, production URL for production +const API_URL = + import.meta.env.VITE_API_URL || + "https://splitwiser-production.up.railway.app"; const api = axios.create({ baseURL: API_URL, headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, }); -api.interceptors.request.use((config) => { - const token = localStorage.getItem('access_token'); - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; -}, (error) => Promise.reject(error)); +api.interceptors.request.use( + (config) => { + const token = localStorage.getItem("access_token"); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => Promise.reject(error), +); + +// Response interceptor to handle token refresh +api.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config; + + // If 401 and we haven't retried yet, try to refresh the token + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + try { + const refreshToken = localStorage.getItem("refresh_token"); + if (!refreshToken) { + // No refresh token, redirect to login + localStorage.removeItem("access_token"); + window.location.href = "/login"; + return Promise.reject(error); + } + + // Try to refresh the token + const response = await axios.post(`${API_URL}/auth/refresh`, { + refresh_token: refreshToken, + }); + + const { access_token, refresh_token: newRefreshToken } = response.data; + localStorage.setItem("access_token", access_token); + if (newRefreshToken) { + localStorage.setItem("refresh_token", newRefreshToken); + } + + // Retry the original request with new token + originalRequest.headers.Authorization = `Bearer ${access_token}`; + return api(originalRequest); + } catch (refreshError) { + // Refresh failed, redirect to login + localStorage.removeItem("access_token"); + localStorage.removeItem("refresh_token"); + window.location.href = "/login"; + return Promise.reject(refreshError); + } + } + + return Promise.reject(error); + }, +); // Auth -export const login = async (data: any) => api.post('/auth/login/email', data); -export const signup = async (data: any) => api.post('/auth/signup/email', data); -export const loginWithGoogle = async (idToken: string) => api.post('/auth/login/google', { id_token: idToken }); -export const getProfile = async () => api.get('/users/me'); +export const login = async (data: any) => api.post("/auth/login/email", data); +export const signup = async (data: any) => api.post("/auth/signup/email", data); +export const loginWithGoogle = async (idToken: string) => + api.post("/auth/login/google", { id_token: idToken }); +export const getProfile = async () => api.get("/users/me"); // Groups -export const getGroups = async () => api.get('/groups'); -export const createGroup = async (data: {name: string, currency?: string}) => api.post('/groups', data); +export const getGroups = async () => api.get("/groups"); +export const createGroup = async (data: { name: string; currency?: string }) => + api.post("/groups", data); export const getGroupDetails = async (id: string) => api.get(`/groups/${id}`); -export const updateGroup = async (id: string, data: {name?: string, imageUrl?: string}) => api.patch(`/groups/${id}`, data); +export const updateGroup = async ( + id: string, + data: { name?: string; imageUrl?: string; currency?: string }, +) => api.patch(`/groups/${id}`, data); export const deleteGroup = async (id: string) => api.delete(`/groups/${id}`); -export const getGroupMembers = async (id: string) => api.get(`/groups/${id}/members`); -export const joinGroup = async (joinCode: string) => api.post('/groups/join', { joinCode }); +export const getGroupMembers = async (id: string) => + api.get(`/groups/${id}/members`); +export const joinGroup = async (joinCode: string) => + api.post("/groups/join", { joinCode }); // Expenses -export const getExpenses = async (groupId: string) => api.get(`/groups/${groupId}/expenses`); -export const createExpense = async (groupId: string, data: any) => api.post(`/groups/${groupId}/expenses`, data); -export const updateExpense = async (groupId: string, expenseId: string, data: any) => api.patch(`/groups/${groupId}/expenses/${expenseId}`, data); -export const deleteExpense = async (groupId: string, expenseId: string) => api.delete(`/groups/${groupId}/expenses/${expenseId}`); +export const getExpenses = async ( + groupId: string, + page: number = 1, + limit: number = 20, + search?: string, +) => { + const params = new URLSearchParams({ + page: page.toString(), + limit: limit.toString(), + }); + if (search) params.append("search", search); + return api.get(`/groups/${groupId}/expenses?${params.toString()}`); +}; +export const createExpense = async (groupId: string, data: any) => + api.post(`/groups/${groupId}/expenses`, data); +export const updateExpense = async ( + groupId: string, + expenseId: string, + data: any, +) => api.patch(`/groups/${groupId}/expenses/${expenseId}`, data); +export const deleteExpense = async (groupId: string, expenseId: string) => + api.delete(`/groups/${groupId}/expenses/${expenseId}`); // Settlements -export const getSettlements = async (groupId: string) => api.get(`/groups/${groupId}/settlements?status=pending`); -export const createSettlement = async (groupId: string, data: {payer_id: string, payee_id: string, amount: number}) => api.post(`/groups/${groupId}/settlements`, data); -export const getOptimizedSettlements = async (groupId: string) => api.post(`/groups/${groupId}/settlements/optimize`); -export const markSettlementPaid = async (groupId: string, settlementId: string) => api.patch(`/groups/${groupId}/settlements/${settlementId}`, { status: 'completed' }); +export const getSettlements = async (groupId: string) => + api.get(`/groups/${groupId}/settlements?status=pending`); +export const createSettlement = async ( + groupId: string, + data: { payer_id: string; payee_id: string; amount: number }, +) => api.post(`/groups/${groupId}/settlements`, data); +export const getOptimizedSettlements = async (groupId: string) => + api.post(`/groups/${groupId}/settlements/optimize`); +export const markSettlementPaid = async ( + groupId: string, + settlementId: string, +) => + api.patch(`/groups/${groupId}/settlements/${settlementId}`, { + status: "completed", + }); // Users -export const getBalanceSummary = async () => api.get('/users/me/balance-summary'); -export const getFriendsBalance = async () => api.get('/users/me/friends-balance'); -export const updateProfile = async (data: { name?: string; imageUrl?: string }) => api.patch('/users/me', data); +export const getBalanceSummary = async () => + api.get("/users/me/balance-summary"); +export const getFriendsBalance = async () => + api.get("/users/me/friends-balance"); +export const updateProfile = async (data: { + name?: string; + imageUrl?: string; +}) => api.patch("/users/me", data); + +// Analytics +export const getGroupAnalytics = async ( + groupId: string, + period: string = "month", + year?: number, + month?: number, +) => { + const params = new URLSearchParams({ period }); + if (year) params.append("year", year.toString()); + if (month) params.append("month", month.toString()); + return api.get(`/groups/${groupId}/analytics?${params.toString()}`); +}; + +// Dashboard Analytics (all groups combined) +export const getDashboardAnalytics = async () => + api.get("/users/me/friends-balance"); + +// Splitwise Import +export const getSplitwiseAuthUrl = async () => + api.get("/import/splitwise/authorize"); +export const handleSplitwiseCallback = async ( + code?: string, + state?: string, + selectedGroupIds?: string[], + accessToken?: string, +) => { + const payload: any = {}; + + if (accessToken) { + payload.accessToken = accessToken; + } else if (code) { + payload.code = code; + payload.state = state || ""; + } + + if (selectedGroupIds && selectedGroupIds.length > 0) { + payload.options = { selectedGroupIds }; + } + + return api.post("/import/splitwise/callback", payload); +}; +export const getSplitwisePreview = async (accessToken: string) => + api.post("/import/splitwise/preview", { access_token: accessToken }); +export const startSplitwiseImport = async () => + api.post("/import/splitwise/start"); +export const getImportStatus = async (importJobId: string) => + api.get(`/import/status/${importJobId}`); +export const rollbackImport = async (importJobId: string) => + api.post(`/import/rollback/${importJobId}`); // Group Management -export const leaveGroup = async (groupId: string) => api.post(`/groups/${groupId}/leave`); -export const removeMember = async (groupId: string, userId: string) => api.delete(`/groups/${groupId}/members/${userId}`); +export const leaveGroup = async (groupId: string) => + api.post(`/groups/${groupId}/leave`); +export const removeMember = async (groupId: string, userId: string) => + api.delete(`/groups/${groupId}/members/${userId}`); -export default api; \ No newline at end of file +export default api; diff --git a/web/types.ts b/web/types.ts index b86e621e..07d8ddf4 100644 --- a/web/types.ts +++ b/web/types.ts @@ -26,15 +26,15 @@ export interface Group { export interface GroupMember { userId: string; - role: 'admin' | 'member'; + role: "admin" | "member"; joinedAt: string; user?: User; // Details populated } export enum SplitType { - EQUAL = 'equal', - UNEQUAL = 'unequal', - PERCENTAGE = 'percentage' + EQUAL = "equal", + UNEQUAL = "unequal", + PERCENTAGE = "percentage", } export interface ExpenseSplit { @@ -63,14 +63,14 @@ export interface Settlement { payerName: string; payeeName: string; amount: number; - status: 'pending' | 'completed' | 'cancelled'; + status: "pending" | "completed" | "cancelled"; description?: string; } export interface GroupBalanceSummary { - groupId: string; - groupName: string; - amount: number; // Positive = owed to you, Negative = you owe + group_id: string; + group_name: string; + yourBalanceInGroup: number; // Positive = owed to you, Negative = you owe } export interface BalanceSummary { @@ -79,4 +79,56 @@ export interface BalanceSummary { netBalance: number; currency: string; groupsSummary: GroupBalanceSummary[]; -} \ No newline at end of file +} + +export interface FriendBalanceBreakdown { + groupId: string; + groupName: string; + balance: number; + owesYou: boolean; +} + +export interface FriendBalance { + userId: string; + userName: string; + userImageUrl?: string; + netBalance: number; + owesYou: boolean; + breakdown: FriendBalanceBreakdown[]; + lastActivity: string; +} + +export interface CategorySpending { + category: string; + amount: number; + percentage: number; + count: number; +} + +export interface ExpenseTrend { + date: string; + amount: number; + count: number; +} + +export interface MemberContribution { + userId: string; + userName: string; + totalPaid: number; + totalOwed: number; + netContribution: number; +} + +export interface GroupAnalytics { + period: string; + totalExpenses: number; + expenseCount: number; + avgExpenseAmount: number; + topCategories: CategorySpending[]; + memberContributions: MemberContribution[]; + contributionTimeline?: Array<{ + date: string; + [memberName: string]: number | string; + }>; + expenseTrends: ExpenseTrend[]; +} diff --git a/web/utils/formatters.ts b/web/utils/formatters.ts new file mode 100644 index 00000000..2eb40d49 --- /dev/null +++ b/web/utils/formatters.ts @@ -0,0 +1,46 @@ +import { CURRENCIES } from '../constants'; + +// Type for valid currency codes +type CurrencyCode = keyof typeof CURRENCIES; + +/** + * Check if a currency code is valid + */ +const isValidCurrencyCode = (code: string): code is CurrencyCode => { + return code in CURRENCIES; +}; + +/** + * Format a number as currency + * @param amount - The amount to format + * @param currencyCode - The currency code (defaults to USD) + * @param locale - Optional locale for number formatting (defaults to user's browser locale) + */ +export const formatCurrency = ( + amount: number, + currencyCode: string = 'USD', + locale?: string +): string => { + const currency = isValidCurrencyCode(currencyCode) + ? CURRENCIES[currencyCode] + : CURRENCIES.USD; + + // Use provided locale, or undefined to use browser default + return new Intl.NumberFormat(locale, { + style: 'currency', + currency: currency.code, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount); +}; + +/** + * Get the symbol for a currency + * @param currencyCode - The currency code (defaults to USD) + */ +export const getCurrencySymbol = (currencyCode: string = 'USD'): string => { + const currency = isValidCurrencyCode(currencyCode) + ? CURRENCIES[currencyCode] + : CURRENCIES.USD; + return currency.symbol; +};