Skip to content

Commit bf01953

Browse files
committed
feat(friends): implement friends balance calculation and debug mode for better user experience
1 parent 40d72db commit bf01953

File tree

3 files changed

+976
-10
lines changed

3 files changed

+976
-10
lines changed

ui-poc/pages/Friends.py

Lines changed: 336 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,350 @@
11
import streamlit as st
22
import requests
3+
from datetime import datetime
4+
import json
5+
import time
6+
from collections import defaultdict
37

48
# Base URL for API
59
API_URL = "https://splitwiser-production.up.railway.app"
610

11+
# Helper function to retry API calls
12+
def make_api_request(method, url, headers=None, json_data=None, max_retries=3, retry_delay=1):
13+
retries = 0
14+
while retries < max_retries:
15+
try:
16+
if method.lower() == 'get':
17+
response = requests.get(url, headers=headers)
18+
elif method.lower() == 'post':
19+
response = requests.post(url, headers=headers, json=json_data)
20+
else:
21+
raise ValueError(f"Unsupported method: {method}")
22+
23+
return response
24+
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e:
25+
retries += 1
26+
if retries < max_retries:
27+
if st.session_state.debug_mode:
28+
st.sidebar.warning(f"API request failed, retrying ({retries}/{max_retries}): {e}")
29+
time.sleep(retry_delay)
30+
else:
31+
raise
32+
33+
return None
34+
735
st.title("Friends")
836

37+
# Debug mode toggle
38+
if "debug_mode" not in st.session_state:
39+
st.session_state.debug_mode = False
40+
41+
# Connection status and refresh button in sidebar
42+
with st.sidebar:
43+
st.session_state.debug_mode = st.checkbox("Debug Mode", value=st.session_state.debug_mode)
44+
45+
# Connection status check
46+
try:
47+
status_response = requests.get(f"{API_URL}/health", timeout=3)
48+
if status_response.status_code == 200:
49+
st.success("API Connection: Online")
50+
else:
51+
st.error(f"API Connection: Error (Status {status_response.status_code})")
52+
except Exception as e:
53+
st.error(f"API Connection: Offline")
54+
if st.session_state.debug_mode:
55+
st.write(f"Error: {str(e)}")
56+
57+
# Refresh button
58+
refresh_col1, refresh_col2 = st.columns([8, 1])
59+
with refresh_col2:
60+
if st.button("🔄"):
61+
st.rerun()
62+
963
# Check if user is logged in
1064
if "access_token" not in st.session_state or not st.session_state.access_token:
1165
st.warning("Please log in first")
1266
st.stop()
1367

14-
st.info("Friends feature is coming soon!")
68+
# Function to fetch user's groups
69+
def fetch_user_groups():
70+
try:
71+
headers = {"Authorization": f"Bearer {st.session_state.access_token}"}
72+
response = make_api_request(
73+
'get',
74+
f"{API_URL}/groups",
75+
headers=headers
76+
)
77+
78+
if st.session_state.debug_mode:
79+
st.sidebar.subheader("Debug: /groups response")
80+
st.sidebar.write(f"Status Code: {response.status_code}")
81+
try:
82+
st.sidebar.json(response.json())
83+
except:
84+
st.sidebar.write("Failed to parse response as JSON")
85+
st.sidebar.text(response.text)
86+
87+
if response.status_code == 200:
88+
data = response.json()
89+
if 'groups' in data:
90+
return data['groups']
91+
else:
92+
st.error(f"Unexpected response format: 'groups' key not found in response")
93+
return []
94+
else:
95+
st.error(f"Failed to fetch groups: {response.status_code}")
96+
return []
97+
except Exception as e:
98+
st.error(f"Error fetching groups: {str(e)}")
99+
return []
100+
101+
# Function to fetch group members
102+
def fetch_group_members(group_id):
103+
try:
104+
headers = {"Authorization": f"Bearer {st.session_state.access_token}"}
105+
response = make_api_request(
106+
'get',
107+
f"{API_URL}/groups/{group_id}/members",
108+
headers=headers
109+
)
110+
111+
if response.status_code == 200:
112+
return response.json()
113+
else:
114+
return []
115+
except Exception as e:
116+
if st.session_state.debug_mode:
117+
st.error(f"Error fetching members for group {group_id}: {str(e)}")
118+
return []
119+
120+
# Function to fetch group expenses
121+
def fetch_group_expenses(group_id):
122+
try:
123+
headers = {"Authorization": f"Bearer {st.session_state.access_token}"}
124+
response = make_api_request(
125+
'get',
126+
f"{API_URL}/groups/{group_id}/expenses",
127+
headers=headers
128+
)
129+
130+
if response.status_code == 200:
131+
data = response.json()
132+
if 'expenses' in data:
133+
return data['expenses']
134+
else:
135+
return []
136+
else:
137+
return []
138+
except Exception as e:
139+
if st.session_state.debug_mode:
140+
st.error(f"Error fetching expenses for group {group_id}: {str(e)}")
141+
return []
142+
143+
# Function to calculate net balance between current user and a friend across all groups
144+
def calculate_friend_balance(current_user_id, friend_user_id, groups, all_members, all_expenses):
145+
net_balance = 0.0
146+
shared_groups = []
147+
148+
for group in groups:
149+
group_id = group.get('_id')
150+
151+
# Check if both users are in this group
152+
group_members = all_members.get(group_id, [])
153+
group_member_ids = [m.get('userId') for m in group_members]
154+
155+
if current_user_id in group_member_ids and friend_user_id in group_member_ids:
156+
shared_groups.append(group.get('name'))
157+
158+
# Get expenses for this group
159+
group_expenses = all_expenses.get(group_id, [])
160+
161+
for expense in group_expenses:
162+
splits = expense.get('splits', [])
163+
payer_id = expense.get('createdBy')
164+
total_amount = expense.get('amount', 0)
165+
166+
# Find splits for current user and friend
167+
current_user_split = next((s for s in splits if s.get('userId') == current_user_id), None)
168+
friend_split = next((s for s in splits if s.get('userId') == friend_user_id), None)
169+
170+
if current_user_split or friend_split:
171+
current_user_owes = current_user_split.get('amount', 0) if current_user_split else 0
172+
friend_owes = friend_split.get('amount', 0) if friend_split else 0
173+
174+
current_user_paid = total_amount if payer_id == current_user_id else 0
175+
friend_paid = total_amount if payer_id == friend_user_id else 0
176+
177+
# Calculate net effect on balance between these two users
178+
# If current user paid for friend's share: positive balance (friend owes current user)
179+
# If friend paid for current user's share: negative balance (current user owes friend)
180+
181+
if payer_id == current_user_id and friend_split:
182+
# Current user paid, friend owes
183+
net_balance += friend_owes
184+
185+
if payer_id == friend_user_id and current_user_split:
186+
# Friend paid, current user owes
187+
net_balance -= current_user_owes
188+
189+
return net_balance, shared_groups
190+
191+
# Get current user ID
192+
current_user_id = st.session_state.get('user_id', None)
193+
194+
if not current_user_id:
195+
st.error("Unable to get current user information. Please log in again.")
196+
st.stop()
197+
198+
# Fetch all groups
199+
with st.spinner("Loading your groups and friends..."):
200+
groups = fetch_user_groups()
201+
202+
if not groups:
203+
st.info("You haven't joined any groups yet. Join groups to see friends!")
204+
st.stop()
205+
206+
# Fetch members and expenses for all groups
207+
all_members = {}
208+
all_expenses = {}
209+
210+
for group in groups:
211+
group_id = group.get('_id')
212+
all_members[group_id] = fetch_group_members(group_id)
213+
all_expenses[group_id] = fetch_group_expenses(group_id)
214+
215+
# Find all unique friends (users who share at least one group)
216+
friends_data = {}
217+
group_names_map = {g.get('_id'): g.get('name') for g in groups}
218+
219+
for group in groups:
220+
group_id = group.get('_id')
221+
group_members = all_members.get(group_id, [])
222+
223+
for member in group_members:
224+
user_id = member.get('userId')
225+
user_info = member.get('user', {})
226+
user_name = user_info.get('name', 'Unknown User')
227+
228+
# Skip current user
229+
if user_id == current_user_id:
230+
continue
231+
232+
if user_id not in friends_data:
233+
friends_data[user_id] = {
234+
'name': user_name,
235+
'shared_groups': set(),
236+
'balance': 0.0
237+
}
238+
239+
friends_data[user_id]['shared_groups'].add(group_names_map[group_id])
240+
241+
# Calculate balances for each friend
242+
for friend_id in friends_data:
243+
balance, shared_groups = calculate_friend_balance(
244+
current_user_id,
245+
friend_id,
246+
groups,
247+
all_members,
248+
all_expenses
249+
)
250+
friends_data[friend_id]['balance'] = balance
251+
252+
# Display friends
253+
if friends_data:
254+
st.subheader(f"Your Friends ({len(friends_data)})")
255+
st.caption("People you share groups with")
256+
257+
# Sort friends by absolute balance (highest debts/credits first)
258+
sorted_friends = sorted(
259+
friends_data.items(),
260+
key=lambda x: abs(x[1]['balance']),
261+
reverse=True
262+
)
263+
264+
# Summary cards
265+
total_owed_to_you = sum(max(0, data['balance']) for data in friends_data.values())
266+
total_you_owe = sum(abs(min(0, data['balance'])) for data in friends_data.values())
267+
268+
col1, col2, col3 = st.columns(3)
269+
with col1:
270+
st.metric("Friends", len(friends_data))
271+
with col2:
272+
st.metric("You are owed", f"₹{total_owed_to_you:.2f}")
273+
with col3:
274+
st.metric("You owe", f"₹{total_you_owe:.2f}")
275+
276+
st.divider()
277+
278+
# Friends list
279+
for friend_id, friend_data in sorted_friends:
280+
with st.container():
281+
col1, col2, col3 = st.columns([3, 1, 1])
282+
283+
with col1:
284+
st.subheader(friend_data['name'])
285+
shared_groups_text = ", ".join(sorted(friend_data['shared_groups']))
286+
st.caption(f"Shared groups: {shared_groups_text}")
287+
288+
with col2:
289+
balance = friend_data['balance']
290+
if balance > 0:
291+
st.markdown(f":green[**₹{balance:.2f}**]")
292+
st.caption("owes you")
293+
elif balance < 0:
294+
st.markdown(f":red[**₹{abs(balance):.2f}**]")
295+
st.caption("you owe")
296+
else:
297+
st.markdown("**₹0.00**")
298+
st.caption("settled up")
299+
300+
with col3:
301+
if st.button("View Details", key=f"friend_details_{friend_id}"):
302+
# Store friend details in session state for detailed view
303+
st.session_state.selected_friend = {
304+
'id': friend_id,
305+
'name': friend_data['name'],
306+
'balance': balance,
307+
'shared_groups': friend_data['shared_groups']
308+
}
309+
st.rerun()
310+
311+
st.divider()
312+
313+
else:
314+
st.info("No friends found. Join groups with other people to see them here!")
315+
316+
# Show detailed view if a friend is selected
317+
if 'selected_friend' in st.session_state:
318+
friend = st.session_state.selected_friend
319+
320+
st.subheader(f"Details with {friend['name']}")
321+
322+
# Clear selection button
323+
if st.button("← Back to Friends List"):
324+
del st.session_state.selected_friend
325+
st.rerun()
326+
327+
# Show balance
328+
balance = friend['balance']
329+
if balance > 0:
330+
st.success(f"{friend['name']} owes you ₹{balance:.2f}")
331+
elif balance < 0:
332+
st.error(f"You owe {friend['name']}{abs(balance):.2f}")
333+
else:
334+
st.info(f"You and {friend['name']} are settled up!")
335+
336+
# Show shared groups
337+
st.write("### Shared Groups")
338+
for group_name in sorted(friend['shared_groups']):
339+
st.write(f"• {group_name}")
340+
341+
# TODO: Add detailed expense breakdown by group
342+
st.info("Detailed expense breakdown coming soon!")
15343

16-
# Placeholder for future functionality
17-
st.subheader("Features to be added:")
18-
st.markdown("""
19-
- Add friends
20-
- View friend requests
21-
- See shared expenses with friends
22-
- Settle up with friends
23-
""")
344+
# Debug information display
345+
if st.session_state.debug_mode:
346+
st.subheader("Debug Information")
347+
st.write("API URL:", API_URL)
348+
st.write("Current User ID:", current_user_id)
349+
st.write("Groups Count:", len(groups) if groups else 0)
350+
st.write("Friends Data:", friends_data if 'friends_data' in locals() else "Not loaded")

0 commit comments

Comments
 (0)