ui update and some backend functionality adding in accordance with research on adhd and ux design
This commit is contained in:
@@ -11,6 +11,7 @@ import jwt
|
||||
from psycopg2.extras import Json
|
||||
import core.auth as auth
|
||||
import core.postgres as postgres
|
||||
import core.tz as tz
|
||||
|
||||
|
||||
def _get_user_uuid(token):
|
||||
@@ -72,7 +73,7 @@ def _compute_next_dose_date(med):
|
||||
interval = med.get("interval_days")
|
||||
if not interval:
|
||||
return None
|
||||
return (date.today() + timedelta(days=interval)).isoformat()
|
||||
return (tz.user_today() + timedelta(days=interval)).isoformat()
|
||||
|
||||
|
||||
def _count_expected_doses(med, period_start, days):
|
||||
@@ -162,7 +163,7 @@ def register(app):
|
||||
# Compute next_dose_date for interval meds
|
||||
if data.get("frequency") == "every_n_days" and data.get("start_date") and data.get("interval_days"):
|
||||
start = datetime.strptime(data["start_date"], "%Y-%m-%d").date()
|
||||
today = date.today()
|
||||
today = tz.user_today()
|
||||
if start > today:
|
||||
row["next_dose_date"] = data["start_date"]
|
||||
else:
|
||||
@@ -322,8 +323,8 @@ def register(app):
|
||||
return flask.jsonify({"error": "unauthorized"}), 401
|
||||
|
||||
meds = postgres.select("medications", where={"user_uuid": user_uuid, "active": True})
|
||||
now = datetime.now()
|
||||
today = date.today()
|
||||
now = tz.user_now()
|
||||
today = now.date()
|
||||
today_str = today.isoformat()
|
||||
current_day = now.strftime("%a").lower() # "mon","tue", etc.
|
||||
current_hour = now.hour
|
||||
@@ -361,7 +362,7 @@ def register(app):
|
||||
if current_hour >= 22:
|
||||
tomorrow = today + timedelta(days=1)
|
||||
tomorrow_str = tomorrow.isoformat()
|
||||
tomorrow_day = (now + timedelta(days=1)).strftime("%a").lower()
|
||||
tomorrow_day = tomorrow.strftime("%a").lower()
|
||||
for med in meds:
|
||||
if med["id"] in seen_med_ids:
|
||||
continue
|
||||
@@ -398,7 +399,7 @@ def register(app):
|
||||
if current_hour < 2:
|
||||
yesterday = today - timedelta(days=1)
|
||||
yesterday_str = yesterday.isoformat()
|
||||
yesterday_day = (now - timedelta(days=1)).strftime("%a").lower()
|
||||
yesterday_day = yesterday.strftime("%a").lower()
|
||||
for med in meds:
|
||||
if med["id"] in seen_med_ids:
|
||||
continue
|
||||
@@ -443,7 +444,7 @@ def register(app):
|
||||
return flask.jsonify({"error": "unauthorized"}), 401
|
||||
num_days = flask.request.args.get("days", 30, type=int)
|
||||
meds = postgres.select("medications", where={"user_uuid": user_uuid, "active": True})
|
||||
today = date.today()
|
||||
today = tz.user_today()
|
||||
period_start = today - timedelta(days=num_days)
|
||||
period_start_str = period_start.isoformat()
|
||||
|
||||
@@ -485,7 +486,7 @@ def register(app):
|
||||
if not med:
|
||||
return flask.jsonify({"error": "not found"}), 404
|
||||
num_days = flask.request.args.get("days", 30, type=int)
|
||||
today = date.today()
|
||||
today = tz.user_today()
|
||||
period_start = today - timedelta(days=num_days)
|
||||
period_start_str = period_start.isoformat()
|
||||
|
||||
@@ -542,7 +543,7 @@ def register(app):
|
||||
if not user_uuid:
|
||||
return flask.jsonify({"error": "unauthorized"}), 401
|
||||
days_ahead = flask.request.args.get("days_ahead", 7, type=int)
|
||||
cutoff = (datetime.now() + timedelta(days=days_ahead)).strftime("%Y-%m-%d")
|
||||
cutoff = (tz.user_today() + timedelta(days=days_ahead)).isoformat()
|
||||
meds = postgres.select(
|
||||
"medications",
|
||||
where={"user_uuid": user_uuid},
|
||||
|
||||
94
api/routes/notifications.py
Normal file
94
api/routes/notifications.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
Notifications API - Web push subscription management and VAPID key endpoint
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import flask
|
||||
import jwt
|
||||
import core.auth as auth
|
||||
import core.postgres as postgres
|
||||
|
||||
|
||||
def _get_user_uuid(token):
|
||||
try:
|
||||
payload = jwt.decode(token, os.getenv("JWT_SECRET"), algorithms=["HS256"])
|
||||
return payload.get("sub")
|
||||
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
|
||||
return None
|
||||
|
||||
|
||||
def _auth(request):
|
||||
"""Extract and verify token. Returns user_uuid or None."""
|
||||
header = request.headers.get("Authorization", "")
|
||||
if not header.startswith("Bearer "):
|
||||
return None
|
||||
token = header[7:]
|
||||
user_uuid = _get_user_uuid(token)
|
||||
if not user_uuid or not auth.verifyLoginToken(token, userUUID=user_uuid):
|
||||
return None
|
||||
return user_uuid
|
||||
|
||||
|
||||
def register(app):
|
||||
|
||||
@app.route("/api/notifications/vapid-public-key", methods=["GET"])
|
||||
def api_vapidPublicKey():
|
||||
"""Return the VAPID public key for push subscription."""
|
||||
key = os.environ.get("VAPID_PUBLIC_KEY")
|
||||
if not key:
|
||||
return flask.jsonify({"error": "VAPID not configured"}), 503
|
||||
return flask.jsonify({"public_key": key}), 200
|
||||
|
||||
@app.route("/api/notifications/subscribe", methods=["POST"])
|
||||
def api_pushSubscribe():
|
||||
"""Register a push subscription. Body: {endpoint, keys: {p256dh, auth}}"""
|
||||
user_uuid = _auth(flask.request)
|
||||
if not user_uuid:
|
||||
return flask.jsonify({"error": "unauthorized"}), 401
|
||||
data = flask.request.get_json()
|
||||
if not data:
|
||||
return flask.jsonify({"error": "missing body"}), 400
|
||||
|
||||
endpoint = data.get("endpoint")
|
||||
keys = data.get("keys", {})
|
||||
p256dh = keys.get("p256dh")
|
||||
auth_key = keys.get("auth")
|
||||
|
||||
if not endpoint or not p256dh or not auth_key:
|
||||
return flask.jsonify({"error": "missing endpoint or keys"}), 400
|
||||
|
||||
# Upsert: remove existing subscription with same endpoint for this user
|
||||
existing = postgres.select("push_subscriptions", where={
|
||||
"user_uuid": user_uuid,
|
||||
"endpoint": endpoint,
|
||||
})
|
||||
if existing:
|
||||
postgres.delete("push_subscriptions", {"id": existing[0]["id"]})
|
||||
|
||||
row = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"user_uuid": user_uuid,
|
||||
"endpoint": endpoint,
|
||||
"p256dh": p256dh,
|
||||
"auth": auth_key,
|
||||
}
|
||||
postgres.insert("push_subscriptions", row)
|
||||
return flask.jsonify({"subscribed": True}), 201
|
||||
|
||||
@app.route("/api/notifications/subscribe", methods=["DELETE"])
|
||||
def api_pushUnsubscribe():
|
||||
"""Remove a push subscription. Body: {endpoint}"""
|
||||
user_uuid = _auth(flask.request)
|
||||
if not user_uuid:
|
||||
return flask.jsonify({"error": "unauthorized"}), 401
|
||||
data = flask.request.get_json()
|
||||
if not data or not data.get("endpoint"):
|
||||
return flask.jsonify({"error": "missing endpoint"}), 400
|
||||
|
||||
postgres.delete("push_subscriptions", {
|
||||
"user_uuid": user_uuid,
|
||||
"endpoint": data["endpoint"],
|
||||
})
|
||||
return flask.jsonify({"unsubscribed": True}), 200
|
||||
74
api/routes/preferences.py
Normal file
74
api/routes/preferences.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Preferences API - user settings
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import flask
|
||||
import jwt
|
||||
import core.auth as auth
|
||||
import core.postgres as postgres
|
||||
|
||||
|
||||
def _get_user_uuid(token):
|
||||
try:
|
||||
payload = jwt.decode(token, os.getenv("JWT_SECRET"), algorithms=["HS256"])
|
||||
return payload.get("sub")
|
||||
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
|
||||
return None
|
||||
|
||||
|
||||
def _auth(request):
|
||||
header = request.headers.get("Authorization", "")
|
||||
if not header.startswith("Bearer "):
|
||||
return None
|
||||
token = header[7:]
|
||||
user_uuid = _get_user_uuid(token)
|
||||
if not user_uuid or not auth.verifyLoginToken(token, userUUID=user_uuid):
|
||||
return None
|
||||
return user_uuid
|
||||
|
||||
|
||||
def register(app):
|
||||
|
||||
@app.route("/api/preferences", methods=["GET"])
|
||||
def api_getPreferences():
|
||||
"""Get user preferences."""
|
||||
user_uuid = _auth(flask.request)
|
||||
if not user_uuid:
|
||||
return flask.jsonify({"error": "unauthorized"}), 401
|
||||
prefs = postgres.select_one("user_preferences", {"user_uuid": user_uuid})
|
||||
if not prefs:
|
||||
# Return defaults
|
||||
return flask.jsonify({
|
||||
"sound_enabled": False,
|
||||
"haptic_enabled": True,
|
||||
"show_launch_screen": True,
|
||||
"celebration_style": "standard",
|
||||
}), 200
|
||||
return flask.jsonify(prefs), 200
|
||||
|
||||
@app.route("/api/preferences", methods=["PUT"])
|
||||
def api_updatePreferences():
|
||||
"""Update user preferences."""
|
||||
user_uuid = _auth(flask.request)
|
||||
if not user_uuid:
|
||||
return flask.jsonify({"error": "unauthorized"}), 401
|
||||
data = flask.request.get_json()
|
||||
if not data:
|
||||
return flask.jsonify({"error": "missing body"}), 400
|
||||
|
||||
allowed = ["sound_enabled", "haptic_enabled", "show_launch_screen", "celebration_style", "timezone_offset"]
|
||||
updates = {k: v for k, v in data.items() if k in allowed}
|
||||
if not updates:
|
||||
return flask.jsonify({"error": "no valid fields"}), 400
|
||||
|
||||
existing = postgres.select_one("user_preferences", {"user_uuid": user_uuid})
|
||||
if existing:
|
||||
result = postgres.update("user_preferences", updates, {"user_uuid": user_uuid})
|
||||
return flask.jsonify(result[0] if result else {}), 200
|
||||
else:
|
||||
updates["id"] = str(uuid.uuid4())
|
||||
updates["user_uuid"] = user_uuid
|
||||
result = postgres.insert("user_preferences", updates)
|
||||
return flask.jsonify(result), 201
|
||||
114
api/routes/rewards.py
Normal file
114
api/routes/rewards.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Rewards API - variable reward system for routine completion
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import random
|
||||
import flask
|
||||
import jwt
|
||||
import core.auth as auth
|
||||
import core.postgres as postgres
|
||||
|
||||
|
||||
def _get_user_uuid(token):
|
||||
try:
|
||||
payload = jwt.decode(token, os.getenv("JWT_SECRET"), algorithms=["HS256"])
|
||||
return payload.get("sub")
|
||||
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
|
||||
return None
|
||||
|
||||
|
||||
def _auth(request):
|
||||
header = request.headers.get("Authorization", "")
|
||||
if not header.startswith("Bearer "):
|
||||
return None
|
||||
token = header[7:]
|
||||
user_uuid = _get_user_uuid(token)
|
||||
if not user_uuid or not auth.verifyLoginToken(token, userUUID=user_uuid):
|
||||
return None
|
||||
return user_uuid
|
||||
|
||||
|
||||
def register(app):
|
||||
|
||||
@app.route("/api/rewards/random", methods=["GET"])
|
||||
def api_getRandomReward():
|
||||
"""Get a weighted random reward. Query: ?context=completion"""
|
||||
user_uuid = _auth(flask.request)
|
||||
if not user_uuid:
|
||||
return flask.jsonify({"error": "unauthorized"}), 401
|
||||
|
||||
context = flask.request.args.get("context", "completion")
|
||||
|
||||
# Fetch all rewards from pool
|
||||
all_rewards = postgres.select("reward_pool", where={})
|
||||
if not all_rewards:
|
||||
return flask.jsonify({"reward": None}), 200
|
||||
|
||||
# Weight by rarity: common=70%, uncommon=25%, rare=5%
|
||||
weights = {
|
||||
"common": 70,
|
||||
"uncommon": 25,
|
||||
"rare": 5,
|
||||
}
|
||||
weighted = []
|
||||
for reward in all_rewards:
|
||||
w = weights.get(reward.get("rarity", "common"), 70)
|
||||
weighted.extend([reward] * w)
|
||||
|
||||
if not weighted:
|
||||
return flask.jsonify({"reward": None}), 200
|
||||
|
||||
selected = random.choice(weighted)
|
||||
|
||||
# Record that user earned this reward
|
||||
try:
|
||||
postgres.insert("user_rewards", {
|
||||
"id": str(uuid.uuid4()),
|
||||
"user_uuid": user_uuid,
|
||||
"reward_id": selected["id"],
|
||||
"context": context,
|
||||
})
|
||||
except Exception:
|
||||
pass # Don't fail if recording fails
|
||||
|
||||
return flask.jsonify({
|
||||
"reward": {
|
||||
"id": selected["id"],
|
||||
"category": selected["category"],
|
||||
"content": selected["content"],
|
||||
"emoji": selected.get("emoji"),
|
||||
"rarity": selected.get("rarity", "common"),
|
||||
}
|
||||
}), 200
|
||||
|
||||
@app.route("/api/rewards/history", methods=["GET"])
|
||||
def api_getRewardHistory():
|
||||
"""Get user's past earned rewards."""
|
||||
user_uuid = _auth(flask.request)
|
||||
if not user_uuid:
|
||||
return flask.jsonify({"error": "unauthorized"}), 401
|
||||
|
||||
earned = postgres.select(
|
||||
"user_rewards",
|
||||
where={"user_uuid": user_uuid},
|
||||
order_by="earned_at DESC",
|
||||
limit=50,
|
||||
)
|
||||
|
||||
# Enrich with reward content
|
||||
result = []
|
||||
for entry in earned:
|
||||
reward = postgres.select_one("reward_pool", {"id": entry["reward_id"]})
|
||||
if reward:
|
||||
result.append({
|
||||
"earned_at": entry["earned_at"],
|
||||
"context": entry.get("context"),
|
||||
"category": reward["category"],
|
||||
"content": reward["content"],
|
||||
"emoji": reward.get("emoji"),
|
||||
"rarity": reward.get("rarity"),
|
||||
})
|
||||
|
||||
return flask.jsonify(result), 200
|
||||
@@ -9,6 +9,7 @@ import flask
|
||||
import jwt
|
||||
import core.auth as auth
|
||||
import core.postgres as postgres
|
||||
import core.tz as tz
|
||||
|
||||
|
||||
def _get_user_uuid(token):
|
||||
@@ -45,7 +46,7 @@ def register(app):
|
||||
return flask.jsonify({"error": "session not active"}), 400
|
||||
result = postgres.update(
|
||||
"routine_sessions",
|
||||
{"status": "paused", "paused_at": datetime.now().isoformat()},
|
||||
{"status": "paused", "paused_at": tz.user_now().isoformat()},
|
||||
{"id": session_id}
|
||||
)
|
||||
return flask.jsonify({"status": "paused"}), 200
|
||||
@@ -81,7 +82,7 @@ def register(app):
|
||||
reason = data.get("reason", "Aborted by user")
|
||||
result = postgres.update(
|
||||
"routine_sessions",
|
||||
{"status": "aborted", "abort_reason": reason, "completed_at": datetime.now().isoformat()},
|
||||
{"status": "aborted", "abort_reason": reason, "completed_at": tz.user_now().isoformat()},
|
||||
{"id": session_id}
|
||||
)
|
||||
return flask.jsonify({"status": "aborted", "reason": reason}), 200
|
||||
|
||||
@@ -8,6 +8,7 @@ import flask
|
||||
import jwt
|
||||
import core.auth as auth
|
||||
import core.postgres as postgres
|
||||
import core.tz as tz
|
||||
|
||||
|
||||
def _get_user_uuid(token):
|
||||
@@ -128,7 +129,7 @@ def register(app):
|
||||
"routines_started": 0,
|
||||
"routines": [],
|
||||
}), 200
|
||||
week_ago = (datetime.now() - timedelta(days=7)).isoformat()
|
||||
week_ago = (tz.user_now() - timedelta(days=7)).isoformat()
|
||||
sessions = postgres.select("routine_sessions", where={"user_uuid": user_uuid})
|
||||
week_sessions = [s for s in sessions if s.get("created_at") and str(s["created_at"]) >= week_ago]
|
||||
completed = [s for s in week_sessions if s.get("status") == "completed"]
|
||||
|
||||
@@ -6,10 +6,13 @@ Routines have ordered steps. Users start sessions to walk through them.
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
import flask
|
||||
import jwt
|
||||
import core.auth as auth
|
||||
import core.postgres as postgres
|
||||
import core.routines as routines_core
|
||||
import core.tz as tz
|
||||
|
||||
|
||||
def _get_user_uuid(token):
|
||||
@@ -32,6 +35,111 @@ def _auth(request):
|
||||
return user_uuid
|
||||
|
||||
|
||||
def _record_step_result(session_id, step_id, step_index, result, session):
|
||||
"""Record a per-step result (completed or skipped)."""
|
||||
try:
|
||||
# Compute duration from previous step completion or session start
|
||||
prev_results = postgres.select(
|
||||
"routine_step_results",
|
||||
where={"session_id": session_id},
|
||||
order_by="completed_at DESC",
|
||||
limit=1,
|
||||
)
|
||||
now = tz.user_now()
|
||||
if prev_results:
|
||||
last_completed = prev_results[0].get("completed_at")
|
||||
if last_completed:
|
||||
if isinstance(last_completed, str):
|
||||
last_completed = datetime.fromisoformat(last_completed)
|
||||
# Make naive datetimes comparable with aware ones
|
||||
if last_completed.tzinfo is None:
|
||||
duration_seconds = int((now.replace(tzinfo=None) - last_completed).total_seconds())
|
||||
else:
|
||||
duration_seconds = int((now - last_completed).total_seconds())
|
||||
else:
|
||||
duration_seconds = None
|
||||
else:
|
||||
created_at = session.get("created_at")
|
||||
if created_at:
|
||||
if isinstance(created_at, str):
|
||||
created_at = datetime.fromisoformat(created_at)
|
||||
if created_at.tzinfo is None:
|
||||
duration_seconds = int((now.replace(tzinfo=None) - created_at).total_seconds())
|
||||
else:
|
||||
duration_seconds = int((now - created_at).total_seconds())
|
||||
else:
|
||||
duration_seconds = None
|
||||
|
||||
postgres.insert("routine_step_results", {
|
||||
"id": str(uuid.uuid4()),
|
||||
"session_id": session_id,
|
||||
"step_id": step_id,
|
||||
"step_index": step_index,
|
||||
"result": result,
|
||||
"duration_seconds": duration_seconds,
|
||||
"completed_at": now.isoformat(),
|
||||
})
|
||||
except Exception:
|
||||
pass # Don't fail the step completion if tracking fails
|
||||
|
||||
|
||||
def _complete_session_with_celebration(session_id, user_uuid, session):
|
||||
"""Complete a session and return celebration data."""
|
||||
now = tz.user_now()
|
||||
created_at = session.get("created_at")
|
||||
if created_at:
|
||||
if isinstance(created_at, str):
|
||||
created_at = datetime.fromisoformat(created_at)
|
||||
# Handle naive vs aware datetime comparison
|
||||
if created_at.tzinfo is None:
|
||||
duration_minutes = round((now.replace(tzinfo=None) - created_at).total_seconds() / 60, 1)
|
||||
else:
|
||||
duration_minutes = round((now - created_at).total_seconds() / 60, 1)
|
||||
else:
|
||||
duration_minutes = 0
|
||||
|
||||
# Update session as completed with duration
|
||||
postgres.update("routine_sessions", {
|
||||
"status": "completed",
|
||||
"completed_at": now.isoformat(),
|
||||
"actual_duration_minutes": int(duration_minutes),
|
||||
}, {"id": session_id})
|
||||
|
||||
# Update streak (returns streak with optional 'milestone' key)
|
||||
streak_result = routines_core._update_streak(user_uuid, session["routine_id"])
|
||||
|
||||
# Get streak data
|
||||
streak = postgres.select_one("routine_streaks", {
|
||||
"user_uuid": user_uuid,
|
||||
"routine_id": session["routine_id"],
|
||||
})
|
||||
streak_milestone = streak_result.get("milestone") if streak_result else None
|
||||
|
||||
# Count step results for this session
|
||||
step_results = postgres.select("routine_step_results", {"session_id": session_id})
|
||||
steps_completed = sum(1 for r in step_results if r.get("result") == "completed")
|
||||
steps_skipped = sum(1 for r in step_results if r.get("result") == "skipped")
|
||||
|
||||
# Total completions for this routine
|
||||
all_completed = postgres.select("routine_sessions", {
|
||||
"routine_id": session["routine_id"],
|
||||
"user_uuid": user_uuid,
|
||||
"status": "completed",
|
||||
})
|
||||
|
||||
result = {
|
||||
"streak_current": streak["current_streak"] if streak else 1,
|
||||
"streak_longest": streak["longest_streak"] if streak else 1,
|
||||
"session_duration_minutes": duration_minutes,
|
||||
"total_completions": len(all_completed),
|
||||
"steps_completed": steps_completed,
|
||||
"steps_skipped": steps_skipped,
|
||||
}
|
||||
if streak_milestone:
|
||||
result["streak_milestone"] = streak_milestone
|
||||
return result
|
||||
|
||||
|
||||
def register(app):
|
||||
|
||||
# ── Routines CRUD ─────────────────────────────────────────────
|
||||
@@ -89,7 +197,7 @@ def register(app):
|
||||
existing = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid})
|
||||
if not existing:
|
||||
return flask.jsonify({"error": "not found"}), 404
|
||||
allowed = ["name", "description", "icon"]
|
||||
allowed = ["name", "description", "icon", "location", "environment_prompts", "habit_stack_after"]
|
||||
updates = {k: v for k, v in data.items() if k in allowed}
|
||||
if not updates:
|
||||
return flask.jsonify({"error": "no valid fields to update"}), 400
|
||||
@@ -257,6 +365,8 @@ def register(app):
|
||||
if not user_uuid:
|
||||
return flask.jsonify({"error": "unauthorized"}), 401
|
||||
session = postgres.select_one("routine_sessions", {"user_uuid": user_uuid, "status": "active"})
|
||||
if not session:
|
||||
session = postgres.select_one("routine_sessions", {"user_uuid": user_uuid, "status": "paused"})
|
||||
if not session:
|
||||
return flask.jsonify({"error": "no active session"}), 404
|
||||
routine = postgres.select_one("routines", {"id": session["routine_id"]})
|
||||
@@ -277,18 +387,33 @@ def register(app):
|
||||
session = postgres.select_one("routine_sessions", {"id": session_id, "user_uuid": user_uuid})
|
||||
if not session:
|
||||
return flask.jsonify({"error": "not found"}), 404
|
||||
if session["status"] != "active":
|
||||
if session["status"] not in ("active", "paused"):
|
||||
return flask.jsonify({"error": "session not active"}), 400
|
||||
# Auto-resume if paused
|
||||
if session["status"] == "paused":
|
||||
postgres.update("routine_sessions", {"status": "active", "paused_at": None}, {"id": session_id})
|
||||
data = flask.request.get_json() or {}
|
||||
steps = postgres.select(
|
||||
"routine_steps",
|
||||
where={"routine_id": session["routine_id"]},
|
||||
order_by="position",
|
||||
)
|
||||
next_index = session["current_step_index"] + 1
|
||||
current_index = session["current_step_index"]
|
||||
current_step = steps[current_index] if current_index < len(steps) else None
|
||||
|
||||
# Record step result
|
||||
if current_step:
|
||||
_record_step_result(session_id, current_step["id"], current_index, "completed", session)
|
||||
|
||||
next_index = current_index + 1
|
||||
if next_index >= len(steps):
|
||||
postgres.update("routine_sessions", {"status": "completed"}, {"id": session_id})
|
||||
return flask.jsonify({"session": {"status": "completed"}, "next_step": None}), 200
|
||||
# Session complete — compute celebration data
|
||||
celebration = _complete_session_with_celebration(session_id, user_uuid, session)
|
||||
return flask.jsonify({
|
||||
"session": {"status": "completed"},
|
||||
"next_step": None,
|
||||
"celebration": celebration,
|
||||
}), 200
|
||||
postgres.update("routine_sessions", {"current_step_index": next_index}, {"id": session_id})
|
||||
return flask.jsonify({"session": {"current_step_index": next_index}, "next_step": steps[next_index]}), 200
|
||||
|
||||
@@ -301,17 +426,31 @@ def register(app):
|
||||
session = postgres.select_one("routine_sessions", {"id": session_id, "user_uuid": user_uuid})
|
||||
if not session:
|
||||
return flask.jsonify({"error": "not found"}), 404
|
||||
if session["status"] != "active":
|
||||
if session["status"] not in ("active", "paused"):
|
||||
return flask.jsonify({"error": "session not active"}), 400
|
||||
# Auto-resume if paused
|
||||
if session["status"] == "paused":
|
||||
postgres.update("routine_sessions", {"status": "active", "paused_at": None}, {"id": session_id})
|
||||
steps = postgres.select(
|
||||
"routine_steps",
|
||||
where={"routine_id": session["routine_id"]},
|
||||
order_by="position",
|
||||
)
|
||||
next_index = session["current_step_index"] + 1
|
||||
current_index = session["current_step_index"]
|
||||
current_step = steps[current_index] if current_index < len(steps) else None
|
||||
|
||||
# Record step result as skipped
|
||||
if current_step:
|
||||
_record_step_result(session_id, current_step["id"], current_index, "skipped", session)
|
||||
|
||||
next_index = current_index + 1
|
||||
if next_index >= len(steps):
|
||||
postgres.update("routine_sessions", {"status": "completed"}, {"id": session_id})
|
||||
return flask.jsonify({"session": {"status": "completed"}, "next_step": None}), 200
|
||||
celebration = _complete_session_with_celebration(session_id, user_uuid, session)
|
||||
return flask.jsonify({
|
||||
"session": {"status": "completed"},
|
||||
"next_step": None,
|
||||
"celebration": celebration,
|
||||
}), 200
|
||||
postgres.update("routine_sessions", {"current_step_index": next_index}, {"id": session_id})
|
||||
return flask.jsonify({"session": {"current_step_index": next_index}, "next_step": steps[next_index]}), 200
|
||||
|
||||
|
||||
132
api/routes/victories.py
Normal file
132
api/routes/victories.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Victories API - compute noteworthy achievements from session history
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
import flask
|
||||
import jwt
|
||||
import core.auth as auth
|
||||
import core.postgres as postgres
|
||||
import core.tz as tz
|
||||
|
||||
|
||||
def _get_user_uuid(token):
|
||||
try:
|
||||
payload = jwt.decode(token, os.getenv("JWT_SECRET"), algorithms=["HS256"])
|
||||
return payload.get("sub")
|
||||
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
|
||||
return None
|
||||
|
||||
|
||||
def _auth(request):
|
||||
header = request.headers.get("Authorization", "")
|
||||
if not header.startswith("Bearer "):
|
||||
return None
|
||||
token = header[7:]
|
||||
user_uuid = _get_user_uuid(token)
|
||||
if not user_uuid or not auth.verifyLoginToken(token, userUUID=user_uuid):
|
||||
return None
|
||||
return user_uuid
|
||||
|
||||
|
||||
def register(app):
|
||||
|
||||
@app.route("/api/victories", methods=["GET"])
|
||||
def api_getVictories():
|
||||
"""Compute noteworthy achievements. Query: ?days=30"""
|
||||
user_uuid = _auth(flask.request)
|
||||
if not user_uuid:
|
||||
return flask.jsonify({"error": "unauthorized"}), 401
|
||||
|
||||
days = flask.request.args.get("days", 30, type=int)
|
||||
cutoff = tz.user_now() - timedelta(days=days)
|
||||
|
||||
sessions = postgres.select("routine_sessions", {"user_uuid": user_uuid})
|
||||
recent = [
|
||||
s for s in sessions
|
||||
if s.get("created_at") and s["created_at"] >= cutoff
|
||||
]
|
||||
completed = [s for s in recent if s.get("status") == "completed"]
|
||||
|
||||
victories = []
|
||||
|
||||
# Comeback: completed after 2+ day gap
|
||||
if completed:
|
||||
sorted_completed = sorted(completed, key=lambda s: s["created_at"])
|
||||
for i in range(1, len(sorted_completed)):
|
||||
prev = sorted_completed[i - 1]["created_at"]
|
||||
curr = sorted_completed[i]["created_at"]
|
||||
gap = (curr - prev).days
|
||||
if gap >= 2:
|
||||
victories.append({
|
||||
"type": "comeback",
|
||||
"message": f"Came back after {gap} days — that takes real strength",
|
||||
"date": curr.isoformat() if hasattr(curr, 'isoformat') else str(curr),
|
||||
})
|
||||
|
||||
# Weekend completion
|
||||
for s in completed:
|
||||
created = s["created_at"]
|
||||
if hasattr(created, 'weekday') and created.weekday() >= 5: # Saturday=5, Sunday=6
|
||||
victories.append({
|
||||
"type": "weekend",
|
||||
"message": "Completed a routine on the weekend",
|
||||
"date": created.isoformat() if hasattr(created, 'isoformat') else str(created),
|
||||
})
|
||||
break # Only show once
|
||||
|
||||
# Variety: 3+ different routines in a week
|
||||
routine_ids_by_week = {}
|
||||
for s in completed:
|
||||
created = s["created_at"]
|
||||
if hasattr(created, 'isocalendar'):
|
||||
week_key = created.isocalendar()[:2]
|
||||
if week_key not in routine_ids_by_week:
|
||||
routine_ids_by_week[week_key] = set()
|
||||
routine_ids_by_week[week_key].add(s.get("routine_id"))
|
||||
|
||||
for week_key, routine_ids in routine_ids_by_week.items():
|
||||
if len(routine_ids) >= 3:
|
||||
victories.append({
|
||||
"type": "variety",
|
||||
"message": f"Completed {len(routine_ids)} different routines in one week",
|
||||
"date": None,
|
||||
})
|
||||
break
|
||||
|
||||
# Full week consistency: completed every day for 7 consecutive days
|
||||
if completed:
|
||||
dates_set = set()
|
||||
for s in completed:
|
||||
created = s["created_at"]
|
||||
if hasattr(created, 'date'):
|
||||
dates_set.add(created.date())
|
||||
|
||||
sorted_dates = sorted(dates_set)
|
||||
max_streak = 1
|
||||
current_streak = 1
|
||||
for i in range(1, len(sorted_dates)):
|
||||
if (sorted_dates[i] - sorted_dates[i-1]).days == 1:
|
||||
current_streak += 1
|
||||
max_streak = max(max_streak, current_streak)
|
||||
else:
|
||||
current_streak = 1
|
||||
|
||||
if max_streak >= 7:
|
||||
victories.append({
|
||||
"type": "consistency",
|
||||
"message": f"Completed routines every day for {max_streak} days straight",
|
||||
"date": None,
|
||||
})
|
||||
|
||||
# Limit and deduplicate
|
||||
seen_types = set()
|
||||
unique_victories = []
|
||||
for v in victories:
|
||||
if v["type"] not in seen_types:
|
||||
unique_victories.append(v)
|
||||
seen_types.add(v["type"])
|
||||
|
||||
return flask.jsonify(unique_victories[:10]), 200
|
||||
Reference in New Issue
Block a user