diff --git a/backend/app/api/checkins.py b/backend/app/api/checkins.py index db50d8d..ae9b3c6 100644 --- a/backend/app/api/checkins.py +++ b/backend/app/api/checkins.py @@ -18,6 +18,9 @@ class CheckinReq(BaseModel): @router.post("") async def insert_checkin(req: CheckinReq, user_id: str = Depends(get_current_user)): db = get_db() + thirty_days_ago = datetime.datetime.utcnow() - datetime.timedelta(days=30) + await db.checkin_logs.delete_many({"user_id": user_id, "created_at": {"$lt": thirty_days_ago}}) + log_id = str(uuid.uuid4()) gap_hours = req.gap_hours if req.gap_hours is not None else req.food_gap_hours await db.checkin_logs.insert_one({ diff --git a/backend/app/api/insights.py b/backend/app/api/insights.py index d945a40..eaed370 100644 --- a/backend/app/api/insights.py +++ b/backend/app/api/insights.py @@ -346,284 +346,16 @@ async def get_runway_forecast(user_id: str = Depends(get_current_user)): @router.get("/wellness") async def get_wellness_insights(user_id: str = Depends(get_current_user)): db = get_db() - now = datetime.datetime.utcnow() - - # Fetch last 60 days of transactions - since = now - datetime.timedelta(days=60) - cursor = db.transactions.find({"user_id": user_id, "created_at": {"$gte": since}}).sort("created_at", -1) - txns = await cursor.to_list(length=2000) - - # Fetch user profile - profile = await db.profiles.find_one({"_id": user_id}) - if not profile: - profile = {} - - # 1. Late-night activity (last 7 days, hours 0 to 4) - since_7 = now - datetime.timedelta(days=7) - late_txns_7d = [ - t for t in txns - if t.get("created_at") and t["created_at"] >= since_7 and ( - 0 <= t["created_at"].hour < 5 - ) - ] - late_night_spend_7d = len(late_txns_7d) - - # 2. Meal regularity: avg_food_gap_hours_7d - food_txns_7d = [ - t for t in txns - if t.get("category") == "food" and t.get("created_at") and t["created_at"] >= since_7 - ] - food_txns_7d.sort(key=lambda t: t["created_at"]) - - gaps_7d = [] - if len(food_txns_7d) > 0: - for i in range(1, len(food_txns_7d)): - gap = (food_txns_7d[i]["created_at"] - food_txns_7d[i-1]["created_at"]).total_seconds() / 3600.0 - gaps_7d.append(gap) - current_gap = (now - food_txns_7d[-1]["created_at"]).total_seconds() / 3600.0 - gaps_7d.append(current_gap) - avg_food_gap_hours_7d = sum(gaps_7d) / len(gaps_7d) - else: - avg_food_gap_hours_7d = 168.0 - - # Calculate unpaid pool debts (committed spend runway impact) - unpaid_pool_debt_paise = 0 - user_doc = await db.users.find_one({"_id": user_id}) - full_name = user_doc.get("full_name", "") if user_doc else "" - if full_name and profile and profile.get("wing_label"): - wing_label = profile["wing_label"] - def local_name_key(v): - return " ".join((v or "").strip().split()).casefold() - - async for p in db.cart_pools.find({"wing_label": wing_label, "status": "completed", "host_id": {"$ne": user_id}}): - pool_id = p["_id"] - items_cursor = db.cart_pool_items.find({"pool_id": pool_id}) - items = await items_cursor.to_list(length=1000) - - participants = list(set(it["added_by_name"] for it in items if it.get("is_purchased", True))) - user_items = [it for it in items if it.get("is_purchased", True) and local_name_key(it["added_by_name"]) == local_name_key(full_name)] - if user_items: - payments = p.get("payments", []) - user_payment = next((pay for pay in payments if local_name_key(pay["name"]) == local_name_key(full_name)), None) - if not user_payment or user_payment.get("status") != "verified": - p_items_total = sum(it["estimated_price"] for it in user_items) - final_overhead = p.get("final_overhead", 0) - final_discount = p.get("final_discount", 0) - net_overhead = final_overhead - final_discount - num_people = len(participants) - overhead_share = int(net_overhead / num_people) if num_people > 0 else 0 - unpaid_pool_debt_paise += (p_items_total + overhead_share) - - # 3. Financial runway & safe daily limit - cycle_start_day = profile.get("cycle_start_day") or 1 - monthly_allowance = profile.get("monthly_allowance") or 1000000 # in paise - total_allowance_rs = monthly_allowance / 100 - - cycle_start = get_cycle_start(cycle_start_day, now) - cycle_end = get_cycle_end(cycle_start) - - cycle_txns = [t for t in txns if t.get("created_at") and t["created_at"] >= cycle_start] - total_spent_rs = (sum(t.get("amount", 0) for t in cycle_txns) + unpaid_pool_debt_paise) / 100 - remaining_rs = max(0.0, total_allowance_rs - total_spent_rs) - - days_since_start = max(1, (now - cycle_start).days) - avg_daily_spend_rs = total_spent_rs / days_since_start - days_left = max(1, (cycle_end - now).days) - - if avg_daily_spend_rs > 0: - runway_days = int(remaining_rs / avg_daily_spend_rs) - else: - runway_days = days_left - - runway_days = min(runway_days, days_left + 5) - - # safe_daily_limit_rs - safe_daily_limit_rs = remaining_rs / days_left - - # 4. Spending velocity - spend_7_rs = sum(t.get("amount", 0) for t in txns if t.get("created_at") and t["created_at"] >= since_7) / 100.0 - avg_daily_spend_7d_rs = spend_7_rs / 7.0 - - if safe_daily_limit_rs > 0: - spend_velocity = avg_daily_spend_7d_rs / safe_daily_limit_rs - else: - spend_velocity = 1.5 if avg_daily_spend_7d_rs > 0 else 0.0 - - # 5. Exam window - in_exam_period = False - if profile: - exam_start = profile.get("exam_start_date") - exam_end = profile.get("exam_end_date") - if exam_start and exam_end: - try: - import datetime as dt - es = dt.datetime.fromisoformat(str(exam_start)) - ee = dt.datetime.fromisoformat(str(exam_end) + "T23:59:59") - if es <= now <= ee: - in_exam_period = True - except Exception: - pass - - # 6. Social signal from cart pools - user_doc = await db.users.find_one({"_id": user_id}) - full_name = user_doc.get("full_name", "") if user_doc else "" - user_hosted_pools = await db.cart_pools.find({"host_id": user_id}).to_list(length=100) - participated_pool_ids = [] - if full_name: - import re - name_regex = re.compile(f"^{re.escape(full_name)}$", re.IGNORECASE) - user_items = await db.cart_pool_items.find({"added_by_name": name_regex}).to_list(length=500) - participated_pool_ids = [item["pool_id"] for item in user_items] - - all_pool_ids = list(set([p["_id"] for p in user_hosted_pools] + participated_pool_ids)) - if all_pool_ids: - latest_pools = await db.cart_pools.find({"_id": {"$in": all_pool_ids}}).sort("created_at", -1).to_list(length=1) - if latest_pools: - last_pool_time = latest_pools[0].get("created_at") - days_since_last_pool = (now - last_pool_time).days - else: - days_since_last_pool = None - else: - days_since_last_pool = None - - # Calculate Wellness Score - score = 100 - - # Sleep: late_night_spend_7d > 3 (-20), > 1 (-10) - if late_night_spend_7d > 3: - score -= 20 - elif late_night_spend_7d > 1: - score -= 10 - - # Meal regularity: avg_food_gap_hours_7d > 10 (-20), > 6 (-10) - if avg_food_gap_hours_7d > 10: - score -= 20 - elif avg_food_gap_hours_7d > 6: - score -= 10 - - # Runway: runway_days < 5 (-20), < 10 (-10) - if runway_days < 5: - score -= 20 - elif runway_days < 10: - score -= 10 - - # Exam pressure: in_exam_window: -15 - if in_exam_period: - score -= 15 - - # Spending control: spend_velocity > 1.4 (-15), > 1.2 (-8) - if spend_velocity > 1.4: - score -= 15 - elif spend_velocity > 1.2: - score -= 8 - - # Social signal: days_since_last_pool > 7 (-10) - if days_since_last_pool is not None and days_since_last_pool > 7: - score -= 10 - - score = max(0, min(100, score)) - - # Determine status bucket and messages - if score >= 70: - status = "steady" - label = "Your routine looks steady" - message = "Your routine looks steady this week. Keep meals regular and stay within today's safe spend target." - elif score >= 50: - status = "watch" - label = "A few patterns need attention" - message = "A few patterns need attention: your food timing, spending pace, or exam pressure is starting to stack up. Pick one reset today: a proper meal, a low-spend window, or a short break." - else: - status = "stressed" - label = "Pattern suggests high stress" - message = "Your recent pattern suggests you may be stretched thin. You do not need to fix everything today; start with one meal and one planned spend decision, then check in again." - - # Construct signals list - signals = [] - - # Food gap signal - food_gap_severity = "ok" - if avg_food_gap_hours_7d > 10: - food_gap_severity = "stressed" - elif avg_food_gap_hours_7d > 6: - food_gap_severity = "watch" - signals.append({ - "key": "food_gap", - "label": "Avg Food gap", - "value": f"{avg_food_gap_hours_7d:.1f}h" if avg_food_gap_hours_7d < 168.0 else "—", - "severity": food_gap_severity, - "detail": "Long gaps between meals detected" if food_gap_severity != "ok" else "Regular meal timing" - }) - - # Runway signal - runway_severity = "ok" - if runway_days < 5: - runway_severity = "stressed" - elif runway_days < 10: - runway_severity = "watch" - signals.append({ - "key": "runway", - "label": "Runway", - "value": f"{runway_days} days" if runway_days is not None else "—", - "severity": runway_severity, - "detail": "Allowance may not last the cycle" if runway_severity != "ok" else "Runway looks stable" - }) - - # Late night signal - late_night_severity = "ok" - if late_night_spend_7d > 3: - late_night_severity = "stressed" - elif late_night_spend_7d > 1: - late_night_severity = "watch" - signals.append({ - "key": "late_night", - "label": "Late-night spending", - "value": f"{late_night_spend_7d} txns", - "severity": late_night_severity, - "detail": "Frequent late-night activity" if late_night_severity != "ok" else "Healthy nighttime routine" - }) - - # Velocity signal - velocity_severity = "ok" - if spend_velocity > 1.4: - velocity_severity = "stressed" - elif spend_velocity > 1.2: - velocity_severity = "watch" - signals.append({ - "key": "velocity", - "label": "Spend velocity", - "value": f"{spend_velocity:.2f}x", - "severity": velocity_severity, - "detail": "Spending is accelerating rapidly" if velocity_severity == "stressed" else "Spending is slightly elevated" if velocity_severity == "watch" else "Spending velocity is stable" - }) - - # Exam signal - exam_severity = "stressed" if in_exam_period else "ok" - signals.append({ - "key": "exam", - "label": "Exam period", - "value": "Active" if in_exam_period else "No", - "severity": exam_severity, - "detail": "Active exam schedule" if in_exam_period else "No exams active" - }) - - # Social signal from cart pools - pool_severity = "stressed" if (days_since_last_pool is not None and days_since_last_pool > 7) else "ok" - signals.append({ - "key": "cart_pool", - "label": "Social index", - "value": f"{days_since_last_pool}d idle" if days_since_last_pool is not None else "None", - "severity": pool_severity, - "detail": "Low cart pool participation recently (possible social withdrawal)" if pool_severity == "stressed" else "Active in cart pools" - }) - + from app.services.wellness import compute_wellness + package = await compute_wellness(db, user_id) return { - "score": score, - "status": status, - "label": label, - "message": message, - "signals": signals, - "generated_by": "local_rules", - "avg_food_gap_hours_7d": round(avg_food_gap_hours_7d, 1) + "score": package["score"], + "status": package["status"], + "label": package["label"], + "message": package["message"], + "signals": package["signals"], + "generated_by": package["generated_by"], + "avg_food_gap_hours_7d": package["avg_food_gap_hours_7d"] } diff --git a/backend/app/api/wellness.py b/backend/app/api/wellness.py new file mode 100644 index 0000000..d804ca8 --- /dev/null +++ b/backend/app/api/wellness.py @@ -0,0 +1,202 @@ +"""Routine check API (feature 7.7). + +Turns the deterministic routine signal engine into a practical check-in +experience: an AI-narrated read on the week, concrete resets, and a lightweight +check-in log with streaks. + +Framing rule: detect routine and money signals, then offer a nudge. Never +diagnose. +""" + +import datetime +import uuid +from typing import Optional + +from fastapi import APIRouter, Depends +from pydantic import BaseModel + +from app.core.database import get_db +from app.core.security import get_current_user +from app.services.wellness import ( + build_reset_actions, + compute_wellness, + generate_supportive_message, +) + +router = APIRouter() + +# Responses that count as a wellness check-in (used for streaks/history). +WELLNESS_RESPONSES = { + "wellness_checkin", + "wellness_ate", + "wellness_need_break", + "wellness_plan_spending", + "wellness_text_response", + "wellness_mood", +} + +MOOD_LABELS = { + "good": "Feeling good", + "okay": "Doing okay", + "stretched": "Stretched thin", +} + + +def _weather_of(status: str) -> dict[str, str]: + """Reframe the score as gentle 'week weather'.""" + return { + "steady": {"weather": "calm", "emoji": "sun", "headline": "Stable routine"}, + "watch": {"weather": "cloudy", "emoji": "cloud", "headline": "Needs attention"}, + "stressed": {"weather": "stormy", "emoji": "storm", "headline": "Reset suggested"}, + }.get(status, {"weather": "calm", "emoji": "sun", "headline": "Stable routine"}) + + +async def _streak_and_history(db, user_id: str, limit: int = 8): + cursor = db.checkin_logs.find( + {"user_id": user_id, "response": {"$in": list(WELLNESS_RESPONSES)}} + ).sort("created_at", -1) + logs = await cursor.to_list(length=400) + + # Distinct check-in days (UTC date) for streak calculation. + days = sorted( + {log["created_at"].date() for log in logs if log.get("created_at")}, + reverse=True, + ) + streak = 0 + today = datetime.datetime.utcnow().date() + expected = today + for d in days: + if d == expected: + streak += 1 + expected = expected - datetime.timedelta(days=1) + elif d == expected - datetime.timedelta(days=1): + # allow the streak to still count if they missed *today* but checked in yesterday + if streak == 0 and d == today - datetime.timedelta(days=1): + streak += 1 + expected = d - datetime.timedelta(days=1) + else: + break + else: + break + + history = [] + for log in logs[:limit]: + history.append({ + "id": str(log.get("_id")), + "response": log.get("response"), + "mood": log.get("mood"), + "note": log.get("stress_note"), + "created_at": log["created_at"].isoformat() if log.get("created_at") else None, + }) + + return {"streak": streak, "total": len(logs), "history": history} + + +@router.get("/checkin") +async def get_checkin(user_id: str = Depends(get_current_user)): + """The routine check package for a compact nudge/check-in UI.""" + db = get_db() + + # Enforce 30-day privacy retention policy: check-in logs are automatically purged after 30 days. + # Note: PocketBuddy enforces a strict "no cross-user aggregation" constraint to protect student privacy. + thirty_days_ago = datetime.datetime.utcnow() - datetime.timedelta(days=30) + await db.checkin_logs.delete_many({"user_id": user_id, "created_at": {"$lt": thirty_days_ago}}) + package = await compute_wellness(db, user_id) + metrics = package["metrics"] + + # NOTE: the Bedrock-narrated line is served separately by GET /coach so the + # dashboard card renders instantly. Here we return the deterministic message. + message, source = package["message"], "local_rules" + + weather = _weather_of(package["status"]) + + # Only surface the patterns that actually need attention. + patterns = [s for s in package["signals"] if s.get("severity") in ("watch", "stressed")] + + streak_info = await _streak_and_history(db, user_id, limit=8) + + return { + "score": package["score"], + "status": package["status"], + "label": package["label"], + "weather": weather["weather"], + "emoji": weather["emoji"], + "headline": weather["headline"], + "message": message, + "ai_source": source, + "signals": package["signals"], + "drivers": package["drivers"], + "primary_driver": package["primary_driver"], + "driver_summary": package["driver_summary"], + "patterns": patterns, + "reset_actions": build_reset_actions(metrics), + "metrics": metrics, + "streak": streak_info["streak"], + "total_checkins": streak_info["total"], + "recent_checkins": streak_info["history"], + } + + +@router.get("/coach") +async def get_coach(user_id: str = Depends(get_current_user)): + """AI-narrated routine check line (Bedrock, deterministic fallback). + + Served on its own so the dashboard card renders instantly and the grounded + message loads when ready, never blocking the page. + """ + db = get_db() + profile = await db.profiles.find_one({"_id": user_id}) or {} + package = await compute_wellness(db, user_id) + message, source = generate_supportive_message(package, profile) + return {"message": message, "source": source, "status": package["status"]} + + +@router.get("/history") +async def get_history(user_id: str = Depends(get_current_user)): + db = get_db() + return await _streak_and_history(db, user_id, limit=20) + + +class WellnessCheckinReq(BaseModel): + mood: Optional[str] = None # good | okay | stretched + action: Optional[str] = None # reset action key, if triggered from a card + response: Optional[str] = None # explicit response override + note: Optional[str] = None # free-text reflection + score: Optional[int] = None # wellness score captured at check-in time + food_gap_hours: Optional[float] = None + + +@router.post("/checkin") +async def post_checkin(req: WellnessCheckinReq, user_id: str = Depends(get_current_user)): + db = get_db() + + # Enforce 30-day privacy retention policy: check-in logs are automatically purged after 30 days. + # Note: PocketBuddy enforces a strict "no cross-user aggregation" constraint to protect student privacy. + thirty_days_ago = datetime.datetime.utcnow() - datetime.timedelta(days=30) + await db.checkin_logs.delete_many({"user_id": user_id, "created_at": {"$lt": thirty_days_ago}}) + + response = req.response + if not response: + response = "wellness_mood" if req.mood else "wellness_checkin" + + note = req.note + if not note and req.mood: + note = f"Mood check-in: {MOOD_LABELS.get(req.mood, req.mood)}" + + log_id = str(uuid.uuid4()) + await db.checkin_logs.insert_one({ + "_id": log_id, + "user_id": user_id, + "response": response, + "mood": req.mood, + "action": req.action, + "gap_hours": req.food_gap_hours or 0, + "food_gap_hours": req.food_gap_hours or 0, + "suggestion_given": "wellness_companion", + "score_at_checkin": req.score, + "stress_note": note, + "created_at": datetime.datetime.utcnow(), + }) + + streak_info = await _streak_and_history(db, user_id, limit=8) + return {"status": "ok", "id": log_id, "streak": streak_info["streak"], "total": streak_info["total"]} diff --git a/backend/app/main.py b/backend/app/main.py index 3b25ee2..844b8ef 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -18,6 +18,7 @@ transactions, webhook, travel, + wellness, ) app = FastAPI(title="PocketBuddy API") @@ -43,6 +44,7 @@ app.include_router(seed.router, prefix="/api/seed", tags=["seed"]) app.include_router(insights.router, prefix="/api/insights", tags=["insights"]) app.include_router(travel.router, prefix="/api/travel", tags=["travel"]) +app.include_router(wellness.router, prefix="/api/wellness", tags=["wellness"]) app.include_router(webhook.router, prefix="/api/ingest", tags=["ingest"]) app.include_router(webhook.router, prefix="/webhook", tags=["webhook"]) diff --git a/backend/app/services/wellness.py b/backend/app/services/wellness.py new file mode 100644 index 0000000..00c3cab --- /dev/null +++ b/backend/app/services/wellness.py @@ -0,0 +1,495 @@ +"""Wellness signal engine (feature 7.7). + +Deterministic-first design: this module computes wellness *signals* from real +spend/meal/exam/social data, then optionally lets Bedrock narrate a warm, +supportive check-in. The engine never diagnoses — it detects risk patterns and +offers a supportive check-in with concrete, campus-relevant resets. + +Consumers: + - app/api/insights.py -> legacy /api/insights/wellness (dashboard card) + - app/api/wellness.py -> /api/wellness/* (the Wellness Companion feature) +""" + +import calendar +import datetime +import re +from typing import Any, Optional + + +# ── Cycle helpers ────────────────────────────────────────────────────────── +def get_cycle_start(cycle_start_day: int, now: datetime.datetime) -> datetime.datetime: + y, m, d = now.year, now.month, now.day + try: + candidate = datetime.datetime(y, m, cycle_start_day, 0, 0, 0) + except ValueError: + _, max_days = calendar.monthrange(y, m) + candidate = datetime.datetime(y, m, min(cycle_start_day, max_days), 0, 0, 0) + + if d >= candidate.day: + return candidate + + prev_m = m - 1 if m > 1 else 12 + prev_y = y if m > 1 else y - 1 + try: + return datetime.datetime(prev_y, prev_m, cycle_start_day, 0, 0, 0) + except ValueError: + _, max_days = calendar.monthrange(prev_y, prev_m) + return datetime.datetime(prev_y, prev_m, min(cycle_start_day, max_days), 0, 0, 0) + + +def get_cycle_end(cycle_start: datetime.datetime) -> datetime.datetime: + return cycle_start + datetime.timedelta(days=30) + + +def _severity_of(value: float, watch: float, stressed: float, invert: bool = False) -> str: + """Return ok/watch/stressed. If invert, lower values are worse.""" + if invert: + if value < stressed: + return "stressed" + if value < watch: + return "watch" + return "ok" + if value > stressed: + return "stressed" + if value > watch: + return "watch" + return "ok" + + +async def compute_wellness(db, user_id: str) -> dict[str, Any]: + """Compute the full wellness signal package for a user. + + Returns a dict that is backward-compatible with the legacy dashboard card + (score/status/label/message/signals/generated_by/avg_food_gap_hours_7d) and + additionally exposes a `metrics` block with the raw values the Wellness + Companion feature builds resets and support decisions from. + """ + now = datetime.datetime.utcnow() + retention_cutoff = now - datetime.timedelta(days=30) + await db.checkin_logs.delete_many({"user_id": user_id, "created_at": {"$lt": retention_cutoff}}) + + 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) + + profile = await db.profiles.find_one({"_id": user_id}) or {} + + # 1. Late-night activity (last 7 days, 00:00–05:00) — sleep-disruption proxy + since_7 = now - datetime.timedelta(days=7) + late_txns_7d = [ + t for t in txns + if t.get("created_at") and t["created_at"] >= since_7 and 0 <= t["created_at"].hour < 5 + ] + late_night_spend_7d = len(late_txns_7d) + + # 2. Meal regularity — average gap between food transactions or check-in meal events over 7 days + # This prevents false food-gaps for students eating prepaid mess/home food. + ate_logs_cursor = db.checkin_logs.find({ + "user_id": user_id, + "created_at": {"$gte": since_7}, + "response": "wellness_ate" + }) + ate_logs = await ate_logs_cursor.to_list(length=100) + + meal_events = [] + for t in txns: + if t.get("category") == "food" and t.get("created_at") and t["created_at"] >= since_7: + meal_events.append(t["created_at"]) + for l in ate_logs: + if l.get("created_at"): + meal_events.append(l["created_at"]) + + meal_events.sort() + + if meal_events: + gaps_7d = [ + (meal_events[i] - meal_events[i - 1]).total_seconds() / 3600.0 + for i in range(1, len(meal_events)) + ] + gaps_7d.append((now - meal_events[-1]).total_seconds() / 3600.0) + avg_food_gap_hours_7d = sum(gaps_7d) / len(gaps_7d) + current_food_gap_hours = (now - meal_events[-1]).total_seconds() / 3600.0 + else: + avg_food_gap_hours_7d = 168.0 + current_food_gap_hours = 168.0 + + # 3. Financial runway & safe daily limit + cycle_start_day = profile.get("cycle_start_day") or 1 + monthly_allowance = profile.get("monthly_allowance") or 1000000 # 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) / 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) + + runway_days = int(remaining_rs / avg_daily_spend_rs) if avg_daily_spend_rs > 0 else days_left + runway_days = min(runway_days, days_left + 5) + safe_daily_limit_rs = remaining_rs / days_left + + # 4. Spending velocity (recent daily pace vs safe target) + spend_7_rs = sum( + t.get("amount", 0) for t in txns if t.get("created_at") and t["created_at"] >= since_7 + ) / 100.0 + avg_daily_spend_7d_rs = spend_7_rs / 7.0 + if safe_daily_limit_rs > 0: + spend_velocity = avg_daily_spend_7d_rs / safe_daily_limit_rs + else: + spend_velocity = 1.5 if avg_daily_spend_7d_rs > 0 else 0.0 + + # 5. Exam window + in_exam_period = False + exam_days_left: Optional[int] = None + exam_start = profile.get("exam_start_date") + exam_end = profile.get("exam_end_date") + if exam_start and exam_end: + try: + es = datetime.datetime.fromisoformat(str(exam_start)) + ee = datetime.datetime.fromisoformat(str(exam_end) + "T23:59:59") + if es <= now <= ee: + in_exam_period = True + exam_days_left = (ee - now).days + except Exception: + pass + + # 6. Social signal — days since last cart-pool participation (withdrawal proxy) + 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: list = [] + if full_name: + 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({p["_id"] for p in user_hosted_pools} | set(participated_pool_ids)) + days_since_last_pool: Optional[int] = None + 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 and latest_pools[0].get("created_at"): + days_since_last_pool = (now - latest_pools[0]["created_at"]).days + + # ── Wellness score (deterministic, explainable) ────────────────────────── + # Every deduction is attributed to a *driver* bucket so we can tell the + # student WHAT is pressuring them — the money-vs-routine-vs-academic split is + # PocketBuddy's differentiator: only a money app that also reads routine can + # say "this week's stress is mostly financial." + score = 100 + driver_points = {"money": 0, "routine": 0, "academic": 0} + + def deduct(points: int, driver: str) -> None: + nonlocal score + score -= points + driver_points[driver] += points + + if late_night_spend_7d > 3: + deduct(20, "routine") + elif late_night_spend_7d > 1: + deduct(10, "routine") + + if avg_food_gap_hours_7d > 10: + deduct(20, "routine") + elif avg_food_gap_hours_7d > 6: + deduct(10, "routine") + + if runway_days < 5: + deduct(20, "money") + elif runway_days < 10: + deduct(10, "money") + + if in_exam_period: + deduct(15, "academic") + + if spend_velocity > 1.4: + deduct(15, "money") + elif spend_velocity > 1.2: + deduct(8, "money") + + score = max(0, min(100, score)) + + if score >= 70: + status = "steady" + label = "Stable routine" + message = ( + "Your routine looks steady this week. Keep meals regular and stay " + "within today's safe spend target." + ) + elif score >= 50: + status = "watch" + label = "Needs 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 = "Reset suggested" + message = ( + "Your recent spending or routine pattern suggests a reset. You do not need " + "to adjust everything today; start with one proper meal and one planned spend " + "decision, then check in again." + ) + + # ── Signals (same shape the dashboard already consumes) ─────────────────── + signals = [] + + food_gap_sev = _severity_of(avg_food_gap_hours_7d, 6, 10) + signals.append({ + "key": "food_gap", + "label": "Meal gap", + "value": f"{avg_food_gap_hours_7d:.1f}h" if avg_food_gap_hours_7d < 168.0 else "—", + "severity": food_gap_sev, + "detail": "Long gap between meals" if food_gap_sev != "ok" else "Meal timing looks regular", + }) + + runway_sev = _severity_of(runway_days, 10, 5, invert=True) + signals.append({ + "key": "runway", + "label": "Runway pressure", + "value": f"{runway_days} days", + "severity": runway_sev, + "detail": "Allowance may not last the cycle" if runway_sev != "ok" else "Runway looks stable", + }) + + late_sev = _severity_of(late_night_spend_7d, 1, 3) + signals.append({ + "key": "late_night", + "label": "Late-night activity", + "value": f"{late_night_spend_7d} txns", + "severity": late_sev, + "detail": "Frequent late-night activity" if late_sev != "ok" else "Night spend pattern is quiet", + }) + + vel_sev = _severity_of(spend_velocity, 1.2, 1.4) + signals.append({ + "key": "velocity", + "label": "Spend velocity", + "value": f"{spend_velocity:.2f}x", + "severity": vel_sev, + "detail": ( + "Spending is accelerating rapidly" if vel_sev == "stressed" + else "Spending is slightly elevated" if vel_sev == "watch" + else "Spending velocity is stable" + ), + }) + + exam_sev = "stressed" if in_exam_period else "ok" + signals.append({ + "key": "exam", + "label": "Exam window", + "value": "Active" if in_exam_period else "Clear", + "severity": exam_sev, + "detail": "Active exam schedule" if in_exam_period else "No exam window active", + }) + + signals.append({ + "key": "cart_pool", + "label": "Shared-order activity", + "value": f"{days_since_last_pool}d idle" if days_since_last_pool is not None else "None", + "severity": "ok", + "detail": "No recent cart pool orders" if (days_since_last_pool is not None and days_since_last_pool > 7) else "Regular shared orders", + }) + + # ── Stress-source attribution (the standout insight) ───────────────────── + driver_meta = { + "money": {"label": "Money", "color": "#ef4444"}, + "routine": {"label": "Routine", "color": "#f59e0b"}, + "academic": {"label": "Academics", "color": "#8b5cf6"}, + } + total_deducted = sum(driver_points.values()) + drivers = [] + if total_deducted > 0: + for key, pts in driver_points.items(): + if pts > 0: + drivers.append({ + "key": key, + "label": driver_meta[key]["label"], + "color": driver_meta[key]["color"], + "points": pts, + "pct": round(pts / total_deducted * 100), + }) + drivers.sort(key=lambda d: -d["points"]) + + primary_driver = drivers[0]["key"] if drivers else None + driver_summary = { + "money": "Most of this week's pressure looks financial — your runway and spending pace are the biggest factors.", + "routine": "Most of this week's pressure is routine-related — meal timing and late nights are adding up.", + "academic": "Exam pressure is the biggest factor this week. Protecting meals and sleep matters most right now.", + }.get(primary_driver, "Your money, meals, and routine all look balanced this week.") + + return { + "score": score, + "status": status, + "label": label, + "message": message, + "signals": signals, + "drivers": drivers, + "primary_driver": primary_driver, + "driver_summary": driver_summary, + "generated_by": "local_rules", + "avg_food_gap_hours_7d": round(avg_food_gap_hours_7d, 1), + "metrics": { + "late_night_spend_7d": late_night_spend_7d, + "avg_food_gap_hours_7d": round(avg_food_gap_hours_7d, 1), + "current_food_gap_hours": round(current_food_gap_hours, 1), + "runway_days": runway_days, + "safe_daily_limit_rs": round(safe_daily_limit_rs, 1), + "remaining_rs": round(remaining_rs, 1), + "spend_velocity": round(spend_velocity, 2), + "in_exam_period": in_exam_period, + "exam_days_left": exam_days_left, + "days_since_last_pool": days_since_last_pool, + "hour": now.hour, + "has_data": len(txns) > 0, + }, + } + + +# ── Reset actions ─────────────────────────────────────────────────────────── +# Each reset is a small, concrete, campus-relevant next step. `kind` tells the +# frontend whether to navigate ("link") or log a check-in in place ("checkin"). +def build_reset_actions(metrics: dict[str, Any]) -> list[dict[str, Any]]: + actions: list[dict[str, Any]] = [] + m = metrics + + # Meal reminder — driven by food gap / late-night pattern + if m["avg_food_gap_hours_7d"] > 6 or m["current_food_gap_hours"] > 6: + actions.append({ + "key": "eat_something", + "icon": "utensils", + "title": "Have a proper meal", + "body": ( + f"It's been about {int(m['current_food_gap_hours'])}h since your last " + "food spend. Mess, home food, or a simple canteen plate works." + ), + "kind": "checkin", + "checkin_response": "wellness_ate", + "cta": "I just ate", + }) + + # Spending reset — driven by velocity / runway + if m["spend_velocity"] > 1.2 or m["runway_days"] < 10: + safe = m["safe_daily_limit_rs"] + actions.append({ + "key": "plan_spend", + "icon": "wallet", + "title": "Set today's spend window", + "body": ( + f"Your safe spend today is about ₹{int(safe)}. Planning one low-spend " + "day resets the pace without feeling restrictive." + ), + "kind": "checkin", + "checkin_response": "wellness_plan_spending", + "cta": "I'll plan my spends", + }) + + # Split instead of solo order — social + budget + actions.append({ + "key": "join_pool", + "icon": "users", + "title": "Split an order with your wing", + "body": ( + "Craving something? Joining a wing cart pool splits delivery fees and " + "keeps you connected instead of ordering solo." + ), + "kind": "link", + "to": "/pool", + "cta": "Open pools", + }) + + # Negotiate travel — surfaces when budget is tight + if m["runway_days"] < 12 or m["spend_velocity"] > 1.2: + actions.append({ + "key": "travel_fair", + "icon": "compass", + "title": "Check a fair fare before you travel", + "body": ( + "Heading out? A quick fare check avoids overpaying and protects the " + "runway you have left this cycle." + ), + "kind": "link", + "to": "/travel", + "cta": "Open travel guard", + }) + + # Take a break — exam / late-night pressure + if m["in_exam_period"] or m["late_night_spend_7d"] > 1: + actions.append({ + "key": "take_break", + "icon": "coffee", + "title": "Take a 15-minute reset", + "body": ( + "Step away from the screen, stretch, get some water. Short breaks " + "beat long late-night stretches during a heavy week." + ), + "kind": "checkin", + "checkin_response": "wellness_need_break", + "cta": "I'll take a break", + }) + + return actions + + +# ── Bedrock routine narration (deterministic fallback) ─────────────────────── +def _elevated_summary(signals: list[dict[str, Any]]) -> str: + parts = [] + for s in signals: + if s.get("severity") in ("watch", "stressed"): + parts.append(f"{s['label'].lower()} ({s['value']})") + return ", ".join(parts) if parts else "nothing standing out" + + +def generate_supportive_message(package: dict[str, Any], profile: dict[str, Any]) -> tuple[str, str]: + """Return (message, source). Bedrock narrates; falls back to rules on failure.""" + status = package["status"] + signals = package["signals"] + metrics = package["metrics"] + + if not metrics.get("has_data"): + return ( + "There isn't much activity to read yet. Once a few days of spends come " + "in, I'll give you a routine read on meals, timing, and runway.", + "local_rules", + ) + + elevated = _elevated_summary(signals) + primary = package.get("primary_driver") + + prompt = f"""You are PocketBuddy, a practical money and routine assistant for an Indian college student. +Write a short routine check (2 sentences, max 45 words) based strictly on spending pace, meal gaps, and study/exam routine. + +Rules: +- Keep the message focused strictly on money behavior, routine signals, and simple corrective resets. +- DO NOT use therapy-style or soothing language. +- DO NOT diagnose, and DO NOT use clinical words like burnout, depression, anxiety, disorder, stress patterns. +- Do not be preachy or alarmist. No emojis. No markdown. +- Name the main signal (e.g. money, meal gap, late-night activity, or exam window) in plain words, then point to ONE small next step. + +Overall read: {status}. Main pressure source: {primary or "nothing in particular"}. +Patterns worth noting: {elevated}. +Food gap: {metrics['avg_food_gap_hours_7d']}h. Runway: {metrics['runway_days']} days left. +Late-night activity (7d): {metrics['late_night_spend_7d']}. Exam period: {metrics['in_exam_period']}. + +Write only the message text.""" + + try: + from app.services.bedrock import generate_text + + text = generate_text(prompt, max_tokens=120, temperature=0.5) + if text: + return text.strip(), "bedrock" + except Exception: + pass + + return package["message"], "local_rules" diff --git a/frontend/src/lib/api/db.functions.ts b/frontend/src/lib/api/db.functions.ts index b505efd..0698eeb 100644 --- a/frontend/src/lib/api/db.functions.ts +++ b/frontend/src/lib/api/db.functions.ts @@ -206,6 +206,26 @@ export async function getWellnessInsights() { return apiRequest("/api/insights/wellness"); } +// ── Wellness Companion (feature 7.7) ──────────────────────────────────────── +export async function getWellnessCheckin() { + return apiRequest("/api/wellness/checkin"); +} + +export async function getWellnessHistory() { + return apiRequest("/api/wellness/history"); +} + +export async function getWellnessCoach() { + return apiRequest("/api/wellness/coach"); +} + +export async function submitWellnessCheckin({ data }: { data: { mood?: string; action?: string; response?: string; note?: string; score?: number; food_gap_hours?: number } }) { + return apiRequest("/api/wellness/checkin", { + method: "POST", + body: JSON.stringify(data), + }); +} + export async function getCatalog(catalogType: string) { return apiRequest(`/api/catalog/${catalogType}`); } @@ -354,4 +374,3 @@ export async function confirmTransaction({ id }: { id: string }) { return apiRequest(`/api/transactions/${id}/confirm`, { method: "POST" }); } - diff --git a/frontend/src/routes/_authenticated/dashboard.tsx b/frontend/src/routes/_authenticated/dashboard.tsx index 187c194..a24590a 100644 --- a/frontend/src/routes/_authenticated/dashboard.tsx +++ b/frontend/src/routes/_authenticated/dashboard.tsx @@ -5,8 +5,8 @@ import { useAuth } from "@/lib/auth-context"; 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 + Plus, ChevronRight, AlertTriangle, Utensils, ShoppingBag, + Receipt, Wallet, Timer, MapPin, Compass, TrendingDown, X } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -47,6 +47,7 @@ import { getCampusIntel, getWingFeed, getWellnessInsights, + getWellnessCoach, updateTransaction, getCatalog, addCatalogItem, @@ -58,6 +59,8 @@ import { } from "@/lib/api/db.functions"; +const ROUTINE_SIGNAL_KEYS = new Set(["food_gap", "late_night", "exam", "runway", "velocity"]); + export const Route = createFileRoute("/_authenticated/dashboard")({ ssr: false, validateSearch: (search: Record) => { @@ -476,6 +479,14 @@ function Dashboard() { queryFn: () => getWellnessInsights(), }); + const { data: routineCoach } = useQuery({ + queryKey: ["wellness-coach", user?.id], + enabled: !!user && !!wellness && (txns ?? []).length > 0, + staleTime: 5 * 60_000, + retry: false, + queryFn: () => getWellnessCoach(), + }); + const { data: campusIntel } = useQuery({ queryKey: ["campus-intel", user?.id], enabled: !!user, @@ -505,7 +516,7 @@ function Dashboard() { }); const wingEvents = wingFeed?.events ?? []; - // Burnout score derived from insights + // Routine and runway metrics derived from insights const calc = useMemo(() => { if (!profile) return null; const totalAllowance = profile.monthly_allowance / 100; @@ -540,7 +551,7 @@ function Dashboard() { }; }, [profile, txns, insights]); - // Burnout score is now calculated on the backend via /api/insights/wellness + // Routine check status is calculated on the backend via /api/insights/wellness // ── Survive-Until runway timestamp ───────────────────────────────────── const surviveUntilMs = useMemo(() => { @@ -715,12 +726,24 @@ function Dashboard() { // Exam check-in const [showCheckIn, setShowCheckIn] = useState(false); const [checkInExpanded, setCheckInExpanded] = useState(false); - const [stressNote, setStressNote] = useState(""); + const [checkInNote, setCheckInNote] = useState(""); const checkinChecked = useRef(false); - // Red State Wellness Check-in - const [redCheckinText, setRedCheckinText] = useState(""); - const [redCheckinSubmitting, setRedCheckinSubmitting] = useState(false); + const [dismissedRoutineNudgeKey, setDismissedRoutineNudgeKey] = useState( + () => localStorage.getItem("pocketbuddy_routine_nudge_dismissed_key") || "" + ); + const [routineActionState, setRoutineActionState] = useState(null); + const [routineTick, setRoutineTick] = useState(Date.now()); + + useEffect(() => { + if (routineActionState?.kind !== "break") return; + const id = window.setInterval(() => setRoutineTick(Date.now()), 1000); + return () => window.clearInterval(id); + }, [routineActionState?.kind]); useEffect(() => { if (checkinChecked.current || !profile || !txns) return; @@ -796,13 +819,13 @@ function Dashboard() { }); } - // Exam stress + // Exam window if (insights.exam?.in_exam_period) { list.push({ - id: "exam_stress", + id: "exam_window", icon: AlertTriangle, accent: "#ef4444", - title: `Exam period — ${insights.exam.days_left}d left`, + title: `Exam window — ${insights.exam.days_left}d left`, body: "Your budget matters most right now. Aim for mess meals to keep daily food cost under ₹80. Campus canteens are usually open late.", }); } @@ -869,12 +892,12 @@ function Dashboard() { response: "skipped", food_gap_hours: foodGapHours, suggestion_given: suggestion, - stress_note: stressNote, + stress_note: checkInNote, }, }); localStorage.setItem("pocketbuddy_last_checkin", String(Date.now())); setShowCheckIn(false); - setStressNote(""); + setCheckInNote(""); setCheckInExpanded(false); if (bestFood) { toast(`${bestFood.venue_name} has ${bestFood.item_name} (${rupees(bestFood.price)}) — go grab something.`); @@ -887,13 +910,13 @@ function Dashboard() { const foodGapHoursNum = parseFloat(foodGapSig); let response = ""; - let stress_note = ""; + let checkin_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 💪"; + checkin_note = "Routine check: ate a meal"; + toastMsg = "Meal logged. Routine check updated."; try { await insertTransaction({ @@ -910,23 +933,30 @@ function Dashboard() { } } 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 ☕"; + checkin_note = "Routine check: taking a 15-minute reset"; + toastMsg = "Reset logged. Check back after the break."; } 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 📊"; + checkin_note = "Routine check: planning today's spending"; + toastMsg = "Spend plan logged. Runway check updated."; } try { await insertCheckinLog({ data: { response, - stress_note, - suggestion_given: "wellness_index", + stress_note: checkin_note, + suggestion_given: "routine_check", food_gap_hours: foodGapHoursNum, }, }); + const startedAt = Date.now(); + setRoutineActionState({ + kind: action, + startedAt, + endsAt: action === "break" ? startedAt + 15 * 60_000 : undefined, + }); + setRoutineTick(startedAt); toast.success(toastMsg); qc.invalidateQueries({ queryKey: ["wellness-insights"] }); qc.invalidateQueries({ queryKey: ["insights"] }); @@ -937,30 +967,84 @@ function Dashboard() { } } - async function handleRedCheckinSubmit(e: React.FormEvent) { - e.preventDefault(); - if (!redCheckinText.trim() || !user || !wellness) return; - setRedCheckinSubmitting(true); - 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", - }, - }); - toast.success("Thank you for sharing. Hang in there!"); - setRedCheckinText(""); - qc.invalidateQueries({ queryKey: ["wellness-insights"] }); - qc.invalidateQueries({ queryKey: ["insights"] }); - qc.invalidateQueries({ queryKey: ["txns"] }); - qc.invalidateQueries({ queryKey: ["wing-feed"] }); - } catch (err) { - toast.error("Failed to submit check-in"); - } finally { - setRedCheckinSubmitting(false); + const routineSignals = ((wellness?.signals ?? []) as any[]).filter((sig) => ROUTINE_SIGNAL_KEYS.has(sig.key)); + const elevatedRoutineSignals = routineSignals.filter((sig) => sig.severity === "watch" || sig.severity === "stressed"); + const primaryRoutineSignal = elevatedRoutineSignals[0] ?? routineSignals[0]; + const shownRoutineSignals = routineSignals.slice(0, 5); + const routineNudgeKey = wellness + ? `${wellness.status}:${routineSignals.map((sig) => `${sig.key}:${sig.severity}:${sig.value}`).join("|")}` + : ""; + const showRoutineNudge = !!wellness && wellness.status !== "steady" && routineNudgeKey !== dismissedRoutineNudgeKey; + const showRoutinePanel = showRoutineNudge || !!routineActionState; + const spendRoomRs = calc ? Math.max(0, calc.safeDailyLimit - calc.spentToday) : 0; + const resetRemainingMs = routineActionState?.kind === "break" && routineActionState.endsAt + ? Math.max(0, routineActionState.endsAt - routineTick) + : 0; + const resetMins = Math.floor(resetRemainingMs / 60_000); + const resetSecs = Math.floor((resetRemainingMs % 60_000) / 1000); + const routineTone = + wellness?.status === "steady" + ? { text: "text-success", border: "border-success/25", bg: "bg-success/5", dot: "bg-success", ring: "border-success/30" } + : wellness?.status === "watch" + ? { text: "text-warning", border: "border-warning/25", bg: "bg-warning/5", dot: "bg-warning", ring: "border-warning/30" } + : { text: "text-destructive", border: "border-destructive/25", bg: "bg-destructive/5", dot: "bg-destructive", ring: "border-destructive/30" }; + const primarySignalKey = primaryRoutineSignal?.key; + const routineSuggestion = (() => { + if (primarySignalKey === "food_gap" || primarySignalKey === "exam") { + const foodText = bestFood + ? `${bestFood.venue_name}: ${bestFood.item_name} for ${rupees(bestFood.price)}.` + : "Use mess or a simple canteen plate."; + return { + title: "Close the meal gap", + detail: `${foodText} Keep it under today's safe spend if you are ordering outside.`, + cta: "Food options", + action: "food" as const, + }; + } + if (primarySignalKey === "runway" || primarySignalKey === "velocity") { + return { + title: "Set today's spend ceiling", + detail: calc + ? `${rupees(calc.safeDailyLimit * 100)} safe today, ${rupees(spendRoomRs * 100)} left after current spends.` + : "Open runway to set a safe spend target for the rest of today.", + cta: "Open runway", + action: "runway" as const, + }; + } + if (primarySignalKey === "late_night") { + return { + title: "Avoid a late order spiral", + detail: "Stock a low-cost snack or use a shared order instead of a solo delivery tonight.", + cta: "Plan spend", + action: "spending" as const, + }; + } + return { + title: "Keep the day steady", + detail: calc + ? `Stay near ${rupees(calc.safeDailyLimit * 100)} today and keep meals on schedule.` + : "Keep meals regular and stay within today's safe spend target.", + cta: "Plan spend", + action: "spending" as const, + }; + })(); + const routineSuggestionDetail = routineCoach?.message || routineSuggestion.detail; + + function dismissRoutineNudge() { + if (routineNudgeKey) { + localStorage.setItem("pocketbuddy_routine_nudge_dismissed_key", routineNudgeKey); + setDismissedRoutineNudgeKey(routineNudgeKey); + } + setRoutineActionState(null); + } + + function handleRoutineSuggestionAction() { + if (routineSuggestion.action === "food") { + setShowFoodSheet(true); + } else if (routineSuggestion.action === "runway") { + nav({ to: "/runway" }); + } else { + void handleWellnessAction("spending"); } } @@ -1003,52 +1087,45 @@ function Dashboard() { {/* ── Main Column ─────────────────────────────────────────────── */}
- {/* Student Wellness Index Card */} -
-
- -
-
-

- Student Wellness Index -

- + {/* Routine Check Card */} +
+
+ +
+
+
+

Routine Check

+

+ Money, meal, and timing signals from your week. +

+
+ {wellness && ( - - {wellness.status === "steady" ? "STEADY" : wellness.status === "watch" ? "WATCH" : "STRESSED"} + + {wellness.label} )}
{wellnessLoading ? (
- - - + + +
) : wellnessError ? (
-

Wellness metrics unavailable

-

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

+

Routine check unavailable

+

We could not load your routine signals. Please try again later.

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

No Transaction History

-

Add a few spends to build your wellness pattern.

+

No transaction history

+

Add a few spends to build your meal and runway pattern.

) : ( <> -
- - {wellness.score} - - / 100 Wellness Score -
- -

- {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} -

+
+
+
+

Routine Index

+
+ + {wellness.score} + + /100 +
+ +
- {/* Contributing Signals */} -
-

Contributing Signals

- -
- {wellness.signals?.map((sig: any) => ( -
- {sig.label}: - {sig.value} - +
+
+ +

+ {wellness.label} +

+ {routineCoach?.source === "bedrock" && ( + + Bedrock + + )}
- ))} +

+ {routineSuggestion.title} +

+

+ {routineSuggestionDetail} +

+
+ +
- {/* Conditional layouts based on status */} - {wellness.status === "watch" && ( -
- Quick Check-in: -
- - +
+ {shownRoutineSignals.map((sig: any) => ( +
+ {sig.label}: + {sig.value} + +
+ ))} +
+ + {showRoutinePanel && ( +
+
+
+

Suggested check-in

+

+ {routineActionState + ? "Action captured. Use the next step below to finish the loop." + : "Optional. Dismiss it if meals or spending are already handled today."} +

+
-
- )} - {wellness.status === "stressed" && ( -
-
-

Submit Feedback Check-in

-