ui update and some backend functionality adding in accordance with research on adhd and ux design

This commit is contained in:
2026-02-14 17:21:37 -06:00
parent 4d3a9fbd54
commit fb480eacb2
32 changed files with 9549 additions and 248 deletions

View File

@@ -18,6 +18,9 @@ import api.routes.routine_templates as routine_templates_routes
import api.routes.routine_stats as routine_stats_routes
import api.routes.routine_tags as routine_tags_routes
import api.routes.notifications as notifications_routes
import api.routes.preferences as preferences_routes
import api.routes.rewards as rewards_routes
import api.routes.victories as victories_routes
app = flask.Flask(__name__)
CORS(app)
@@ -31,6 +34,9 @@ ROUTE_MODULES = [
routine_stats_routes,
routine_tags_routes,
notifications_routes,
preferences_routes,
rewards_routes,
victories_routes,
]

View File

@@ -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},

View 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
View 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
View 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

View File

@@ -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

View File

@@ -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"]

View File

@@ -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
View 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