diff --git a/backend/app/api/checkins.py b/backend/app/api/checkins.py index db50d8d..2367f7e 100644 --- a/backend/app/api/checkins.py +++ b/backend/app/api/checkins.py @@ -5,29 +5,47 @@ import datetime from app.core.database import get_db from app.core.security import get_current_user +from app.services.wellness import MEAL_CHECKIN_RESPONSES, SKIPPED_MEAL_RESPONSES router = APIRouter() +MEAL_SOURCES = {"mess", "cooked", "home", "outside_cash", "snack", "other"} + class CheckinReq(BaseModel): response: str gap_hours: Optional[float] = None food_gap_hours: Optional[float] = None suggestion_given: Optional[str] = None + context_note: Optional[str] = None stress_note: Optional[str] = None + meal_source: Optional[str] = None @router.post("") async def insert_checkin(req: CheckinReq, user_id: str = Depends(get_current_user)): db = get_db() log_id = str(uuid.uuid4()) gap_hours = req.gap_hours if req.gap_hours is not None else req.food_gap_hours + response = (req.response or "").strip().lower() + meal_source = (req.meal_source or "").strip().lower() + if meal_source not in MEAL_SOURCES: + meal_source = None + note = (req.context_note if req.context_note is not None else req.stress_note or "").strip() + if len(note) > 500: + note = note[:500] + is_meal_signal = response in MEAL_CHECKIN_RESPONSES or ( + bool(meal_source) and response not in SKIPPED_MEAL_RESPONSES + ) await db.checkin_logs.insert_one({ "_id": log_id, "user_id": user_id, - "response": req.response, + "response": response, "gap_hours": gap_hours or 0, "food_gap_hours": gap_hours or 0, "suggestion_given": req.suggestion_given, - "stress_note": req.stress_note, + "context_note": note or None, + "stress_note": note or None, + "meal_source": meal_source, + "is_meal_signal": is_meal_signal, "created_at": datetime.datetime.utcnow() }) return {"status": "ok", "id": log_id} diff --git a/backend/app/api/insights.py b/backend/app/api/insights.py index 1f7f6fe..e227282 100644 --- a/backend/app/api/insights.py +++ b/backend/app/api/insights.py @@ -3,6 +3,16 @@ from app.core.security import get_current_user from app.services.runway import build_runway_forecast, derive_pool_obligations from app.services.subscriptions import amount_paise_from_doc, detect_recurring_subscriptions +from app.services.wellness import ( + INDIA_STANDARD_TIME_OFFSET_MINUTES, + average_meal_gap_hours, + build_wellness_summary, + current_meal_gap_hours, + is_debit_transaction, + is_late_night_activity, + is_meal_checkin, + meal_signal_events, +) import datetime import calendar import re @@ -20,6 +30,15 @@ def _to_dict(doc): d[k] = v.isoformat() return d + +def _campus_timezone_offset_minutes(profile: dict | None) -> int: + if profile and profile.get("timezone_offset_minutes") is not None: + try: + return int(profile["timezone_offset_minutes"]) + except (TypeError, ValueError): + pass + return INDIA_STANDARD_TIME_OFFSET_MINUTES + @router.get("") async def get_insights(user_id: str = Depends(get_current_user)): db = get_db() @@ -32,11 +51,16 @@ async def get_insights(user_id: str = Depends(get_current_user)): # Fetch profile for exam dates profile = await db.profiles.find_one({"_id": user_id}) + campus_timezone_offset_minutes = _campus_timezone_offset_minutes(profile) now = datetime.datetime.utcnow() # ── Category breakdown (last 30 days) ───────────────────────────────── since_30 = now - datetime.timedelta(days=30) + checkins_30 = await db.checkin_logs.find({ + "user_id": user_id, + "created_at": {"$gte": since_30}, + }).sort("created_at", -1).to_list(length=500) cat_totals: dict[str, int] = {} for t in txns: if t.get("created_at", now) >= since_30: @@ -63,27 +87,22 @@ async def get_insights(user_id: str = Depends(get_current_user)): "amount_paise": total, }) - # ── Late-night spend (11pm – 4am) ────────────────────────────────────── + # ── After-hours payments (11PM-5AM, last 30 days) ───────────────────── late_txns = [ t for t in txns - if t.get("created_at") and ( - t["created_at"].hour >= 23 or t["created_at"].hour < 4 - ) + if t.get("created_at") and t.get("created_at", now) >= since_30 and is_late_night_activity(t["created_at"], campus_timezone_offset_minutes) ] late_night_total_paise = sum(t.get("amount", 0) for t in late_txns) late_night_txn_count = len(late_txns) # ── Food gap analysis ────────────────────────────────────────────────── food_txns = [t for t in txns if t.get("category") == "food"] - if food_txns: - last_food = food_txns[0] # already sorted desc - food_gap_hours = (now - last_food["created_at"]).total_seconds() / 3600 - # Average daily food spend - food_30 = [t for t in food_txns if t.get("created_at", now) >= since_30] - avg_daily_food = sum(t.get("amount", 0) for t in food_30) / 30 - else: - food_gap_hours = 0.0 - avg_daily_food = 0.0 + food_30 = [t for t in food_txns if t.get("created_at", now) >= since_30] + meal_events_30 = meal_signal_events(food_30, checkins_30) + food_gap_hours = current_meal_gap_hours(now, meal_events_30, default=0.0) + last_food_source = meal_events_30[-1]["source"] if meal_events_30 else None + meal_checkin_count_30d = sum(1 for ck in checkins_30 if is_meal_checkin(ck)) + avg_daily_food = sum(t.get("amount", 0) for t in food_30) / 30 if food_30 else 0.0 # ── Spending velocity (avg of last 7 days vs prior 7 days) ──────────── since_7 = now - datetime.timedelta(days=7) @@ -97,7 +116,7 @@ async def get_insights(user_id: str = Depends(get_current_user)): if spend_7_prior > 0: velocity_pct = round((spend_7 - spend_7_prior) / spend_7_prior * 100) - # ── Exam stress signal ───────────────────────────────────────────────── + # ── Exam window signal ───────────────────────────────────────────────── in_exam_period = False exam_days_left = None if profile: @@ -116,12 +135,12 @@ async def get_insights(user_id: str = Depends(get_current_user)): # ── Mess vs delivery ratio ───────────────────────────────────────────── delivery_keywords = ["blinkit", "zepto", "swiggy", "zomato", "instamart", "dunzo"] delivery_txns = [ - t for t in food_txns + t for t in food_30 if any(kw in (t.get("raw_merchant_string") or "").lower() for kw in delivery_keywords) or any(kw in (t.get("mapped_merchant_name") or "").lower() for kw in delivery_keywords) ] delivery_count = len(delivery_txns) - mess_count = max(0, len(food_txns) - delivery_count) + mess_count = max(0, len(food_30) - delivery_count) + meal_checkin_count_30d delivery_spend_paise = sum(t.get("amount", 0) for t in delivery_txns) # Calculate unpaid pool debts (committed spend runway impact) @@ -171,6 +190,8 @@ def local_name_key(v): "late_night": { "total_paise": late_night_total_paise, "txn_count": late_night_txn_count, + "window": "11PM-5AM", + "timezone": "Asia/Kolkata", }, "food": { "gap_hours": round(food_gap_hours, 1), @@ -178,6 +199,8 @@ def local_name_key(v): "delivery_count_30d": delivery_count, "mess_count_30d": mess_count, "delivery_spend_paise": delivery_spend_paise, + "checkin_count_30d": meal_checkin_count_30d, + "last_signal_source": last_food_source, }, "velocity": { "pct_change": velocity_pct, @@ -243,8 +266,8 @@ async def get_wing_feed(user_id: str = Depends(get_current_user)): created_at = ck.get("created_at", now) mins_ago = int((now - created_at).total_seconds() / 60) response = ck.get("response", "ate") - if response == "ate": - text = "A student checked in — ate at campus mess" + if is_meal_checkin(ck): + text = "A student checked in - meal logged" icon = "🍽️" elif response == "travel_fare_report": text = ck.get("stress_note", "New travel fare report submitted") @@ -253,9 +276,14 @@ async def get_wing_feed(user_id: str = Depends(get_current_user)): text = ck.get("stress_note", "Saved money on travel fare negotiation") icon = "💰" else: - text = "A student checked in — reported skipping a meal during exam period" + text = "A student checked in - meal was skipped or delayed" icon = "⚠️" - + if is_meal_checkin(ck): + source = str(ck.get("meal_source") or "meal").replace("_", " ") + text = f"A student checked in - {source} meal logged" + elif response not in {"travel_fare_report", "travel_savings"}: + text = "A student checked in - meal was skipped or delayed" + events.append({ "type": "checkin", "icon": icon, @@ -298,15 +326,15 @@ def get_cycle_end(cycle_start: datetime.datetime) -> datetime.datetime: return cycle_start + datetime.timedelta(days=30) -@router.get("/forecast") -async def get_runway_forecast(user_id: str = Depends(get_current_user)): - """Return the deterministic Runway Engine V2 forecast for the current user.""" - db = get_db() - now = datetime.datetime.utcnow() - profile = await db.profiles.find_one({"_id": user_id}) or {} +async def _load_runway_forecast_for_user( + db, + *, + user_id: str, + now: datetime.datetime, + profile: dict | None = None, +): + profile = profile or await db.profiles.find_one({"_id": user_id}) or {} - # Recurrence detection is server-owned and runs before the forecast reads - # active subscriptions. This keeps forecasts consistent across web clients. await detect_recurring_subscriptions(db, user_id) history_start = now - datetime.timedelta(days=120) @@ -361,6 +389,20 @@ async def get_runway_forecast(user_id: str = Depends(get_current_user)): ) +@router.get("/forecast") +async def get_runway_forecast(user_id: str = Depends(get_current_user)): + """Return the deterministic Runway Engine V2 forecast for the current user.""" + db = get_db() + now = datetime.datetime.utcnow() + profile = await db.profiles.find_one({"_id": user_id}) or {} + return await _load_runway_forecast_for_user( + db, + user_id=user_id, + now=now, + profile=profile, + ) + + @router.get("/wellness") async def get_wellness_insights(user_id: str = Depends(get_current_user)): db = get_db() @@ -369,94 +411,49 @@ async def get_wellness_insights(user_id: str = Depends(get_current_user)): # 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) + txns = [t for t in await cursor.to_list(length=2000) if is_debit_transaction(t)] + checkins = await db.checkin_logs.find({ + "user_id": user_id, + "created_at": {"$gte": since}, + }).sort("created_at", -1).to_list(length=1000) # 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) + campus_timezone_offset_minutes = _campus_timezone_offset_minutes(profile) + + # 1. Late-night payment activity (last 7 days, 11PM to 5AM campus time) 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 - ) + if t.get("created_at") and t["created_at"] >= since_7 and is_late_night_activity(t["created_at"], campus_timezone_offset_minutes) ] - late_night_spend_7d = len(late_txns_7d) + late_night_activity_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 + checkins_7d = [ + ck for ck in checkins + if ck.get("created_at") and ck["created_at"] >= since_7 + ] + meal_events_7d = meal_signal_events(food_txns_7d, checkins_7d) + meal_checkins_7d = sum(1 for ck in checkins_7d if is_meal_checkin(ck)) + current_food_gap_hours = current_meal_gap_hours(now, meal_events_7d, default=0.0) if meal_events_7d else None + avg_food_gap_hours_7d = average_meal_gap_hours(now, meal_events_7d, default=0.0) if meal_events_7d else None - runway_days = min(runway_days, days_left + 5) - - # safe_daily_limit_rs - safe_daily_limit_rs = remaining_rs / days_left + runway_forecast = await _load_runway_forecast_for_user( + db, + user_id=user_id, + now=now, + profile=profile, + ) + runway_days = int(runway_forecast.get("days") or 0) + safe_daily_limit_rs = float((runway_forecast.get("safeDailyPaise") or 0) / 100.0) # 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 @@ -482,166 +479,37 @@ def local_name_key(v): 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" - }) + signal_basis = [ + "food_payments", + "meal_checkins", + "runway_forecast", + "after_hours_payments", + "spend_velocity", + "exam_dates", + ] + summary = build_wellness_summary( + meal_events_count_7d=len(meal_events_7d), + current_food_gap_hours=current_food_gap_hours, + avg_food_gap_hours_7d=avg_food_gap_hours_7d, + late_night_activity_7d=late_night_activity_7d, + runway_days=runway_days, + safe_daily_limit_rs=safe_daily_limit_rs, + spend_velocity=spend_velocity, + in_exam_period=in_exam_period, + ) return { - "score": score, - "status": status, - "label": label, - "message": message, - "signals": signals, + **summary, "generated_by": "local_rules", - "avg_food_gap_hours_7d": round(avg_food_gap_hours_7d, 1) + "signal_basis": signal_basis, + "avg_food_gap_hours_7d": round(avg_food_gap_hours_7d, 1) if avg_food_gap_hours_7d is not None else None, + "current_food_gap_hours": round(current_food_gap_hours, 1) if current_food_gap_hours is not None else None, + "meal_checkins_7d": meal_checkins_7d, + "meal_signals_7d": len(meal_events_7d), + "late_night_activity_count_7d": late_night_activity_7d, + "late_night_window": "11PM-5AM", + "late_night_timezone": "Asia/Kolkata", + "disclaimer": "Routine score uses payments, meal check-ins, runway, spend pace, and exam dates only; it is not medical advice.", } diff --git a/backend/app/api/rag.py b/backend/app/api/rag.py index e316191..3c65e07 100644 --- a/backend/app/api/rag.py +++ b/backend/app/api/rag.py @@ -13,6 +13,7 @@ from app.services.bedrock import generate_text from app.services.runway import build_runway_forecast, derive_pool_obligations from app.services.subscriptions import detect_recurring_subscriptions +from app.services.wellness import current_meal_gap_hours, meal_signal_events router = APIRouter() logger = logging.getLogger(__name__) @@ -117,15 +118,19 @@ async def get_campus_intel(user_id: str = Depends(get_current_user)): profile = await db.profiles.find_one({"_id": user_id}) # Basic spending stats - since_7 = datetime.datetime.utcnow() - datetime.timedelta(days=7) + now = datetime.datetime.utcnow() + since_7 = now - datetime.timedelta(days=7) cursor = db.transactions.find({"user_id": user_id, "created_at": {"$gte": since_7}}) txns = await cursor.to_list(length=500) spend_7 = sum(t.get("amount", 0) for t in txns) / 100 food_txns = [t for t in txns if t.get("category") == "food"] - last_food_hours = 0 - if food_txns: - last_food = max(food_txns, key=lambda t: t.get("created_at", datetime.datetime.min)) - last_food_hours = (datetime.datetime.utcnow() - last_food["created_at"]).total_seconds() / 3600 + checkins = await db.checkin_logs.find({ + "user_id": user_id, + "created_at": {"$gte": since_7}, + }).sort("created_at", -1).to_list(length=500) + meal_events = meal_signal_events(food_txns, checkins) + last_food_hours = current_meal_gap_hours(now, meal_events, default=0.0) + last_food_source = meal_events[-1]["source"] if meal_events else None remaining = (profile.get("monthly_allowance", 0) / 100) if profile else 0 @@ -149,33 +154,49 @@ async def get_campus_intel(user_id: str = Depends(get_current_user)): limit=5, ) - prompt = f"""You are PocketBuddy, an AI financial wellness guard for Indian college students. + prompt = f"""You are PocketBuddy, a student budget and routine assistant for Indian college students. Student context: - Spent Rs {spend_7:.0f} in last 7 days - Remaining budget: Rs {remaining:.0f} -- Last food transaction: {last_food_hours:.0f} hours ago +- Last food payment/check-in signal: {last_food_hours:.0f} hours ago - Trusted campus food options: {json.dumps([{"venue": f.get("venue_name"), "item": f.get("item_name"), "price_rs": f.get("price", 0)//100, "why": f.get("why"), "trust": f.get("trust_badge")} for f in ranked_foods[:5]], indent=None)} -Generate exactly 2 concise, specific, actionable sentences as a campus financial intelligence summary. Be direct, mention real numbers. No emojis. Do not cite any food option outside the trusted campus food options above.""" +Generate exactly 2 concise, specific, actionable sentences as a campus financial intelligence summary. +Be direct and mention real numbers. +Describe only budget and routine signals; do not infer illness, stress, sleep quality, or medical risk. +If you mention food, cite only trusted campus food options above. +No emojis. No preamble.""" text = generate_text(prompt, max_tokens=120, temperature=0.2) if text: - return {"summary": text, "source": "bedrock", "spend_7d": spend_7, "last_food_hours": round(last_food_hours, 1)} + return { + "summary": text, + "source": "bedrock", + "spend_7d": spend_7, + "last_food_hours": round(last_food_hours, 1), + "last_food_signal_source": last_food_source, + } except Exception as exc: logger.warning("Bedrock campus-intel failed: %s", exc) # Local fallback - parts = [] + routine_parts = [] if spend_7 > 0: - parts.append(f"You've spent ₹{spend_7:.0f} in the last 7 days.") + routine_parts.append(f"You've spent Rs {spend_7:.0f} in the last 7 days.") if last_food_hours > 8: - parts.append(f"Your last food transaction was {last_food_hours:.0f} hours ago — consider eating soon.") + routine_parts.append(f"Your last meal signal was {last_food_hours:.0f} hours ago; log a meal or use a trusted campus option if needed.") elif last_food_hours > 0: - parts.append(f"Last meal logged {last_food_hours:.0f} hours ago, you're on track.") + routine_parts.append(f"Last meal signal was {last_food_hours:.0f} hours ago.") if remaining > 0: - parts.append(f"₹{remaining:.0f} remaining in your current cycle.") - summary = " ".join(parts) if parts else "Start logging transactions to activate campus intelligence." - return {"summary": summary, "source": "local_fallback", "spend_7d": spend_7, "last_food_hours": round(last_food_hours, 1)} + routine_parts.append(f"Rs {remaining:.0f} remaining in your current cycle.") + summary = " ".join(routine_parts) if routine_parts else "Start logging transactions to activate campus intelligence." + return { + "summary": summary, + "source": "local_fallback", + "spend_7d": spend_7, + "last_food_hours": round(last_food_hours, 1), + "last_food_signal_source": last_food_source, + } @router.get("/runway-intel") diff --git a/backend/app/services/wellness.py b/backend/app/services/wellness.py new file mode 100644 index 0000000..31d4cf6 --- /dev/null +++ b/backend/app/services/wellness.py @@ -0,0 +1,554 @@ +import datetime as dt +from typing import Any + + +LATE_NIGHT_START_HOUR = 23 +LATE_NIGHT_END_HOUR = 5 +INDIA_STANDARD_TIME_OFFSET_MINUTES = 330 + +MEAL_CHECKIN_RESPONSES = { + "ate", + "meal_logged", + "ate_without_transaction", + "wellness_ate", +} + +SKIPPED_MEAL_RESPONSES = { + "skipped", + "meal_skipped", + "could_not_eat", +} + + +def is_debit_transaction(txn: dict[str, Any]) -> bool: + direction = str(txn.get("direction") or "").strip().lower() + if direction: + return direction != "credit" + try: + return float(txn.get("amount") or 0) >= 0 + except (TypeError, ValueError): + return True + + +def as_naive_datetime(value: Any) -> dt.datetime | None: + if isinstance(value, dt.datetime): + return value.astimezone(dt.timezone.utc).replace(tzinfo=None) if value.tzinfo else value + if isinstance(value, str): + raw = value.strip() + if not raw: + return None + try: + parsed = dt.datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError: + return None + return parsed.astimezone(dt.timezone.utc).replace(tzinfo=None) if parsed.tzinfo else parsed + return None + + +def is_late_night_activity(value: Any, timezone_offset_minutes: int = 0) -> bool: + timestamp = as_naive_datetime(value) + if not timestamp: + return False + if timezone_offset_minutes: + timestamp = timestamp + dt.timedelta(minutes=timezone_offset_minutes) + return timestamp.hour >= LATE_NIGHT_START_HOUR or timestamp.hour < LATE_NIGHT_END_HOUR + + +def is_meal_checkin(log: dict[str, Any]) -> bool: + response = str(log.get("response") or "").strip().lower() + if response in SKIPPED_MEAL_RESPONSES: + return False + if response in MEAL_CHECKIN_RESPONSES: + return True + return bool(log.get("meal_source")) and response not in SKIPPED_MEAL_RESPONSES + + +def meal_signal_events( + transactions: list[dict[str, Any]], + checkins: list[dict[str, Any]], +) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + for txn in transactions: + if txn.get("category") != "food": + continue + timestamp = as_naive_datetime(txn.get("created_at")) + if timestamp: + events.append({"at": timestamp, "source": "transaction"}) + + for checkin in checkins: + if not is_meal_checkin(checkin): + continue + timestamp = as_naive_datetime(checkin.get("created_at")) + if timestamp: + events.append({ + "at": timestamp, + "source": "checkin", + "meal_source": checkin.get("meal_source"), + }) + + events.sort(key=lambda event: event["at"]) + return events + + +def current_meal_gap_hours( + now: dt.datetime, + events: list[dict[str, Any]], + default: float = 0.0, +) -> float: + if not events: + return default + return max(0.0, (now - events[-1]["at"]).total_seconds() / 3600.0) + + +def average_meal_gap_hours( + now: dt.datetime, + events: list[dict[str, Any]], + default: float = 168.0, +) -> float: + if not events: + return default + + gaps: list[float] = [] + for index in range(1, len(events)): + gap = (events[index]["at"] - events[index - 1]["at"]).total_seconds() / 3600.0 + if gap >= 0: + gaps.append(gap) + + gaps.append(current_meal_gap_hours(now, events, default=default)) + return sum(gaps) / len(gaps) if gaps else default + + +def build_wellness_summary( + *, + meal_events_count_7d: int, + current_food_gap_hours: float | None, + avg_food_gap_hours_7d: float | None, + late_night_activity_7d: int, + runway_days: int, + safe_daily_limit_rs: float, + spend_velocity: float, + in_exam_period: bool, +) -> dict[str, Any]: + meal_signal = _meal_signal_summary( + meal_events_count_7d=meal_events_count_7d, + current_food_gap_hours=current_food_gap_hours, + avg_food_gap_hours_7d=avg_food_gap_hours_7d, + ) + runway_signal = _runway_signal_summary(runway_days) + late_night_signal = _late_night_signal_summary(late_night_activity_7d) + velocity_signal = _velocity_signal_summary(spend_velocity) + exam_signal = _exam_signal_summary( + in_exam_period=in_exam_period, + meal_signal=meal_signal, + runway_signal=runway_signal, + velocity_signal=velocity_signal, + ) + + score = 100 + score -= _severity_weight(late_night_signal["severity"], watch=6, attention=12) + score -= _severity_weight(runway_signal["severity"], watch=10, attention=20) + score -= _severity_weight(velocity_signal["severity"], watch=6, attention=12) + if meal_signal["state"] == "due": + score -= 8 + elif meal_signal["state"] == "stale": + score -= 16 + elif meal_signal["state"] == "missing" and in_exam_period: + score -= 6 + if in_exam_period and any( + signal["severity"] != "ok" for signal in (meal_signal, runway_signal, velocity_signal) + ): + score -= 6 + if meal_signal["state"] == "missing": + score = min(score, 72 if not in_exam_period else 66) + score = max(0, min(100, score)) + + primary_action = _primary_action_summary( + meal_signal=meal_signal, + runway_signal=runway_signal, + velocity_signal=velocity_signal, + late_night_signal=late_night_signal, + runway_days=runway_days, + safe_daily_limit_rs=safe_daily_limit_rs, + spend_velocity=spend_velocity, + in_exam_period=in_exam_period, + ) + + if score >= 74: + status = "steady" + label = "Routine looks steady" + message = ( + "Exam dates are active, but your meal and spend signals still look steady. " + "Keep the next meal predictable and stay inside today's safe spend." + if in_exam_period + else "Your meal and spend signals look steady this week. Keep the next meal predictable and stay inside today's safe spend." + ) + elif score >= 56: + status = "watch" + label = "One reset would help today" + message = _wellness_status_message( + status=status, + meal_signal=meal_signal, + runway_signal=runway_signal, + velocity_signal=velocity_signal, + late_night_signal=late_night_signal, + primary_action=primary_action, + runway_days=runway_days, + safe_daily_limit_rs=safe_daily_limit_rs, + in_exam_period=in_exam_period, + ) + else: + status = "attention" + label = "Routine needs a reset today" + message = _wellness_status_message( + status=status, + meal_signal=meal_signal, + runway_signal=runway_signal, + velocity_signal=velocity_signal, + late_night_signal=late_night_signal, + primary_action=primary_action, + runway_days=runway_days, + safe_daily_limit_rs=safe_daily_limit_rs, + in_exam_period=in_exam_period, + ) + + return { + "score": score, + "status": status, + "label": label, + "message": message, + "primary_action": primary_action, + "signals": [ + meal_signal, + runway_signal, + late_night_signal, + velocity_signal, + exam_signal, + ], + } + + +def _wellness_status_message( + *, + status: str, + meal_signal: dict[str, Any], + runway_signal: dict[str, Any], + velocity_signal: dict[str, Any], + late_night_signal: dict[str, Any], + primary_action: dict[str, Any], + runway_days: int, + safe_daily_limit_rs: float, + in_exam_period: bool, +) -> str: + safe_daily_copy = f"Rs {round(safe_daily_limit_rs):.0f}" if safe_daily_limit_rs > 0 else None + + if status == "watch": + if meal_signal["state"] == "missing": + return ( + "PocketBuddy is missing a recent meal signal, so the routine view is less certain than usual. " + "Log the last meal or keep the next one simple." + ) + if in_exam_period and meal_signal["state"] in {"due", "stale"}: + return ( + "Exam dates are active and the meal signal is getting old. " + "Keep the next meal predictable so the day stays easier to manage." + ) + if runway_signal["severity"] == "attention": + return ( + "Runway is already critically tight, even if the rest of the routine signals are comparatively stable. " + "Pause routine orders until the next spend is reviewed." + ) + if runway_signal["severity"] == "watch": + return ( + f"Runway is tightening, with about {max(runway_days, 0)} days left at the current pace. " + "The next routine spend should stay deliberate." + ) + if velocity_signal["severity"] == "attention": + return "Spending is moving well above the safe pace. Pull the next routine spends back before the cycle gets harder to recover." + if velocity_signal["severity"] == "watch": + return "Spending has crept above the safe pace this week. One low-spend window today will help settle it." + if late_night_signal["severity"] == "attention": + return "After-hours payments are starting to look routine. A predictable snack, ride, or meal fallback would reduce that pressure." + if late_night_signal["severity"] == "watch": + return "A few after-hours payments showed up this week. A more predictable meal or snack plan will reduce that drift." + return str(primary_action.get("detail") or "One reset would help today.") + + if meal_signal["state"] in {"missing", "stale"}: + return ( + "Routine pressure is stacking because PocketBuddy either missed the last meal signal or the gap has gone stale. " + "Reset that first, then make the next spend deliberate." + ) + if runway_signal["severity"] == "attention": + return ( + "Runway is critically tight today. Essentials should come first, and the next routine spend needs a quick review." + ) + if velocity_signal["severity"] == "attention": + return "Spending is moving well above the safe pace. Pull the next few routine spends back before the cycle gets harder to manage." + if late_night_signal["severity"] == "attention": + return "Repeated after-hours payments are now part of the routine pattern. A more predictable food or travel fallback would reduce that pressure." + if in_exam_period: + return ( + f"Exam dates are active and routine pressure is visible. Keep the next meal predictable{f' near {safe_daily_copy}' if safe_daily_copy else ''} and avoid stacking extra spend." + ) + return str(primary_action.get("detail") or "Routine needs a reset today.") + + +def _meal_signal_summary( + *, + meal_events_count_7d: int, + current_food_gap_hours: float | None, + avg_food_gap_hours_7d: float | None, +) -> dict[str, Any]: + if meal_events_count_7d <= 0: + return { + "key": "food_gap", + "label": "Meal signal", + "state": "missing", + "value": "Missing", + "severity": "watch", + "detail": "No food payment or meal check-in was recorded this week.", + } + + gap_hours = max(0.0, float(current_food_gap_hours or 0.0)) + avg_gap = max(0.0, float(avg_food_gap_hours_7d or 0.0)) + if gap_hours >= 14 or avg_gap > 10: + state = "stale" + severity = "attention" + detail = "The last meal signal is old. Log mess, cash, or home meals before ordering again." + elif gap_hours >= 9 or avg_gap > 6: + state = "due" + severity = "watch" + detail = "The next meal should stay simple, and cash or mess meals should be checked in if no payment appears." + else: + state = "current" + severity = "ok" + detail = "Recent food payments or meal check-ins are keeping the routine signal current." + + return { + "key": "food_gap", + "label": "Meal signal", + "state": state, + "value": _format_gap_value(gap_hours), + "severity": severity, + "detail": detail, + } + + +def _runway_signal_summary(runway_days: int) -> dict[str, Any]: + if runway_days < 5: + severity = "attention" + detail = "Allowance may not last the cycle unless the next few days stay tighter." + elif runway_days < 10: + severity = "watch" + detail = "Runway is getting shorter, so the next routine spends should stay deliberate." + else: + severity = "ok" + detail = "Runway still looks stable against the current pace." + + return { + "key": "runway", + "label": "Runway", + "state": "stable" if severity == "ok" else "tight", + "value": f"{runway_days}d", + "severity": severity, + "detail": detail, + } + + +def _late_night_signal_summary(late_night_activity_7d: int) -> dict[str, Any]: + if late_night_activity_7d > 3: + severity = "attention" + detail = "Repeated debit payments landed between 11PM and 5AM campus time." + elif late_night_activity_7d > 1: + severity = "watch" + detail = "A few debit payments landed between 11PM and 5AM campus time." + else: + severity = "ok" + detail = "No repeated after-hours payment pattern is showing right now." + + return { + "key": "late_night", + "label": "After-hours", + "state": "active" if severity != "ok" else "quiet", + "value": f"{late_night_activity_7d} payments", + "severity": severity, + "detail": detail, + } + + +def _velocity_signal_summary(spend_velocity: float) -> dict[str, Any]: + if spend_velocity > 1.4: + severity = "attention" + detail = "Spending is moving well above the safe daily pace." + elif spend_velocity > 1.2: + severity = "watch" + detail = "Spending is slightly above the safe daily pace." + else: + severity = "ok" + detail = "Spending pace is still close to the safe daily limit." + + return { + "key": "velocity", + "label": "Spend pace", + "state": "elevated" if severity != "ok" else "steady", + "value": f"{spend_velocity:.2f}x", + "severity": severity, + "detail": detail, + } + + +def _exam_signal_summary( + *, + in_exam_period: bool, + meal_signal: dict[str, Any], + runway_signal: dict[str, Any], + velocity_signal: dict[str, Any], +) -> dict[str, Any]: + if not in_exam_period: + return { + "key": "exam", + "label": "Exam window", + "state": "inactive", + "value": "Off", + "severity": "ok", + "detail": "No active exam dates are configured right now.", + } + + severity = "watch" + if meal_signal["state"] in {"missing", "stale"} or runway_signal["severity"] == "attention" or velocity_signal["severity"] == "attention": + severity = "attention" + + return { + "key": "exam", + "label": "Exam window", + "state": "active", + "value": "Active", + "severity": severity, + "detail": "Configured exam dates are active. Keep one predictable meal and review runway before late orders.", + } + + +def _primary_action_summary( + *, + meal_signal: dict[str, Any], + runway_signal: dict[str, Any], + velocity_signal: dict[str, Any], + late_night_signal: dict[str, Any], + runway_days: int, + safe_daily_limit_rs: float, + spend_velocity: float, + in_exam_period: bool, +) -> dict[str, Any]: + safe_daily_copy = f"Rs {round(safe_daily_limit_rs):.0f}" if safe_daily_limit_rs > 0 else None + + if meal_signal["state"] == "missing": + detail = ( + "PocketBuddy cannot tell whether you ate through mess, cash, or home food this week. " + "Log the last meal so Food Guard and runway stay accurate." + ) + if in_exam_period and safe_daily_copy: + detail += f" Keep the next meal near {safe_daily_copy} if you still need to eat." + return { + "key": "meal_checkin", + "title": "Log the last meal signal", + "detail": detail, + "cta_label": "Meal check-in", + "destination": "checkin", + } + + if in_exam_period and meal_signal["state"] in {"due", "stale"}: + detail = f"The last meal signal is {meal_signal['value']} old. Log it if you already ate" + if safe_daily_copy: + detail += f", or keep the next meal near {safe_daily_copy}" + detail += " so exam-day food stays predictable." + return { + "key": "meal_checkin", + "title": "Protect the next exam-day meal", + "detail": detail, + "cta_label": "Meal check-in", + "destination": "checkin", + } + + if runway_signal["severity"] == "attention": + if runway_days <= 0: + detail = "Today's runway is effectively exhausted at the current pace." + elif runway_days == 1: + detail = "You have about 1 day of runway left at the current pace." + else: + detail = f"You have about {runway_days} days of runway left at the current pace." + if safe_daily_copy: + detail += f" Keep today close to {safe_daily_copy}." + detail += " Review runway before placing another routine order." + return { + "key": "review_runway", + "title": "Review runway before the next spend", + "detail": detail, + "cta_label": "Review runway", + "destination": "runway", + } + + if velocity_signal["severity"] != "ok": + detail = "Use one low-spend window today so the next few routine payments do not stack." + if safe_daily_copy: + detail += f" Aim to stay near {safe_daily_copy} for the day." + return { + "key": "low_spend_window", + "title": "Slow the spend pace today", + "detail": detail, + "cta_label": "Review runway", + "destination": "runway", + } + + if late_night_signal["severity"] != "ok": + return { + "key": "after_hours_reset", + "title": "Keep after-hours spends intentional", + "detail": "A planned snack, ride, or meal option will reduce late orders and make the routine easier to manage.", + "cta_label": "Campus food", + "destination": "food", + } + + if meal_signal["state"] == "due": + detail = "The next meal should stay predictable. Log mess, cash, or home food if no payment record appears." + if safe_daily_copy: + detail += f" Keep it near {safe_daily_copy} if possible." + return { + "key": "meal_routine", + "title": "Keep the next meal simple", + "detail": detail, + "cta_label": "Campus food", + "destination": "food", + } + + if in_exam_period: + detail = "Exam dates are active, so one predictable meal and one planned spend decision will keep the day cleaner." + if safe_daily_copy: + detail += f" Stay near {safe_daily_copy} for routine food." + return { + "key": "exam_routine", + "title": "Keep the routine predictable today", + "detail": detail, + "cta_label": "Campus food", + "destination": "food", + } + + return { + "key": "keep_steady", + "title": "Keep the routine predictable", + "detail": "Nothing urgent is showing right now. Keep meals regular and stay inside the current safe daily spend.", + "cta_label": "Review runway", + "destination": "runway", + } + + +def _severity_weight(severity: str, *, watch: int, attention: int) -> int: + if severity == "attention": + return attention + if severity == "watch": + return watch + return 0 + + +def _format_gap_value(hours: float) -> str: + if hours < 1: + return "<1h" + return f"{round(hours):.0f}h" diff --git a/backend/tests/test_checkins_api.py b/backend/tests/test_checkins_api.py new file mode 100644 index 0000000..0a2a270 --- /dev/null +++ b/backend/tests/test_checkins_api.py @@ -0,0 +1,71 @@ +import os +import unittest +from unittest.mock import patch + +os.environ.setdefault("JWT_SECRET", "test-secret") +os.environ.setdefault("MONGO_URI", "mongodb://localhost:27017/pocketbuddy_test") + +from app.api import checkins # noqa: E402 + + +class FakeCheckinLogs: + def __init__(self): + self.inserted = [] + + async def insert_one(self, doc): + self.inserted.append(doc) + return {"inserted_id": doc["_id"]} + + +class FakeDb: + def __init__(self): + self.checkin_logs = FakeCheckinLogs() + + +class CheckinApiTests(unittest.IsolatedAsyncioTestCase): + async def test_meal_checkin_normalizes_source_and_context_note(self): + db = FakeDb() + req = checkins.CheckinReq( + response=" Meal_Logged ", + food_gap_hours=9.5, + meal_source="MESS", + context_note="x" * 520, + suggestion_given="meal_gap_checkin", + ) + + with patch("app.api.checkins.get_db", return_value=db): + result = await checkins.insert_checkin(req, user_id="user-1") + + self.assertEqual(result["status"], "ok") + doc = db.checkin_logs.inserted[0] + self.assertEqual(doc["user_id"], "user-1") + self.assertEqual(doc["response"], "meal_logged") + self.assertEqual(doc["meal_source"], "mess") + self.assertTrue(doc["is_meal_signal"]) + self.assertEqual(doc["gap_hours"], 9.5) + self.assertEqual(doc["food_gap_hours"], 9.5) + self.assertEqual(len(doc["context_note"]), 500) + self.assertEqual(doc["stress_note"], doc["context_note"]) + + async def test_skipped_meal_uses_legacy_note_but_does_not_reset_meal_signal(self): + db = FakeDb() + req = checkins.CheckinReq( + response="meal_skipped", + food_gap_hours=11, + meal_source="outside_cash", + stress_note="mess closed", + ) + + with patch("app.api.checkins.get_db", return_value=db): + await checkins.insert_checkin(req, user_id="user-2") + + doc = db.checkin_logs.inserted[0] + self.assertEqual(doc["response"], "meal_skipped") + self.assertEqual(doc["context_note"], "mess closed") + self.assertEqual(doc["stress_note"], "mess closed") + self.assertEqual(doc["meal_source"], "outside_cash") + self.assertFalse(doc["is_meal_signal"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_wellness_signals.py b/backend/tests/test_wellness_signals.py new file mode 100644 index 0000000..a1f0485 --- /dev/null +++ b/backend/tests/test_wellness_signals.py @@ -0,0 +1,181 @@ +import datetime as dt +import os +import unittest + +os.environ.setdefault("JWT_SECRET", "test-secret") +os.environ.setdefault("MONGO_URI", "mongodb://localhost:27017/pocketbuddy_test") + +from app.services.wellness import ( # noqa: E402 + INDIA_STANDARD_TIME_OFFSET_MINUTES, + average_meal_gap_hours, + build_wellness_summary, + current_meal_gap_hours, + is_debit_transaction, + is_late_night_activity, + is_meal_checkin, + meal_signal_events, +) + + +class WellnessSignalTests(unittest.TestCase): + def test_late_night_activity_uses_clear_11pm_to_5am_window(self): + day = dt.datetime(2026, 7, 9) + + self.assertTrue(is_late_night_activity(day.replace(hour=23, minute=10))) + self.assertTrue(is_late_night_activity(day.replace(hour=4, minute=59))) + self.assertFalse(is_late_night_activity(day.replace(hour=5, minute=0))) + self.assertFalse(is_late_night_activity(day.replace(hour=22, minute=59))) + + def test_late_night_activity_can_use_campus_timezone_offset(self): + day = dt.datetime(2026, 7, 9) + + self.assertTrue(is_late_night_activity( + day.replace(hour=18, minute=0), + timezone_offset_minutes=INDIA_STANDARD_TIME_OFFSET_MINUTES, + )) + self.assertFalse(is_late_night_activity( + day.replace(hour=11, minute=0), + timezone_offset_minutes=INDIA_STANDARD_TIME_OFFSET_MINUTES, + )) + + def test_meal_checkin_counts_without_creating_transaction(self): + now = dt.datetime(2026, 7, 9, 15, 0) + transactions = [ + {"category": "food", "created_at": now - dt.timedelta(hours=12)}, + ] + checkins = [ + { + "response": "meal_logged", + "meal_source": "mess", + "created_at": now - dt.timedelta(hours=2), + } + ] + + events = meal_signal_events(transactions, checkins) + + self.assertEqual(len(events), 2) + self.assertEqual(events[-1]["source"], "checkin") + self.assertEqual(current_meal_gap_hours(now, events), 2) + + def test_skipped_meal_context_does_not_reset_gap(self): + now = dt.datetime(2026, 7, 9, 15, 0) + transactions = [ + {"category": "food", "created_at": now - dt.timedelta(hours=9)}, + ] + checkins = [ + { + "response": "meal_skipped", + "created_at": now - dt.timedelta(hours=1), + } + ] + + events = meal_signal_events(transactions, checkins) + + self.assertFalse(is_meal_checkin(checkins[0])) + self.assertEqual(len(events), 1) + self.assertEqual(current_meal_gap_hours(now, events), 9) + + def test_meal_source_is_enough_for_forward_compatible_meal_logs(self): + checkin = { + "response": "new_meal_label", + "meal_source": "cooked", + "created_at": dt.datetime(2026, 7, 9, 13, 0), + } + + self.assertTrue(is_meal_checkin(checkin)) + + def test_average_meal_gap_includes_current_gap(self): + now = dt.datetime(2026, 7, 9, 18, 0) + events = meal_signal_events( + [ + {"category": "food", "created_at": now - dt.timedelta(hours=10)}, + {"category": "food", "created_at": now - dt.timedelta(hours=4)}, + ], + [], + ) + + self.assertEqual(average_meal_gap_hours(now, events), 5) + + def test_credit_transactions_are_not_treated_as_debits(self): + self.assertFalse(is_debit_transaction({"direction": "credit", "amount": 1000})) + self.assertTrue(is_debit_transaction({"direction": "debit", "amount": 1000})) + self.assertTrue(is_debit_transaction({"amount": 1000})) + + def test_missing_meal_signal_requests_checkin_without_marking_attention(self): + summary = build_wellness_summary( + meal_events_count_7d=0, + current_food_gap_hours=None, + avg_food_gap_hours_7d=None, + late_night_activity_7d=0, + runway_days=15, + safe_daily_limit_rs=220, + spend_velocity=1.0, + in_exam_period=False, + ) + + self.assertEqual(summary["status"], "watch") + self.assertEqual(summary["primary_action"]["key"], "meal_checkin") + self.assertEqual(summary["signals"][0]["state"], "missing") + self.assertEqual(summary["signals"][0]["value"], "Missing") + + def test_exam_window_only_changes_context_when_other_signals_are_stable(self): + baseline = build_wellness_summary( + meal_events_count_7d=4, + current_food_gap_hours=4, + avg_food_gap_hours_7d=5, + late_night_activity_7d=0, + runway_days=16, + safe_daily_limit_rs=250, + spend_velocity=1.0, + in_exam_period=False, + ) + exam = build_wellness_summary( + meal_events_count_7d=4, + current_food_gap_hours=4, + avg_food_gap_hours_7d=5, + late_night_activity_7d=0, + runway_days=16, + safe_daily_limit_rs=250, + spend_velocity=1.0, + in_exam_period=True, + ) + + self.assertEqual(baseline["score"], exam["score"]) + self.assertEqual(exam["status"], "steady") + self.assertEqual(exam["signals"][-1]["severity"], "watch") + + def test_exam_period_prefers_meal_action_when_meal_signal_is_stale(self): + summary = build_wellness_summary( + meal_events_count_7d=2, + current_food_gap_hours=13, + avg_food_gap_hours_7d=11, + late_night_activity_7d=0, + runway_days=14, + safe_daily_limit_rs=180, + spend_velocity=1.0, + in_exam_period=True, + ) + + self.assertEqual(summary["primary_action"]["key"], "meal_checkin") + self.assertEqual(summary["signals"][0]["severity"], "attention") + self.assertIn("exam-day meal", summary["primary_action"]["title"].lower()) + + def test_runway_attention_message_and_action_are_not_duplicate_copy(self): + summary = build_wellness_summary( + meal_events_count_7d=3, + current_food_gap_hours=4, + avg_food_gap_hours_7d=5, + late_night_activity_7d=0, + runway_days=0, + safe_daily_limit_rs=0, + spend_velocity=1.6, + in_exam_period=False, + ) + + self.assertEqual(summary["primary_action"]["key"], "review_runway") + self.assertIn("effectively exhausted", summary["primary_action"]["detail"].lower()) + self.assertNotEqual(summary["message"], summary["primary_action"]["detail"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/frontend/src/routes/_authenticated/dashboard.lazy.tsx b/frontend/src/routes/_authenticated/dashboard.lazy.tsx index e7e879f..1949d8b 100644 --- a/frontend/src/routes/_authenticated/dashboard.lazy.tsx +++ b/frontend/src/routes/_authenticated/dashboard.lazy.tsx @@ -6,7 +6,7 @@ import { AppShell, MobileMenuButton } from "@/components/AppShell"; import { PlatformIcon } from "@/components/PlatformIcon"; import { Plus, ChevronRight, AlertTriangle, Users, Utensils, ShoppingBag, - Bus, Receipt, MoreHorizontal, Wallet, Timer, MessageSquare, Phone, Mail, MapPin, ExternalLink, Compass, TrendingDown, Calendar, + Bus, Receipt, MoreHorizontal, Wallet, Timer, MapPin, Compass, TrendingDown, Calendar, ChevronDown, ChevronUp } from "lucide-react"; import { Badge } from "@/components/ui/badge"; @@ -159,6 +159,10 @@ type Sub = any; type Pool = any; type PoolItem = any; +function isAttentionSignal(value?: string) { + return value === "attention" || value === "stressed"; +} + function transactionTrustLabel(txn: any) { if (txn.verification_status === "aa_verified" || txn.data_origin === "account_aggregator") return "Sandbox source"; if (txn.needs_verification || txn.verification_status === "needs_review") return "Needs review"; @@ -210,6 +214,38 @@ const CAT_COLORS: Record = { other: "#6b7280", }; +const CHECKIN_NOTE_PRESETS = [ + "Exam stretch", + "Mess closed", + "Travel day", + "Cash issue", +] as const; + +function isFiniteHours(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function formatMealGapHours( + hours: number | null | undefined, + options: { + missingLabel?: string; + justNowLabel?: string; + includeAgo?: boolean; + } = {}, +) { + const { + missingLabel = "—", + justNowLabel = "Just now", + includeAgo = true, + } = options; + + if (!isFiniteHours(hours) || hours < 0) return missingLabel; + if (hours < 1) return justNowLabel; + + const rounded = Math.round(hours); + return includeAgo ? `${rounded}h ago` : `${rounded}h`; +} + function CountUp({ to, duration = 400 }: { to: number; duration?: number }) { const [v, setV] = useState(0); useEffect(() => { @@ -258,39 +294,6 @@ function SpendBar({ days }: { days: { date: string; amount_paise: number }[] }) ); } -// ── Burnout Risk Gauge (SVG arc) ──────────────────────────────────────── -function BurnoutGauge({ score }: { score: number }) { - const r = 54, cx = 70, cy = 70; - const startAngle = 200, endAngle = 340; - const range = endAngle - startAngle; - const angleNow = startAngle + (score / 100) * range; - const toRad = (a: number) => (a * Math.PI) / 180; - const arcX = (a: number) => cx + r * Math.cos(toRad(a)); - const arcY = (a: number) => cy + r * Math.sin(toRad(a)); - const largeArc = range > 180 ? 1 : 0; - const trackPath = `M ${arcX(startAngle)} ${arcY(startAngle)} A ${r} ${r} 0 ${largeArc} 1 ${arcX(endAngle)} ${arcY(endAngle)}`; - const filledAngle = startAngle + (score / 100) * range; - const filledLargeArc = (filledAngle - startAngle) > 180 ? 1 : 0; - const fillPath = score > 0 - ? `M ${arcX(startAngle)} ${arcY(startAngle)} A ${r} ${r} 0 ${filledLargeArc} 1 ${arcX(filledAngle)} ${arcY(filledAngle)}` - : ""; - const color = score >= 70 ? "#ef4444" : score >= 40 ? "#f59e0b" : "#4ade80"; - const label = score >= 70 ? "HIGH RISK" : score >= 40 ? "MODERATE" : "HEALTHY"; - return ( -
- - - {fillPath && ( - - )} - {score} - {label} - -
- ); -} - // ── Survive-Until countdown ────────────────────────────────────────────── // ── Category donut (pure SVG) ──────────────────────────────────────────── function CategoryDonut({ breakdown }: { breakdown: { category: string; pct: number; amount_paise: number }[] }) { @@ -855,7 +858,7 @@ function Dashboard() { }); const wingEvents = wingFeed?.events ?? []; - // Burnout score derived from insights + // Runway score derived from insights const calc = useMemo(() => { if (!profile) return null; const totalAllowance = profile.monthly_allowance / 100; @@ -888,9 +891,9 @@ function Dashboard() { pct: Math.min(100, Math.round((totalSpent / totalAllowance) * 100)), unpaidPoolDebt, }; - }, [profile, txns, insights]); + }, [profile, txns, insights, user?.id]); - // Burnout score is now calculated on the backend via /api/insights/wellness + // Routine score is calculated on the backend via /api/insights/wellness const runwayView = useMemo(() => { if (!runwayForecast) return null; @@ -950,9 +953,11 @@ function Dashboard() { }); const menuFoodGapHours = useMemo(() => { + const serverGap = insights?.food?.gap_hours; + if (insights?.food?.last_signal_source && typeof serverGap === "number" && Number.isFinite(serverGap)) return serverGap; const lastFood = (txns ?? []).find((t) => t.category === "food"); return lastFood ? (Date.now() - new Date(lastFood.created_at).getTime()) / 3600000 : undefined; - }, [txns]); + }, [insights, txns]); const { data: foods } = useQuery({ queryKey: [ @@ -1070,6 +1075,24 @@ function Dashboard() { const [editingTxn, setEditingTxn] = useState(null); const [adding, setAdding] = useState(false); const [isWellnessExpanded, setIsWellnessExpanded] = useState(false); + const wellnessStatus = String(wellness?.status ?? ""); + const wellnessIsSteady = wellnessStatus === "steady"; + const wellnessIsWatch = wellnessStatus === "watch"; + const wellnessToneColor = wellnessIsSteady + ? "var(--pb-green)" + : wellnessIsWatch + ? "var(--pb-amber)" + : "var(--pb-red)"; + const wellnessToneBorder = wellnessIsSteady + ? "rgba(22,163,74,0.3)" + : wellnessIsWatch + ? "rgba(217,119,6,0.3)" + : "rgba(220,38,38,0.3)"; + const wellnessToneBackground = wellnessIsSteady + ? "rgba(22,163,74,0.05)" + : wellnessIsWatch + ? "rgba(217,119,6,0.05)" + : "rgba(220,38,38,0.05)"; // Student Fuel Swapper State const [showFoodSheet, setShowFoodSheet] = useState(false); @@ -1295,15 +1318,15 @@ function Dashboard() { // Exam check-in const [showCheckIn, setShowCheckIn] = useState(false); const [checkInExpanded, setCheckInExpanded] = useState(false); - const [stressNote, setStressNote] = useState(""); + const [checkInMealSource, setCheckInMealSource] = useState<"mess" | "cooked" | "home" | "outside_cash" | null>(null); + const [checkInNote, setCheckInNote] = useState(""); + const [checkInSaving, setCheckInSaving] = useState<"ate" | "skipped" | null>(null); const checkinChecked = useRef(false); - - // Red State Wellness Check-in - const [redCheckinText, setRedCheckinText] = useState(""); - const [redCheckinSubmitting, setRedCheckinSubmitting] = useState(false); + const checkInStorageKey = `pocketbuddy_last_checkin_${user?.id ?? "anon"}`; + const checkInSnoozeKey = `pocketbuddy_checkin_snooze_${user?.id ?? "anon"}`; useEffect(() => { - if (checkinChecked.current || !profile || !txns) return; + if (checkinChecked.current || !profile || !txns || !insights) return; checkinChecked.current = true; const now = new Date(); if (!profile.exam_start_date || !profile.exam_end_date) return; @@ -1312,12 +1335,23 @@ function Dashboard() { now <= new Date(profile.exam_end_date + "T23:59:59"); if (!inExam) return; const lastFood = txns.find((t) => t.category === "food"); - const hours = lastFood ? (Date.now() - new Date(lastFood.created_at).getTime()) / 3600000 : 999; + const fallbackHours = lastFood ? (Date.now() - new Date(lastFood.created_at).getTime()) / 3600000 : 999; + const hours = insights.food?.last_signal_source && typeof insights.food?.gap_hours === "number" ? insights.food.gap_hours : fallbackHours; if (hours < 16) return; - const lastCk = localStorage.getItem("pocketbuddy_last_checkin"); + const snoozedAt = localStorage.getItem(checkInSnoozeKey); + if (snoozedAt && Date.now() - parseInt(snoozedAt, 10) < 4 * 3600000) return; + const lastCk = localStorage.getItem(checkInStorageKey); if (lastCk && Date.now() - parseInt(lastCk, 10) < 16 * 3600000) return; setShowCheckIn(true); - }, [profile, txns]); + }, [profile, txns, insights, checkInStorageKey, checkInSnoozeKey]); + + useEffect(() => { + if (!showCheckIn) { + setCheckInExpanded(false); + setCheckInMealSource(null); + setCheckInNote(""); + } + }, [showCheckIn]); const search = Route.useSearch(); useEffect(() => { @@ -1327,8 +1361,58 @@ function Dashboard() { } }, [search.log]); - - const foodGapHours = menuFoodGapHours ?? 0; + const hasFoodGapSignal = isFiniteHours(menuFoodGapHours); + const foodGapHours = hasFoodGapSignal ? menuFoodGapHours : 0; + const examActive = Boolean(insights?.exam?.in_exam_period); + const examMealGapHours = + typeof insights?.food?.gap_hours === "number" && insights?.food?.last_signal_source + ? insights.food.gap_hours + : foodGapHours; + const examNeedsMealSignal = examActive && (!insights?.food?.last_signal_source || examMealGapHours >= 8); + const examFoodCapPaise = runwayView?.foodRoutine?.recommended_daily_food_cap ?? runwayView?.safeDailyPaise ?? 0; + const examBestFoodFitsCap = Boolean(bestFood && (!examFoodCapPaise || Number(bestFood.price ?? 0) <= examFoodCapPaise)); + const examMealPlan = bestFood + ? { + title: `${bestFood.item_name} at ${bestFood.venue_name}`, + detail: `${rupees(bestFood.price)}${examBestFoodFitsCap ? " fits" : " is closest to"} today's exam food target${examFoodCapPaise > 0 ? ` of ${rupees(examFoodCapPaise)}` : ""}.`, + } + : { + title: "Use mess, cooked food, or a trusted campus meal", + detail: examFoodCapPaise > 0 + ? `Keep the next meal near ${rupees(examFoodCapPaise)} so exam-week spending stays predictable.` + : "Keep the next meal predictable and log cash or mess food if there is no payment record.", + }; + const examMealGapKnown = + (typeof insights?.food?.gap_hours === "number" && Boolean(insights?.food?.last_signal_source)) || + hasFoodGapSignal; + const examMealGapLabel = formatMealGapHours(examMealGapKnown ? examMealGapHours : null, { + missingLabel: "No signal", + includeAgo: false, + }); + const examMealSignalSource = + insights?.food?.last_signal_source === "checkin" + ? "Check-in" + : insights?.food?.last_signal_source === "transaction" + ? "Payment" + : "Missing"; + const routineMealSignalLine = insights?.food?.last_signal_source + ? `Last meal signal: ${examMealSignalSource.toLowerCase()}, ${formatMealGapHours(insights.food.gap_hours)}` + : hasFoodGapSignal + ? `Last meal signal: recent food spend, ${formatMealGapHours(foodGapHours)}` + : "Last meal signal: no payment or check-in yet"; + const checkInMealSignalLead = !hasFoodGapSignal + ? "No recent meal signal is available yet." + : foodGapHours < 1 + ? "PocketBuddy already saw a recent food payment or meal check-in just now." + : `PocketBuddy has not seen a food payment or meal check-in for ${Math.round(foodGapHours)} hours.`; + const checkInMealSignalFollowup = hasFoodGapSignal && foodGapHours < 1 + ? "Only add a check-in if that meal happened through mess, cooking, home food, or cash so Food Guard and runway stay accurate." + : "If you ate through mess, cooking, home food, or cash, log it so Food Guard and runway stay accurate."; + const wellnessPrimaryAction = wellness?.primary_action as + | { key?: string; title?: string; detail?: string; cta_label?: string; destination?: string } + | undefined; + const wellnessActionDestination = String(wellnessPrimaryAction?.destination ?? ""); + const isPrimaryWellnessAction = (destination: string) => wellnessActionDestination === destination; // ── Smart nudges derived from insights ────────────────────────────────── const [dismissedNudges, setDismissedNudges] = useState>(new Set()); @@ -1368,19 +1452,8 @@ function Dashboard() { id: "late_night", icon: Timer, accent: "#5E17EB", - title: "Late-night spending detected", - body: `₹${Math.round(lateTotal)} spent between 11PM–4AM this month. Late orders often cost 1.5× more with surge fees — try stocking room snacks.`, - }); - } - - // Exam stress - if (insights.exam?.in_exam_period) { - list.push({ - id: "exam_stress", - icon: AlertTriangle, - accent: "#ef4444", - title: `Exam period — ${insights.exam.days_left}d left`, - body: runwayView?.foodRoutine?.action?.detail ?? "Your budget matters most right now. Keep meals predictable and avoid using exam buffer for routine food.", + title: "After-hours payment signal", + body: `₹${Math.round(lateTotal)} spent after-hours in the last 30 days. Keep a low-cost snack or mess backup so late orders do not shrink runway.`, }); } @@ -1409,135 +1482,76 @@ function Dashboard() { } return list; - }, [insights, calc, runwayView]); + }, [insights, calc, runwayView, examActive]); const visibleNudges = nudges.filter((n) => !dismissedNudges.has(n.id)).slice(0, 2); async function handleCheckInAte() { - if (!user) return; - await insertTransaction({ - data: { - amount: 0, - raw_merchant_string: "Self-reported: Ate at mess", - mapped_merchant_name: "Self-reported", - category: "food", - source: "manual", - }, - }); - await insertCheckinLog({ - data: { - response: "ate", - food_gap_hours: foodGapHours, - }, - }); - localStorage.setItem("pocketbuddy_last_checkin", String(Date.now())); - setShowCheckIn(false); - qc.invalidateQueries({ queryKey: ["txns"] }); - toast.success("Great, keep fueling through exams 💪"); - } - - async function handleCheckInSkipped() { - if (!user) return; - const suggestion = bestFood - ? `${bestFood.venue_name} ${bestFood.item_name} ${rupees(bestFood.price)}` - : "Campus Café"; - await insertCheckinLog({ - data: { - response: "skipped", - food_gap_hours: foodGapHours, - suggestion_given: suggestion, - stress_note: stressNote, - }, - }); - localStorage.setItem("pocketbuddy_last_checkin", String(Date.now())); - setShowCheckIn(false); - setStressNote(""); - setCheckInExpanded(false); - if (bestFood) { - toast(`${bestFood.venue_name} has ${bestFood.item_name} (${rupees(bestFood.price)}) — go grab something.`); - } - } - - async function handleWellnessAction(action: "ate" | "break" | "spending") { - if (!user || !wellness) return; - const foodGapSig = wellness.signals?.find((s: any) => s.key === "food_gap")?.value || "0"; - const foodGapHoursNum = parseFloat(foodGapSig); - - let response = ""; - let stress_note = ""; - let toastMsg = ""; - - if (action === "ate") { - response = "wellness_ate"; - stress_note = "User tapped wellness check-in: I ate"; - toastMsg = "Great, logged! Keep fueling through the week 💪"; - - try { - await insertTransaction({ - data: { - amount: 0, - raw_merchant_string: "Self-reported: Ate at mess", - mapped_merchant_name: "Self-reported", - category: "food", - source: "manual", - }, - }); - } catch (err) { - // transaction insert optional failure handling - } - } else if (action === "break") { - response = "wellness_need_break"; - stress_note = "User tapped wellness check-in: I need a break"; - toastMsg = "Take a breather. A 15-minute break does wonders ☕"; - } else { - response = "wellness_plan_spending"; - stress_note = "User tapped wellness check-in: I'll plan spending"; - toastMsg = "Smart! Planning your spends keeps your runway safe 📊"; + if (!user || checkInSaving) return; + if (!checkInMealSource) { + toast.error("Select where you ate so PocketBuddy updates the right meal signal."); + return; } - + setCheckInSaving("ate"); try { await insertCheckinLog({ data: { - response, - stress_note, - suggestion_given: "wellness_index", - food_gap_hours: foodGapHoursNum, + response: "meal_logged", + food_gap_hours: foodGapHours, + meal_source: checkInMealSource, + suggestion_given: "meal_gap_checkin", }, }); - toast.success(toastMsg); - qc.invalidateQueries({ queryKey: ["wellness-insights"] }); + localStorage.setItem(checkInStorageKey, String(Date.now())); + setShowCheckIn(false); + setCheckInExpanded(false); qc.invalidateQueries({ queryKey: ["insights"] }); - qc.invalidateQueries({ queryKey: ["txns"] }); + qc.invalidateQueries({ queryKey: ["wellness-insights"] }); + qc.invalidateQueries({ queryKey: ["campus-intel"] }); + qc.invalidateQueries({ queryKey: ["foods"] }); + qc.invalidateQueries({ queryKey: ["runway-forecast"] }); qc.invalidateQueries({ queryKey: ["wing-feed"] }); - } catch (err) { - toast.error("Failed to submit check-in"); + toast.success("Meal check-in saved. Runway and campus food signals are updated."); + } catch (err: any) { + toast.error(err?.message || "Could not save meal check-in."); + } finally { + setCheckInSaving(null); } } - async function handleRedCheckinSubmit(e: React.FormEvent) { - e.preventDefault(); - if (!redCheckinText.trim() || !user || !wellness) return; - setRedCheckinSubmitting(true); + async function handleCheckInSkipped() { + if (!user || checkInSaving) return; + setCheckInSaving("skipped"); + const suggestion = bestFood + ? `${bestFood.venue_name} ${bestFood.item_name} ${rupees(bestFood.price)}` + : "Campus Café"; try { - const foodGapSig = wellness.avg_food_gap_hours_7d || 0; await insertCheckinLog({ data: { - response: "wellness_text_response", - stress_note: `User wellness check-in: ${redCheckinText}`, - food_gap_hours: foodGapSig, - suggestion_given: "wellness_index", + response: "meal_skipped", + food_gap_hours: foodGapHours, + suggestion_given: suggestion, + context_note: checkInNote, }, }); - toast.success("Thank you for sharing. Hang in there!"); - setRedCheckinText(""); + localStorage.setItem(checkInSnoozeKey, String(Date.now())); + setShowCheckIn(false); + setCheckInNote(""); + setCheckInExpanded(false); qc.invalidateQueries({ queryKey: ["wellness-insights"] }); qc.invalidateQueries({ queryKey: ["insights"] }); - qc.invalidateQueries({ queryKey: ["txns"] }); + qc.invalidateQueries({ queryKey: ["campus-intel"] }); + qc.invalidateQueries({ queryKey: ["runway-forecast"] }); qc.invalidateQueries({ queryKey: ["wing-feed"] }); - } catch (err) { - toast.error("Failed to submit check-in"); + if (bestFood) { + toast(`Logged. Campus Food Guard suggests ${bestFood.item_name} at ${bestFood.venue_name} (${rupees(bestFood.price)}).`); + } else { + toast("Logged. Campus Food Guard can help when you are ready to eat."); + } + } catch (err: any) { + toast.error(err?.message || "Could not save delayed meal note."); } finally { - setRedCheckinSubmitting(false); + setCheckInSaving(null); } } @@ -1580,15 +1594,9 @@ function Dashboard() { {/* ── Main Column ─────────────────────────────────────────────── */}
- {/* Student Wellness Index Card */} -
-
+ {/* Routine Signal Index Card */} +
+
{wellnessLoading ? (
@@ -1598,13 +1606,13 @@ function Dashboard() {
) : wellnessError ? (
-

Wellness metrics unavailable

-

We couldn't load your wellness metrics. Please try again later.

+

Routine signals unavailable

+

We couldn't load your routine signals. Please try again later.

) : (txns ?? []).length === 0 ? (

No Transaction History

-

Add a few spends to build your wellness pattern.

+

Add a few spends to build your routine pattern.

- ) : (wellness?.status === "steady" && !isWellnessExpanded) ? ( -
setIsWellnessExpanded(true)} - > -
- -

Student Wellness Index:

- {wellness.score} - - Steady - - - {wellness.message || "Your spending and meal habits are currently steady and within safe bounds."} - -
-
- Details - -
-
) : ( -
- {/* Expanded Header: restore large prominent score numbers and badge side-by-side */} -
-
-

- Student Wellness Index -

-
- {wellness.score} - / 100 Wellness Score -
-
- -
- - {wellness.status === "steady" ? "STEADY" : wellness.status === "watch" ? "WATCH" : "STRESSED"} + (wellnessIsSteady && !isWellnessExpanded) ? ( +
setIsWellnessExpanded(true)} + > +
+ +

Routine Signal Index:

+ {wellness.score} + + Steady - - {wellness?.status === "steady" && ( - - )} + + {wellness.message || "Your spending and meal signals are currently steady and within today's runway targets."} +
-
- -

- {wellness.status === "stressed" - ? "We noticed a stack of stressful signals today. Remember, your runway and meals don't define you. Taking it one step at a time is enough. You can do this." - : wellness.message} -

- -
-

Contributing Signals

- -
- {wellness.signals?.map((sig: any) => ( -
- {sig.label}: - {sig.value} - -
- ))} +
+ Details +
- - {wellness.status === "watch" && ( -
- Quick Check-in: -
- - - + ) : ( +
+
+
+

+ Routine Signal Index +

+
+ {wellness.score} + / 100 Routine Score +
-
- )} - {wellness.status === "stressed" && ( -
-
- {/* Column 1: Check-in Form */} -
-

Submit Feedback Check-in

-