Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/app/api/checkins.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ class CheckinReq(BaseModel):
@router.post("")
async def insert_checkin(req: CheckinReq, user_id: str = Depends(get_current_user)):
db = get_db()
thirty_days_ago = datetime.datetime.utcnow() - datetime.timedelta(days=30)
await db.checkin_logs.delete_many({"user_id": user_id, "created_at": {"$lt": thirty_days_ago}})

log_id = str(uuid.uuid4())
gap_hours = req.gap_hours if req.gap_hours is not None else req.food_gap_hours
await db.checkin_logs.insert_one({
Expand Down
286 changes: 9 additions & 277 deletions backend/app/api/insights.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,284 +346,16 @@ async def get_runway_forecast(user_id: str = Depends(get_current_user)):
@router.get("/wellness")
async def get_wellness_insights(user_id: str = Depends(get_current_user)):
db = get_db()
now = datetime.datetime.utcnow()

# Fetch last 60 days of transactions
since = now - datetime.timedelta(days=60)
cursor = db.transactions.find({"user_id": user_id, "created_at": {"$gte": since}}).sort("created_at", -1)
txns = await cursor.to_list(length=2000)

# Fetch user profile
profile = await db.profiles.find_one({"_id": user_id})
if not profile:
profile = {}

# 1. Late-night activity (last 7 days, hours 0 to 4)
since_7 = now - datetime.timedelta(days=7)
late_txns_7d = [
t for t in txns
if t.get("created_at") and t["created_at"] >= since_7 and (
0 <= t["created_at"].hour < 5
)
]
late_night_spend_7d = len(late_txns_7d)

# 2. Meal regularity: avg_food_gap_hours_7d
food_txns_7d = [
t for t in txns
if t.get("category") == "food" and t.get("created_at") and t["created_at"] >= since_7
]
food_txns_7d.sort(key=lambda t: t["created_at"])

gaps_7d = []
if len(food_txns_7d) > 0:
for i in range(1, len(food_txns_7d)):
gap = (food_txns_7d[i]["created_at"] - food_txns_7d[i-1]["created_at"]).total_seconds() / 3600.0
gaps_7d.append(gap)
current_gap = (now - food_txns_7d[-1]["created_at"]).total_seconds() / 3600.0
gaps_7d.append(current_gap)
avg_food_gap_hours_7d = sum(gaps_7d) / len(gaps_7d)
else:
avg_food_gap_hours_7d = 168.0

# Calculate unpaid pool debts (committed spend runway impact)
unpaid_pool_debt_paise = 0
user_doc = await db.users.find_one({"_id": user_id})
full_name = user_doc.get("full_name", "") if user_doc else ""
if full_name and profile and profile.get("wing_label"):
wing_label = profile["wing_label"]
def local_name_key(v):
return " ".join((v or "").strip().split()).casefold()

async for p in db.cart_pools.find({"wing_label": wing_label, "status": "completed", "host_id": {"$ne": user_id}}):
pool_id = p["_id"]
items_cursor = db.cart_pool_items.find({"pool_id": pool_id})
items = await items_cursor.to_list(length=1000)

participants = list(set(it["added_by_name"] for it in items if it.get("is_purchased", True)))
user_items = [it for it in items if it.get("is_purchased", True) and local_name_key(it["added_by_name"]) == local_name_key(full_name)]
if user_items:
payments = p.get("payments", [])
user_payment = next((pay for pay in payments if local_name_key(pay["name"]) == local_name_key(full_name)), None)
if not user_payment or user_payment.get("status") != "verified":
p_items_total = sum(it["estimated_price"] for it in user_items)
final_overhead = p.get("final_overhead", 0)
final_discount = p.get("final_discount", 0)
net_overhead = final_overhead - final_discount
num_people = len(participants)
overhead_share = int(net_overhead / num_people) if num_people > 0 else 0
unpaid_pool_debt_paise += (p_items_total + overhead_share)

# 3. Financial runway & safe daily limit
cycle_start_day = profile.get("cycle_start_day") or 1
monthly_allowance = profile.get("monthly_allowance") or 1000000 # in paise
total_allowance_rs = monthly_allowance / 100

cycle_start = get_cycle_start(cycle_start_day, now)
cycle_end = get_cycle_end(cycle_start)

cycle_txns = [t for t in txns if t.get("created_at") and t["created_at"] >= cycle_start]
total_spent_rs = (sum(t.get("amount", 0) for t in cycle_txns) + unpaid_pool_debt_paise) / 100
remaining_rs = max(0.0, total_allowance_rs - total_spent_rs)

days_since_start = max(1, (now - cycle_start).days)
avg_daily_spend_rs = total_spent_rs / days_since_start
days_left = max(1, (cycle_end - now).days)

if avg_daily_spend_rs > 0:
runway_days = int(remaining_rs / avg_daily_spend_rs)
else:
runway_days = days_left

runway_days = min(runway_days, days_left + 5)

# safe_daily_limit_rs
safe_daily_limit_rs = remaining_rs / days_left

# 4. Spending velocity
spend_7_rs = sum(t.get("amount", 0) for t in txns if t.get("created_at") and t["created_at"] >= since_7) / 100.0
avg_daily_spend_7d_rs = spend_7_rs / 7.0

if safe_daily_limit_rs > 0:
spend_velocity = avg_daily_spend_7d_rs / safe_daily_limit_rs
else:
spend_velocity = 1.5 if avg_daily_spend_7d_rs > 0 else 0.0

# 5. Exam window
in_exam_period = False
if profile:
exam_start = profile.get("exam_start_date")
exam_end = profile.get("exam_end_date")
if exam_start and exam_end:
try:
import datetime as dt
es = dt.datetime.fromisoformat(str(exam_start))
ee = dt.datetime.fromisoformat(str(exam_end) + "T23:59:59")
if es <= now <= ee:
in_exam_period = True
except Exception:
pass

# 6. Social signal from cart pools
user_doc = await db.users.find_one({"_id": user_id})
full_name = user_doc.get("full_name", "") if user_doc else ""
user_hosted_pools = await db.cart_pools.find({"host_id": user_id}).to_list(length=100)
participated_pool_ids = []
if full_name:
import re
name_regex = re.compile(f"^{re.escape(full_name)}$", re.IGNORECASE)
user_items = await db.cart_pool_items.find({"added_by_name": name_regex}).to_list(length=500)
participated_pool_ids = [item["pool_id"] for item in user_items]

all_pool_ids = list(set([p["_id"] for p in user_hosted_pools] + participated_pool_ids))
if all_pool_ids:
latest_pools = await db.cart_pools.find({"_id": {"$in": all_pool_ids}}).sort("created_at", -1).to_list(length=1)
if latest_pools:
last_pool_time = latest_pools[0].get("created_at")
days_since_last_pool = (now - last_pool_time).days
else:
days_since_last_pool = None
else:
days_since_last_pool = None

# Calculate Wellness Score
score = 100

# Sleep: late_night_spend_7d > 3 (-20), > 1 (-10)
if late_night_spend_7d > 3:
score -= 20
elif late_night_spend_7d > 1:
score -= 10

# Meal regularity: avg_food_gap_hours_7d > 10 (-20), > 6 (-10)
if avg_food_gap_hours_7d > 10:
score -= 20
elif avg_food_gap_hours_7d > 6:
score -= 10

# Runway: runway_days < 5 (-20), < 10 (-10)
if runway_days < 5:
score -= 20
elif runway_days < 10:
score -= 10

# Exam pressure: in_exam_window: -15
if in_exam_period:
score -= 15

# Spending control: spend_velocity > 1.4 (-15), > 1.2 (-8)
if spend_velocity > 1.4:
score -= 15
elif spend_velocity > 1.2:
score -= 8

# Social signal: days_since_last_pool > 7 (-10)
if days_since_last_pool is not None and days_since_last_pool > 7:
score -= 10

score = max(0, min(100, score))

# Determine status bucket and messages
if score >= 70:
status = "steady"
label = "Your routine looks steady"
message = "Your routine looks steady this week. Keep meals regular and stay within today's safe spend target."
elif score >= 50:
status = "watch"
label = "A few patterns need attention"
message = "A few patterns need attention: your food timing, spending pace, or exam pressure is starting to stack up. Pick one reset today: a proper meal, a low-spend window, or a short break."
else:
status = "stressed"
label = "Pattern suggests high stress"
message = "Your recent pattern suggests you may be stretched thin. You do not need to fix everything today; start with one meal and one planned spend decision, then check in again."

# Construct signals list
signals = []

# Food gap signal
food_gap_severity = "ok"
if avg_food_gap_hours_7d > 10:
food_gap_severity = "stressed"
elif avg_food_gap_hours_7d > 6:
food_gap_severity = "watch"
signals.append({
"key": "food_gap",
"label": "Avg Food gap",
"value": f"{avg_food_gap_hours_7d:.1f}h" if avg_food_gap_hours_7d < 168.0 else "—",
"severity": food_gap_severity,
"detail": "Long gaps between meals detected" if food_gap_severity != "ok" else "Regular meal timing"
})

# Runway signal
runway_severity = "ok"
if runway_days < 5:
runway_severity = "stressed"
elif runway_days < 10:
runway_severity = "watch"
signals.append({
"key": "runway",
"label": "Runway",
"value": f"{runway_days} days" if runway_days is not None else "—",
"severity": runway_severity,
"detail": "Allowance may not last the cycle" if runway_severity != "ok" else "Runway looks stable"
})

# Late night signal
late_night_severity = "ok"
if late_night_spend_7d > 3:
late_night_severity = "stressed"
elif late_night_spend_7d > 1:
late_night_severity = "watch"
signals.append({
"key": "late_night",
"label": "Late-night spending",
"value": f"{late_night_spend_7d} txns",
"severity": late_night_severity,
"detail": "Frequent late-night activity" if late_night_severity != "ok" else "Healthy nighttime routine"
})

# Velocity signal
velocity_severity = "ok"
if spend_velocity > 1.4:
velocity_severity = "stressed"
elif spend_velocity > 1.2:
velocity_severity = "watch"
signals.append({
"key": "velocity",
"label": "Spend velocity",
"value": f"{spend_velocity:.2f}x",
"severity": velocity_severity,
"detail": "Spending is accelerating rapidly" if velocity_severity == "stressed" else "Spending is slightly elevated" if velocity_severity == "watch" else "Spending velocity is stable"
})

# Exam signal
exam_severity = "stressed" if in_exam_period else "ok"
signals.append({
"key": "exam",
"label": "Exam period",
"value": "Active" if in_exam_period else "No",
"severity": exam_severity,
"detail": "Active exam schedule" if in_exam_period else "No exams active"
})

# Social signal from cart pools
pool_severity = "stressed" if (days_since_last_pool is not None and days_since_last_pool > 7) else "ok"
signals.append({
"key": "cart_pool",
"label": "Social index",
"value": f"{days_since_last_pool}d idle" if days_since_last_pool is not None else "None",
"severity": pool_severity,
"detail": "Low cart pool participation recently (possible social withdrawal)" if pool_severity == "stressed" else "Active in cart pools"
})

from app.services.wellness import compute_wellness
package = await compute_wellness(db, user_id)
return {
"score": score,
"status": status,
"label": label,
"message": message,
"signals": signals,
"generated_by": "local_rules",
"avg_food_gap_hours_7d": round(avg_food_gap_hours_7d, 1)
"score": package["score"],
"status": package["status"],
"label": package["label"],
"message": package["message"],
"signals": package["signals"],
"generated_by": package["generated_by"],
"avg_food_gap_hours_7d": package["avg_food_gap_hours_7d"]
}


Expand Down
Loading