diff --git a/backend/app/api/insights.py b/backend/app/api/insights.py
index a34a0c3..1f7f6fe 100644
--- a/backend/app/api/insights.py
+++ b/backend/app/api/insights.py
@@ -320,10 +320,19 @@ async def get_runway_forecast(user_id: str = Depends(get_current_user)):
user = await db.users.find_one({"_id": user_id}) or {}
full_name = str(user.get("full_name") or "").strip()
pool_ids: set[str] = set()
+ user_item_query: dict = {"added_by_user_id": user_id}
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=1000)
- pool_ids.update(str(item.get("pool_id")) for item in user_items if item.get("pool_id"))
+ user_item_query = {
+ "$or": [
+ {"added_by_user_id": user_id},
+ {"added_by_user_id": {"$exists": False}, "added_by_name": name_regex},
+ {"added_by_user_id": None, "added_by_name": name_regex},
+ {"added_by_user_id": "", "added_by_name": name_regex},
+ ]
+ }
+ user_items = await db.cart_pool_items.find(user_item_query).to_list(length=1000)
+ pool_ids.update(str(item.get("pool_id")) for item in user_items if item.get("pool_id"))
hosted = await db.cart_pools.find(
{"host_id": user_id, "status": {"$in": ["open", "closed", "completed"]}}
diff --git a/backend/app/api/profile.py b/backend/app/api/profile.py
index 183da3d..65a4f8f 100644
--- a/backend/app/api/profile.py
+++ b/backend/app/api/profile.py
@@ -16,6 +16,8 @@ class ProfileUpdateReq(BaseModel):
hostel_block: Optional[str] = None
wing_label: Optional[str] = None
room_number: Optional[str] = None
+ residence_type: Optional[Literal["hostel", "pg", "day_scholar", "mixed"]] = None
+ meal_routine: Optional[Literal["hostel_mess", "pg_cooking", "day_scholar", "mixed"]] = None
exam_start_date: Optional[str] = None
exam_end_date: Optional[str] = None
mess_enrolled: Optional[bool] = None
diff --git a/backend/app/api/rag.py b/backend/app/api/rag.py
index 0e66c18..e316191 100644
--- a/backend/app/api/rag.py
+++ b/backend/app/api/rag.py
@@ -203,10 +203,19 @@ async def get_runway_intel(user_id: str = Depends(get_current_user)):
user = await db.users.find_one({"_id": user_id}) or {}
full_name = str(user.get("full_name") or "").strip()
pool_ids: set[str] = set()
+ user_item_query: dict = {"added_by_user_id": user_id}
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=1000)
- pool_ids.update(str(item.get("pool_id")) for item in user_items if item.get("pool_id"))
+ user_item_query = {
+ "$or": [
+ {"added_by_user_id": user_id},
+ {"added_by_user_id": {"$exists": False}, "added_by_name": name_regex},
+ {"added_by_user_id": None, "added_by_name": name_regex},
+ {"added_by_user_id": "", "added_by_name": name_regex},
+ ]
+ }
+ user_items = await db.cart_pool_items.find(user_item_query).to_list(length=1000)
+ pool_ids.update(str(item.get("pool_id")) for item in user_items if item.get("pool_id"))
hosted = await db.cart_pools.find(
{"host_id": user_id, "status": {"$in": ["open", "closed", "completed"]}}
@@ -253,12 +262,30 @@ async def get_runway_intel(user_id: str = Depends(get_current_user)):
next_action_title = str(next_action.get("title") or "Keep runway stable")
next_action_detail = str(next_action.get("detail") or "Keep discretionary spend inside the safe daily limit.")
decision_summary = str(decision_engine.get("summary") or "")
+ pace_source = str((forecast.get("projection") or {}).get("pace_source") or "")
+ stress_band = (forecast.get("projection") or {}).get("stress_band") or {}
+ expected_days = int((stress_band.get("expected") or {}).get("days_until_broke") or broke_days)
+ stress_days = int((stress_band.get("stress") or {}).get("days_until_broke") or broke_days)
# Build local fallback
- if status == "shortfall":
+ if status == "setup_required":
+ fallback_summary = (
+ "Runway needs your allowance or an allowance credit before it can produce a trusted daily limit. "
+ "Add funding in Settings or sync transactions first; no spending recommendation is shown yet."
+ )
+ elif next_action.get("type") == "pause_flexible":
+ fallback_summary = (
+ "There is no safe discretionary amount left after known spending and commitments. "
+ "Pause flexible spending until reset, or add funding before making non-essential payments."
+ )
+ elif pace_source == "no_recent_history":
+ fallback_summary = (
+ f"Use Rs {safe_daily:,}/day only as a temporary cap because there is not enough recent spend history yet. "
+ "Sync or add a few real payments before relying on pace-based suggestions."
+ )
+ elif status == "shortfall":
fallback_summary = (
- f"Based on your current spending pace, you will run out of allowance in {broke_days} days "
- f"({days_left - broke_days} days before the cycle ends). "
+ f"Expected runway is {expected_days} days, with a stress case of {stress_days} days. "
f"Ask home for Rs {ask_amount:,} and follow this next action: {next_action_detail}"
)
elif status == "watch" or shortfall_prob >= 0.35:
@@ -275,14 +302,15 @@ async def get_runway_intel(user_id: str = Depends(get_current_user)):
# Try Bedrock
if settings.BEDROCK_ENABLED:
try:
- prompt = f"""You are PocketBuddy, a student budget advisor for college students.
+ prompt = f"""You are PocketBuddy, a campus affordability explainer for college students.
Here is the student's runway forecast details:
- Cycle days left: {days_left} days
- Safe daily spend limit: Rs {safe_daily}
- Current daily spend pace (EWMA): Rs {projected_daily}
- Forecast status: {status.upper()}
- Shortfall probability: {shortfall_prob * 100:.0f}%
-- Days until broke: {broke_days} (out of {days_left} days left)
+- Expected runway: {expected_days} days
+- Stress-case runway: {stress_days} days
- Ask home amount needed: Rs {ask_amount}
- Upcoming commitments total: Rs {commitments_total}
- Meal routine: {food_label}
@@ -291,7 +319,7 @@ async def get_runway_intel(user_id: str = Depends(get_current_user)):
- Decision engine summary: {decision_summary}
- Next best action: {next_action_title} — {next_action_detail}
-Generate exactly 2 concise, personalized, and action-oriented sentences. Be direct, reference the specific numbers, and suggest concrete actions that match their meal routine. Do not invent phone numbers, contacts, merchant names, or guarantees. No emojis. No preamble."""
+Generate exactly 2 concise, personalized, and action-oriented sentences. Explain only the deterministic values above. Do not calculate new amounts. Do not invent amounts, dates, probabilities, contacts, merchant names, guarantees, or extra actions. No emojis. No preamble."""
text = generate_text(prompt, max_tokens=150, temperature=0.25)
if text:
diff --git a/backend/app/services/runway.py b/backend/app/services/runway.py
index 93d86d4..f785aa3 100644
--- a/backend/app/services/runway.py
+++ b/backend/app/services/runway.py
@@ -71,6 +71,16 @@
FOOD_CATEGORIES = {"food", "mess", "canteen", "dining", "snacks", "grocery", "groceries"}
PG_CONTEXT_WORDS = {"pg", "paying guest", "flat", "rented", "rental", "apartment", "off campus"}
DAY_SCHOLAR_CONTEXT_WORDS = {"day scholar", "commuter", "commute", "home", "local", "with parents"}
+MEAL_ROUTINE_TYPES = {"hostel_mess", "pg_cooking", "day_scholar", "mixed"}
+RESIDENCE_TO_ROUTINE = {
+ "hostel": "hostel_mess",
+ "dorm": "hostel_mess",
+ "pg": "pg_cooking",
+ "paying_guest": "pg_cooking",
+ "day_scholar": "day_scholar",
+ "commuter": "day_scholar",
+ "mixed": "mixed",
+}
def _utc_naive(value: Any) -> Optional[dt.datetime]:
@@ -175,12 +185,16 @@ def _is_food_expense(txn: dict) -> bool:
def _food_stats(items: list[dict]) -> dict:
- total = sum(_valid_amount(txn) for txn in items)
- count = len(items)
+ amounts = sorted(_valid_amount(txn) for txn in items if _valid_amount(txn))
+ total = sum(amounts)
+ count = len(amounts)
return {
"count": count,
"spend": total,
"avg_order": round(total / count) if count else 0,
+ "median_order": _percentile(amounts, 0.50) if amounts else 0,
+ "min_order": amounts[0] if amounts else 0,
+ "max_order": amounts[-1] if amounts else 0,
}
@@ -197,6 +211,21 @@ def _profile_context(profile: dict) -> str:
return " ".join(str(profile.get(key) or "") for key in keys).casefold()
+def _normalise_token(value: Any) -> str:
+ return str(value or "").strip().casefold().replace("-", "_").replace(" ", "_")
+
+
+def _explicit_food_routine(profile: dict) -> tuple[Optional[str], Optional[str]]:
+ meal_routine = _normalise_token(profile.get("meal_routine"))
+ if meal_routine in MEAL_ROUTINE_TYPES:
+ return meal_routine, "profile_routine"
+ residence_type = _normalise_token(profile.get("residence_type"))
+ mapped = RESIDENCE_TO_ROUTINE.get(residence_type)
+ if mapped:
+ return mapped, "profile_residence"
+ return None, None
+
+
def _routine_fallback_daily(routine_type: str) -> int:
# Conservative Indian campus daily food caps in paise. These are used only
# when the user has no configured meal cost and not enough food history.
@@ -209,6 +238,123 @@ def _routine_fallback_daily(routine_type: str) -> int:
return 20_000
+def _routine_option_label(routine_type: str, *, mess_model: str, mess_enrolled: bool) -> str:
+ if routine_type == "hostel_mess":
+ return "Use hostel mess" if mess_enrolled or mess_model in {"monthly", "per_meal", "included"} else "Use campus meal"
+ if routine_type == "pg_cooking":
+ return "Cook or heat PG meal"
+ if routine_type == "day_scholar":
+ return "Packed/home meal + campus snack"
+ return "Use campus meal"
+
+
+def _routine_cost_basis_label(source: str, *, adjusted: bool = False) -> str:
+ labels = {
+ "mess_included": "Covered by your mess plan",
+ "mess_per_meal": "From your mess settings",
+ "campus_meal_history": "From recent campus meals",
+ "grocery_history": "From recent grocery pace",
+ "food_history": "From recent food history",
+ "fallback_routine_estimate": "Routine fallback estimate",
+ "delivery_history": "From recent delivery orders",
+ "delivery_fallback": "Delivery fallback estimate",
+ "shared_delivery_estimate": "Split estimate from delivery history",
+ "split_grocery_estimate": "Split estimate from grocery routine",
+ "shared_fallback": "Shared-order fallback estimate",
+ }
+ label = labels.get(source, "Estimated from current routine")
+ return f"{label} (capped for noisy history)" if adjusted else label
+
+
+def _fallback_routine_meal_cost(routine_type: str, recommended_daily_food_cap: int) -> int:
+ fallback = max(4_000, round(_routine_fallback_daily(routine_type) / 2))
+ if recommended_daily_food_cap > 0:
+ fallback = min(fallback, max(4_000, recommended_daily_food_cap))
+ return fallback
+
+
+def _normalize_routine_meal_cost(
+ *,
+ candidate: int,
+ source: str,
+ routine_type: str,
+ recommended_daily_food_cap: int,
+ delivery_avg: int,
+ sample_count: int,
+) -> tuple[int, str, bool]:
+ if source == "mess_included":
+ return 0, "high", False
+ if source == "mess_per_meal":
+ return max(0, candidate), "high", False
+
+ fallback = _fallback_routine_meal_cost(routine_type, recommended_daily_food_cap)
+ cost = candidate if candidate > 0 else fallback
+ confidence = "medium" if candidate > 0 and sample_count >= 2 else "low"
+ adjusted = False
+
+ high_history_ceiling = max(
+ fallback + 4_000,
+ int(recommended_daily_food_cap * 1.35) if recommended_daily_food_cap > 0 else fallback + 4_000,
+ )
+ if cost > high_history_ceiling:
+ cost = fallback
+ confidence = "low"
+ adjusted = True
+
+ if delivery_avg >= 5_000 and cost >= delivery_avg:
+ discount = max(500, round(delivery_avg * 0.10))
+ cost = max(4_000, delivery_avg - discount)
+ confidence = "low"
+ adjusted = True
+
+ return max(4_000, cost), confidence, adjusted
+
+
+def _estimate_delivery_meal_cost(
+ *,
+ delivery: dict,
+ recommended_daily_food_cap: int,
+ routine_meal_cost: int,
+) -> tuple[int, str, str]:
+ if delivery["avg_order"]:
+ confidence = "high" if delivery["count"] >= 2 else "medium"
+ return delivery["avg_order"], "delivery_history", confidence
+
+ fallback = max(
+ 9_000,
+ routine_meal_cost + 2_000 if routine_meal_cost > 0 else 9_000,
+ int(recommended_daily_food_cap * 1.2) if recommended_daily_food_cap > 0 else 0,
+ )
+ return min(25_000, fallback), "delivery_fallback", "low"
+
+
+def _estimate_shared_meal_cost(
+ *,
+ routine_type: str,
+ routine_meal_cost: int,
+ delivery_meal_cost: int,
+ delivery_cost_source: str,
+) -> tuple[int, str, str]:
+ if routine_type == "pg_cooking":
+ base = max(4_000, round(max(routine_meal_cost, 4_000) * 0.9))
+ if delivery_meal_cost >= 5_000:
+ base = min(base, max(4_000, delivery_meal_cost - max(500, round(delivery_meal_cost * 0.15))))
+ return base, "split_grocery_estimate", "medium" if routine_meal_cost else "low"
+
+ if delivery_cost_source == "delivery_history" and delivery_meal_cost >= 5_000:
+ discount = max(500, round(delivery_meal_cost * 0.15))
+ shared = delivery_meal_cost - discount
+ if routine_meal_cost > 0:
+ shared = min(shared, round((delivery_meal_cost + routine_meal_cost) / 2))
+ shared = max(4_000, shared)
+ if shared >= delivery_meal_cost:
+ shared = max(4_000, delivery_meal_cost - discount)
+ return shared, "shared_delivery_estimate", "medium"
+
+ fallback = max(4_000, routine_meal_cost or 6_000)
+ return fallback, "shared_fallback", "low"
+
+
def _build_food_routine(
*,
profile: dict,
@@ -245,9 +391,13 @@ def _build_food_routine(
campus_daily_pace = round(campus_direct["spend"] / elapsed_days) if campus_direct["spend"] else 0
context = _profile_context(profile)
+ explicit_routine, explicit_source = _explicit_food_routine(profile)
routine_type = "mixed"
detected_from: list[str] = []
- if profile.get("mess_enrolled") or mess_model in {"monthly", "per_meal", "included"}:
+ if explicit_routine:
+ routine_type = explicit_routine
+ detected_from.append(explicit_source or "profile_routine")
+ elif profile.get("mess_enrolled") or mess_model in {"monthly", "per_meal", "included"}:
routine_type = "hostel_mess"
detected_from.append("profile_mess")
elif _contains_any(context, DAY_SCHOLAR_CONTEXT_WORDS):
@@ -297,16 +447,56 @@ def _build_food_routine(
projected_remaining_food_cap = recommended_daily_food_cap * days_left
over_cap_remaining = max(0, projected_remaining_food_pace - projected_remaining_food_cap)
- if mess_per_meal:
- routine_meal_cost = mess_per_meal
- elif campus_direct["avg_order"]:
- routine_meal_cost = campus_direct["avg_order"]
+ routine_option_label = _routine_option_label(
+ routine_type,
+ mess_model=mess_model,
+ mess_enrolled=bool(profile.get("mess_enrolled")),
+ )
+ if routine_type == "hostel_mess" and mess_model in {"monthly", "included"}:
+ routine_meal_candidate = 0
+ routine_meal_source = "mess_included"
+ routine_meal_samples = meals_per_day
+ elif mess_per_meal:
+ routine_meal_candidate = mess_per_meal
+ routine_meal_source = "mess_per_meal"
+ routine_meal_samples = meals_per_day
+ elif campus_direct["median_order"]:
+ routine_meal_candidate = campus_direct["median_order"]
+ routine_meal_source = "campus_meal_history"
+ routine_meal_samples = campus_direct["count"]
elif cooking_daily_pace:
- routine_meal_cost = max(4_000, round(cooking_daily_pace / 2))
+ routine_meal_candidate = max(4_000, round(cooking_daily_pace / 2))
+ routine_meal_source = "grocery_history"
+ routine_meal_samples = cooking["count"]
+ elif food["median_order"]:
+ routine_meal_candidate = food["median_order"]
+ routine_meal_source = "food_history"
+ routine_meal_samples = food["count"]
else:
- routine_meal_cost = round(_routine_fallback_daily(routine_type) / 2)
+ routine_meal_candidate = _fallback_routine_meal_cost(routine_type, recommended_daily_food_cap)
+ routine_meal_source = "fallback_routine_estimate"
+ routine_meal_samples = 0
+ routine_meal_cost, routine_meal_confidence, routine_meal_adjusted = _normalize_routine_meal_cost(
+ candidate=routine_meal_candidate,
+ source=routine_meal_source,
+ routine_type=routine_type,
+ recommended_daily_food_cap=recommended_daily_food_cap,
+ delivery_avg=delivery["avg_order"],
+ sample_count=routine_meal_samples,
+ )
+ delivery_meal_cost, delivery_cost_source, delivery_cost_confidence = _estimate_delivery_meal_cost(
+ delivery=delivery,
+ recommended_daily_food_cap=recommended_daily_food_cap,
+ routine_meal_cost=routine_meal_cost,
+ )
+ shared_meal_cost, shared_cost_source, shared_cost_confidence = _estimate_shared_meal_cost(
+ routine_type=routine_type,
+ routine_meal_cost=routine_meal_cost,
+ delivery_meal_cost=delivery_meal_cost,
+ delivery_cost_source=delivery_cost_source,
+ )
savings_if_replace_two_deliveries = (
- max(0, delivery["avg_order"] - routine_meal_cost) * 2 if delivery["avg_order"] else 0
+ max(0, delivery_meal_cost - routine_meal_cost) * 2 if delivery_meal_cost else 0
)
if not food["count"]:
@@ -366,7 +556,23 @@ def _build_food_routine(
"projected_remaining_food_cap": projected_remaining_food_cap,
"over_cap_remaining": over_cap_remaining,
"avg_meal_cost": food["avg_order"],
+ "routine_option_label": routine_option_label,
"routine_meal_cost": routine_meal_cost,
+ "routine_meal_cost_source": routine_meal_source,
+ "routine_meal_cost_basis": _routine_cost_basis_label(
+ routine_meal_source,
+ adjusted=routine_meal_adjusted,
+ ),
+ "routine_meal_cost_confidence": routine_meal_confidence,
+ "routine_meal_cost_adjusted": routine_meal_adjusted,
+ "delivery_meal_cost": delivery_meal_cost,
+ "delivery_cost_source": delivery_cost_source,
+ "delivery_cost_basis": _routine_cost_basis_label(delivery_cost_source),
+ "delivery_cost_confidence": delivery_cost_confidence,
+ "shared_meal_cost": shared_meal_cost,
+ "shared_cost_source": shared_cost_source,
+ "shared_cost_basis": _routine_cost_basis_label(shared_cost_source),
+ "shared_cost_confidence": shared_cost_confidence,
"delivery": {**delivery, "daily_pace": delivery_daily_pace},
"campus_direct": {**campus_direct, "daily_pace": campus_daily_pace},
"cooking": {**cooking, "daily_pace": cooking_daily_pace},
@@ -421,6 +627,21 @@ def _monthly_subscription_cost(subscription: dict) -> float:
return amount / count
+def _percentile(values: list[int], percentile: float) -> int:
+ if not values:
+ return 0
+ ordered = sorted(values)
+ if len(ordered) == 1:
+ return ordered[0]
+ position = (len(ordered) - 1) * min(1.0, max(0.0, percentile))
+ lower = math.floor(position)
+ upper = math.ceil(position)
+ if lower == upper:
+ return ordered[int(position)]
+ weight = position - lower
+ return round(ordered[lower] * (1 - weight) + ordered[upper] * weight)
+
+
def _profile_amount(profile: dict, key: str) -> int:
value = profile.get(key, 0)
return value if isinstance(value, int) and not isinstance(value, bool) and value > 0 else 0
@@ -453,6 +674,24 @@ def derive_pool_obligations(
def name_key(value: Any) -> str:
return re.sub(r"\s+", " ", str(value or "").strip()).casefold()
+ def user_key(value: Any) -> str:
+ return str(value or "").strip()
+
+ def item_user_id(item: dict) -> str:
+ for key in ("added_by_user_id", "user_id", "participant_user_id", "member_user_id"):
+ if item.get(key):
+ return user_key(item.get(key))
+ return ""
+
+ def payment_user_id(payment: dict) -> str:
+ for key in ("user_id", "participant_user_id", "member_user_id", "added_by_user_id"):
+ if payment.get(key):
+ return user_key(payment.get(key))
+ return ""
+
+ current_user_id = user_key(user_id)
+ current_name = name_key(user_name)
+
obligations: list[dict] = []
for pool in pools:
pool_id = str(pool.get("_id") or pool.get("id") or "")
@@ -463,12 +702,33 @@ def name_key(value: Any) -> str:
effective_name = user_name
if str(pool.get("host_id") or "") == user_id and not effective_name:
effective_name = str(pool.get("created_by_name") or "")
- mine = [item for item in pool_items if name_key(item.get("added_by_name")) == name_key(effective_name)]
+ mine = []
+ for item in pool_items:
+ stable_id = item_user_id(item)
+ if stable_id and stable_id == current_user_id:
+ mine.append(item)
+ continue
+ # Legacy public pool links stored only display names. Keep this
+ # fallback only when the item has no stable user identifier.
+ if not stable_id and current_name and name_key(item.get("added_by_name")) == name_key(effective_name):
+ mine.append(item)
if not mine:
continue
payments = pool.get("payments") or []
- payment = next((p for p in payments if name_key(p.get("name")) == name_key(effective_name)), None)
+ payment = next(
+ (
+ p
+ for p in payments
+ if (payment_user_id(p) and payment_user_id(p) == current_user_id)
+ or (
+ not payment_user_id(p)
+ and current_name
+ and name_key(p.get("name")) == name_key(effective_name)
+ )
+ ),
+ None,
+ )
if status == "completed" and payment and str(payment.get("status") or "") in {
"verified", "auto_verified", "manual_verified"
}:
@@ -477,7 +737,11 @@ def name_key(value: Any) -> str:
if status == "completed" and str(pool.get("host_id") or "") == user_id:
continue
- participants = {name_key(item.get("added_by_name")) for item in pool_items}
+ participants = {
+ item_user_id(item) or name_key(item.get("added_by_name"))
+ for item in pool_items
+ if item_user_id(item) or name_key(item.get("added_by_name"))
+ }
participant_count = max(1, len(participants))
item_total = sum(_valid_amount({"amount": item.get("estimated_price")}) for item in mine)
if status == "completed":
@@ -510,7 +774,13 @@ def _pace_model(expenses: list[dict], now: dt.datetime) -> dict:
dates = [history_start + dt.timedelta(days=i) for i in range(max(0, (complete_end - history_start).days + 1))]
recent_dates = dates[-28:]
values = [by_day[day] for day in recent_dates]
+ active_values = [value for value in values if value > 0]
active_days = sum(1 for value in values if value > 0)
+ max_daily = max(values) if values else 0
+ max_daily_date = None
+ if max_daily > 0:
+ max_index = values.index(max_daily)
+ max_daily_date = recent_dates[max_index]
ewma = 0.0
alpha = 0.24
@@ -549,6 +819,14 @@ def _pace_model(expenses: list[dict], now: dt.datetime) -> dict:
"history_days": len(dates),
"active_days": active_days,
"observed_total": sum(values),
+ "p25_daily": _percentile(active_values, 0.25),
+ "p50_daily": _percentile(active_values, 0.50),
+ "p80_daily": _percentile(active_values, 0.80),
+ "p90_daily": _percentile(active_values, 0.90),
+ "max_daily": max_daily,
+ "max_daily_date": max_daily_date.isoformat() if max_daily_date else None,
+ "recent_7_total": sum(values[-7:]),
+ "previous_7_total": sum(values[-14:-7]) if len(values) >= 14 else 0,
}
@@ -560,14 +838,18 @@ def _confidence(pace: dict, subscriptions: list[dict], profile: dict) -> dict:
score += 10 if profile.get("mess_billing_model") else 0
volatility = pace["daily_std"] / pace["ewma_daily"] if pace["ewma_daily"] else 1.0
score += 5 if volatility <= 0.75 else 2 if volatility <= 1.25 else 0
+ if pace["active_days"] <= 0:
+ score = min(score, 35)
score = min(100, score)
level = "high" if score >= 75 else "medium" if score >= 45 else "low"
reason = (
- "Enough recent activity and recurring-cost context for a stable estimate."
+ "Enough synced spending history and recurring-cost context for a stable runway estimate."
if level == "high"
- else "The range stays wider until more daily spending history is available."
+ else "Enough spend data is available, but the range stays wider until fixed costs and routine spends are more complete."
if level == "medium"
- else "Early estimate: add transactions and configure fixed costs to improve it."
+ else "Early estimate: sync more transactions before relying on pace-based suggestions."
+ if pace["active_days"] <= 0
+ else "Low confidence: add more recent transactions and fixed commitments to narrow the runway range."
)
return {"score": score, "level": level, "reason": reason, "history_days": pace["history_days"], "active_days": pace["active_days"]}
@@ -608,15 +890,22 @@ def build_runway_forecast(
additional_income += amount
# A recorded allowance transfer is evidence of the configured budget, not a second allowance.
funding = max(allowance, allowance_credits) + additional_income
+ setup_required = funding <= 0
+ setup_reason = (
+ "Add your monthly allowance or sync an allowance credit before Runway can calculate a safe daily limit."
+ if setup_required
+ else None
+ )
spent = sum(_valid_amount(txn) for txn in cycle_expenses)
committed_spent = sum(_valid_amount(txn) for txn in cycle_expenses if _is_committed_expense(txn))
discretionary_spent = spent - committed_spent
remaining = funding - spent
active_subscriptions = [
- sub for sub in subscriptions
+ sub
+ for sub in subscriptions
if sub.get("is_active", True)
- and sub.get("status", "confirmed") in ("confirmed", "active", "missed")
+ and str(sub.get("status", "confirmed")).casefold() in {"confirmed", "active", "missed"}
and _valid_amount(sub)
]
commitments: list[dict] = []
@@ -632,9 +921,10 @@ def build_runway_forecast(
possible_commitments: list[dict] = []
possible_subscriptions = [
- sub for sub in subscriptions
+ sub
+ for sub in subscriptions
if sub.get("is_active", True)
- and sub.get("status") == "possible"
+ and str(sub.get("status") or "").casefold() == "possible"
and _valid_amount(sub)
]
for sub in possible_subscriptions:
@@ -692,6 +982,18 @@ def build_runway_forecast(
projected_days = [now.date() + dt.timedelta(days=i) for i in range(days_left)]
daily_projection = [pace["ewma_daily"] * pace["weekday_factors"][day.weekday()] for day in projected_days]
projected_discretionary = sum(daily_projection)
+ projected_daily = round(projected_discretionary / max(1, days_left))
+ stress_daily = max(
+ projected_daily,
+ int(pace.get("p80_daily") or 0),
+ round(float(pace.get("ewma_daily") or 0) + float(pace.get("daily_std") or 0)),
+ )
+ calm_daily = min(
+ projected_daily,
+ max(0, int(pace.get("p50_daily") or round(projected_daily * 0.7))),
+ ) if projected_daily > 0 else 0
+ stress_remaining = stress_daily * days_left
+ calm_remaining = calm_daily * days_left
projection_std = pace["daily_std"] * math.sqrt(max(1, days_left))
z80 = 1.2815515655446004
discretionary_low = max(0, projected_discretionary - z80 * projection_std)
@@ -702,9 +1004,30 @@ def build_runway_forecast(
available_for_discretionary = remaining - commitment_total
if projection_std > 0:
- shortfall_probability = 1 - NormalDist().cdf((available_for_discretionary - projected_discretionary) / projection_std)
+ normal_shortfall_probability = 1 - NormalDist().cdf((available_for_discretionary - projected_discretionary) / projection_std)
else:
- shortfall_probability = 1.0 if projected_discretionary > available_for_discretionary else 0.0
+ normal_shortfall_probability = 1.0 if projected_discretionary > available_for_discretionary else 0.0
+ stress_days_until_broke = (
+ days_left
+ if setup_required or stress_daily <= 0
+ else min(days_left, max(0, math.floor(max(0, available_for_discretionary) / stress_daily)))
+ )
+ expected_days_until_broke = (
+ days_left
+ if setup_required or projected_daily <= 0
+ else min(days_left, max(0, math.floor(max(0, available_for_discretionary) / projected_daily)))
+ )
+ calm_days_until_broke = (
+ days_left
+ if setup_required or calm_daily <= 0
+ else min(days_left, max(0, math.floor(max(0, available_for_discretionary) / calm_daily)))
+ )
+ empirical_stress_probability = (
+ 0.0
+ if setup_required or days_left <= 0 or stress_days_until_broke >= days_left
+ else min(1.0, max(0.0, (days_left - stress_days_until_broke) / max(1, days_left)))
+ )
+ shortfall_probability = max(normal_shortfall_probability, empirical_stress_probability)
shortfall_probability = round(min(1.0, max(0.0, shortfall_probability)), 4)
safe_daily = max(0, math.floor(available_for_discretionary / max(1, days_left)))
food_routine = _build_food_routine(
@@ -730,16 +1053,65 @@ def build_runway_forecast(
if balance < 0:
broke_at = dt.datetime.combine(day, dt.time(23, 59, 59))
break
- runway_days = max(0, (broke_at.date() - now.date()).days) if broke_at else days_left
+ runway_days = 0 if setup_required else max(0, (broke_at.date() - now.date()).days) if broke_at else days_left
+ stress_band = {
+ "calm": {
+ "label": "Calm case",
+ "daily_spend": calm_daily,
+ "projected_discretionary": round(calm_remaining),
+ "days_until_broke": calm_days_until_broke,
+ "forecast_end_balance": round(remaining - commitment_total - calm_remaining),
+ },
+ "expected": {
+ "label": "Expected",
+ "daily_spend": projected_daily,
+ "projected_discretionary": round(projected_discretionary),
+ "days_until_broke": expected_days_until_broke,
+ "forecast_end_balance": round(end_balance),
+ },
+ "stress": {
+ "label": "Stress case",
+ "daily_spend": stress_daily,
+ "projected_discretionary": round(stress_remaining),
+ "days_until_broke": stress_days_until_broke,
+ "forecast_end_balance": round(remaining - commitment_total - stress_remaining),
+ "basis": "recent high-spend days" if pace.get("active_days") else "temporary no-history cap",
+ },
+ "risk_sources": {
+ "normal_probability": round(min(1.0, max(0.0, normal_shortfall_probability)), 4),
+ "empirical_stress_probability": round(empirical_stress_probability, 4),
+ },
+ }
- ask_home = _round_ask_home(-end_balance_low) if end_balance < 0 else 0
- projected_daily = round(projected_discretionary / max(1, days_left))
- if ask_home:
+ ask_home = 0 if setup_required else _round_ask_home(-end_balance_low) if end_balance < 0 else 0
+ no_spend_history = pace["active_days"] <= 0 and projected_daily <= 0 and discretionary_spent <= 0
+ if setup_required:
+ action = {
+ "type": "complete_setup",
+ "title": "Add allowance to activate Runway",
+ "detail": setup_reason,
+ "impact": 0,
+ }
+ elif ask_home:
action = {"type": "ask_home", "title": f"Ask home for Rs {ask_home // 100:,}", "detail": "This covers the forecast shortfall plus the high-spend side of the confidence range."}
+ elif safe_daily <= 0:
+ action = {
+ "type": "pause_flexible",
+ "title": "Pause flexible spend until reset",
+ "detail": "After known commitments and spending, there is no safe discretionary amount left for this cycle.",
+ "impact": 0,
+ }
elif shortfall_probability >= 0.35:
action = {"type": "slow_down", "title": f"Hold flexible spend near Rs {safe_daily // 100:,}/day", "detail": "The base forecast survives, but the high-spend range can still end below zero."}
elif commitment_total and safe_daily < projected_daily:
action = {"type": "review_commitments", "title": "Review the next fixed debit", "detail": "Known subscriptions, mess, exam reserve, or pool shares are reducing today's flexible limit."}
+ elif no_spend_history:
+ action = {
+ "type": "calibrate_pace",
+ "title": f"Use Rs {safe_daily // 100:,}/day as a temporary cap",
+ "detail": "Runway does not have enough recent spending history yet. Sync or add a few payments before trusting pace-based suggestions.",
+ "impact": 0,
+ }
else:
action = {"type": "on_track", "title": "Keep the current pace", "detail": "The current forecast reaches reset with a non-negative balance."}
@@ -763,6 +1135,8 @@ def build_runway_forecast(
horizons.append({
"key": key,
"label": label,
+ "mode": "scenario",
+ "basis": "recent pace repeated with known recurring costs; not a guaranteed prediction.",
"months": months,
"projected_spend": round(expected_spend),
"projected_funding": round(expected_funding),
@@ -773,10 +1147,25 @@ def build_runway_forecast(
})
confidence = _confidence(pace, active_subscriptions, profile)
+ if setup_required:
+ confidence = {
+ **confidence,
+ "score": 0,
+ "level": "setup_required",
+ "reason": "Allowance or funding source is missing, so Runway cannot produce a trusted forecast yet.",
+ }
commitment_by_kind: dict[str, int] = defaultdict(int)
for item in commitments:
commitment_by_kind[item["kind"]] += item["amount"]
- status = "shortfall" if end_balance < 0 else "watch" if shortfall_probability >= 0.35 else "healthy"
+ status = (
+ "setup_required"
+ if setup_required
+ else "shortfall"
+ if end_balance < 0
+ else "watch"
+ if available_for_discretionary <= 0 or shortfall_probability >= 0.35
+ else "healthy"
+ )
subscription_total = commitment_by_kind.get("subscription", 0)
pool_total = commitment_by_kind.get("pool", 0)
exam_total = commitment_by_kind.get("exam_buffer", 0)
@@ -793,7 +1182,11 @@ def build_runway_forecast(
"label": "Food pace",
"amount": food_routine["projected_remaining_food_pace"],
"daily_amount": food_routine["food_daily_pace"],
- "detail": f"{food_routine['label']} projected from this cycle.",
+ "detail": (
+ f"{food_routine['label']} projected from this cycle."
+ if food_routine["food_daily_pace"]
+ else "No food payments this cycle; profile routine is used only as a temporary cap."
+ ),
},
{
"kind": "pool_debts",
@@ -812,7 +1205,11 @@ def build_runway_forecast(
"label": "Safe/day",
"amount": safe_daily,
"daily_amount": safe_daily,
- "detail": "What remains per day after spend, commitments, and buffers.",
+ "detail": (
+ "What remains per day after spend, commitments, and buffers."
+ if safe_daily > 0
+ else "No flexible money remains after spend, commitments, and buffers."
+ ),
},
]
reserved_labels = []
@@ -826,28 +1223,136 @@ def build_runway_forecast(
reserved_labels.append("exam buffer")
reserved_text = ", ".join(reserved_labels) if reserved_labels else "known reserves"
- next_best_action = action
+ drivers: list[dict] = []
+
+ def add_driver(kind: str, label: str, detail: str, impact: int, severity: str = "medium") -> None:
+ if impact <= 0 and severity != "info":
+ return
+ drivers.append({
+ "kind": kind,
+ "label": label,
+ "detail": detail,
+ "impact": max(0, int(impact)),
+ "severity": severity,
+ })
+
+ pace_gap_remaining = max(0, projected_daily - safe_daily) * days_left
+ add_driver(
+ "pace_gap",
+ "Current pace is above safe/day",
+ f"Projected pace is Rs {projected_daily // 100:,}/day while safe/day is Rs {safe_daily // 100:,}.",
+ pace_gap_remaining,
+ "high" if pace_gap_remaining > 0 and shortfall_probability >= 0.35 else "medium",
+ )
+ add_driver(
+ "food_pace",
+ "Food pace is shaping runway",
+ food_routine.get("action", {}).get("detail") or "Meal routine affects the daily safe spend.",
+ int(food_routine.get("over_cap_remaining") or 0),
+ "high" if int(food_routine.get("over_cap_remaining") or 0) > 0 else "medium",
+ )
+ add_driver(
+ "subscriptions",
+ "Subscription due before reset",
+ "A scheduled recurring debit is reserved before flexible daily spend.",
+ subscription_total,
+ "high" if subscription_total and safe_daily < projected_daily else "medium",
+ )
+ add_driver(
+ "pool_debts",
+ "Pool dues are pending",
+ "Shared-cart dues are reserved so the safe/day number does not look falsely high.",
+ pool_total,
+ "medium",
+ )
+ add_driver(
+ "exam_buffer",
+ "Exam buffer is protected",
+ "Runway keeps this reserve separate from routine food, travel, and shopping.",
+ exam_total,
+ "medium",
+ )
+ high_spend_threshold = max(int(pace.get("p80_daily") or 0), safe_daily * 2, projected_daily * 2)
+ if pace.get("max_daily") and int(pace["max_daily"]) >= high_spend_threshold > 0:
+ date_label = pace.get("max_daily_date") or "recently"
+ add_driver(
+ "high_spend_day",
+ "High-spend day widened the stress case",
+ f"Recent peak spend was Rs {int(pace['max_daily']) // 100:,} on {date_label}.",
+ int(pace["max_daily"]),
+ "high",
+ )
+ drivers = sorted(
+ drivers,
+ key=lambda driver: (
+ {"high": 2, "medium": 1, "info": 0}.get(str(driver.get("severity")), 0),
+ int(driver.get("impact") or 0),
+ ),
+ reverse=True,
+ )[:3]
+
food_action = food_routine.get("action") or {}
- if action["type"] not in {"ask_home"} and int(food_action.get("impact") or 0) > 0:
+ if setup_required:
+ next_best_action = action
+ elif subscription_total and (shortfall_probability >= 0.35 or safe_daily <= 0 or safe_daily < projected_daily):
+ next_best_action = {
+ "type": "review_subscription",
+ "title": "Review the next subscription debit",
+ "detail": "A recurring debit is reducing safe/day. Pause or move the next non-essential subscription before cutting meals.",
+ "impact": subscription_total,
+ }
+ elif int(food_action.get("impact") or 0) > 0:
next_best_action = {
"type": food_action.get("type") or "food_pace",
"title": food_action.get("title") or "Stabilize food pace",
"detail": food_action.get("detail") or "Food pace is the fastest lever to extend runway this cycle.",
"impact": int(food_action.get("impact") or 0),
}
- elif action["type"] == "on_track" and pool_total:
+ elif pool_total and (shortfall_probability >= 0.20 or safe_daily <= 0 or action["type"] == "on_track"):
next_best_action = {
"type": "settle_pool",
"title": "Confirm pending pool shares",
"detail": "Settling shared-cart dues keeps the safe/day number accurate before the next reset.",
"impact": pool_total,
}
+ elif safe_daily <= 0:
+ next_best_action = {**action, "impact": 0}
+ elif shortfall_probability >= 0.20 and safe_daily > 0:
+ next_best_action = {
+ "type": "travel_caution",
+ "title": "Check travel before booking rides",
+ "detail": f"Keep any ride or outing inside the Rs {safe_daily // 100:,}/day safe limit unless it is essential.",
+ "impact": max(0, projected_daily - safe_daily) * days_left,
+ }
+ elif ask_home:
+ next_best_action = {**action, "impact": ask_home}
else:
next_best_action = {**action, "impact": ask_home or max(0, projected_daily - safe_daily) * days_left}
+ decision_summary = (
+ setup_reason
+ if setup_required
+ else "No flexible spend is available after known commitments. Pause discretionary spending until reset or add funding."
+ if safe_daily <= 0
+ else f"No reliable daily pace yet. Use Rs {safe_daily // 100:,}/day as a temporary cap until recent transactions sync."
+ if no_spend_history
+ else f"Runway leaves Rs {safe_daily // 100:,}/day after reserving {reserved_text} and tracking food at Rs {food_routine['food_daily_pace'] // 100:,}/day."
+ )
+ commitment_items = [
+ {**item, "due_at": _utc_naive(item["due_at"]).isoformat() + "Z"}
+ for item in sorted(commitments, key=lambda entry: entry["due_at"])
+ ]
+ possible_commitment_items = [
+ {**item, "due_at": _utc_naive(item["due_at"]).isoformat() + "Z"}
+ for item in sorted(possible_commitments, key=lambda entry: entry["due_at"])
+ ]
+ possible_commitment_total = sum(item["amount"] for item in possible_commitments)
+
return {
"generated_at": now.isoformat() + "Z",
"status": status,
+ "setup_required": setup_required,
+ "setup_reason": setup_reason,
"current_cycle": {
"start": cycle_start.isoformat() + "Z",
"end": cycle_end.isoformat() + "Z",
@@ -859,11 +1364,15 @@ def build_runway_forecast(
"committed_spent": committed_spent,
"discretionary_spent": discretionary_spent,
"remaining": remaining,
+ "setup_required": setup_required,
+ "setup_reason": setup_reason,
},
"commitments": {
"total": commitment_total,
"by_kind": dict(commitment_by_kind),
- "items": [{**item, "due_at": _utc_naive(item["due_at"]).isoformat() + "Z"} for item in sorted(commitments, key=lambda entry: entry["due_at"])],
+ "items": commitment_items,
+ "possible_commitments": possible_commitment_items,
+ "possible_commitments_total": possible_commitment_total,
},
"projection": {
"days_until_broke": runway_days,
@@ -876,19 +1385,25 @@ def build_runway_forecast(
"balance_high": round(end_balance_high),
"shortfall_probability": shortfall_probability,
"ask_home_amount": ask_home,
+ "setup_required": setup_required,
+ "setup_reason": setup_reason,
+ "pace_source": "no_recent_history" if no_spend_history else "weekday_adjusted_ewma",
+ "stress_band": stress_band,
},
"spend_split": {
"committed": committed_spent + commitment_total,
"flexible": discretionary_spent + round(projected_discretionary),
},
"food_routine": food_routine,
- "possible_commitments": possible_commitments,
- "possible_commitments_total": sum(item["amount"] for item in possible_commitments),
+ "possible_commitments": possible_commitment_items,
+ "possible_commitments_total": possible_commitment_total,
"decision_engine": {
- "summary": f"Runway leaves Rs {safe_daily // 100:,}/day after reserving {reserved_text} and tracking food at Rs {food_routine['food_daily_pace'] // 100:,}/day.",
+ "summary": decision_summary,
"absorbed": absorbed,
+ "drivers": drivers,
"next_best_action": next_best_action,
},
+ "drivers": drivers,
"action": action,
"confidence": confidence,
"horizons": horizons,
@@ -902,6 +1417,7 @@ def build_runway_forecast(
"Duplicates, failed/reversed events, credits, and committed historical spend are excluded from flexible-spend pace.",
"Food pace is separated into delivery, campus meals, and grocery/cooking patterns so hostel, PG, day-scholar, and mixed routines are handled differently.",
"The ask-home amount is shown only when the base forecast is negative and is rounded up to Rs 100.",
+ "When no allowance or funding source is available, Runway returns setup_required instead of inventing a forecast.",
],
},
}
diff --git a/backend/tests/test_runway_forecast.py b/backend/tests/test_runway_forecast.py
new file mode 100644
index 0000000..d602f52
--- /dev/null
+++ b/backend/tests/test_runway_forecast.py
@@ -0,0 +1,394 @@
+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.runway import build_runway_forecast, derive_pool_obligations # noqa: E402
+
+
+NOW = dt.datetime(2026, 7, 8, 12, 0, 0)
+
+
+def txn(
+ amount: int,
+ *,
+ days_ago: int = 1,
+ direction: str = "debit",
+ category: str = "food",
+ merchant: str = "Campus Canteen",
+ status: str = "posted",
+):
+ return {
+ "_id": f"txn-{amount}-{days_ago}-{direction}-{status}",
+ "amount": amount,
+ "direction": direction,
+ "category": category,
+ "mapped_merchant_name": merchant,
+ "raw_merchant_string": merchant,
+ "status": status,
+ "created_at": NOW - dt.timedelta(days=days_ago),
+ }
+
+
+class RunwayForecastTests(unittest.TestCase):
+ def test_missing_allowance_returns_setup_required_not_healthy(self):
+ forecast = build_runway_forecast(
+ profile={},
+ transactions=[],
+ subscriptions=[],
+ now=NOW,
+ )
+
+ self.assertEqual(forecast["status"], "setup_required")
+ self.assertTrue(forecast["setup_required"])
+ self.assertEqual(forecast["projection"]["ask_home_amount"], 0)
+ self.assertEqual(forecast["action"]["type"], "complete_setup")
+
+ def test_no_spend_history_uses_temporary_cap_and_low_confidence(self):
+ forecast = build_runway_forecast(
+ profile={"monthly_allowance": 1_000_000, "cycle_start_day": 1},
+ transactions=[],
+ subscriptions=[],
+ now=NOW,
+ )
+
+ self.assertEqual(forecast["status"], "healthy")
+ self.assertEqual(forecast["action"]["type"], "calibrate_pace")
+ self.assertEqual(forecast["confidence"]["level"], "low")
+ self.assertEqual(forecast["projection"]["pace_source"], "no_recent_history")
+ self.assertGreater(forecast["projection"]["safe_daily_spend"], 0)
+
+ def test_zero_safe_daily_pauses_flexible_spend_without_fake_budget(self):
+ forecast = build_runway_forecast(
+ profile={
+ "monthly_allowance": 100_000,
+ "cycle_start_day": 1,
+ "mess_enrolled": True,
+ "mess_billing_model": "monthly",
+ "mess_monthly_cost": 100_000,
+ },
+ transactions=[],
+ subscriptions=[],
+ now=NOW,
+ )
+
+ self.assertEqual(forecast["status"], "watch")
+ self.assertEqual(forecast["action"]["type"], "pause_flexible")
+ self.assertEqual(forecast["projection"]["safe_daily_spend"], 0)
+ self.assertIn("No flexible spend", forecast["decision_engine"]["summary"])
+
+ def test_subscription_due_before_reset_reduces_safe_daily(self):
+ forecast = build_runway_forecast(
+ profile={"monthly_allowance": 1_000_000, "cycle_start_day": 1},
+ transactions=[txn(10_000, days_ago=1)],
+ subscriptions=[
+ {
+ "service_name": "Music",
+ "amount": 99_00,
+ "is_active": True,
+ "billing_cycle": "monthly",
+ "next_debit_date": NOW + dt.timedelta(days=2),
+ }
+ ],
+ now=NOW,
+ )
+
+ self.assertEqual(forecast["commitments"]["by_kind"]["subscription"], 99_00)
+ self.assertLess(
+ forecast["projection"]["safe_daily_spend"],
+ forecast["current_cycle"]["remaining"] // max(1, forecast["current_cycle"]["days_left"]),
+ )
+
+ def test_inactive_subscription_is_ignored(self):
+ forecast = build_runway_forecast(
+ profile={"monthly_allowance": 1_000_000, "cycle_start_day": 1},
+ transactions=[txn(10_000, days_ago=1)],
+ subscriptions=[
+ {
+ "service_name": "Paused",
+ "amount": 499_00,
+ "is_active": False,
+ "next_debit_date": NOW + dt.timedelta(days=2),
+ }
+ ],
+ now=NOW,
+ )
+
+ self.assertEqual(forecast["commitments"]["by_kind"].get("subscription", 0), 0)
+
+ def test_possible_subscription_is_review_only_not_committed_spend(self):
+ forecast = build_runway_forecast(
+ profile={"monthly_allowance": 1_000_000, "cycle_start_day": 1},
+ transactions=[txn(10_000, days_ago=1)],
+ subscriptions=[
+ {
+ "service_name": "Maybe Cloud Storage",
+ "amount_paise": 299_00,
+ "is_active": True,
+ "status": "possible",
+ "billing_cycle": "monthly",
+ "next_debit_date": NOW + dt.timedelta(days=2),
+ }
+ ],
+ now=NOW,
+ )
+
+ self.assertEqual(forecast["commitments"]["by_kind"].get("subscription", 0), 0)
+ self.assertEqual(forecast["commitments"]["possible_commitments_total"], 299_00)
+ self.assertEqual(forecast["commitments"]["possible_commitments"][0]["status"], "possible")
+ self.assertEqual(forecast["possible_commitments_total"], 299_00)
+ self.assertEqual(forecast["possible_commitments"][0]["due_at"], forecast["commitments"]["possible_commitments"][0]["due_at"])
+ self.assertIsInstance(forecast["possible_commitments"][0]["due_at"], str)
+
+ def test_confirmed_subscription_accepts_amount_paise(self):
+ forecast = build_runway_forecast(
+ profile={"monthly_allowance": 1_000_000, "cycle_start_day": 1},
+ transactions=[txn(10_000, days_ago=1)],
+ subscriptions=[
+ {
+ "service_name": "Confirmed Cloud Storage",
+ "amount_paise": 199_00,
+ "is_active": True,
+ "status": "confirmed",
+ "billing_cycle": "monthly",
+ "next_debit_date": NOW + dt.timedelta(days=2),
+ }
+ ],
+ now=NOW,
+ )
+
+ self.assertEqual(forecast["commitments"]["by_kind"]["subscription"], 199_00)
+
+ def test_next_best_action_prefers_controllable_subscription_before_ask_home(self):
+ transactions = [txn(45_000, days_ago=day, category="food") for day in range(1, 12)]
+
+ forecast = build_runway_forecast(
+ profile={"monthly_allowance": 1_000_000, "cycle_start_day": 1},
+ transactions=transactions,
+ subscriptions=[
+ {
+ "service_name": "Premium Storage",
+ "amount": 350_000,
+ "is_active": True,
+ "status": "confirmed",
+ "billing_cycle": "monthly",
+ "next_debit_date": NOW + dt.timedelta(days=2),
+ }
+ ],
+ now=NOW,
+ )
+
+ self.assertEqual(forecast["action"]["type"], "ask_home")
+ self.assertEqual(forecast["decision_engine"]["next_best_action"]["type"], "review_subscription")
+
+ def test_long_horizon_outputs_are_marked_as_scenarios(self):
+ forecast = build_runway_forecast(
+ profile={"monthly_allowance": 1_000_000, "cycle_start_day": 1},
+ transactions=[txn(10_000, days_ago=day, category="food") for day in range(1, 8)],
+ subscriptions=[],
+ now=NOW,
+ )
+
+ self.assertTrue(forecast["horizons"])
+ self.assertTrue(all(item["mode"] == "scenario" for item in forecast["horizons"]))
+ self.assertTrue(all("recent pace" in item["basis"] for item in forecast["horizons"]))
+
+ def test_exam_buffer_is_reserved_only_during_overlap(self):
+ with_overlap = build_runway_forecast(
+ profile={
+ "monthly_allowance": 1_000_000,
+ "cycle_start_day": 1,
+ "exam_start_date": "2026-07-10",
+ "exam_end_date": "2026-07-15",
+ "exam_safety_buffer": 75_000,
+ },
+ transactions=[txn(10_000, days_ago=1)],
+ subscriptions=[],
+ now=NOW,
+ )
+ without_overlap = build_runway_forecast(
+ profile={
+ "monthly_allowance": 1_000_000,
+ "cycle_start_day": 1,
+ "exam_start_date": "2026-08-10",
+ "exam_end_date": "2026-08-15",
+ "exam_safety_buffer": 75_000,
+ },
+ transactions=[txn(10_000, days_ago=1)],
+ subscriptions=[],
+ now=NOW,
+ )
+
+ self.assertEqual(with_overlap["commitments"]["by_kind"]["exam_buffer"], 75_000)
+ self.assertEqual(without_overlap["commitments"]["by_kind"].get("exam_buffer", 0), 0)
+
+ def test_per_meal_mess_commitment_uses_days_left(self):
+ forecast = build_runway_forecast(
+ profile={
+ "monthly_allowance": 1_000_000,
+ "cycle_start_day": 1,
+ "mess_enrolled": True,
+ "mess_billing_model": "per_meal",
+ "mess_per_meal_cost": 5_000,
+ "mess_meals_per_day": 2,
+ },
+ transactions=[txn(10_000, days_ago=1)],
+ subscriptions=[],
+ now=NOW,
+ )
+
+ self.assertEqual(
+ forecast["commitments"]["by_kind"]["mess"],
+ forecast["current_cycle"]["days_left"] * 10_000,
+ )
+
+ def test_monthly_mess_routine_is_covered_without_fake_extra_meal_cost(self):
+ forecast = build_runway_forecast(
+ profile={
+ "monthly_allowance": 1_000_000,
+ "cycle_start_day": 1,
+ "mess_enrolled": True,
+ "mess_billing_model": "monthly",
+ "mess_monthly_cost": 90_000,
+ },
+ transactions=[txn(10_000, days_ago=1, category="shopping", merchant="Notebook Store")],
+ subscriptions=[],
+ now=NOW,
+ )
+
+ food_routine = forecast["food_routine"]
+ self.assertEqual(food_routine["routine_option_label"], "Use hostel mess")
+ self.assertEqual(food_routine["routine_meal_cost"], 0)
+ self.assertEqual(food_routine["routine_meal_cost_source"], "mess_included")
+ self.assertEqual(food_routine["routine_meal_cost_confidence"], "high")
+
+ def test_noisy_campus_history_falls_back_to_sane_routine_estimate(self):
+ forecast = build_runway_forecast(
+ profile={
+ "monthly_allowance": 1_000_000,
+ "cycle_start_day": 1,
+ "meal_routine": "day_scholar",
+ },
+ transactions=[
+ txn(311_300, days_ago=2, category="food", merchant="Campus Canteen"),
+ txn(9_800, days_ago=1, category="food", merchant="Swiggy"),
+ ],
+ subscriptions=[],
+ now=NOW,
+ )
+
+ food_routine = forecast["food_routine"]
+ self.assertEqual(food_routine["routine_meal_cost_source"], "campus_meal_history")
+ self.assertEqual(food_routine["routine_meal_cost"], 9_000)
+ self.assertEqual(food_routine["routine_meal_cost_confidence"], "low")
+ self.assertTrue(food_routine["routine_meal_cost_adjusted"])
+ self.assertEqual(food_routine["delivery_meal_cost"], 9_800)
+ self.assertLess(food_routine["shared_meal_cost"], food_routine["delivery_meal_cost"])
+
+ def test_pool_obligations_match_user_id_before_legacy_name(self):
+ pools = [
+ {
+ "_id": "pool-1",
+ "status": "closed",
+ "host_id": "host-1",
+ "platform": "food_delivery",
+ "delivery_fee": 6_000,
+ }
+ ]
+ items = [
+ {"pool_id": "pool-1", "added_by_user_id": "user-1", "added_by_name": "Same Name", "estimated_price": 40_000, "is_purchased": True},
+ {"pool_id": "pool-1", "added_by_user_id": "user-2", "added_by_name": "Same Name", "estimated_price": 50_000, "is_purchased": True},
+ ]
+
+ obligations = derive_pool_obligations(pools, items, user_id="user-1", user_name="Same Name", now=NOW)
+
+ self.assertEqual(len(obligations), 1)
+ self.assertEqual(obligations[0]["amount"], 43_000)
+
+ def test_verified_completed_pool_payment_removes_obligation(self):
+ pools = [
+ {
+ "_id": "pool-1",
+ "status": "completed",
+ "host_id": "host-1",
+ "payments": [{"user_id": "user-1", "status": "verified"}],
+ }
+ ]
+ items = [
+ {"pool_id": "pool-1", "added_by_user_id": "user-1", "added_by_name": "Student", "estimated_price": 40_000, "is_purchased": True},
+ ]
+
+ obligations = derive_pool_obligations(pools, items, user_id="user-1", user_name="Student", now=NOW)
+
+ self.assertEqual(obligations, [])
+
+ def test_duplicates_refunds_and_allowance_credit_are_not_double_counted(self):
+ forecast = build_runway_forecast(
+ profile={"monthly_allowance": 1_000_000, "cycle_start_day": 1},
+ transactions=[
+ txn(1_000_000, days_ago=1, direction="credit", category="income", merchant="Allowance from home"),
+ txn(10_000, days_ago=1, category="food"),
+ txn(90_000, days_ago=1, category="food", status="duplicate"),
+ txn(80_000, days_ago=1, category="food", status="refunded"),
+ ],
+ subscriptions=[],
+ now=NOW,
+ )
+
+ self.assertEqual(forecast["current_cycle"]["available_funding"], 1_000_000)
+ self.assertEqual(forecast["current_cycle"]["spent"], 10_000)
+
+ def test_profile_routine_explicitly_supports_pg_day_scholar_and_mixed(self):
+ for routine in ("pg_cooking", "day_scholar", "mixed"):
+ forecast = build_runway_forecast(
+ profile={"monthly_allowance": 1_000_000, "cycle_start_day": 1, "meal_routine": routine},
+ transactions=[txn(10_000, days_ago=1)],
+ subscriptions=[],
+ now=NOW,
+ )
+ self.assertEqual(forecast["food_routine"]["type"], routine)
+
+ def test_empirical_stress_band_uses_high_spend_days(self):
+ transactions = [txn(10_000, days_ago=day, category="food") for day in range(1, 8)]
+ transactions.append(txn(90_000, days_ago=2, category="shopping", merchant="Mall"))
+
+ forecast = build_runway_forecast(
+ profile={"monthly_allowance": 1_000_000, "cycle_start_day": 1},
+ transactions=transactions,
+ subscriptions=[],
+ now=NOW,
+ )
+
+ band = forecast["projection"]["stress_band"]
+ self.assertGreaterEqual(band["stress"]["daily_spend"], band["expected"]["daily_spend"])
+ self.assertLessEqual(band["stress"]["days_until_broke"], band["expected"]["days_until_broke"])
+ self.assertIn("empirical_stress_probability", band["risk_sources"])
+ self.assertTrue(any(driver["kind"] == "high_spend_day" for driver in forecast["drivers"]))
+
+ def test_subscription_pressure_gets_single_priority_action(self):
+ transactions = [txn(30_000, days_ago=day, category="food") for day in range(1, 10)]
+ transactions.append(txn(90_000, days_ago=2, category="shopping", merchant="Mall"))
+
+ forecast = build_runway_forecast(
+ profile={"monthly_allowance": 1_500_000, "cycle_start_day": 1},
+ transactions=transactions,
+ subscriptions=[
+ {
+ "service_name": "Cloud Storage",
+ "amount": 300_000,
+ "is_active": True,
+ "billing_cycle": "monthly",
+ "next_debit_date": NOW + dt.timedelta(days=2),
+ }
+ ],
+ now=NOW,
+ )
+
+ self.assertEqual(forecast["decision_engine"]["next_best_action"]["type"], "review_subscription")
+ self.assertTrue(any(driver["kind"] == "subscriptions" for driver in forecast["drivers"]))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/backend/tests/test_travel_geo_provider.py b/backend/tests/test_travel_geo_provider.py
index 38593f6..d447de3 100644
--- a/backend/tests/test_travel_geo_provider.py
+++ b/backend/tests/test_travel_geo_provider.py
@@ -1,6 +1,7 @@
import datetime
import os
import unittest
+from unittest.mock import patch
os.environ.setdefault("JWT_SECRET", "test-secret")
os.environ.setdefault("MONGO_URI", "mongodb://localhost:27017/pocketbuddy_test")
@@ -104,6 +105,76 @@ async def test_compute_route_can_reuse_cached_osrm_route_without_provider_call(s
self.assertEqual(result[6], "OSRM cached")
self.assertTrue(result[7])
+ async def test_compute_route_prefers_tomtom_over_stale_osrm_cache_when_configured(self):
+ cache = FakeGeoCacheCollection()
+ db = FakeTravelDb(cache)
+ lat1, lon1 = 26.2514, 78.1685
+ lat2, lon2 = 26.2183, 78.1828
+ osrm_base_url = settings.osrm_route_url.rstrip("/")
+ key = build_geo_cache_key("osrm_route", osrm_base_url, lat1, lon1, lat2, lon2)
+ cache.docs[key] = {
+ "_id": key,
+ "payload": {
+ "distance_km": 8.4,
+ "duration_mins": 24,
+ "geometry": [[lat1, lon1], [lat2, lon2]],
+ },
+ "expires_at": utc_now() + datetime.timedelta(days=1),
+ }
+
+ class FakeTomTomResponse:
+ status_code = 200
+ text = ""
+
+ def json(self):
+ return {
+ "routes": [
+ {
+ "summary": {
+ "lengthInMeters": 12500,
+ "travelTimeInSeconds": 1500,
+ "trafficDelayInSeconds": 300,
+ },
+ "legs": [
+ {
+ "points": [
+ {"latitude": lat1, "longitude": lon1},
+ {"latitude": lat2, "longitude": lon2},
+ ]
+ }
+ ],
+ }
+ ]
+ }
+
+ class FakeAsyncClient:
+ def __init__(self, *args, **kwargs):
+ pass
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *args):
+ return False
+
+ async def get(self, *args, **kwargs):
+ return FakeTomTomResponse()
+
+ old_key = settings.TOMTOM_API_KEY
+ try:
+ settings.TOMTOM_API_KEY = "test-tomtom-key"
+ with patch("app.api.travel.httpx.AsyncClient", FakeAsyncClient):
+ result = await compute_route(lat1, lon1, lat2, lon2, db=db)
+ finally:
+ settings.TOMTOM_API_KEY = old_key
+
+ self.assertEqual(result[0], 12.5)
+ self.assertEqual(result[1], 25)
+ self.assertEqual(result[4], True)
+ self.assertEqual(result[5], 5)
+ self.assertEqual(result[6], "TomTom Traffic")
+ self.assertFalse(result[7])
+
def test_source_note_separates_demo_public_provider_from_production_option(self):
note = travel_geo_source_note("https://nominatim.openstreetmap.org")
diff --git a/frontend/src/routes/_authenticated/dashboard.lazy.tsx b/frontend/src/routes/_authenticated/dashboard.lazy.tsx
index 94d1454..e7e879f 100644
--- a/frontend/src/routes/_authenticated/dashboard.lazy.tsx
+++ b/frontend/src/routes/_authenticated/dashboard.lazy.tsx
@@ -292,41 +292,6 @@ function BurnoutGauge({ score }: { score: number }) {
}
// ── Survive-Until countdown ──────────────────────────────────────────────
-function SurviveCountdown({ runwayMs }: { runwayMs: number }) {
- const [tick, setTick] = useState(0);
- useEffect(() => {
- const id = setInterval(() => setTick((t) => t + 1), 1000);
- return () => clearInterval(id);
- }, []);
- const remaining = Math.max(0, runwayMs - Date.now());
- const days = Math.floor(remaining / 86400000);
- const hrs = Math.floor((remaining % 86400000) / 3600000);
- const mins = Math.floor((remaining % 3600000) / 60000);
- const secs = Math.floor((remaining % 60000) / 1000);
- const pad = (n: number) => String(n).padStart(2, "0");
- const parts = [
- ...(days > 0 ? [{ value: String(days), label: "d" }] : []),
- { value: pad(hrs), label: "h" },
- { value: pad(mins), label: "m" },
- { value: pad(secs), label: "s", pulse: true },
- ];
- return (
-
- {parts.map((part) => (
-
-
- {part.value}
-
- {part.label}
-
- ))}
-
- );
-}
-
// ── Category donut (pure SVG) ────────────────────────────────────────────
function CategoryDonut({ breakdown }: { breakdown: { category: string; pct: number; amount_paise: number }[] }) {
const r = 36, cx = 44, cy = 44, stroke = 10;
@@ -565,12 +530,25 @@ function MealRunwayCheck({ calc, runwayView }: { calc: any; runwayView?: any })
const routine = runwayView?.foodRoutine ?? {};
const safeDailyPaise = runwayView?.safeDailyPaise ?? Math.round((calc?.safeDailyLimit ?? 200) * 100);
const foodCapPaise = routine?.recommended_daily_food_cap ?? safeDailyPaise;
- const deliveryCostPaise = routine?.delivery?.avg_order || 25_000;
const routineType = routine?.type ?? "mixed";
+ const routineCostSource = String(routine?.routine_meal_cost_source ?? "");
+ const routineCostBasis = String(routine?.routine_meal_cost_basis ?? "");
+ const routineCostConfidence = String(routine?.routine_meal_cost_confidence ?? "low");
+ const deliveryCostPaise =
+ routine?.delivery_meal_cost ||
+ routine?.delivery?.avg_order ||
+ Math.max(9_000, Math.min(25_000, (foodCapPaise || 9_000) + 2_000));
+ const deliveryCostBasis = String(routine?.delivery_cost_basis ?? "");
+ const deliveryCostConfidence = String(routine?.delivery_cost_confidence ?? (routine?.delivery?.count > 1 ? "high" : "low"));
+ const sharedCostPaise =
+ routine?.shared_meal_cost ||
+ Math.max(4_000, Math.min(deliveryCostPaise, Math.round(deliveryCostPaise * 0.85)));
+ const sharedCostBasis = String(routine?.shared_cost_basis ?? "");
+ const sharedCostConfidence = String(routine?.shared_cost_confidence ?? "low");
const routineMeta: Record = {
hostel_mess: {
label: "Hostel mess / campus meals",
- option: "Use mess or campus meal",
+ option: "Use hostel mess",
detail: "Best when your mess is prepaid or predictable. It keeps delivery from eating into the safe/day number.",
},
pg_cooking: {
@@ -591,27 +569,44 @@ function MealRunwayCheck({ calc, runwayView }: { calc: any; runwayView?: any })
};
const activeRoutine = routineMeta[routineType] ?? routineMeta.mixed;
const routineMealCostPaise =
- routine?.routine_meal_cost ||
- Math.max(4_000, Math.min(foodCapPaise || 14_000, Math.round((foodCapPaise || 14_000) / 2)));
- const sharedCostPaise = Math.max(
- 4_000,
- Math.min(deliveryCostPaise, Math.round((deliveryCostPaise + routineMealCostPaise) / 2))
- );
+ typeof routine?.routine_meal_cost === "number"
+ ? routine.routine_meal_cost
+ : Math.max(4_000, Math.min(foodCapPaise || 14_000, Math.round((foodCapPaise || 14_000) / 2)));
+ const routineOptionLabel = String(routine?.routine_option_label ?? activeRoutine.option);
+ const routineDetail =
+ routineCostSource === "mess_included"
+ ? "Your mess plan is already accounted for in runway, so this meal does not add extra spend today."
+ : routineCostSource === "mess_per_meal"
+ ? "This uses your configured mess rate and is the most stable routine option for today."
+ : activeRoutine.detail;
+
+ const renderPlanCost = (cost: number, source: string, confidence: string) => {
+ if (source === "mess_included" && cost <= 0) return "Covered";
+ const amount = rupees(cost);
+ return confidence === "low" ? `Est. ${amount}` : amount;
+ };
+
const plans = [
{
id: "routine" as const,
- label: activeRoutine.option,
+ label: routineOptionLabel,
cost: routineMealCostPaise,
+ costSource: routineCostSource,
+ costConfidence: routineCostConfidence,
+ basis: routineCostBasis,
icon: Utensils,
tone: "text-pb-green",
border: "border-pb-green/20",
bg: "bg-pb-green/5",
- detail: activeRoutine.detail,
+ detail: routineDetail,
},
{
id: "shared" as const,
label: routineType === "pg_cooking" ? "Split groceries with roommate" : "Pool / shared campus order",
cost: sharedCostPaise,
+ costSource: String(routine?.shared_cost_source ?? "shared"),
+ costConfidence: sharedCostConfidence,
+ basis: sharedCostBasis,
icon: Users,
tone: "text-primary",
border: "border-primary/20",
@@ -625,119 +620,164 @@ function MealRunwayCheck({ calc, runwayView }: { calc: any; runwayView?: any })
id: "delivery" as const,
label: "Individual delivery order",
cost: deliveryCostPaise,
+ costSource: "delivery",
+ costConfidence: deliveryCostConfidence,
+ basis: deliveryCostBasis,
icon: ShoppingBag,
tone: "text-pb-red",
border: "border-pb-red/20",
bg: "bg-pb-red/5",
- detail: "Convenient, but this is usually the fastest way food pace starts reducing runway.",
+ detail:
+ deliveryCostConfidence === "low"
+ ? "PocketBuddy is using a delivery estimate until it sees more delivery payments."
+ : "Convenient, but this is usually the fastest way food pace starts reducing runway.",
},
];
- const selected = plans.find((plan) => plan.id === selectedPlan);
- const savedVsDelivery = selected ? Math.max(0, deliveryCostPaise - selected.cost) : 0;
- const safeUsage = selected && safeDailyPaise > 0 ? Math.round((selected.cost / safeDailyPaise) * 100) : 0;
- const capGap = selected ? selected.cost - foodCapPaise : 0;
- const SelectedIcon = selected?.icon;
- if (selected && SelectedIcon) {
- return (
-
-
-
-
-
-
- {activeRoutine.label}
-
-
- {rupees(selected.cost)} today
-
-
-
-
-
-
-
-
{selected.label}
-
{selected.detail}
-
-
-
- {capGap > 0 ? (
- <>
- This is {rupees(capGap)} above your food cap of{" "}
- {rupees(foodCapPaise)} . Choose lighter spends for the rest of today.
- >
- ) : (
- <>
- This stays within your food cap of {rupees(foodCapPaise)} and uses{" "}
- {safeUsage}% of your safe daily limit.
- >
- )}
-
-
+ const planViews = plans.map((plan) => {
+ const isCovered = plan.costSource === "mess_included" && plan.cost <= 0;
+ const capGap = Math.max(0, plan.cost - foodCapPaise);
+ const saved = Math.max(0, deliveryCostPaise - plan.cost);
+ const safeUsage = !isCovered && safeDailyPaise > 0 ? Math.round((plan.cost / safeDailyPaise) * 100) : 0;
+ const withinCap = isCovered || capGap <= 0;
+ const fitLabel = isCovered ? "Covered" : withinCap ? "Within cap" : `+${rupees(capGap)}`;
+ const fitTone = isCovered
+ ? "border-emerald-500/15 bg-emerald-500/8 text-emerald-400"
+ : withinCap
+ ? "border-primary/15 bg-primary/8 text-primary"
+ : "border-amber-500/15 bg-amber-500/8 text-amber-400";
+ const confidenceLabel =
+ plan.costConfidence === "high" ? "Observed" : plan.costConfidence === "medium" ? "Recent pattern" : "Estimate";
+ const supportLine = isCovered
+ ? "Already accounted for in runway."
+ : capGap > 0
+ ? `${rupees(capGap)} above today's food cap.`
+ : saved > 0 && plan.id !== "delivery"
+ ? `Keeps ${rupees(saved)} inside runway vs solo delivery.`
+ : "Fits today's runway without extra pressure.";
+ const score =
+ (isCovered ? 100 : 0) +
+ (withinCap ? 28 : -Math.min(24, Math.round(capGap / 1000))) +
+ (plan.id === "routine" ? 8 : plan.id === "shared" ? 4 : -8) +
+ (plan.costConfidence === "high" ? 6 : plan.costConfidence === "medium" ? 3 : 0) +
+ Math.min(18, Math.round(saved / 1000)) -
+ Math.round(Math.max(plan.cost, 0) / 1000);
+ return {
+ ...plan,
+ isCovered,
+ capGap,
+ saved,
+ safeUsage,
+ withinCap,
+ fitLabel,
+ fitTone,
+ confidenceLabel,
+ supportLine,
+ score,
+ };
+ });
-
+ const recommendedPlan = planViews.reduce((best, current) => (current.score > best.score ? current : best), planViews[0]);
+ const activePlan = planViews.find((plan) => plan.id === selectedPlan) ?? recommendedPlan;
+ const activeIsRecommended = activePlan.id === recommendedPlan.id;
+ const activeSummary = activePlan.isCovered
+ ? "Already covered by your mess plan."
+ : activePlan.capGap > 0
+ ? `${rupees(activePlan.capGap)} above today's cap.`
+ : activePlan.id !== "delivery" && activePlan.saved > 0
+ ? `Saves ${rupees(activePlan.saved)} vs solo delivery.`
+ : `${activePlan.safeUsage}% of your safe daily limit.`;
+ const activeContext = activePlan.id === "delivery" && activePlan.costConfidence === "low"
+ ? "Delivery estimate will improve as more food payment history arrives."
+ : activePlan.basis || activePlan.supportLine;
+ const recommendedSummary = recommendedPlan.isCovered
+ ? "covered by your mess plan"
+ : recommendedPlan.capGap > 0
+ ? `${rupees(recommendedPlan.capGap)} above cap`
+ : recommendedPlan.id !== "delivery" && recommendedPlan.saved > 0
+ ? `saves ${rupees(recommendedPlan.saved)} vs solo delivery`
+ : "fits today's cap";
+
+ return (
+
+
+
+
+
- {savedVsDelivery > 0
- ? `Choosing this instead of individual delivery keeps about ${rupees(savedVsDelivery)} inside your runway today.`
- : "This option is useful only when today's safe/day can absorb the full cost."}
+ Use the option that fits today's runway best.
-
-
- Full Runway
-
- setSelectedPlan(null)} className="h-8 rounded-lg bg-surface-raised text-zinc-400 px-3 text-[10px] md:text-xs font-bold uppercase tracking-wider hover:text-zinc-200 transition-all cursor-pointer">
- Change
-
-
+
+ Cap {rupees(foodCapPaise)}
+
-
- );
- }
- return (
-
-
-
-
-
-
- Pick the likely meal and see if it fits today.
-
+
+ Recommended: {" "}
+ {recommendedPlan.label}
+ , {recommendedSummary}.
-
- Food cap {rupees(foodCapPaise)}
-
-
-
- {plans.map((plan) => {
- const Icon = plan.icon;
- const saved = Math.max(0, deliveryCostPaise - plan.cost);
- return (
-
setSelectedPlan(plan.id)}
- className="w-full text-left p-3 rounded-xl border border-border bg-surface-raised/60 hover:bg-surface hover:border-primary/35 transition-all cursor-pointer group"
- >
-
-
-
-
-
{plan.label}
+
+ {planViews.map((plan) => {
+ const Icon = plan.icon;
+ const isActive = activePlan.id === plan.id;
+ const isRecommended = recommendedPlan.id === plan.id;
+ return (
+
setSelectedPlan(plan.id)}
+ className={`w-full text-left px-3 py-3 transition-colors cursor-pointer ${
+ isActive
+ ? "bg-surface"
+ : "hover:bg-surface/70"
+ }`}
+ >
+
+
+
+
+
+
+ {plan.label}
+
+ {plan.fitLabel} • {plan.confidenceLabel}
+
+
+
+ {isActive ? activeSummary : plan.supportLine}
+
+
+
+
+
+ {renderPlanCost(plan.cost, plan.costSource, plan.costConfidence)}
+
+
+ {isActive ? (activeIsRecommended ? "Recommended" : "Selected") : isRecommended ? "Best fit" : ""}
+
-
{rupees(plan.cost)}
- {saved > 0 &&
Saves {rupees(saved)} vs delivery
}
-
-
-
- );
- })}
+
+ );
+ })}
+
+
+
+
+ {activeContext}
+
+
+ Full runway
+
+
);
@@ -853,24 +893,31 @@ function Dashboard() {
// Burnout score is now calculated on the backend via /api/insights/wellness
const runwayView = useMemo(() => {
- if (!runwayForecast && !calc) return null;
+ if (!runwayForecast) return null;
const currentCycle = runwayForecast?.current_cycle;
const projection = runwayForecast?.projection;
+ const stressBand = projection?.stress_band;
const foodRoutine = runwayForecast?.food_routine;
const decision = runwayForecast?.decision_engine;
- const allowancePaise = currentCycle?.available_funding ?? Math.round((calc?.totalAllowance ?? 0) * 100);
- const spentPaise = currentCycle?.spent ?? Math.round((calc?.totalSpent ?? 0) * 100);
- const pct = allowancePaise > 0 ? Math.min(100, Math.round((spentPaise / allowancePaise) * 100)) : calc?.pct ?? 0;
+ const allowancePaise = currentCycle?.available_funding ?? 0;
+ const spentPaise = currentCycle?.spent ?? 0;
+ const pct = allowancePaise > 0 ? Math.min(100, Math.round((spentPaise / allowancePaise) * 100)) : 0;
return {
- days: projection?.days_until_broke ?? calc?.runwayDays ?? 0,
- safeDailyPaise: projection?.safe_daily_spend ?? Math.round((calc?.safeDailyLimit ?? 0) * 100),
- remainingPaise: currentCycle?.remaining ?? Math.round((calc?.remaining ?? 0) * 100),
+ setupRequired: runwayForecast?.status === "setup_required" || runwayForecast?.setup_required,
+ setupReason: runwayForecast?.setup_reason ?? currentCycle?.setup_reason,
+ days: projection?.days_until_broke ?? 0,
+ expectedDays: stressBand?.expected?.days_until_broke ?? projection?.days_until_broke ?? 0,
+ stressDays: stressBand?.stress?.days_until_broke ?? projection?.days_until_broke ?? 0,
+ calmDays: stressBand?.calm?.days_until_broke ?? projection?.days_until_broke ?? 0,
+ safeDailyPaise: projection?.safe_daily_spend ?? 0,
+ remainingPaise: currentCycle?.remaining ?? 0,
spentTodayPaise: Math.round((calc?.spentToday ?? 0) * 100),
allowancePaise,
- cycleEnd: currentCycle?.end ? new Date(currentCycle.end) : calc?.cycleEnd,
- daysLeft: currentCycle?.days_left ?? calc?.daysLeft ?? 0,
+ cycleEnd: currentCycle?.end ? new Date(currentCycle.end) : undefined,
+ daysLeft: currentCycle?.days_left ?? 0,
projectedDailyPaise: projection?.projected_daily_spend ?? 0,
shortfallProbability: projection?.shortfall_probability ?? 0,
+ riskSources: stressBand?.risk_sources,
nextAction: decision?.next_best_action,
pct,
status: runwayForecast?.status ?? "healthy",
@@ -884,18 +931,6 @@ function Dashboard() {
}, [runwayForecast, calc, snoozedSubs]);
// ── Survive-Until runway timestamp ─────────────────────────────────────
- const surviveUntilMs = useMemo(() => {
- if (!calc || !insights) return 0;
- const avgDailyPaise = (insights.velocity?.spend_7d_paise ?? 0) / 7;
- if (avgDailyPaise <= 0) {
- // fallback: use daysLeft * 24h
- return Date.now() + calc.daysLeft * 86400000;
- }
- const remainingPaise = calc.remaining * 100;
- const msUntilBroke = (remainingPaise / avgDailyPaise) * 86400000;
- return Date.now() + msUntilBroke;
- }, [calc, insights]);
-
const { data: subs } = useQuery({
queryKey: ["subs", user?.id],
enabled: !!user,
@@ -922,17 +957,17 @@ function Dashboard() {
const { data: foods } = useQuery({
queryKey: [
"foods",
- runwayView?.safeDailyPaise,
+ runwayView?.foodRoutine?.recommended_daily_food_cap ?? runwayView?.safeDailyPaise,
menuFoodGapHours ? Math.floor(menuFoodGapHours) : null,
- runwayView?.foodRoutine?.routine_type,
+ runwayView?.foodRoutine?.type,
profile?.mess_enrolled,
],
staleTime: 30_000,
queryFn: () =>
getCampusFood({
- safeFoodBudgetPaise: runwayView?.safeDailyPaise,
+ safeFoodBudgetPaise: runwayView?.foodRoutine?.recommended_daily_food_cap ?? runwayView?.safeDailyPaise,
mealGapHours: menuFoodGapHours,
- foodRoutineType: runwayView?.foodRoutine?.routine_type,
+ foodRoutineType: runwayView?.foodRoutine?.type,
messEnrolled: profile?.mess_enrolled,
}),
});
@@ -973,15 +1008,19 @@ function Dashboard() {
}, [foods]);
const runwayColor = runwayView
- ? runwayView.days >= 15
+ ? runwayView.setupRequired
+ ? "var(--primary)"
+ : runwayView.days >= 15
? "var(--success)"
: runwayView.days >= 7
? "var(--warning)"
: "var(--destructive)"
: "var(--primary)";
- const runwayStatusLabel = runwayView?.status === "shortfall" ? "Shortfall" : runwayView?.status === "watch" ? "Watch" : "Healthy";
+ const runwayStatusLabel = runwayView?.setupRequired ? "Setup needed" : runwayView?.status === "shortfall" ? "Shortfall" : runwayView?.status === "watch" ? "Watch" : "Healthy";
const runwayStatusClass =
- runwayView?.status === "shortfall"
+ runwayView?.setupRequired
+ ? "bg-primary/10 border-primary/25 text-primary"
+ : runwayView?.status === "shortfall"
? "bg-destructive/10 border-destructive/25 text-destructive"
: runwayView?.status === "watch"
? "bg-warning/10 border-warning/25 text-warning"
@@ -1030,9 +1069,11 @@ function Dashboard() {
const [identifying, setIdentifying] = useState
(null);
const [editingTxn, setEditingTxn] = useState(null);
const [adding, setAdding] = useState(false);
- const [showFoodSheet, setShowFoodSheet] = useState(false);
const [isWellnessExpanded, setIsWellnessExpanded] = useState(false);
+ // Student Fuel Swapper State
+ const [showFoodSheet, setShowFoodSheet] = useState(false);
+
// Food scanner and crowdsourced verification state & hooks
const [foodTab, setFoodTab] = useState<"menus" | "add" | "signals" | "verify">("menus");
const [scanVenue, setScanVenue] = useState("");
@@ -1357,7 +1398,7 @@ function Dashboard() {
// Subscription bleed
const subBleed = (insights.subscriptions?.monthly_bleed_paise ?? 0) / 100;
- if (subBleed > 300 && runwayView && runwayView.safeDailyPaise < 15_000) {
+ if (subBleed > 300 && runwayView && !runwayView.setupRequired && runwayView.safeDailyPaise < 15_000) {
list.push({
id: "sub_bleed",
icon: Receipt,
@@ -1765,7 +1806,7 @@ function Dashboard() {
Runway Status
- How long your money can last at the current pace.
+ Expected and stress-case view from the runway engine.
@@ -1789,18 +1830,34 @@ function Dashboard() {
{!runwayView ? (
+ ) : runwayView.setupRequired ? (
+
+
Setup needed
+
Add allowance to activate Runway
+
+ {runwayView.setupReason ?? "Runway needs your monthly allowance or a synced allowance credit before it can calculate safe/day and shortfall guidance."}
+
+
+
+ Open Settings
+
+
+ View Transactions
+
+
+
) : (
<>
-
+
Days
- You can safely spend {rupees(runwayView.safeDailyPaise)}/day until your allowance resets.
+ Expected runway at {rupees(runwayView.safeDailyPaise)}/day . Stress case: {runwayView.stressDays} days .
Reset in {runwayView.daysLeft} days{runwayView.cycleEnd ? ` (${shortDate(runwayView.cycleEnd)})` : ""}
@@ -1918,8 +1975,9 @@ function Dashboard() {
{runwayView.possibleCommitments.map((sub: any) => {
- const dailyImpact = calc && calc.daysLeft > 0 ? Math.round(sub.amount / 100 / calc.daysLeft) : 0;
- const newLimit = calc && calc.daysLeft > 0 ? Math.max(0, Math.round((calc.remaining - sub.amount / 100) / calc.daysLeft)) : 0;
+ const daysLeft = Math.max(1, runwayView.daysLeft || 0);
+ const dailyImpactPaise = Math.round((sub.amount || 0) / daysLeft);
+ const newSafeDailyPaise = Math.max(0, Math.floor(((runwayView.remainingPaise || 0) - (sub.amount || 0)) / daysLeft));
return (
0 && (
+ {dailyImpactPaise > 0 && (
- Runway impact if confirmed: Safe daily spend would drop by {rupees(dailyImpact * 100)}/day (adjusting to {rupees(newLimit * 100)}/day).
+ Runway impact if confirmed: Safe daily spend would drop by {rupees(dailyImpactPaise)}/day (adjusting to {rupees(newSafeDailyPaise)}/day).
)}
@@ -2015,7 +2073,7 @@ function Dashboard() {
)}
- {runwayView &&
}
+ {runwayView && !runwayView.setupRequired &&
}
{/* ── Behaviour Analytics Row ─────────────────────────────── */}
@@ -2033,13 +2091,8 @@ function Dashboard() {
)}
>
) : (
-
- {["M", "T", "W", "T", "F", "S", "S"].map((d, i) => (
-
- ))}
+
+
No weekly spend pattern yet. Sync or add transactions to build this chart.
)}
@@ -2075,7 +2128,7 @@ function Dashboard() {
Meal gaps, delivery, groceries, and subscriptions feed directly into runway.
- {runwayView?.foodRoutine?.label && (
+ {runwayView?.foodRoutine?.label && !runwayView?.setupRequired && (
{runwayView.foodRoutine.label}
@@ -2099,10 +2152,10 @@ function Dashboard() {
Delivery
- {runwayView?.foodRoutine?.delivery?.count ?? insights?.food?.delivery_count_30d ?? "—"}×
+ {!runwayView?.setupRequired ? runwayView?.foodRoutine?.delivery?.count ?? insights?.food?.delivery_count_30d ?? "—" : insights?.food?.delivery_count_30d ?? "—"}×
- {runwayView?.foodRoutine ? `${rupees(runwayView.foodRoutine.food_daily_pace ?? 0)}/day food pace` : `vs ${insights?.food?.mess_count_30d ?? "—"} mess visits`}
+ {runwayView?.foodRoutine && !runwayView?.setupRequired ? `${rupees(runwayView.foodRoutine.food_daily_pace ?? 0)}/day food pace` : `vs ${insights?.food?.mess_count_30d ?? "—"} mess visits`}
@@ -2359,29 +2412,7 @@ function Dashboard() {
)}
- {/* ── Survive Until Broke Card ─────────────────── */}
-
-
-
-
-
Survive Until Broke
-
- LIVE COUNTDOWN
-
-
-
-
- {surviveUntilMs > 0 ? (
-
- ) : (
-
—
- )}
-
- Estimated exact date your allowance will run out. Click to view detailed forecasts.
-
-
-
-
+ {/* ── Interactive Student Allocation Planner ─────────────────── */}
{/* ── AI Campus Intelligence (Bedrock) ──────────────────── */}
@@ -2510,7 +2541,7 @@ function Dashboard() {
)}
{/* Alert Widget */}
- {runwayView && (runwayView.days < 7 || runwayView.safeDailyPaise < 15_000) && (
+ {runwayView && !runwayView.setupRequired && (runwayView.days < 7 || runwayView.safeDailyPaise < 15_000) && (
@@ -2814,7 +2845,8 @@ function Dashboard() {
{items.map((it) => {
const trustLabel = getTrustBadgeLabel(it);
- const isSafeBudget = it.price <= (insights?.safe_daily_limit_paise || (runwayView?.safeDailyPaise ?? 23800));
+ const safeBudgetLimit = insights?.safe_daily_limit_paise ?? (!runwayView?.setupRequired ? runwayView?.safeDailyPaise : undefined);
+ const isSafeBudget = typeof safeBudgetLimit === "number" && safeBudgetLimit > 0 ? it.price <= safeBudgetLimit : null;
const freshnessState = String(it.price_freshness_state || "");
const showFreshness =
freshnessState === "needs_price_check" ||
@@ -2851,10 +2883,12 @@ function Dashboard() {
-
-
- {isSafeBudget ? "Within today's food budget" : "Over daily safe budget"}
-
+ {isSafeBudget !== null && (
+
+
+ {isSafeBudget ? "Within today's food budget" : "Over daily safe budget"}
+
+ )}
{it.source_type === "student_confirmed" && (
diff --git a/frontend/src/routes/_authenticated/onboarding.tsx b/frontend/src/routes/_authenticated/onboarding.tsx
index f7703ed..daf86b9 100644
--- a/frontend/src/routes/_authenticated/onboarding.tsx
+++ b/frontend/src/routes/_authenticated/onboarding.tsx
@@ -142,6 +142,8 @@ function Onboarding() {
// Step 2
const [mess, setMess] = useState(true);
+ const [residenceType, setResidenceType] = useState("hostel");
+ const [mealRoutine, setMealRoutine] = useState("hostel_mess");
const [meals, setMeals] = useState<{ breakfast: boolean; lunch: boolean; dinner: boolean }>({
breakfast: false,
lunch: true,
@@ -204,6 +206,9 @@ function Onboarding() {
if (data.wing_label) setWing(data.wing_label);
if (data.room_number) setRoom(data.room_number);
if (data.phone) setPhone(data.phone);
+ if (data.residence_type) setResidenceType(data.residence_type);
+ if (data.meal_routine) setMealRoutine(data.meal_routine);
+ if (typeof data.mess_enrolled === "boolean") setMess(data.mess_enrolled);
})
.catch((err) => console.error("Onboarding profile load error:", err));
}, [user]);
@@ -279,6 +284,8 @@ function Onboarding() {
await updateProfile({
data: {
mess_enrolled: mess,
+ residence_type: residenceType,
+ meal_routine: mealRoutine,
meal_schedule: meals,
upi_apps_used: upiApps.map((a) => a.toLowerCase().replace(/\s+/g, "")),
exam_start_date: examStart || null,
@@ -524,14 +531,21 @@ function Onboarding() {
setMess(true)}
+ onClick={() => {
+ setMess(true);
+ setResidenceType("hostel");
+ setMealRoutine("hostel_mess");
+ }}
className={`rounded-md border p-3.5 text-left text-xs transition-all cursor-pointer ${mess ? "border-primary bg-primary/5 font-semibold text-foreground" : "border-border bg-surface-raised/40 text-muted-foreground hover:border-white/10"}`}
>
Yes
Mess enrolled
setMess(false)}
+ onClick={() => {
+ setMess(false);
+ if (mealRoutine === "hostel_mess") setMealRoutine("mixed");
+ }}
className={`rounded-md border p-3.5 text-left text-xs transition-all cursor-pointer ${!mess ? "border-primary bg-primary/5 font-semibold text-foreground" : "border-border bg-surface-raised/40 text-muted-foreground hover:border-white/10"}`}
>
No
@@ -540,6 +554,53 @@ function Onboarding() {
+
+
+ {
+ setResidenceType(value);
+ if (value === "hostel") {
+ setMess(true);
+ setMealRoutine("hostel_mess");
+ }
+ if (value === "pg") {
+ setMess(false);
+ setMealRoutine("pg_cooking");
+ }
+ if (value === "day_scholar") {
+ setMess(false);
+ setMealRoutine("day_scholar");
+ }
+ }}
+ >
+
+
+
+
+ Hostel / dorm
+ PG / rented room
+ Day scholar / commute
+ Mixed routine
+
+
+
+
+
+
+
+
+
+
+ Hostel mess / campus meals
+ PG cooking / groceries
+ Day scholar meals
+ Mixed routine
+
+
+
+
+
{mess && (
diff --git a/frontend/src/routes/_authenticated/runway.lazy.tsx b/frontend/src/routes/_authenticated/runway.lazy.tsx
index a1b5c91..3588fda 100644
--- a/frontend/src/routes/_authenticated/runway.lazy.tsx
+++ b/frontend/src/routes/_authenticated/runway.lazy.tsx
@@ -4,11 +4,12 @@ import { useAuth } from "@/lib/auth-context";
import { AppShell, MobileMenuButton } from "@/components/AppShell";
import { getRunwayForecast, getRunwayIntel } from "@/lib/api/db.functions";
import { rupees } from "@/lib/format";
-import {
+import {
TrendingUp, TrendingDown, AlertTriangle, AlertCircle, CheckCircle2,
Calendar, CreditCard, PieChart, Info, HelpCircle, ChevronRight,
ShieldCheck, ArrowRight, Activity, Wallet,
- Clock, Zap, Compass, RefreshCw, Layers, TrendingUp as TrendUpIcon, ArrowUpRight
+ Clock, Zap, Compass, RefreshCw, Layers, TrendingUp as TrendUpIcon, ArrowUpRight,
+ Check, Utensils, Coffee, Car, ShoppingBag, Calculator, Sparkles, Users
} from "lucide-react";
import { Card } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
@@ -17,6 +18,18 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useState, useMemo } from "react";
import { toast } from "sonner";
import { Tooltip as UITooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/components/ui/tooltip";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
import {
Dialog,
DialogContent,
@@ -36,6 +49,9 @@ function RunwayPage() {
const { user } = useAuth();
const [activeTab, setActiveTab] = useState<"overview" | "commitments" | "horizons">("overview");
const [showGuideModal, setShowGuideModal] = useState(false);
+ const [showForecastInputs, setShowForecastInputs] = useState(false);
+ const [affordAmountRs, setAffordAmountRs] = useState("");
+ const [affordCategory, setAffordCategory] = useState<"food" | "travel" | "shopping" | "other">("food");
// Fetch forecast data
const { data: forecast, isLoading: forecastLoading, isError: forecastError, refetch: refetchForecast } = useQuery({
@@ -66,14 +82,35 @@ function RunwayPage() {
const [flightProtocol, setFlightProtocol] = useState<"normal" | "glide" | "turbulence">("normal");
const defaultPace = useMemo(() => {
- return Math.round((forecast?.projection?.projected_daily_spend || 20000) / 100);
+ return Math.round(Number(forecast?.projection?.projected_daily_spend ?? 0) / 100);
}, [forecast]);
const [simulatedDailySpend, setSimulatedDailySpend] = useState
(null);
- const activeSimulatedSpend = simulatedDailySpend ?? defaultPace;
const daysLeftInCycle = forecast?.current_cycle?.days_left ?? 30;
- const projectedDailyPaise = Math.max(0, Number(forecast?.projection?.projected_daily_spend ?? defaultPace * 100));
+ const projectedDailyPaise = Math.max(0, Number(forecast?.projection?.projected_daily_spend ?? 0));
const safeDailyPaise = Math.max(0, Number(forecast?.projection?.safe_daily_spend ?? 0));
+ const safeDailyRs = Math.max(0, Math.round(safeDailyPaise / 100));
+ const simulatorMinSpend = 10;
+ const forecastNeedsSetup = forecast?.status === "setup_required" || forecast?.setup_required;
+ const noSpendHistory = !forecastNeedsSetup && (forecast?.projection?.pace_source === "no_recent_history" || (forecast?.confidence?.active_days ?? 0) <= 0);
+ const safeDailyIsZero = !forecastNeedsSetup && safeDailyPaise <= 0;
+ const hasForecastPace = !forecastNeedsSetup && (defaultPace > 0 || safeDailyRs > 0);
+ const activeSimulatedSpend = simulatedDailySpend ?? (defaultPace > 0 ? defaultPace : safeDailyRs > 0 ? safeDailyRs : simulatorMinSpend);
+ const decisionEngine = forecast?.decision_engine;
+ const absorbedFactors = decisionEngine?.absorbed ?? [];
+ const foodRoutine = forecast?.food_routine;
+ const stressBand = forecast?.projection?.stress_band;
+ const expectedRunwayDays = Number(stressBand?.expected?.days_until_broke ?? forecast?.projection?.days_until_broke ?? 0);
+ const stressRunwayDays = Number(stressBand?.stress?.days_until_broke ?? forecast?.projection?.days_until_broke ?? 0);
+ const calmRunwayDays = Number(stressBand?.calm?.days_until_broke ?? expectedRunwayDays);
+ const topDrivers = (decisionEngine?.drivers ?? forecast?.drivers ?? []) as Array<{
+ kind: string;
+ label: string;
+ detail: string;
+ impact?: number;
+ severity?: string;
+ }>;
+ const nextBestAction = decisionEngine?.next_best_action ?? forecast?.action;
const subscriptionCommitmentTotal = useMemo(() => {
if (!forecast?.commitments?.items) return 0;
return forecast.commitments.items
@@ -99,23 +136,22 @@ function RunwayPage() {
.reduce((sum: number, i: any) => sum + (Number(i.amount) || 0), 0);
}, [forecast]);
const foodSwitchSaving = Math.max(0, Number(forecast?.food_routine?.savings_if_replace_two_deliveries ?? 0));
- const foodPacePaise = Math.max(0, Number(forecast?.food_routine?.food_daily_pace ?? projectedDailyPaise));
- const foodCapPaise = Math.max(0, Number(forecast?.food_routine?.recommended_daily_food_cap ?? safeDailyPaise));
- const mealPlanLeverAmount = Math.max(
- foodSwitchSaving,
- Math.round(Math.max(projectedDailyPaise * 0.2, foodPacePaise - foodCapPaise, 3_000))
- );
- const fixedCostLeverAmount = subscriptionCommitmentTotal || movableCommitmentTotal || Math.round(Math.max(projectedDailyPaise * 0.35, 4_000));
+ const foodPacePaise = Math.max(0, Number(forecast?.food_routine?.food_daily_pace ?? 0));
+ const foodCapPaise = Math.max(0, Number(forecast?.food_routine?.recommended_daily_food_cap ?? 0));
+ const mealPlanLeverAmount = Math.max(foodSwitchSaving, foodPacePaise > foodCapPaise ? foodPacePaise - foodCapPaise : 0);
+ const canUseMealLever = mealPlanLeverAmount > 0;
+ const fixedCostLeverAmount = subscriptionCommitmentTotal || movableCommitmentTotal;
+ const canUseFixedCostLever = fixedCostLeverAmount > 0;
const fixedCostLeverLabel = subscriptionCommitmentTotal
? "Pause scheduled subscriptions"
: movableCommitmentTotal
? "Move the next fixed debit"
- : "Skip one optional spend";
- const sharedPlanLeverAmount = poolCommitmentTotal || Math.round(Math.max((forecast?.food_routine?.delivery?.avg_order ?? projectedDailyPaise) * 0.25, 3_000));
- const sharedPlanLeverLabel = poolCommitmentTotal ? "Settle pool dues" : "Use shared cart once";
+ : "No fixed debit found";
+ const sharedPlanLeverAmount = poolCommitmentTotal;
+ const canUseSharedPlanLever = sharedPlanLeverAmount > 0;
+ const sharedPlanLeverLabel = poolCommitmentTotal ? "Settle pool dues" : "No pool dues found";
const highSpendDayAmount = Math.max(projectedDailyPaise, safeDailyPaise, defaultPace * 100);
- const safeDailyRs = Math.max(0, Math.round(safeDailyPaise / 100));
- const simulatorMinSpend = 10;
+ const canStressHighSpend = highSpendDayAmount > 0;
const stretchModeDailyRs = Math.max(
simulatorMinSpend,
Math.min(defaultPace, Math.round((safeDailyRs || defaultPace) * 0.8))
@@ -130,7 +166,7 @@ function RunwayPage() {
const sliderMaxSpend = Math.max(500, Math.ceil(Math.max(defaultPace, safeDailyRs, 250) * 2 / 100) * 100);
const simulatorPresets = useMemo(() => {
const entries = [
- { label: "Actual pace", value: defaultPace },
+ defaultPace > 0 ? { label: "Actual pace", value: defaultPace } : null,
safeDailyRs > 0 ? { label: "Safe/day", value: safeDailyRs } : null,
forecast?.food_routine?.recommended_daily_food_cap
? { label: "Food cap", value: Math.max(20, Math.round(forecast.food_routine.recommended_daily_food_cap / 100)) }
@@ -146,12 +182,50 @@ function RunwayPage() {
const adjustedCommitmentsTotal = useMemo(() => {
if (!forecast) return 0;
- let total = forecast.commitments.total;
- if (flightProtocol === "turbulence") {
- total = Math.max(0, total - examBufferCommitmentTotal);
- }
- return total;
- }, [forecast, flightProtocol, examBufferCommitmentTotal]);
+ return forecast.commitments.total;
+ }, [forecast]);
+
+ const routineMealUnitRs = useMemo(() => {
+ const routineMealCost = Math.round(Number(foodRoutine?.routine_meal_cost ?? 0) / 100);
+ const foodCap = Math.round(Number(foodRoutine?.recommended_daily_food_cap ?? 0) / 100);
+ return Math.max(40, routineMealCost || foodCap || 80);
+ }, [foodRoutine]);
+
+ const sharedMealUnitRs = useMemo(() => {
+ const sharedMealCost = Math.round(Number(foodRoutine?.shared_meal_cost ?? 0) / 100);
+ return Math.max(60, sharedMealCost || Math.round(routineMealUnitRs * 1.5));
+ }, [foodRoutine, routineMealUnitRs]);
+
+ const deliveryOrderUnitRs = useMemo(() => {
+ const deliveryMealCost = Math.round(Number(foodRoutine?.delivery_meal_cost ?? 0) / 100);
+ return Math.max(120, deliveryMealCost || Math.round(routineMealUnitRs * 2.5));
+ }, [foodRoutine, routineMealUnitRs]);
+
+ const quickSpendUnitRs = useMemo(() => {
+ return Math.max(20, Math.round(routineMealUnitRs * 0.45));
+ }, [routineMealUnitRs]);
+
+ const smallSpendEquivalent = useMemo(() => {
+ const val = Number(affordAmountRs) || 0;
+ return Math.max(1, Math.round(val / quickSpendUnitRs));
+ }, [affordAmountRs, quickSpendUnitRs]);
+
+ const routineMealEquivalent = useMemo(() => {
+ const val = Number(affordAmountRs) || 0;
+ return Math.max(1, Math.round(val / routineMealUnitRs));
+ }, [affordAmountRs, routineMealUnitRs]);
+
+ const affordPresets = useMemo(() => [
+ { label: "Quick snack", amount: String(quickSpendUnitRs), category: "food" as const, icon: Coffee },
+ { label: "Routine meal", amount: String(routineMealUnitRs), category: "food" as const, icon: Utensils },
+ { label: "Shared order", amount: String(sharedMealUnitRs), category: "food" as const, icon: Users },
+ { label: "Delivery order", amount: String(deliveryOrderUnitRs), category: "food" as const, icon: Utensils },
+ { label: "Auto ride", amount: "60", category: "travel" as const, icon: Car },
+ { label: "Stationery", amount: "100", category: "other" as const, icon: CreditCard },
+ { label: "Outing", amount: "450", category: "shopping" as const, icon: ShoppingBag },
+ ], [quickSpendUnitRs, routineMealUnitRs, sharedMealUnitRs, deliveryOrderUnitRs]);
+
+
const adjustedSpent = useMemo(() => {
if (!forecast) return 0;
@@ -163,24 +237,24 @@ function RunwayPage() {
let totalFunding = forecast.current_cycle.available_funding;
let spent = adjustedSpent;
let commitments = adjustedCommitmentsTotal;
-
+
let baseDiscretionary = totalFunding - spent - commitments;
- if (scenarioFoodSwitch) {
+ if (scenarioFoodSwitch && canUseMealLever) {
baseDiscretionary += mealPlanLeverAmount;
}
- if (scenarioSubscriptionsPaused) {
+ if (scenarioSubscriptionsPaused && canUseFixedCostLever) {
baseDiscretionary += fixedCostLeverAmount;
}
- if (scenarioPoolSettled) {
+ if (scenarioPoolSettled && canUseSharedPlanLever) {
baseDiscretionary += sharedPlanLeverAmount;
}
return Math.max(0, baseDiscretionary);
- }, [forecast, adjustedSpent, adjustedCommitmentsTotal, scenarioFoodSwitch, scenarioSubscriptionsPaused, scenarioPoolSettled, mealPlanLeverAmount, fixedCostLeverAmount, sharedPlanLeverAmount]);
+ }, [forecast, adjustedSpent, adjustedCommitmentsTotal, scenarioFoodSwitch, scenarioSubscriptionsPaused, scenarioPoolSettled, canUseMealLever, mealPlanLeverAmount, canUseFixedCostLever, fixedCostLeverAmount, canUseSharedPlanLever, sharedPlanLeverAmount]);
const simulatedDays = useMemo(() => {
- if (activeSimulatedSpend <= 0) return 999;
+ if (!hasForecastPace || activeSimulatedSpend <= 0) return 0;
return Math.floor(remainingDiscretionary / (activeSimulatedSpend * 100));
- }, [remainingDiscretionary, activeSimulatedSpend]);
+ }, [hasForecastPace, remainingDiscretionary, activeSimulatedSpend]);
const simulatedBrokeDate = useMemo(() => {
if (!forecast) return "";
@@ -191,21 +265,82 @@ function RunwayPage() {
const isSimulatedSafe = simulatedDays >= daysLeftInCycle;
const actualAskHomeAmount = forecast?.projection?.ask_home_amount ?? 0;
const simulatedGapPaise = isSimulatedSafe ? 0 : Math.max(0, (daysLeftInCycle - simulatedDays) * activeSimulatedSpend * 100);
+ const affordAmountPaise = Math.max(0, Math.round((Number(affordAmountRs) || 0) * 100));
+ const affordCheck = useMemo(() => {
+ if (!forecast || affordAmountPaise <= 0) {
+ return {
+ status: "idle",
+ title: "Enter an amount",
+ detail: "Check one meal, ride, or purchase before paying.",
+ tone: "border-border bg-surface/50 text-muted-foreground",
+ runwayDaysLost: 0,
+ };
+ }
+ const runwayDaysLost = safeDailyPaise > 0 ? affordAmountPaise / safeDailyPaise : daysLeftInCycle;
+ const nextSafeDaily = Math.max(0, Math.floor((remainingDiscretionary - affordAmountPaise) / Math.max(1, daysLeftInCycle)));
+ const categoryAdvice = affordCategory === "food"
+ ? foodRoutine?.action?.title || "Use the lowest-cost routine meal if possible."
+ : affordCategory === "travel"
+ ? "Book only if the ride is essential or fits today’s safe limit."
+ : affordCategory === "shopping"
+ ? "Delay this unless it is required before reset."
+ : "Keep it inside today’s safe limit.";
+ if (safeDailyIsZero || affordAmountPaise > remainingDiscretionary) {
+ return {
+ status: "avoid",
+ title: "Avoid for now",
+ detail: `${formatRs(affordAmountPaise)} does not fit the remaining flexible cash. ${categoryAdvice}`,
+ tone: "border-pb-red/20 bg-pb-red/5 text-pb-red",
+ runwayDaysLost,
+ };
+ }
+ if (affordAmountPaise <= safeDailyPaise) {
+ return {
+ status: "safe",
+ title: "Safe if this is today’s main flexible spend",
+ detail: `${formatRs(affordAmountPaise)} keeps the next safe/day near ${formatRs(nextSafeDaily)}. ${categoryAdvice}`,
+ tone: "border-pb-green/20 bg-pb-green/5 text-pb-green",
+ runwayDaysLost,
+ };
+ }
+ return {
+ status: "tight",
+ title: "Tight",
+ detail: `${formatRs(affordAmountPaise)} is above today’s safe/day and uses about ${runwayDaysLost.toFixed(1)} runway days. ${categoryAdvice}`,
+ tone: "border-pb-amber/20 bg-pb-amber/5 text-pb-amber",
+ runwayDaysLost,
+ };
+ }, [forecast, affordAmountPaise, affordCategory, safeDailyPaise, safeDailyIsZero, remainingDiscretionary, daysLeftInCycle, foodRoutine]);
+
+ const calculatorRunwayPct = useMemo(() => {
+ return Math.min(100, (affordCheck.runwayDaysLost / daysLeftInCycle) * 100);
+ }, [affordCheck.runwayDaysLost, daysLeftInCycle]);
+
+ const radius = 42;
+ const circumference = 2 * Math.PI * radius;
+ const gaugePercentage = Math.min(100, (simulatedDays / Math.max(1, daysLeftInCycle)) * 100);
+ const strokeDashoffset = circumference - (gaugePercentage / 100) * circumference;
+ const strokeColor = isSimulatedSafe ? "stroke-pb-green" : "stroke-pb-red";
+ const gaugeFilter = `drop-shadow(0 0 5px ${isSimulatedSafe ? "rgba(22,163,74,0.25)" : "rgba(220,38,38,0.25)"})`;
const copyFlightBrief = () => {
if (!forecast) return;
+ if (forecastNeedsSetup) {
+ toast.error("Add allowance or synced funding before copying a runway brief.");
+ return;
+ }
const brief = `PocketBuddy Runway Brief:
* Current Status: ${isSimulatedSafe ? "Safe through reset" : "Shortfall warning"}
* Remaining Flexible Pool: ${formatRs(remainingDiscretionary)}
* Simulated Daily Pace: ${formatRs(activeSimulatedSpend * 100)}/day
* Estimated Runway: ${simulatedDays} days (survival until ${simulatedBrokeDate})
* Cycle Remaining: ${daysLeftInCycle} days
-${isSimulatedSafe
- ? "* Plan: Staying under budget. On track to complete the allowance cycle safely."
+${isSimulatedSafe
+ ? "* Plan: Staying under budget. On track to complete the allowance cycle safely."
: `* Simulation gap: This sandbox setting runs out ${daysLeftInCycle - simulatedDays} days early with a gap of ${formatRs(simulatedGapPaise)}. The real ask-home amount is ${actualAskHomeAmount > 0 ? formatRs(actualAskHomeAmount) : "not required in the base forecast"}.`}
* Committed Reserve: ${formatRs(adjustedCommitmentsTotal)}
-Generated via PocketBuddy Runway.`;
+From PocketBuddy Runway.`;
navigator.clipboard.writeText(brief);
toast.success("Runway brief copied to clipboard.");
@@ -215,6 +350,15 @@ Generated via PocketBuddy Runway.`;
const statusDetails = useMemo(() => {
if (!forecast) return { color: "text-zinc-400", bg: "bg-zinc-500/10", border: "border-zinc-500/20", icon: HelpCircle, text: "Unknown" };
const status = forecast.status;
+ if (status === "setup_required") {
+ return {
+ color: "text-primary",
+ bg: "bg-primary/10",
+ border: "border-primary/20",
+ icon: Info,
+ text: "Setup needed"
+ };
+ }
if (status === "shortfall") {
return {
color: "text-pb-red",
@@ -244,6 +388,16 @@ Generated via PocketBuddy Runway.`;
const safetyGrade = useMemo(() => {
if (!forecast) return { grade: "N/A", text: "Calculating", color: "text-zinc-500", bg: "bg-zinc-500/10", border: "border-zinc-500/20", description: "Calculating runway projections..." };
+ if (forecast.status === "setup_required") {
+ return {
+ grade: "—",
+ text: "Setup needed",
+ color: "text-primary",
+ bg: "bg-primary/10",
+ border: "border-primary/20",
+ description: forecast.setup_reason ?? "Add allowance or synced funding before Runway produces a trusted forecast."
+ };
+ }
const prob = forecast.projection.shortfall_probability;
if (prob <= 0) {
return {
@@ -306,17 +460,13 @@ Generated via PocketBuddy Runway.`;
}));
}, [forecast]);
- const decisionEngine = forecast?.decision_engine;
- const absorbedFactors = decisionEngine?.absorbed ?? [];
- const foodRoutine = forecast?.food_routine;
- const nextBestAction = decisionEngine?.next_best_action ?? forecast?.action;
const activeActionType = nextBestAction?.type || forecast?.action?.type || "on_track";
const activeActionTitle = activeActionType === "ask_home" && actualAskHomeAmount > 0
? `Ask home for ${formatRs(actualAskHomeAmount)}`
: nextBestAction?.title || forecast?.action?.title || "Runway action";
const activeActionDetail = activeActionType === "ask_home"
? "This is the real forecast shortfall buffer from your allowance, commitments, current pace, and high-spend range."
- : nextBestAction?.detail || forecast?.action?.detail || "";
+ : nextBestAction?.detail || forecast?.action?.detail || "Keep daily flexible spending inside the safe limit until the next allowance reset.";
const commitmentSummary = useMemo(() => {
if (!forecast?.commitments?.by_kind) return [];
const byKind = forecast.commitments.by_kind;
@@ -337,6 +487,75 @@ Generated via PocketBuddy Runway.`;
balance: firstDeficit?.projected_balance ?? finalHorizon?.projected_balance ?? 0,
};
}, [forecast]);
+ const forecastInputCount = absorbedFactors.length || commitmentSummary.length;
+ const forecastInputSummary = forecastInputCount > 0
+ ? `${forecastInputCount} live input${forecastInputCount === 1 ? "" : "s"} included`
+ : "No reserved-cost inputs yet";
+ const horizonTakeaway = projectionSignal
+ ? projectionSignal.isDeficit
+ ? `In this scenario, the first deficit appears around ${projectionSignal.label}.`
+ : `In this scenario, the long-range balance stays positive through ${projectionSignal.label}.`
+ : "Scenario view will appear once horizon data is available.";
+ const [selectedActionId, setSelectedActionId] = useState("priority");
+ const runwayActions = useMemo(() => {
+ if (!forecast) return [];
+ const actionsList = [
+ {
+ id: "priority",
+ label: `Priority: ${activeActionTitle}`,
+ detail: activeActionDetail,
+ severity: activeActionType === "ask_home" ? "high" : forecast.status === "watch" ? "medium" : "low"
+ }
+ ];
+
+ if (forecast.status === "shortfall") {
+ actionsList.push({
+ id: "action-1",
+ label: "1. Shield Your Exam Safety Buffer",
+ detail: examBufferCommitmentTotal > 0
+ ? `Your configured buffer of ${formatRs(examBufferCommitmentTotal)} is locked. Avoid dipping into it for regular food spending.`
+ : "No exam buffer is configured yet. Keep essentials separate before reducing food or travel spend.",
+ severity: "high"
+ });
+ actionsList.push({
+ id: "action-2",
+ label: "2. Auto-Debit Subscription Alert",
+ detail: `You have ${forecast.commitments.items?.filter((i: any) => i.kind === "subscription").length || 0} recurring subscriptions active. Temporarily pause one to reclaim breathing room.`,
+ severity: "medium"
+ });
+ actionsList.push({
+ id: "action-3",
+ label: `3. ${foodRoutine?.action?.title ?? "Stabilize food pace"}`,
+ detail: foodRoutine?.action?.detail ?? "Use routine meals before delivery becomes the default. This keeps daily food spend inside your safe runway limit.",
+ severity: "low"
+ });
+ } else {
+ actionsList.push({
+ id: "action-1",
+ label: "1. Lock in an Emergency Reserve",
+ detail: examBufferCommitmentTotal > 0
+ ? `Your ${formatRs(examBufferCommitmentTotal)} exam reserve is already protected in the runway calculation.`
+ : "Since you're on track, set an emergency reserve in settings so future spending cannot consume essentials.",
+ severity: "low"
+ });
+ actionsList.push({
+ id: "action-2",
+ label: "2. Spending Pace Guardrails",
+ detail: decisionEngine?.summary ?? `Try to stay within ${formatRs(forecast.projection.safe_daily_spend)} per day to keep your runway healthy.`,
+ severity: "medium"
+ });
+ actionsList.push({
+ id: "action-3",
+ label: `3. ${foodRoutine?.action?.title ?? "Keep meals predictable"}`,
+ detail: foodRoutine?.action?.detail ?? "Keep food pace predictable so runway can reserve enough for travel, exams, and shared-pool dues.",
+ severity: "low"
+ });
+ }
+
+ return actionsList;
+ }, [forecast, activeActionTitle, activeActionDetail, activeActionType, examBufferCommitmentTotal, foodRoutine, decisionEngine]);
+
+
if (forecastLoading) {
return (
@@ -391,10 +610,157 @@ Generated via PocketBuddy Runway.`;
);
}
+ if (forecastNeedsSetup) {
+ return (
+
+
+
+
+
+ Runway & Forecasts
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Runway setup needed
+
Add funding before trusting the forecast
+
+ {forecast.setup_reason ?? "Add your monthly allowance or sync an allowance credit before Runway calculates safe/day, shortfall, and ask-home guidance."}
+
+
+
+
Profile
+
Set monthly allowance and reset day.
+
+
+
Transactions
+
Sync or add recent payments to build daily pace.
+
+
+
+
+ Open Settings
+
+
+ View Transactions
+
+
+
+
+
+
+
+ );
+ }
+
// Spend breakdown percentage
const totalSpendSplit = forecast.spend_split.committed + forecast.spend_split.flexible;
const committedPct = totalSpendSplit > 0 ? Math.round((forecast.spend_split.committed / totalSpendSplit) * 100) : 0;
- const flexiblePct = 100 - committedPct;
+ const flexiblePct = totalSpendSplit > 0 ? 100 - committedPct : 0;
+ const allowanceProgressPct = forecast.current_cycle.available_funding > 0
+ ? Math.min(100, Math.max(0, Math.round((forecast.current_cycle.spent / forecast.current_cycle.available_funding) * 100)))
+ : 0;
+ const discretionaryFuelPct = forecast.current_cycle.available_funding > 0
+ ? Math.min(100, Math.max(0, Math.round((remainingDiscretionary / forecast.current_cycle.available_funding) * 100)))
+ : 0;
+ const dailyGapPaise = projectedDailyPaise - safeDailyPaise;
+ const reservedTotal = forecast.commitments.total;
+ const remainingBalance = forecast.current_cycle.remaining;
+ const coveredDaysLabel = forecast.status === "shortfall"
+ ? `${forecast.projection.days_until_broke} days before shortfall`
+ : safeDailyIsZero
+ ? "No flexible spend left"
+ : `${daysLeftInCycle} days covered`;
+ const paceMessage = safeDailyIsZero
+ ? "No discretionary budget remains after commitments."
+ : noSpendHistory
+ ? "No spend history yet; use this as a temporary cap."
+ : dailyGapPaise > 0
+ ? `You are ${formatRs(dailyGapPaise)}/day above the safe pace.`
+ : dailyGapPaise < 0
+ ? `You are ${formatRs(Math.abs(dailyGapPaise))}/day below the limit.`
+ : "Your current pace matches the safe limit.";
+ const safeDailyDisplay = safeDailyIsZero ? "Pause" : formatRs(safeDailyPaise);
+ const modeCards = [
+ {
+ key: "normal" as const,
+ title: "Current",
+ amount: noSpendHistory ? null : defaultPace > 0 ? defaultPace * 100 : projectedDailyPaise,
+ text: noSpendHistory ? "Needs recent payments before pace is trusted." : "Shows what happens if nothing changes.",
+ },
+ {
+ key: "glide" as const,
+ title: "Stretch",
+ amount: safeDailyIsZero ? null : stretchModeDailyRs * 100,
+ text: safeDailyIsZero ? "No stretch budget is available." : "A realistic lower daily target for the rest of the cycle.",
+ },
+ {
+ key: "turbulence" as const,
+ title: "Emergency",
+ amount: safeDailyIsZero ? null : emergencyModeDailyRs * 100,
+ text: safeDailyIsZero ? "Pause non-essential spend and add funding." : "Strict plan that protects essentials first.",
+ },
+ ];
+ const availableLeverCount = [
+ canUseMealLever,
+ canUseFixedCostLever,
+ canUseSharedPlanLever,
+ ].filter(Boolean).length;
+ const selectRunwayMode = (mode: "normal" | "glide" | "turbulence") => {
+ setFlightProtocol(mode);
+ setScenarioHighSpendDay(false);
+ if (mode === "normal") {
+ setSimulatedDailySpend(null);
+ setScenarioFoodSwitch(false);
+ setScenarioSubscriptionsPaused(false);
+ setScenarioPoolSettled(false);
+ toast.info("Current plan selected.");
+ return;
+ }
+ if (mode === "glide") {
+ if (safeDailyIsZero) {
+ setSimulatedDailySpend(null);
+ setScenarioFoodSwitch(false);
+ setScenarioSubscriptionsPaused(false);
+ setScenarioPoolSettled(false);
+ toast.warning("No discretionary spend is available. Pause non-essential spending or add funding first.");
+ return;
+ }
+ setSimulatedDailySpend(stretchModeDailyRs);
+ setScenarioFoodSwitch(false);
+ setScenarioSubscriptionsPaused(false);
+ setScenarioPoolSettled(false);
+ toast.success(`Stretch plan selected at ${formatRs(stretchModeDailyRs * 100)}/day.`);
+ return;
+ }
+ if (safeDailyIsZero) {
+ setSimulatedDailySpend(null);
+ setScenarioFoodSwitch(false);
+ setScenarioSubscriptionsPaused(false);
+ setScenarioPoolSettled(false);
+ toast.warning("Emergency plan: pause non-essential spending and add funding before spending again.");
+ return;
+ }
+ setSimulatedDailySpend(emergencyModeDailyRs);
+ setScenarioFoodSwitch(canUseMealLever);
+ setScenarioSubscriptionsPaused(canUseFixedCostLever);
+ setScenarioPoolSettled(canUseSharedPlanLever);
+ toast.warning(`Emergency plan selected at ${formatRs(emergencyModeDailyRs * 100)}/day.`);
+ };
return (
@@ -426,32 +792,31 @@ Generated via PocketBuddy Runway.`;
- {/* ── Runway Advisor Narration ── */}
-
-
-
-
-
+ {/* ── Runway Advisor Narration (Sleek Notification Bar) ── */}
+
+
+ {activeActionType === "ask_home" ? (
+
+ ) : (
+
+ )}
+
+ Advisor: {activeActionTitle} — {activeActionDetail}
-
-
-
Runway Advisor
- Amazon Bedrock Nova
-
- {intelLoading ? (
-
-
-
-
- ) : (
-
- {intel?.summary || "Calculating AI insights..."}
-
+ {activeActionType === "ask_home" && actualAskHomeAmount > 0 && (
+
+ Need {formatRs(actualAskHomeAmount)}
+
)}
- {/* ── Tabs ── */}
setActiveTab("overview")}
@@ -482,62 +847,450 @@ Generated via PocketBuddy Runway.`;
{/* ── Tab: Overview ── */}
{activeTab === "overview" && (
- {/* Runway Safety Grade & Fuel Gauge Card */}
-
-
- {safetyGrade.grade}
-
-
-
-
Runway Safety Grade
-
- {safetyGrade.text}
-
-
-
- {safetyGrade.description}
-
-
-
-
Discretionary Fuel Gauge
-
{Math.max(0, Math.round((remainingDiscretionary / forecast.current_cycle.available_funding) * 100))}% remaining
+ {false && (
+ <>
+
+
+
+
+
+
+ {statusDetails.text}
+
+
+ Reset in {daysLeftInCycle} days
+
+
+
+
+
+
Runway
+
+ {coveredDaysLabel}
+
+
+ {forecast.status === "shortfall"
+ ? `At the current pace, you need ${formatRs(actualAskHomeAmount)} to safely reach reset.`
+ : safeDailyIsZero
+ ? "Known spending and commitments have used the flexible budget. Treat this as a pause signal, not a spending allowance."
+ : noSpendHistory
+ ? "Allowance is configured, but Runway still needs recent payments before it can trust the current pace."
+ : "Your current cycle can stay safe if daily flexible spend stays inside the limit shown here."}
+
+
+
+
+
Today's limit
+
{safeDailyDisplay}
+
0 ? "text-destructive" : "text-muted-foreground"}`}>
+ {paceMessage}
+
+
+
+
+
+
+
Balance
+
{formatRs(remainingBalance)}
+
+
+
Spent
+
{formatRs(forecast.current_cycle.spent)}
+
+
+
Reserved
+
{formatRs(reservedTotal)}
+
+
+
Risk
+
{Math.round(forecast.projection.shortfall_probability * 100)}%
+
+
+
+
+
+
+ {formatRs(forecast.current_cycle.spent)} spent
+ {allowanceProgressPct}% of funding used
+
-
+
+
+
+
Do this next
+
{activeActionTitle}
+
{activeActionDetail}
+ {activeActionType === "ask_home" && actualAskHomeAmount > 0 && (
+
+
Request buffer
+
{formatRs(actualAskHomeAmount)}
+
This is from the forecast gap, not a simulator number.
+
+ )}
+ {intel?.summary && !intelError && (
+
+
Advisor note
+
{intel.summary}
+
+ )}
- {decisionEngine && (
-
-
-
-
Decision Engine
-
One runway number, all major student costs included
-
- {decisionEngine.summary}
-
+
+
+
+
+
What changed the runway
+
Main pressure points
- {foodRoutine && (
-
- {foodRoutine.label}
-
- )}
+
+ {forecast.confidence.level} confidence
+
-
- {absorbedFactors.map((factor: any) => (
-
-
{factor.label}
-
- {formatRs(factor.daily_amount ?? factor.amount)}
- {factor.daily_amount ? /day : null}
+
+
+
+
Food routine
+
+ {(foodRoutine?.cycle_food_count ?? 0) > 0
+ ? `${foodRoutine?.label ?? "Meal pattern"} tracked from current cycle.`
+ : "No food payments this cycle; routine is estimated from profile until transactions sync."}
-
{factor.detail}
+
+ {(foodRoutine?.food_daily_pace ?? 0) > 0 ? `${formatRs(foodRoutine?.food_daily_pace ?? 0)}/day` : "No logs"}
+
+
+ {commitmentSummary.length ? commitmentSummary.map((item) => (
+
+
+
{item.label}
+
+ {item.key === "subscription" ? "Scheduled before reset." :
+ item.key === "pool" ? "Shared cart dues included." :
+ item.key === "exam_buffer" ? "Kept aside for exam days." :
+ "Meal cost reserved before flexible spend."}
+
+
+
{formatRs(item.amount)}
+
+ )) : (
+
+ No fixed commitments found yet. Add subscriptions, mess cost, or pool dues to make this more accurate.
+
+ )}
+
+
+
+
+
+
Try a plan
+
Choose how strict you want to be
+
+ Modes change the daily target and the realistic actions used in the simulation.
+
+
+
+
+ {modeCards.map((mode) => (
+
selectRunwayMode(mode.key)}
+ className={`rounded-xl border p-2.5 text-left transition-all cursor-pointer flex flex-col justify-between ${
+ flightProtocol === mode.key
+ ? "border-primary bg-primary/5 text-foreground"
+ : "border-border/60 bg-surface/60 text-muted-foreground hover:border-primary/45 hover:bg-surface"
+ }`}
+ >
+
+ {mode.title}
+
+ {mode.amount === null ? (mode.key === "normal" ? "—" : "Pause") : formatRs(mode.amount)}
+ {mode.amount !== null && /day }
+
+
+ {mode.text}
+
))}
+
+
+
+
+
Simulation result
+
{safeDailyIsZero ? "Pause" : `${simulatedDays} days`}
+
+
+ {isSimulatedSafe ? "Reaches reset" : "Falls short"}
+
+
+
+ {safeDailyIsZero
+ ? "There is no safe discretionary spend left. Add funding, reduce commitments, or wait for reset before non-essential spending."
+ : isSimulatedSafe
+ ? `This plan reaches the reset date with ${formatRs(remainingDiscretionary)} flexible balance before daily spend.`
+ : `This plan runs out ${Math.max(0, daysLeftInCycle - simulatedDays)} days early. Gap: ${formatRs(simulatedGapPaise)}.`}
+
+
+ {!safeDailyIsZero && (
+
+
+ {noSpendHistory ? "Temporary daily cap" : "Fine tune daily target"}
+ {formatRs(activeSimulatedSpend * 100)}/day
+
+
setSimulatedDailySpend(parseInt(e.target.value, 10))}
+ className="w-full h-1.5 cursor-pointer appearance-none rounded-lg bg-border accent-primary focus:outline-none"
+ />
+
+ )}
+
+
+ {flightProtocol === "normal" && (
+
+ Current mode applies no recovery actions. Use it to understand where your month is headed if nothing changes.
+
+ )}
+
+ {flightProtocol === "glide" && (
+
+
Optional stretch actions
+
+ {[
+ { enabled: canUseMealLever, active: scenarioFoodSwitch, set: setScenarioFoodSwitch, label: foodSwitchSaving > 0 ? "Replace delivery twice" : "Tighten food routine", amount: mealPlanLeverAmount },
+ { enabled: canUseFixedCostLever, active: scenarioSubscriptionsPaused, set: setScenarioSubscriptionsPaused, label: fixedCostLeverLabel, amount: fixedCostLeverAmount },
+ { enabled: canUseSharedPlanLever, active: scenarioPoolSettled, set: setScenarioPoolSettled, label: sharedPlanLeverLabel, amount: sharedPlanLeverAmount },
+ ].map((lever) => (
+ lever.set(!lever.active)}
+ className={`rounded-lg border px-3 py-2 text-left text-xs transition-all ${
+ !lever.enabled
+ ? "cursor-not-allowed border-border bg-surface/30 text-muted-foreground opacity-60"
+ : lever.active
+ ? "border-primary bg-primary/10 text-foreground"
+ : "border-border bg-surface text-muted-foreground hover:border-primary/40"
+ }`}
+ >
+ {lever.label}
+ {lever.enabled ? `Adds ${formatRs(lever.amount)}` : "Needs real data first"}
+
+ ))}
+
+
+ )}
+
+ {flightProtocol === "turbulence" && (
+
+
Emergency rules applied
+
+ Uses the strict daily target and applies {availableLeverCount} available recovery action{availableLeverCount === 1 ? "" : "s"}. Use this only when the base forecast is unsafe.
+
+
{
+ if (!canStressHighSpend) return;
+ setScenarioHighSpendDay(!scenarioHighSpendDay);
+ }}
+ className={`mt-3 inline-flex h-8 items-center rounded-lg border px-3 text-xs font-semibold transition ${
+ !canStressHighSpend
+ ? "cursor-not-allowed border-border bg-surface/30 text-muted-foreground opacity-60"
+ : scenarioHighSpendDay ? "border-destructive bg-destructive/10 text-destructive" : "border-border bg-surface text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ {!canStressHighSpend ? "Stress test needs spend history" : scenarioHighSpendDay ? "Remove stress test" : "Stress test one high-spend day"}
+
+
+ )}
+
+
+ Copy Runway Brief
+
+
+
+
+ {foodRoutine?.action && (
+
+
+
+
Meal routine
+
{foodRoutine.action.title}
+
{foodRoutine.action.detail}
+
+
+ {foodRoutine.label}
+
+
)}
+ >
+ )}
+
+ {/* Runway Safety Grade & Fuel Gauge Card */}
+
+
+
+ {/* Header row: badge always inline-left of meta labels */}
+
+ {/* Grade badge — smaller on mobile, with glow */}
+
+
+
+ {safetyGrade.grade}
+
+
+
+
+
Runway overview
+
+ {safetyGrade.text}
+
+
+ {forecast.confidence.level} confidence
+
+
+
+ Expected runway: {expectedRunwayDays} days
+
+
+
+
+
+ {safeDailyIsZero
+ ? "Your current balance is tied up by committed costs. Treat this as a pause signal for discretionary spending."
+ : noSpendHistory
+ ? "Allowance is configured, but recent payment history is still thin. Use the safe/day limit as a temporary guardrail."
+ : safetyGrade.description}
+
+
+ {/* Metrics — 2-col on mobile, 3-col on sm+ */}
+
+
+
Daily Limit
+
+ {safeDailyDisplay}
+ {!safeDailyIsZero && /day }
+
+
{paceMessage}
+
+
+
+
Flexible Pool
+
+ {formatRs(remainingDiscretionary)}
+
+
Discretionary pool
+
+
+
+
Scenario Range
+
+ {stressRunwayDays} – {calmRunwayDays} days
+
+
Stress to Calm
+
+
+
+ {/* Color-coded Fuel Gauge */}
+
+
+ Discretionary Fuel
+
+ {discretionaryFuelPct}% · {daysLeftInCycle}d left
+
+
+
+
+
+
+
+
+ What to do now
+
+
{activeActionTitle}
+
{activeActionDetail}
+ {activeActionType === "ask_home" && actualAskHomeAmount > 0 && (
+
+
Buffer needed
+
{formatRs(actualAskHomeAmount)}
+
Based on the base forecast, not simulator settings.
+
+ )}
+
+
+
+
+ {/* ── Runway Drivers & Inputs ── */}
+
+
+
+
What changed?
+
Main factors affecting today’s runway estimate.
+
+
+ {/* Included in Forecast Dialog Trigger */}
+
setShowForecastInputs(true)}
+ className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border bg-background px-3 text-[11px] font-bold uppercase tracking-wider text-muted-foreground hover:text-foreground hover:bg-surface transition cursor-pointer active:scale-95"
+ >
+ Forecast Inputs
+
+ {forecastInputCount}
+
+
+
+
+ {topDrivers.length ? (
+
+ {topDrivers.map((driver) => (
+
+
+
{driver.label}
+ {Number(driver.impact || 0) > 0 && (
+
+ −{formatRs(Number(driver.impact))}
+
+ )}
+
+
{driver.detail}
+
+ ))}
+
+ ) : (
+
+ No major pressure points detected. Runway behaves as expected.
+
+ )}
+
{/* KPI Cards Grid */}
@@ -545,20 +1298,20 @@ Generated via PocketBuddy Runway.`;
-
Runway Days
+
Can I last?
-
- {forecast.projection.days_until_broke}
- days left
+
+ {expectedRunwayDays}
+ expected days
- {forecast.status === "shortfall"
- ? `Broke before reset! Shortfall of ${formatRs(forecast.projection.ask_home_amount)}`
- : "Safe until next cycle allowance reset date."}
+ {forecast.status === "shortfall"
+ ? `Stress case: ${stressRunwayDays} days. Buffer needed: ${formatRs(actualAskHomeAmount)}.`
+ : `Stress case still shows ${stressRunwayDays} days.`}
@@ -566,18 +1319,18 @@ Generated via PocketBuddy Runway.`;
-
Safe Daily Spend
+
Spend today
-
- {formatRs(forecast.projection.safe_daily_spend)}
- /day
+
+ {safeDailyDisplay}
+ {!safeDailyIsZero && /day }
- Spend limit to reach allowance cycle end with exactly ₹0 balance.
+ {safeDailyIsZero ? "No discretionary budget remains after commitments." : "Spend limit to reach the allowance reset safely."}
@@ -585,18 +1338,18 @@ Generated via PocketBuddy Runway.`;
-
Current Pace
+
Current pace
-
- {formatRs(forecast.projection.projected_daily_spend)}
- /day
+
+ {noSpendHistory ? "No history" : formatRs(forecast.projection.projected_daily_spend)}
+ {!noSpendHistory && /day }
- Calculated using exponential weighted average of discretionary spend.
+ {noSpendHistory ? "Sync a few recent payments before pace is treated as trusted." : "Calculated from recent discretionary spending pace."}
@@ -604,27 +1357,195 @@ Generated via PocketBuddy Runway.`;
-
Shortfall Risk
+
Shortfall risk
= 0.35 ? "bg-pb-red/10 text-pb-red" : "bg-pb-green/10 text-pb-green"}`}>
-
+
{Math.round(forecast.projection.shortfall_probability * 100)}%
- Likelihood of running out of money before your allowance cycle resets.
+ Chance that the current pace runs out before your allowance resets.
+
+ {/* Decorative accent bar */}
+
+
+
+
+
+
+ Can I afford this?
+
+
Check how a purchase impacts your runway before spending.
+
+
+ Limit: {safeDailyDisplay}/day
+
+
+
+
+ {/* Unified Inset Input Group */}
+
+
+
Amount
+
+ ₹
+ setAffordAmountRs(event.target.value.replace(/[^\d.]/g, ""))}
+ inputMode="decimal"
+ placeholder="0.00"
+ className="w-full bg-transparent text-sm font-black text-foreground outline-none placeholder:text-muted-foreground/35 tnum"
+ />
+
+
+
+ Category
+ setAffordCategory(event.target.value as typeof affordCategory)}
+ className="w-full bg-transparent text-sm font-bold text-foreground outline-none cursor-pointer"
+ >
+ Food & Drinks
+ Travel & Auto
+ Shopping & Fun
+ Other / Misc
+
+
+
+
+ {/* Student Quick Presets */}
+
+
Quick Presets
+
+ {affordPresets.map((preset) => {
+ const PresetIcon = preset.icon;
+ const isActive = affordAmountRs === preset.amount && affordCategory === preset.category;
+ return (
+
{
+ setAffordAmountRs(preset.amount);
+ setAffordCategory(preset.category);
+ }}
+ className={`flex items-center gap-1 rounded-lg border px-2.5 py-1 text-[11px] font-bold transition-all active:scale-95 cursor-pointer ${
+ isActive
+ ? "border-primary bg-primary/10 text-primary shadow-sm"
+ : "border-border/60 bg-background text-muted-foreground hover:bg-surface hover:text-foreground"
+ }`}
+ >
+
+ {preset.label}
+
+ );
+ })}
+
+
+
+ {/* Result panel */}
+
+ {affordAmountPaise <= 0 ? (
+
+
+
Interactive Runway Calculator
+
+ Select a quick preset or type any custom amount above to instantly simulate the impact on your allowance cycle and remaining days.
+
+
+ ) : (
+
+
+
+
+ {affordCheck.status === "safe" ? (
+
+ ) : affordCheck.status === "tight" ? (
+
+ ) : (
+
+ )}
+
+
+ {affordCheck.status === "safe"
+ ? "Safe purchase"
+ : affordCheck.status === "tight"
+ ? "Tight spend range"
+ : "Reconsider purchase"}
+
+
+
+ {affordCheck.status}
+
+
+
+
{affordCheck.detail}
+
+ {/* Student analogies */}
+
+
+ Small-spend equivalent
+ {smallSpendEquivalent} units
+
+
+ Routine meals
+ {routineMealEquivalent} meals
+
+
+
+ {/* Runway impact progress bar */}
+
+
+ Runway impact: −{affordCheck.runwayDaysLost.toFixed(1)} days
+ Remaining: {Math.max(0, 100 - calculatorRunwayPct).toFixed(0)}%
+
+
+
+
+ )}
+
+
+
+
+
{foodRoutine && (
Meal Routine
-
{foodRoutine.label}
+
{foodRoutine.label}
Runway separates delivery, campus meals, and groceries so your food budget is based on how you actually eat, not a hostel-only assumption.
@@ -637,22 +1558,26 @@ Generated via PocketBuddy Runway.`;
Food pace
-
{formatRs(foodRoutine.food_daily_pace ?? 0)}/day
+
+ {(foodRoutine.food_daily_pace ?? 0) > 0 ? <>{formatRs(foodRoutine.food_daily_pace ?? 0)}/day > : "No logs"}
+
{foodRoutine.cycle_food_count ?? 0} food logs this cycle
Suggested cap
-
{formatRs(foodRoutine.recommended_daily_food_cap ?? 0)}/day
+
+ {(foodRoutine.recommended_daily_food_cap ?? 0) > 0 ? <>{formatRs(foodRoutine.recommended_daily_food_cap ?? 0)}/day > : "No cap"}
+
Aligned to safe/day
Delivery
-
{foodRoutine.delivery?.count ?? 0}x
+
{foodRoutine.delivery?.count ?? 0}x
{formatRs(foodRoutine.delivery?.spend ?? 0)} this cycle
Two-order switch
-
{formatRs(foodRoutine.savings_if_replace_two_deliveries ?? 0)}
+
{formatRs(foodRoutine.savings_if_replace_two_deliveries ?? 0)}
Potential runway recovery
@@ -664,288 +1589,361 @@ Generated via PocketBuddy Runway.`;
)}
- {/* 🛫 INTERACTIVE RUNWAY SANDBOX & SIMULATOR */}
-
-
-
- {/* Left side: Interactive Sandbox Controls */}
-
-
-
-
-
Daily Spend Simulator
+ {/* 🛫 DAILY SPEND CHECK — SURVIVAL COCKPIT */}
+
+
+
+
+
+ {/* ── Left: Controls ── */}
+
+
+ {/* Header */}
+
+
+
+
+
Daily spend check
+
+
+ Simulate scenarios and see how lifestyle changes extend your runway — without touching real data.
+
+
+
+ {/* Mobile Simulated Days Indicator (Visible only on mobile) */}
+
+
+ Simulated
+
+ {safeDailyIsZero ? "0" : simulatedDays} days
+
+
+
-
- Slide to adjust your daily discretionary spending target. See how your runway days and broke date change in real-time.
-
-
+ {/* Slider */}
+
- Simulated Daily Budget
- {formatRs(activeSimulatedSpend * 100)} /day
-
-
setSimulatedDailySpend(parseInt(e.target.value, 10))}
- onMouseUp={() => {
- toast.info(`Daily budget simulated at ${formatRs(activeSimulatedSpend * 100)}/day. Runway: ${simulatedDays} days.`);
- }}
- onTouchEnd={() => {
- toast.info(`Daily budget simulated at ${formatRs(activeSimulatedSpend * 100)}/day. Runway: ${simulatedDays} days.`);
- }}
- className="w-full h-1.5 bg-border rounded-lg appearance-none cursor-pointer accent-primary focus:outline-none"
- />
-
- {formatRs(simulatorMinSpend * 100)}/day
- {formatRs(Math.round(sliderMaxSpend * 50))}/day
- {formatRs(sliderMaxSpend * 100)}/day
+ Adjust Daily Spend
+
+ {safeDailyIsZero ? "Pause" : formatRs(activeSimulatedSpend * 100)}
+ {!safeDailyIsZero && /day }
+
+ {!safeDailyIsZero ? (
+ <>
+
setSimulatedDailySpend(parseInt(e.target.value, 10))}
+ onMouseUp={() => toast.info(`Simulated at ${formatRs(activeSimulatedSpend * 100)}/day → ${simulatedDays} days runway`)}
+ onTouchEnd={() => toast.info(`Simulated at ${formatRs(activeSimulatedSpend * 100)}/day → ${simulatedDays} days runway`)}
+ className="w-full h-2 rounded-lg appearance-none cursor-pointer bg-gradient-to-r from-pb-green via-pb-amber to-pb-red accent-primary focus:outline-none"
+ />
+
+ {formatRs(simulatorMinSpend * 100)}
+ Mid: {formatRs(Math.round(sliderMaxSpend * 50))}
+ {formatRs(sliderMaxSpend * 100)}
+
+
+ {/* Mobile-only Simulator status feedback */}
+
+
+
+ {isSimulatedSafe
+ ? `This target safely reaches your reset date.`
+ : safeDailyIsZero
+ ? "All discretionary balance consumed."
+ : `Runs out ${Math.max(0, daysLeftInCycle - simulatedDays)} days early. Deficit: ${formatRs(simulatedGapPaise)}.`}
+
+
+ >
+ ) : (
+
+ Discretionary budget exhausted. Pause non-essential spending.
+
+ )}
- {/* Presets */}
-
-
Quick Presets
-
+ {/* Quick targets */}
+
+
Quick Targets
+
{simulatorPresets.map((preset) => (
- setSimulatedDailySpend(preset.value)}
- className={`px-2.5 py-1 text-[11px] font-bold rounded-lg border transition-all cursor-pointer ${activeSimulatedSpend === preset.value ? "bg-primary/10 border-primary text-primary" : "bg-surface border-border hover:bg-surface-raised"}`}
+ type="button"
+ onClick={() => {
+ setSimulatedDailySpend(preset.value);
+ toast.info(`Target: ${preset.label} (${formatRs(preset.value * 100)}/day)`);
+ }}
+ className={`rounded-lg border px-2.5 py-1 text-[11px] font-bold transition-all active:scale-95 cursor-pointer ${
+ activeSimulatedSpend === preset.value
+ ? "border-primary bg-primary/10 text-primary"
+ : "border-border/60 bg-background text-muted-foreground hover:bg-surface hover:text-foreground"
+ }`}
>
{preset.label} ({formatRs(preset.value * 100)})
))}
{simulatedDailySpend !== null && (
- setSimulatedDailySpend(null)}
- className="px-2.5 py-1 text-[11px] md:text-xs font-bold rounded-lg border border-dashed border-zinc-500 hover:border-zinc-300 text-zinc-400 hover:text-zinc-200 transition-all cursor-pointer"
+ { setSimulatedDailySpend(null); toast.info("Reset to actual pace."); }}
+ className="rounded-lg border border-dashed border-border px-2.5 py-1 text-[11px] font-bold text-muted-foreground hover:text-foreground hover:border-primary/40 transition-colors cursor-pointer"
>
- Reset to Actual ({formatRs(defaultPace * 100)})
+ {defaultPace > 0 ? `Reset (${formatRs(defaultPace * 100)}/day)` : "Reset"}
)}
- {/* Budget Modes */}
-
+ {/* Mode cards */}
+
-
Budget Modes
+
Plan Intensity
-
-
+
+
-
-
-
Stretch mode
-
Targets {formatRs(stretchModeDailyRs * 100)}/day without changing fixed commitments. Use the switches below for extra levers.
-
-
-
Emergency mode
-
Uses the configured exam buffer in this sandbox and targets {formatRs(emergencyModeDailyRs * 100)}/day for a stricter survival plan.
-
+
+
+ Stretch mode
+
+ Lower daily target with optional student recovery levers.
+
+ Emergency mode
+
+ Protects essentials first, auto-applies all levers.
-
-
{
- setFlightProtocol("normal");
- setSimulatedDailySpend(null);
- toast.info("Current pace selected.");
- }}
- className={`py-2 text-[10px] font-bold rounded-lg border text-center transition-all cursor-pointer ${
- flightProtocol === "normal"
- ? "bg-primary/10 border-primary text-primary"
- : "bg-surface border-border hover:bg-surface-raised text-muted-foreground"
- }`}
- >
- Current Pace
-
-
{
- setFlightProtocol("glide");
- setSimulatedDailySpend(stretchModeDailyRs);
- toast.success(`Stretch mode selected: daily budget set to ${formatRs(stretchModeDailyRs * 100)}/day.`);
- }}
- className={`py-2 text-[10px] font-bold rounded-lg border text-center transition-all cursor-pointer ${
- flightProtocol === "glide"
- ? "bg-pb-amber/15 border-pb-amber text-pb-amber"
- : "bg-surface border-border hover:bg-surface-raised text-muted-foreground"
- }`}
- >
- Stretch Mode
-
-
{
- setFlightProtocol("turbulence");
- setSimulatedDailySpend(emergencyModeDailyRs);
- toast.warning(`Emergency mode selected: exam buffer used in the simulation and budget set to ${formatRs(emergencyModeDailyRs * 100)}/day.`);
- }}
- className={`py-2 text-[10px] font-bold rounded-lg border text-center transition-all cursor-pointer ${
- flightProtocol === "turbulence"
- ? "bg-pb-red/15 border-pb-red text-pb-red"
- : "bg-surface border-border hover:bg-surface-raised text-muted-foreground"
- }`}
- >
- Emergency Mode
-
+
+ {[
+ { key: "normal" as const, label: "Current Pace", amount: noSpendHistory ? "—" : `${formatRs((defaultPace || safeDailyRs) * 100)}` },
+ { key: "glide" as const, label: "Stretch", amount: safeDailyIsZero ? "Pause" : `${formatRs(stretchModeDailyRs * 100)}` },
+ { key: "turbulence" as const, label: "Emergency", amount: safeDailyIsZero ? "Pause" : `${formatRs(emergencyModeDailyRs * 100)}` }
+ ].map((mode) => (
+ selectRunwayMode(mode.key)}
+ className={`py-2 px-1 text-center rounded-lg transition-all cursor-pointer ${
+ flightProtocol === mode.key
+ ? "bg-background text-foreground shadow-sm border border-border/80 font-bold text-xs"
+ : "text-muted-foreground hover:text-foreground text-xs font-semibold"
+ }`}
+ >
+ {mode.label}
+
+ {mode.amount}
+ {mode.key !== "normal" && mode.amount !== "Pause" && /day }
+ {mode.key === "normal" && !noSpendHistory && /day }
+
+
+ ))}
- {/* Interactive runway levers */}
+ {/* Levers */}
-
Smart what-if switches
-
-
- {
- const val = e.target.checked;
- setScenarioFoodSwitch(val);
- if (val) {
- toast.success(`Meal routine switch adds ${formatRs(mealPlanLeverAmount)} back to flexible runway.`);
- } else {
- toast.info("Meal routine switch removed from the sandbox.");
- }
- }}
- className="rounded border-border text-primary focus:ring-primary w-3.5 h-3.5 accent-primary"
- />
-
- {foodSwitchSaving > 0 ? "Replace two delivery orders" : "Tighten meal routine"} ({formatRs(mealPlanLeverAmount)})
-
-
-
-
- {
- const val = e.target.checked;
- setScenarioSubscriptionsPaused(val);
- if (val) {
- toast.success(`${fixedCostLeverLabel}: ${formatRs(fixedCostLeverAmount)} freed in the sandbox.`);
- } else {
- toast.info("Fixed-cost lever removed from the sandbox.");
- }
- }}
- className="rounded border-border text-primary focus:ring-primary w-3.5 h-3.5 accent-primary"
- />
- {fixedCostLeverLabel} ({formatRs(fixedCostLeverAmount)})
-
+ {flightProtocol === "normal" && (
+
+
+ Switch to Stretch or Emergency mode to unlock recovery levers.
+
+ )}
-
- {
- const val = e.target.checked;
- setScenarioPoolSettled(val);
- if (val) {
- toast.success(`${sharedPlanLeverLabel}: ${formatRs(sharedPlanLeverAmount)} improvement applied.`);
- } else {
- toast.info("Shared-plan lever removed from the sandbox.");
- }
- }}
- className="rounded border-border text-primary focus:ring-primary w-3.5 h-3.5 accent-primary"
- />
- {sharedPlanLeverLabel} ({formatRs(sharedPlanLeverAmount)})
-
+ {flightProtocol === "glide" && (
+
+
+ Optional Stretch Levers
+
+
+ {[
+ { enabled: canUseMealLever, active: scenarioFoodSwitch, icon: Utensils, label: foodSwitchSaving > 0 ? "Replace deliveries" : "Tighten meal routine", detail: canUseMealLever ? `Adds ${formatRs(mealPlanLeverAmount)}` : "Needs food history", onToggle: () => { const v = !scenarioFoodSwitch; setScenarioFoodSwitch(v); if (v) toast.success(`Meal lever: +${formatRs(mealPlanLeverAmount)}`); else toast.info("Meal lever off."); } },
+ { enabled: canUseFixedCostLever, active: scenarioSubscriptionsPaused, icon: CreditCard, label: "Pause subscriptions", detail: canUseFixedCostLever ? `Frees ${formatRs(fixedCostLeverAmount)}` : "No subs found", onToggle: () => { const v = !scenarioSubscriptionsPaused; setScenarioSubscriptionsPaused(v); if (v) toast.success(`Subs paused: +${formatRs(fixedCostLeverAmount)}`); else toast.info("Subs lever off."); } },
+ { enabled: canUseSharedPlanLever, active: scenarioPoolSettled, icon: Users, label: sharedPlanLeverLabel, detail: canUseSharedPlanLever ? `Adds ${formatRs(sharedPlanLeverAmount)}` : "No pool dues", onToggle: () => { const v = !scenarioPoolSettled; setScenarioPoolSettled(v); if (v) toast.success(`Pool settled: +${formatRs(sharedPlanLeverAmount)}`); else toast.info("Pool lever off."); } },
+ { enabled: canStressHighSpend, active: scenarioHighSpendDay, icon: TrendingDown, label: "Stress: high spend day", detail: canStressHighSpend ? `-${formatRs(highSpendDayAmount)}` : "Needs history", onToggle: () => { const v = !scenarioHighSpendDay; setScenarioHighSpendDay(v); if (v) toast.warning(`Stress test: -${formatRs(highSpendDayAmount)}`); else toast.info("Stress removed."); } },
+ ].map((lever) => {
+ const LeverIcon = lever.icon;
+ return (
+
+
+
+
+
+
+ {lever.label}
+ {lever.detail}
+
+
+ {lever.enabled && (
+
+ {lever.active && }
+
+ )}
+
+ );
+ })}
+
+
+ )}
-
- {
- const val = e.target.checked;
- setScenarioHighSpendDay(val);
- if (val) {
- toast.warning(`Added one high-spend day: ${formatRs(highSpendDayAmount)} removed from flexible runway.`);
- } else {
- toast.info("High-spend day removed from the sandbox.");
- }
- }}
- className="rounded border-border text-primary focus:ring-primary w-3.5 h-3.5 accent-primary"
- />
- Add one high-spend day (-{formatRs(highSpendDayAmount)})
-
-
+ {flightProtocol === "turbulence" && (
+
+
+
+
+ {[
+ { label: "Meal", enabled: canUseMealLever, amount: mealPlanLeverAmount },
+ { label: "Fixed cost", enabled: canUseFixedCostLever, amount: fixedCostLeverAmount },
+ { label: "Pool", enabled: canUseSharedPlanLever, amount: sharedPlanLeverAmount },
+ ].map((l) => (
+
+
{l.label}
+
+ {l.enabled ? `+${formatRs(l.amount)}` : "Inactive"}
+
+
+ ))}
+
+
{ const v = !scenarioHighSpendDay; setScenarioHighSpendDay(v); if (v) toast.warning(`Stress test: -${formatRs(highSpendDayAmount)}`); else toast.info("Stress removed."); }}
+ className={`flex w-full items-center justify-between p-3 rounded-xl border text-left transition-all ${
+ !canStressHighSpend ? "opacity-40 cursor-not-allowed border-border/40 bg-surface-raised/10" :
+ scenarioHighSpendDay ? "border-pb-red bg-pb-red/10" : "border-border bg-background hover:bg-surface"
+ }`}
+ >
+
+
+
+
+
+ Include high-spend day
+ {canStressHighSpend ? `-${formatRs(highSpendDayAmount)}` : "Needs spend history"}
+
+
+ {canStressHighSpend && (
+
+ {scenarioHighSpendDay && }
+
+ )}
+
+
+
+ )}
- {/* Right side: Real-time Simulation Results */}
-
-
-
Sandbox Diagnostics
-
-
-
Remaining Discretionary Pool:
-
{formatRs(remainingDiscretionary)}
+ {/* ── Right: Cockpit Gauge ── */}
+
+
+
+ Runway simulator
+
+ {flightProtocol === "normal" ? "Normal" : flightProtocol === "glide" ? "Stretch" : "Emergency"}
+
-
-
Simulated Runway Length:
-
- {simulatedDays} days
-
+ {/* SVG Circular Gauge */}
+
+
+
+
+
+
+
+
+ {safeDailyIsZero ? "0" : simulatedDays}
+
+ days left
+
+
-
-
Simulated Broke Date:
-
- {simulatedBrokeDate}
-
+ {/* Status indicator */}
+
+
+
+
+ {isSimulatedSafe ? "Reaches reset safely" : "Deficit detected"}
+
+
+
+ {isSimulatedSafe
+ ? `Survives the next ${daysLeftInCycle} days on this plan.`
+ : safeDailyIsZero
+ ? "All discretionary balance consumed."
+ : `Runs out ${Math.max(0, daysLeftInCycle - simulatedDays)} days early. Cycle gap: ${formatRs(simulatedGapPaise)}.`}
+
-
-
- {isSimulatedSafe ? (
- <>
-
-
-
Safe speed! (Surplus)
-
You will comfortably survive until the allowance resets in {daysLeftInCycle} days.
-
- >
- ) : (
- <>
-
-
-
Simulation gap
-
This sandbox setting runs out {daysLeftInCycle - simulatedDays} days early with a gap of {formatRs(simulatedGapPaise)}. Slow the daily pace or use a data-backed lever.
-
- >
- )}
-
-
- {!isSimulatedSafe && (
-
-
-
-
Simulation note
+ {/* Stats */}
+
+
+ Flexible cash
+ {formatRs(remainingDiscretionary)}
+
+
+ Simulated rate
+ {safeDailyIsZero ? "₹0" : `${formatRs(activeSimulatedSpend * 100)}/day`}
+
+
+ Reset in
+ {daysLeftInCycle} days
-
- This is not the real ask-home amount. Actual forecast amount: {actualAskHomeAmount > 0 ? formatRs(actualAskHomeAmount) : "none"} .
-
- )}
+
+ {!isSimulatedSafe && actualAskHomeAmount > 0 && (
+
+
+ Actual ask-home buffer: {formatRs(actualAskHomeAmount)}
+
+
+ )}
+
Copy Runway Brief
@@ -956,294 +1954,275 @@ Generated via PocketBuddy Runway.`;
{/* 💡 RUNWAY SURVIVAL NUDGES */}
-
-
-
-
Runway Actions
-
+
+
+
+
Runway Actions
+
Configure settings and habits to stretch your cash runway.
+
-
- {forecast.status === "shortfall" ? (
- <>
-
-
1
-
-
Shield Your Exam Safety Buffer
-
- {examBufferCommitmentTotal > 0
- ? `Your configured buffer of ${formatRs(examBufferCommitmentTotal)} is locked. Avoid dipping into it for regular food spending.`
- : "No exam buffer is configured yet. Keep essentials separate before reducing food or travel spend."}
-
-
-
+
+
+
+
+
+ {runwayActions.map((action) => (
+
+ {action.label}
+
+ ))}
+
+
+
-
-
2
-
-
Auto-Debit Subscription Alert
-
You have {forecast.commitments.items.filter((i: any) => i.kind === "subscription").length} recurring subscriptions active. Temporarily pause one to reclaim breathing room.
-
-
+ {/* Selected Action Content */}
+ {(() => {
+ const selectedAction = runwayActions.find((a) => a.id === selectedActionId) || runwayActions[0];
+ if (!selectedAction) return null;
+
+ const actionPath =
+ selectedAction.id === "priority" && activeActionType === "slow_down" ? "/pool" :
+ selectedAction.id === "action-2" && forecast.status === "shortfall" ? "/settings" : // Subscriptions
+ selectedAction.id === "action-1" ? "/settings" : // Buffer / Reserve
+ selectedAction.id === "action-3" ? "/settings" : // Food cap settings
+ null;
+
+ const actionButtonText =
+ selectedAction.id === "priority" && activeActionType === "slow_down" ? "Open pool dues" :
+ selectedAction.id === "action-2" && forecast.status === "shortfall" ? "Manage subscriptions" :
+ selectedAction.id === "action-1" ? "Configure safety buffer" :
+ selectedAction.id === "action-3" ? "Adjust food cap" :
+ "View settings";
-
-
3
-
-
{foodRoutine?.action?.title ?? "Stabilize food pace"}
-
{foodRoutine?.action?.detail ?? "Use routine meals before delivery becomes the default. This keeps daily food spend inside your safe runway limit."}
-
-
- >
- ) : (
- <>
-
-
1
-
-
Lock in an Emergency Reserve
-
- {examBufferCommitmentTotal > 0
- ? `Your ${formatRs(examBufferCommitmentTotal)} exam reserve is already protected in the runway calculation.`
- : "Since you're on track, set an emergency reserve in settings so future spending cannot consume essentials."}
-
+ return (
+
+
+
+
+ Selected Step
+
+
+ {selectedAction.severity === "high" ? "Critical Priority" : selectedAction.severity === "medium" ? "Recommended" : "Optional"}
+
-
-
-
2
-
Spending Pace Guardrails
-
{decisionEngine?.summary ?? `Try to stay within ${formatRs(forecast.projection.safe_daily_spend)} per day to keep your runway healthy.`}
+
{selectedAction.label}
+
{selectedAction.detail}
-
-
3
-
-
{foodRoutine?.action?.title ?? "Keep meals predictable"}
-
{foodRoutine?.action?.detail ?? "Keep food pace predictable so runway can reserve enough for travel, exams, and shared-pool dues."}
+ {actionPath && (
+
+
+ {actionButtonText}
+
-
- >
- )}
-
+ )}
+
+ );
+ })()}
{/* Runway Progress Card */}
-
- Allowance Cycle Progress
-
-
-
Spent: {formatRs(forecast.current_cycle.spent)}
-
Allowance: {formatRs(forecast.current_cycle.available_funding)}
+
+
+
+
Allowance cycle
+
Cycle timing and cash position at a glance.
+
+
+ {forecast.current_cycle.days_left} days left
+
+
+
+
+
Started
+
+ {new Date(forecast.current_cycle.start).toLocaleDateString("en-IN", { day: "numeric", month: "short" })}
+
+
Funding: {formatRs(forecast.current_cycle.available_funding)}
-
-
-
Cycle start: {new Date(forecast.current_cycle.start).toLocaleDateString("en-IN")}
-
{forecast.current_cycle.days_left} days remaining until reset
-
Cycle end: {new Date(forecast.current_cycle.end).toLocaleDateString("en-IN")}
+
+
Used so far
+
{formatRs(forecast.current_cycle.spent)}
+
{allowanceProgressPct}% of cycle funding spent
+
+
+
Resets
+
+ {new Date(forecast.current_cycle.end).toLocaleDateString("en-IN", { day: "numeric", month: "short" })}
+
+
Balance: {formatRs(remainingBalance)}
{/* Committed vs Flexible Spend visualizer */}
-
- {/* Visual Split */}
-
-
-
Budget Split Analysis
-
- Committed vs Flexible
-
+
+
+
+
Where the runway goes
+
+ This explains the forecast above; it is not a second recommendation.
+
-
- {/* Progress Split Meter */}
-
-
-
-
-
- Committed Spend: {committedPct}%
-
-
-
- Flexible Spend Forecast: {flexiblePct}%
+
+ Reserved vs flexible
+
+
+
+
+ {[
+ { label: "Cycle funding", value: forecast.current_cycle.available_funding, tone: "text-pb-green" },
+ { label: "Already spent", value: -forecast.current_cycle.spent, tone: "text-muted-foreground" },
+ { label: "Reserved costs", value: -forecast.commitments.total, tone: "text-primary" },
+ { label: "Flexible cash left", value: remainingDiscretionary, tone: "text-foreground" },
+ ].map((row, idx) => (
+
0 ? "border-t border-border/60" : ""}`}>
+ {row.label}
+
+ {row.value < 0 ? `-${formatRs(Math.abs(row.value))}` : formatRs(row.value)}
-
-
- {/* Descriptions */}
-
-
-
Committed Spend
-
- Fixed obligations you must pay: subscriptions, monthly mess bills, exam buffers, and pending cart pool settlements.
- Total: {formatRs(forecast.commitments.total)}
-
-
-
-
Flexible/Discretionary Spend
-
- Variables expenses (canteen snacks, travel, shopping). Under current pace, you are projected to spend
- {formatRs(forecast.projection.projected_discretionary)} for the rest of the cycle.
-
-
-
+ ))}
-
- {/* Action Banner */}
-
-
-
Affordability Engine Action
-
-
- {activeActionType.replace("_", " ")}
-
-
{activeActionTitle}
-
- {activeActionDetail}
-
+
+
+
Reserved share
+
{committedPct}%
+
{formatRs(forecast.commitments.total)} protected before daily spend.
+
+
+
Flexible forecast
+
{formatRs(forecast.projection.projected_discretionary)}
+
{flexiblePct}% of the remaining forecasted spend.
- {activeActionType === "ask_home" && actualAskHomeAmount > 0 && (
-
-
Shortfall amount
-
{formatRs(actualAskHomeAmount)}
-
- Request this buffer before the cycle tightens; it covers the forecast gap and high-spend range.
-
+
+
- )}
- {activeActionType === "slow_down" && (
-
-
Coordinate shared cart pools
-
-
- )}
-
-
-
- {/* 📖 EDUCATIONAL GUIDE: HOW THE RUNWAY MATH WORKS */}
-
-
-
-
How the Runway Engine Works
-
-
-
- {/* Step 1 */}
-
-
-
1
-
Calculate Flexible Cash
+
+
+
+
+ Reserved costs
+
+ {committedPct}% · {formatRs(forecast.commitments.total)}
+
+
+
+
+ Flexible forecast
+
+ {flexiblePct}% · {formatRs(forecast.projection.projected_discretionary)}
+
-
- We start with your total cycle allowance ({formatRs(forecast.current_cycle.available_funding)}) and subtract what you've already spent, plus any Fixed Commitments like active subscriptions, meal bills, pool dues, and exam reserve buffers. What is left is your Flexible Discretionary Pool (currently {formatRs(remainingDiscretionary)} ).
-
- {/* Step 2 */}
-
-
-
2
-
Estimate Spend Speed (Pace)
+
+
+
Reserved costs
+
+ Subscriptions, meal routine, exam buffer, and pending pool settlements are protected before daily spending is calculated.
+
-
- Instead of a simple average, we use EWMA (Exponentially Weighted Moving Average) . This means recent days count much more than older days. We also apply weekend weights (typically 1.3x multiplier) because students tend to order more food and travel on weekends.
-
-
-
- {/* Step 3 */}
-
-
-
3
-
Project the Countdown
+
+
Flexible forecast
+
+ Snacks, travel, shopping, and other variable spends are projected from recent pace for the remaining cycle.
+
-
- We divide your Flexible Pool by your Spend Speed to calculate exactly how many days you survive before going broke ({forecast.projection.days_until_broke} days ). If this countdown is shorter than the days remaining in your cycle ({daysLeftInCycle} days), the engine flags the real forecast shortfall and shows the exact buffer needed.
-
+
+
)}
{/* ── Tab: Commitments ── */}
{activeTab === "commitments" && (
-
-
+
+
-
Fixed Commitments
-
- Money reserved before daily spending is calculated.
+
Fixed commitments
+
+ Reserved before safe/day is calculated, so essentials do not get mixed with flexible spend.
-
-
Total Obligations
-
{formatRs(forecast.commitments.total)}
+
+
+ Reserved this cycle
+ {formatRs(forecast.commitments.total)}
+
+
+ {forecast.confidence.level} ({forecast.confidence.score}%)
+
{commitmentSummary.length > 0 && (
-
+
{commitmentSummary.map((item) => (
-
-
{item.label}
-
{formatRs(item.amount)}
+
+
{item.label}
+
{formatRs(item.amount)}
))}
)}
{forecast.commitments.items?.length === 0 ? (
-
- No fixed commitments or reserves found for this allowance cycle.
+
+ No fixed commitments or reserves found for this allowance cycle. Add rent, subscriptions, meal bills, exam reserve, or pool dues to improve runway accuracy.
) : (
-
+
{forecast.commitments.items.map((item: any, i: number) => {
const due = new Date(item.due_at);
return (
-
-
-
- {item.kind === "subscription" ?
:
- item.kind === "mess" ?
:
- item.kind === "exam_buffer" ?
:
-
}
+
+
+
+ {item.kind === "subscription" ? :
+ item.kind === "mess" ? :
+ item.kind === "exam_buffer" ? :
+ }
-
-
{item.label}
-
-
- Due: {due.toLocaleDateString("en-IN", { day: "numeric", month: "short" })}
+
+
{item.label}
+
+
+ Due {due.toLocaleDateString("en-IN", { day: "numeric", month: "short" })}
-
-
+
{item.status}
- {formatRs(item.amount)}
+ {formatRs(item.amount)}
);
@@ -1253,20 +2232,20 @@ Generated via PocketBuddy Runway.`;
{/* Profile setup reminder for Mess and Exams */}
-
-
-
-
+
+
+
+
-
-
Configure your profile contexts
-
- Update your campus settings, mess billing model (per meal or monthly cost), and exam schedules in the settings panel to enhance forecast calculation accuracy.
+
+
Need updates?
+
+ Adjust subscriptions, mess bills, or exam buffer in settings.
-
- Update Settings
+
+ Settings
@@ -1276,30 +2255,39 @@ Generated via PocketBuddy Runway.`;
{activeTab === "horizons" && (
{/* Charts Container */}
-
-
+
+
-
Projections
+
Projection takeaway
- Longer-term view if your allowance and current pace stay similar.
+ {horizonTakeaway}
- {projectionSignal && (
-
+ {projectionSignal && (
+
+ {projectionSignal.isDeficit ? "First deficit" : "Long view"}: {projectionSignal.label}
+
+ )}
+
- {projectionSignal.isDeficit ? "First deficit" : "Long view"}: {projectionSignal.label}
+ {forecast.confidence.level} confidence ({forecast.confidence.score}%)
- )}
+
-
+
-
@@ -1312,7 +2300,7 @@ Generated via PocketBuddy Runway.`;
- {/* Predictions Grid */}
+ {/* Scenario Grid */}
{forecast.horizons.map((h: any) => {
const isNegative = h.projected_balance < 0;
@@ -1320,14 +2308,14 @@ Generated via PocketBuddy Runway.`;
-
{h.label}
+ {h.label}
{isNegative ? "Deficit" : "Surplus"}
-
+
Income
@@ -1346,13 +2334,18 @@ Generated via PocketBuddy Runway.`;
- Plausible variance range ({forecast.confidence.score}% engine confidence):
+ Scenario range ({forecast.confidence.score}% model confidence):
{formatRs(h.balance_low)} to {formatRs(h.balance_high)}
+ {h.basis && (
+
+ Basis: {h.basis}
+
+ )}
{isNegative && (
-
+
Expected monthly gap:
{formatRs(h.monthly_shortfall)} / month
@@ -1364,47 +2357,49 @@ Generated via PocketBuddy Runway.`;
)}
- {/* ── Engine Methodology & Confidence ── */}
-
-
-
-
- Forecast Engine V2 Audit
-
-
- Engine Confidence:
-
- {forecast.confidence.level} ({forecast.confidence.score}%)
-
-
-
-
-
- Confidence Rationale: {forecast.confidence.reason}
-
-
-
Model Parameters & Ground Rules:
-
- {forecast.methodology.notes.map((note: string, idx: number) => (
- {note}
- ))}
-
- Using {forecast.methodology.lookback_days}-day historical lookback with a
- decay factor (alpha) of {forecast.methodology.ewma_alpha} to give higher weight to recent daily spending patterns.
-
-
- Adjusted for day-of-week spending variances (weighting weekend tendencies vs weekday routines).
-
-
-
-
-
+ {/* 📋 FORECAST INPUTS MODAL */}
+
+
+
+
+
+ Forecast Inputs
+
+
+
+
+ {absorbedFactors.length ? absorbedFactors.map((factor: any) => (
+
+
+
{factor.label}
+
{factor.detail}
+
+
+
+ {formatRs(factor.daily_amount ?? factor.amount)}
+
+ {factor.daily_amount ? /day : null}
+
+
+ )) : commitmentSummary.length ? commitmentSummary.map((item) => (
+
+
+
{item.label}
+
Cycle reserve cost
+
+
{formatRs(item.amount)}
+
+ )) : (
+
+ No recurring obligations linked yet.
+
+ )}
+
+
+
+
{/* 📖 RUNWAY FLIGHT MANUAL GUIDE MODAL */}
@@ -1443,7 +2438,7 @@ Generated via PocketBuddy Runway.`;
Deficit Prevention ("Ask Home")
- If the forecast detects a deficit before your allowance resets, the system recommends a rounded-up "Ask Home" amount. Requesting this exact buffer helps cover obligations without over-requesting from parents.
+ If the forecast detects a deficit before your allowance resets, the system recommends a rounded-up "Ask Home" amount. Use it as a practical buffer, not a guaranteed prediction.
diff --git a/frontend/src/routes/_authenticated/settings.lazy.tsx b/frontend/src/routes/_authenticated/settings.lazy.tsx
index 1e041aa..1344b7b 100644
--- a/frontend/src/routes/_authenticated/settings.lazy.tsx
+++ b/frontend/src/routes/_authenticated/settings.lazy.tsx
@@ -93,6 +93,8 @@ function SettingsPage() {
const [hostel, setHostel] = useState("");
const [wing, setWing] = useState("");
const [room, setRoom] = useState("");
+ const [residenceType, setResidenceType] = useState("hostel");
+ const [mealRoutine, setMealRoutine] = useState("hostel_mess");
const [examStart, setExamStart] = useState("");
const [examEnd, setExamEnd] = useState("");
const [mess, setMess] = useState(false);
@@ -112,6 +114,8 @@ function SettingsPage() {
setHostel(profile.hostel_block ?? "");
setWing(profile.wing_label ?? "");
setRoom(profile.room_number ?? "");
+ setResidenceType(profile.residence_type ?? "hostel");
+ setMealRoutine(profile.meal_routine ?? (profile.mess_enrolled ? "hostel_mess" : "mixed"));
setExamStart(profile.exam_start_date ?? "");
setExamEnd(profile.exam_end_date ?? "");
setMess(profile.mess_enrolled ?? false);
@@ -134,6 +138,8 @@ function SettingsPage() {
hostel_block: hostel,
wing_label: wing,
room_number: room,
+ residence_type: residenceType,
+ meal_routine: mealRoutine,
exam_start_date: examStart || null,
exam_end_date: examEnd || null,
mess_enrolled: mess,
@@ -402,6 +408,50 @@ function SettingsPage() {
}}
/>
+
+
+ {
+ setResidenceType(value);
+ if (value === "hostel") setMealRoutine("hostel_mess");
+ if (value === "pg") setMealRoutine("pg_cooking");
+ if (value === "day_scholar") setMealRoutine("day_scholar");
+ }}
+ >
+
+
+
+
+ Hostel / dorm
+ PG / rented room
+ Day scholar / commute
+ Mixed routine
+
+
+
+
+
+
+
+
+
+ Hostel mess / campus meals
+ PG cooking / groceries
+ Day scholar meals
+ Mixed routine
+
+
+
+
+
+
+
-
+
{
+ setMess(value);
+ if (value) setMealRoutine("hostel_mess");
+ }}
+ />
{mess && (