From b905c6486599520b1c4ee1bc8b905bf7aaaf11b9 Mon Sep 17 00:00:00 2001 From: Kanika Date: Thu, 9 Jul 2026 07:05:32 +0530 Subject: [PATCH 1/4] Harden wellness routine signals --- backend/app/api/checkins.py | 22 +- backend/app/api/insights.py | 202 +++--- backend/app/api/rag.py | 53 +- backend/app/services/wellness.py | 108 +++ backend/tests/test_wellness_signals.py | 99 +++ .../routes/_authenticated/dashboard.lazy.tsx | 659 +++++++++--------- .../src/routes/_authenticated/onboarding.tsx | 4 +- frontend/src/routes/index.tsx | 20 +- 8 files changed, 726 insertions(+), 441 deletions(-) create mode 100644 backend/app/services/wellness.py create mode 100644 backend/tests/test_wellness_signals.py 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 a34a0c3..871f02b 100644 --- a/backend/app/api/insights.py +++ b/backend/app/api/insights.py @@ -3,6 +3,14 @@ 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, + current_meal_gap_hours, + is_late_night_activity, + is_meal_checkin, + meal_signal_events, +) import datetime import calendar import re @@ -20,6 +28,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 +49,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 +85,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 +114,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 +133,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 +188,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 +197,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 +264,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 +274,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, @@ -361,39 +387,39 @@ async def get_wellness_insights(user_id: str = Depends(get_current_user)): 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) + 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 + 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=168.0) + avg_food_gap_hours_7d = average_meal_gap_hours(now, meal_events_7d, default=168.0) # Calculate unpaid pool debts (committed spend runway impact) unpaid_pool_debt_paise = 0 @@ -473,35 +499,22 @@ 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 + signal_basis = [ + "food_payments", + "meal_checkins", + "runway", + "after_hours_payments", + "spend_velocity", + "exam_dates", + ] - # Calculate Wellness Score + # Calculate routine score score = 100 - # Sleep: late_night_spend_7d > 3 (-20), > 1 (-10) - if late_night_spend_7d > 3: + # Repeated after-hours payments raise budget uncertainty. + if late_night_activity_7d > 3: score -= 20 - elif late_night_spend_7d > 1: + elif late_night_activity_7d > 1: score -= 10 # Meal regularity: avg_food_gap_hours_7d > 10 (-20), > 6 (-10) @@ -516,7 +529,7 @@ def local_name_key(v): elif runway_days < 10: score -= 10 - # Exam pressure: in_exam_window: -15 + # Exam window: keep food and safe spend more predictable during configured dates. if in_exam_period: score -= 15 @@ -526,25 +539,21 @@ def local_name_key(v): 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." + message = "Your routine signals look steady this week. Keep meals predictable 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." + message = "A few spending and meal signals need attention. Pick one reset today: log a meal, keep a low-spend window, or review the runway before ordering." 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." + status = "attention" + label = "Routine needs attention" + message = "Several routine signals are stacked today. PocketBuddy only uses spends and check-ins here; start with one meal signal and one planned spend decision, then check the runway again." # Construct signals list signals = [] @@ -552,21 +561,21 @@ def local_name_key(v): # Food gap signal food_gap_severity = "ok" if avg_food_gap_hours_7d > 10: - food_gap_severity = "stressed" + food_gap_severity = "attention" elif avg_food_gap_hours_7d > 6: food_gap_severity = "watch" signals.append({ "key": "food_gap", - "label": "Avg Food gap", + "label": "Meal signal 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" + "detail": "Food payments or meal check-ins are spaced out" if food_gap_severity != "ok" else "Meal payment/check-in signals are regular" }) # Runway signal runway_severity = "ok" if runway_days < 5: - runway_severity = "stressed" + runway_severity = "attention" elif runway_days < 10: runway_severity = "watch" signals.append({ @@ -577,24 +586,24 @@ def local_name_key(v): "detail": "Allowance may not last the cycle" if runway_severity != "ok" else "Runway looks stable" }) - # Late night signal + # After-hours payment signal late_night_severity = "ok" - if late_night_spend_7d > 3: - late_night_severity = "stressed" - elif late_night_spend_7d > 1: + if late_night_activity_7d > 3: + late_night_severity = "attention" + elif late_night_activity_7d > 1: late_night_severity = "watch" signals.append({ "key": "late_night", - "label": "Late-night spending", - "value": f"{late_night_spend_7d} txns", + "label": "After-hours payments", + "value": f"{late_night_activity_7d} txns", "severity": late_night_severity, - "detail": "Frequent late-night activity" if late_night_severity != "ok" else "Healthy nighttime routine" + "detail": "Repeated payments between 11PM and 5AM campus time" if late_night_severity != "ok" else "No repeated after-hours payment signal" }) # Velocity signal velocity_severity = "ok" if spend_velocity > 1.4: - velocity_severity = "stressed" + velocity_severity = "attention" elif spend_velocity > 1.2: velocity_severity = "watch" signals.append({ @@ -602,27 +611,17 @@ def local_name_key(v): "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" + "detail": "Spending is accelerating rapidly" if velocity_severity == "attention" 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" + exam_severity = "attention" 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" + "detail": "Exam dates are active; keep food and safe spend predictable" if in_exam_period else "No active exam window" }) return { @@ -632,7 +631,14 @@ def local_name_key(v): "message": message, "signals": signals, "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), + "current_food_gap_hours": round(current_food_gap_hours, 1), + "meal_checkins_7d": meal_checkins_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 0e66c18..f3dcc36 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..e5d16a2 --- /dev/null +++ b/backend/app/services/wellness.py @@ -0,0 +1,108 @@ +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 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 diff --git a/backend/tests/test_wellness_signals.py b/backend/tests/test_wellness_signals.py new file mode 100644 index 0000000..8c21855 --- /dev/null +++ b/backend/tests/test_wellness_signals.py @@ -0,0 +1,99 @@ +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, + current_meal_gap_hours, + 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) + + +if __name__ == "__main__": + unittest.main() diff --git a/frontend/src/routes/_authenticated/dashboard.lazy.tsx b/frontend/src/routes/_authenticated/dashboard.lazy.tsx index 94d1454..817b0f2 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"; @@ -258,39 +262,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 ────────────────────────────────────────────── function SurviveCountdown({ runwayMs }: { runwayMs: number }) { const [tick, setTick] = useState(0); @@ -815,7 +786,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; @@ -848,9 +819,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 && !calc) return null; @@ -915,9 +886,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: [ @@ -1032,6 +1005,25 @@ function Dashboard() { const [adding, setAdding] = useState(false); const [showFoodSheet, setShowFoodSheet] = useState(false); const [isWellnessExpanded, setIsWellnessExpanded] = useState(false); + const wellnessStatus = String(wellness?.status ?? ""); + const wellnessIsSteady = wellnessStatus === "steady"; + const wellnessIsWatch = wellnessStatus === "watch"; + const wellnessNeedsAttention = isAttentionSignal(wellnessStatus); + 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)"; // Food scanner and crowdsourced verification state & hooks const [foodTab, setFoodTab] = useState<"menus" | "add" | "signals" | "verify">("menus"); @@ -1254,15 +1246,13 @@ 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">("mess"); + 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); - 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; @@ -1271,12 +1261,13 @@ 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 lastCk = localStorage.getItem(`pocketbuddy_last_checkin_${user?.id ?? "anon"}`); if (lastCk && Date.now() - parseInt(lastCk, 10) < 16 * 3600000) return; setShowCheckIn(true); - }, [profile, txns]); + }, [profile, txns, insights]); const search = Route.useSearch(); useEffect(() => { @@ -1288,6 +1279,36 @@ function Dashboard() { const foodGapHours = 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 = examMealGapHours > 0 && examMealGapHours < 900; + const examMealGapLabel = examMealGapKnown ? `${Math.round(examMealGapHours)}h` : "No signal"; + const examMealGapProgress = examMealGapKnown + ? Math.min(100, Math.round((Math.min(examMealGapHours, 8) / 8) * 100)) + : 100; + const examMealSignalSource = + insights?.food?.last_signal_source === "checkin" + ? "Check-in" + : insights?.food?.last_signal_source === "transaction" + ? "Payment" + : "Missing"; // ── Smart nudges derived from insights ────────────────────────────────── const [dismissedNudges, setDismissedNudges] = useState>(new Set()); @@ -1327,19 +1348,19 @@ 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.`, + 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.`, }); } - // Exam stress - if (insights.exam?.in_exam_period) { + // Exam window + if (examActive) { list.push({ - id: "exam_stress", + id: "exam_window", 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: `Exam window - ${insights.exam.days_left}d left`, + body: runwayView?.foodRoutine?.action?.detail ?? "Keep meals predictable and review today's safe spend before delivery or subscriptions.", }); } @@ -1368,135 +1389,72 @@ 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; + 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(`pocketbuddy_last_checkin_${user?.id ?? "anon"}`, 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(`pocketbuddy_last_checkin_${user?.id ?? "anon"}`, 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); } } @@ -1539,15 +1497,9 @@ function Dashboard() { {/* ── Main Column ─────────────────────────────────────────────── */}
- {/* Student Wellness Index Card */} -
-
+ {/* Routine Signal Index Card */} +
+
{wellnessLoading ? (
@@ -1557,13 +1509,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) ? ( + ) : (wellnessIsSteady && !isWellnessExpanded) ? (
setIsWellnessExpanded(true)} >
- -

Student Wellness Index:

+ +

Routine Signal Index:

{wellness.score} Steady - {wellness.message || "Your spending and meal habits are currently steady and within safe bounds."} + {wellness.message || "Your spending and meal signals are currently steady and within today's runway targets."}
@@ -1597,30 +1549,29 @@ function Dashboard() {
) : (
- {/* Expanded Header: restore large prominent score numbers and badge side-by-side */}

- Student Wellness Index + Routine Signal Index

{wellness.score} - / 100 Wellness Score + / 100 Routine Score
- {wellness.status === "steady" ? "STEADY" : wellness.status === "watch" ? "WATCH" : "STRESSED"} + {wellnessIsSteady ? "STEADY" : wellnessIsWatch ? "WATCH" : "NEEDS ATTENTION"} - {wellness?.status === "steady" && ( + {wellnessIsSteady && (

- {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." + {wellnessNeedsAttention + ? wellness.message || "Several routine signals are stacked today. PocketBuddy only uses spends and check-ins here; start with one meal signal and one planned spend decision." : wellness.message}

@@ -1647,7 +1598,7 @@ function Dashboard() {
{wellness.signals?.map((sig: any) => (
{sig.label}: {sig.value} ))}
+

+ {wellness.disclaimer || "Routine score uses payments, meal check-ins, runway, spend pace, and exam dates only; it is not medical advice."} +

- {wellness.status === "watch" && ( + {!wellnessIsSteady && (
- Quick Check-in: -
+ Next actions: +
- -
-
- )} - - {wellness.status === "stressed" && ( -
-
- {/* Column 1: Check-in Form */} -
-

Submit Feedback Check-in

-