ui update and some backend functionality adding in accordance with research on adhd and ux design
This commit is contained in:
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -39,6 +39,9 @@ CREATE TABLE IF NOT EXISTS routines (
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
icon VARCHAR(100),
|
||||
location VARCHAR(255),
|
||||
environment_prompts JSON DEFAULT '[]',
|
||||
habit_stack_after VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
@@ -122,6 +125,49 @@ CREATE TABLE IF NOT EXISTS routine_streaks (
|
||||
last_completed_date DATE
|
||||
);
|
||||
|
||||
-- ── Rewards ───────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reward_pool (
|
||||
id UUID PRIMARY KEY,
|
||||
category VARCHAR(50) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
emoji VARCHAR(10),
|
||||
rarity VARCHAR(20) DEFAULT 'common'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_rewards (
|
||||
id UUID PRIMARY KEY,
|
||||
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
reward_id UUID REFERENCES reward_pool(id) ON DELETE CASCADE,
|
||||
earned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
context VARCHAR(50)
|
||||
);
|
||||
|
||||
-- ── User Preferences ──────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
id UUID PRIMARY KEY,
|
||||
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE,
|
||||
sound_enabled BOOLEAN DEFAULT FALSE,
|
||||
haptic_enabled BOOLEAN DEFAULT TRUE,
|
||||
show_launch_screen BOOLEAN DEFAULT TRUE,
|
||||
celebration_style VARCHAR(50) DEFAULT 'standard',
|
||||
timezone_offset INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- ── Step Results (per-step tracking within sessions) ───────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS routine_step_results (
|
||||
id UUID PRIMARY KEY,
|
||||
session_id UUID REFERENCES routine_sessions(id) ON DELETE CASCADE,
|
||||
step_id UUID REFERENCES routine_steps(id) ON DELETE CASCADE,
|
||||
step_index INTEGER NOT NULL,
|
||||
result VARCHAR(20) NOT NULL,
|
||||
duration_seconds INTEGER,
|
||||
completed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- ── Medications ─────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS medications (
|
||||
|
||||
63
config/seed_rewards.sql
Normal file
63
config/seed_rewards.sql
Normal file
@@ -0,0 +1,63 @@
|
||||
-- Seed data for reward_pool
|
||||
-- Categories: affirmation, fact, milestone, visual
|
||||
-- Rarities: common (70%), uncommon (25%), rare (5%)
|
||||
|
||||
-- ── Affirmations (common) ──────────────────────────────────
|
||||
|
||||
INSERT INTO reward_pool (id, category, content, emoji, rarity) VALUES
|
||||
('a0000001-0000-0000-0000-000000000001', 'affirmation', 'You''re someone who follows through.', '💪', 'common'),
|
||||
('a0000001-0000-0000-0000-000000000002', 'affirmation', 'Consistency looks good on you.', '✨', 'common'),
|
||||
('a0000001-0000-0000-0000-000000000003', 'affirmation', 'Your future self is grateful right now.', '🌱', 'common'),
|
||||
('a0000001-0000-0000-0000-000000000004', 'affirmation', 'That took real effort. You showed up anyway.', '🔥', 'common'),
|
||||
('a0000001-0000-0000-0000-000000000005', 'affirmation', 'Small steps, big change. You''re proving it.', '👣', 'common'),
|
||||
('a0000001-0000-0000-0000-000000000006', 'affirmation', 'You don''t need motivation — you just proved discipline works.', '🎯', 'common'),
|
||||
('a0000001-0000-0000-0000-000000000007', 'affirmation', 'This is what building a life looks like.', '🏗️', 'common'),
|
||||
('a0000001-0000-0000-0000-000000000008', 'affirmation', 'You chose to start. That''s the hardest part.', '🚀', 'common'),
|
||||
('a0000001-0000-0000-0000-000000000009', 'affirmation', 'Progress isn''t always visible. But it''s happening.', '🌊', 'common'),
|
||||
('a0000001-0000-0000-0000-000000000010', 'affirmation', 'You''re writing a new story about yourself.', '📖', 'common'),
|
||||
('a0000001-0000-0000-0000-000000000011', 'affirmation', 'Every time you finish, it gets a little easier.', '📈', 'common'),
|
||||
('a0000001-0000-0000-0000-000000000012', 'affirmation', 'You''re not trying to be perfect. You''re choosing to be consistent.', '💎', 'common'),
|
||||
('a0000001-0000-0000-0000-000000000013', 'affirmation', 'Done is better than perfect. And you got it done.', '✅', 'common'),
|
||||
('a0000001-0000-0000-0000-000000000014', 'affirmation', 'You''re investing in yourself. Best investment there is.', '💰', 'common'),
|
||||
('a0000001-0000-0000-0000-000000000015', 'affirmation', 'That routine? It''s becoming part of who you are.', '🧬', 'common')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ── Fun Facts (common) ─────────────────────────────────────
|
||||
|
||||
INSERT INTO reward_pool (id, category, content, emoji, rarity) VALUES
|
||||
('a0000002-0000-0000-0000-000000000001', 'fact', 'Research shows it takes about 66 days to form a habit. You''re on your way.', '🧪', 'common'),
|
||||
('a0000002-0000-0000-0000-000000000002', 'fact', 'Completing routines releases dopamine — your brain literally rewarded itself just now.', '🧠', 'common'),
|
||||
('a0000002-0000-0000-0000-000000000003', 'fact', 'People who write down their intentions are 42% more likely to achieve them.', '📝', 'common'),
|
||||
('a0000002-0000-0000-0000-000000000004', 'fact', 'Habit stacking works because your brain loves connecting new behaviors to existing ones.', '🔗', 'common'),
|
||||
('a0000002-0000-0000-0000-000000000005', 'fact', 'The "two-minute rule" says: if it takes less than 2 minutes, just do it.', '⏱️', 'common'),
|
||||
('a0000002-0000-0000-0000-000000000006', 'fact', 'Your environment shapes your behavior more than willpower does.', '🏠', 'common'),
|
||||
('a0000002-0000-0000-0000-000000000007', 'fact', 'Identity-based habits stick better: you''re not doing a routine — you''re becoming someone who does.', '🪞', 'common'),
|
||||
('a0000002-0000-0000-0000-000000000008', 'fact', 'Morning routines reduce decision fatigue for the rest of the day.', '☀️', 'common'),
|
||||
('a0000002-0000-0000-0000-000000000009', 'fact', 'Tracking your habits makes you 2-3x more likely to stick with them.', '📊', 'common'),
|
||||
('a0000002-0000-0000-0000-000000000010', 'fact', 'The hardest part of any habit is showing up. You just did that.', '🚪', 'common')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ── Milestones (uncommon) ──────────────────────────────────
|
||||
|
||||
INSERT INTO reward_pool (id, category, content, emoji, rarity) VALUES
|
||||
('a0000003-0000-0000-0000-000000000001', 'milestone', 'First routine of the day — the hardest one is done.', '🌅', 'uncommon'),
|
||||
('a0000003-0000-0000-0000-000000000002', 'milestone', 'You''ve completed every step. Not everyone can say that.', '🏆', 'uncommon'),
|
||||
('a0000003-0000-0000-0000-000000000003', 'milestone', 'Your consistency is building something you can''t see yet.', '🌟', 'uncommon'),
|
||||
('a0000003-0000-0000-0000-000000000004', 'milestone', 'Most people quit by now. You didn''t.', '⚡', 'uncommon'),
|
||||
('a0000003-0000-0000-0000-000000000005', 'milestone', 'You''re outpacing yesterday. That''s all that matters.', '🏃', 'uncommon'),
|
||||
('a0000003-0000-0000-0000-000000000006', 'milestone', 'This routine is becoming automatic. That''s the goal.', '⚙️', 'uncommon'),
|
||||
('a0000003-0000-0000-0000-000000000007', 'milestone', 'Showing up repeatedly takes courage. Respect.', '🫡', 'uncommon'),
|
||||
('a0000003-0000-0000-0000-000000000008', 'milestone', 'You''re building trust with yourself, one routine at a time.', '🤝', 'uncommon'),
|
||||
('a0000003-0000-0000-0000-000000000009', 'milestone', 'Another day, another win. Stack ''em up.', '🧱', 'uncommon'),
|
||||
('a0000003-0000-0000-0000-000000000010', 'milestone', 'You showed up when it would''ve been easy not to. That''s character.', '💫', 'uncommon')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ── Visual (rare) ──────────────────────────────────────────
|
||||
|
||||
INSERT INTO reward_pool (id, category, content, emoji, rarity) VALUES
|
||||
('a0000004-0000-0000-0000-000000000001', 'visual', 'Rare reward! You unlocked the sparkle celebration.', '🎆', 'rare'),
|
||||
('a0000004-0000-0000-0000-000000000002', 'visual', 'Rare reward! You unlocked the rainbow celebration.', '🌈', 'rare'),
|
||||
('a0000004-0000-0000-0000-000000000003', 'visual', 'Rare reward! You unlocked the fireworks celebration.', '🎇', 'rare'),
|
||||
('a0000004-0000-0000-0000-000000000004', 'visual', 'Rare reward! You unlocked the cosmic celebration.', '🌌', 'rare'),
|
||||
('a0000004-0000-0000-0000-000000000005', 'visual', 'Rare reward! You unlocked the golden celebration.', '👑', 'rare')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
310
config/seed_templates.sql
Normal file
310
config/seed_templates.sql
Normal file
@@ -0,0 +1,310 @@
|
||||
-- Seed templates for Synculous
|
||||
-- Designed for ADHD: two-minute-rule entry points, 4-7 steps max,
|
||||
-- zero-shame language, concrete instructions, realistic durations.
|
||||
-- Run with: psql -U user -d database -f config/seed_templates.sql
|
||||
|
||||
-- Clean slate (idempotent re-seed)
|
||||
DELETE FROM routine_template_steps;
|
||||
DELETE FROM routine_templates;
|
||||
|
||||
|
||||
-- ================================================================
|
||||
-- 1. Morning Launch
|
||||
-- ================================================================
|
||||
-- The hardest transition of the day. First step is literally just
|
||||
-- sitting up — two-minute rule. Each step cues the next physically.
|
||||
|
||||
INSERT INTO routine_templates (id, name, description, icon, created_by_admin) VALUES
|
||||
('a1b2c3d4-0001-0001-0001-000000000001',
|
||||
'Morning Launch',
|
||||
'Get from bed to ready. Starts small — just sit up.',
|
||||
'☀️', true);
|
||||
|
||||
INSERT INTO routine_template_steps (id, template_id, name, instructions, step_type, duration_minutes, position) VALUES
|
||||
('b2c3d4e5-0001-0001-0001-000000000001', 'a1b2c3d4-0001-0001-0001-000000000001',
|
||||
'Sit up', 'Just sit up in bed. Feet on the floor. That counts.', 'generic', 1, 1),
|
||||
('b2c3d4e5-0001-0001-0002-000000000001', 'a1b2c3d4-0001-0001-0001-000000000001',
|
||||
'Water', 'Drink a glass of water. Your brain needs it.', 'generic', 1, 2),
|
||||
('b2c3d4e5-0001-0001-0003-000000000001', 'a1b2c3d4-0001-0001-0001-000000000001',
|
||||
'Face + teeth', 'Splash water on your face. Brush your teeth.', 'generic', 3, 3),
|
||||
('b2c3d4e5-0001-0001-0004-000000000001', 'a1b2c3d4-0001-0001-0001-000000000001',
|
||||
'Get dressed', 'Real clothes on your body. Doesn''t have to be fancy.', 'generic', 3, 4),
|
||||
('b2c3d4e5-0001-0001-0005-000000000001', 'a1b2c3d4-0001-0001-0001-000000000001',
|
||||
'Eat something', 'Even a handful of crackers. Anything with food in it.', 'generic', 5, 5);
|
||||
|
||||
|
||||
-- ================================================================
|
||||
-- 2. Leaving the House
|
||||
-- ================================================================
|
||||
-- The "where are my keys" routine. Externalizes the checklist
|
||||
-- so working memory doesn't have to hold it.
|
||||
|
||||
INSERT INTO routine_templates (id, name, description, icon, created_by_admin) VALUES
|
||||
('a1b2c3d4-0002-0001-0001-000000000002',
|
||||
'Leaving the House',
|
||||
'Everything you need before you walk out the door.',
|
||||
'🚪', true);
|
||||
|
||||
INSERT INTO routine_template_steps (id, template_id, name, instructions, step_type, duration_minutes, position) VALUES
|
||||
('b2c3d4e5-0002-0001-0001-000000000002', 'a1b2c3d4-0002-0001-0001-000000000002',
|
||||
'Phone + wallet + keys', 'Check your pockets or bag. All three there?', 'generic', 1, 1),
|
||||
('b2c3d4e5-0002-0001-0002-000000000002', 'a1b2c3d4-0002-0001-0001-000000000002',
|
||||
'Check the weather', 'Quick glance. Do you need a jacket or umbrella?', 'generic', 1, 2),
|
||||
('b2c3d4e5-0002-0001-0003-000000000002', 'a1b2c3d4-0002-0001-0001-000000000002',
|
||||
'Grab what you need', 'Bag, lunch, water bottle, charger — whatever applies.', 'generic', 2, 3),
|
||||
('b2c3d4e5-0002-0001-0004-000000000002', 'a1b2c3d4-0002-0001-0001-000000000002',
|
||||
'Quick scan', 'Stove off? Lights? Windows? Pets fed?', 'generic', 1, 4),
|
||||
('b2c3d4e5-0002-0001-0005-000000000002', 'a1b2c3d4-0002-0001-0001-000000000002',
|
||||
'Lock up and go', 'You''re ready. Lock the door behind you.', 'generic', 1, 5);
|
||||
|
||||
|
||||
-- ================================================================
|
||||
-- 3. Focus Sprint
|
||||
-- ================================================================
|
||||
-- ADHD-adapted pomodoro. Starts with environment setup (Barkley:
|
||||
-- "at point of performance"). Work block is shorter than classic
|
||||
-- pomodoro because ADHD sustained attention is shorter.
|
||||
|
||||
INSERT INTO routine_templates (id, name, description, icon, created_by_admin) VALUES
|
||||
('a1b2c3d4-0003-0001-0001-000000000003',
|
||||
'Focus Sprint',
|
||||
'One focused work block. Set up your space first.',
|
||||
'🎯', true);
|
||||
|
||||
INSERT INTO routine_template_steps (id, template_id, name, instructions, step_type, duration_minutes, position) VALUES
|
||||
('b2c3d4e5-0003-0001-0001-000000000003', 'a1b2c3d4-0003-0001-0001-000000000003',
|
||||
'Pick one task', 'Just one. Write it down or say it out loud.', 'generic', 1, 1),
|
||||
('b2c3d4e5-0003-0001-0002-000000000003', 'a1b2c3d4-0003-0001-0001-000000000003',
|
||||
'Set up your space', 'Close extra tabs. Silence phone. Clear your desk.', 'generic', 2, 2),
|
||||
('b2c3d4e5-0003-0001-0003-000000000003', 'a1b2c3d4-0003-0001-0001-000000000003',
|
||||
'Work', 'Just the one task. If you get pulled away, come back.', 'timer', 20, 3),
|
||||
('b2c3d4e5-0003-0001-0004-000000000003', 'a1b2c3d4-0003-0001-0001-000000000003',
|
||||
'Break', 'Stand up. Stretch. Water. Look at something far away.', 'timer', 5, 4);
|
||||
|
||||
|
||||
-- ================================================================
|
||||
-- 4. Wind Down
|
||||
-- ================================================================
|
||||
-- Sleep prep. Designed around the actual problem: screens are
|
||||
-- stimulating, the brain won't stop, and the bed isn't "sleepy"
|
||||
-- enough without a transition.
|
||||
|
||||
INSERT INTO routine_templates (id, name, description, icon, created_by_admin) VALUES
|
||||
('a1b2c3d4-0004-0001-0001-000000000004',
|
||||
'Wind Down',
|
||||
'Transition from awake-brain to sleep-brain.',
|
||||
'🌙', true);
|
||||
|
||||
INSERT INTO routine_template_steps (id, template_id, name, instructions, step_type, duration_minutes, position) VALUES
|
||||
('b2c3d4e5-0004-0001-0001-000000000004', 'a1b2c3d4-0004-0001-0001-000000000004',
|
||||
'Screens away', 'Plug your phone in across the room. TV off.', 'generic', 1, 1),
|
||||
('b2c3d4e5-0004-0001-0002-000000000004', 'a1b2c3d4-0004-0001-0001-000000000004',
|
||||
'Dim the lights', 'Overhead off. Lamp or nightlight only.', 'generic', 1, 2),
|
||||
('b2c3d4e5-0004-0001-0003-000000000004', 'a1b2c3d4-0004-0001-0001-000000000004',
|
||||
'Teeth + face', 'Brush teeth, wash face. Quick is fine.', 'generic', 3, 3),
|
||||
('b2c3d4e5-0004-0001-0004-000000000004', 'a1b2c3d4-0004-0001-0001-000000000004',
|
||||
'Get in bed', 'Pajamas on, blankets up. You''re horizontal now.', 'generic', 2, 4),
|
||||
('b2c3d4e5-0004-0001-0005-000000000004', 'a1b2c3d4-0004-0001-0001-000000000004',
|
||||
'Quiet time', 'Read, listen to something calm, or just breathe.', 'timer', 10, 5);
|
||||
|
||||
|
||||
-- ================================================================
|
||||
-- 5. Quick Tidy
|
||||
-- ================================================================
|
||||
-- Not a full cleaning session. Designed to be completable even on
|
||||
-- a bad executive function day. Each step is one area, one action.
|
||||
|
||||
INSERT INTO routine_templates (id, name, description, icon, created_by_admin) VALUES
|
||||
('a1b2c3d4-0005-0001-0001-000000000005',
|
||||
'Quick Tidy',
|
||||
'A fast sweep through the house. Not deep cleaning — just enough.',
|
||||
'✨', true);
|
||||
|
||||
INSERT INTO routine_template_steps (id, template_id, name, instructions, step_type, duration_minutes, position) VALUES
|
||||
('b2c3d4e5-0005-0001-0001-000000000005', 'a1b2c3d4-0005-0001-0001-000000000005',
|
||||
'Trash round', 'Grab a bag. Walk through every room. Toss what''s trash.', 'timer', 3, 1),
|
||||
('b2c3d4e5-0005-0001-0002-000000000005', 'a1b2c3d4-0005-0001-0001-000000000005',
|
||||
'Dishes', 'Everything into the dishwasher or sink. Wipe the counter.', 'timer', 5, 2),
|
||||
('b2c3d4e5-0005-0001-0003-000000000005', 'a1b2c3d4-0005-0001-0001-000000000005',
|
||||
'Put things back', 'Anything out of place goes back where it lives.', 'timer', 5, 3),
|
||||
('b2c3d4e5-0005-0001-0004-000000000005', 'a1b2c3d4-0005-0001-0001-000000000005',
|
||||
'Surfaces', 'Quick wipe on kitchen counter, bathroom sink, one table.', 'timer', 3, 4);
|
||||
|
||||
|
||||
-- ================================================================
|
||||
-- 6. Body Reset
|
||||
-- ================================================================
|
||||
-- For when basic hygiene feels hard. No judgment. Every step is
|
||||
-- the minimum viable version — just enough to feel a little better.
|
||||
|
||||
INSERT INTO routine_templates (id, name, description, icon, created_by_admin) VALUES
|
||||
('a1b2c3d4-0006-0001-0001-000000000006',
|
||||
'Body Reset',
|
||||
'Basic care for your body. Even partial counts.',
|
||||
'🚿', true);
|
||||
|
||||
INSERT INTO routine_template_steps (id, template_id, name, instructions, step_type, duration_minutes, position) VALUES
|
||||
('b2c3d4e5-0006-0001-0001-000000000006', 'a1b2c3d4-0006-0001-0001-000000000006',
|
||||
'Shower or washcloth', 'Full shower or a warm washcloth on face, pits, bits. Both work.', 'generic', 5, 1),
|
||||
('b2c3d4e5-0006-0001-0002-000000000006', 'a1b2c3d4-0006-0001-0001-000000000006',
|
||||
'Teeth', 'Brush for as long as you can. Even 30 seconds matters.', 'generic', 2, 2),
|
||||
('b2c3d4e5-0006-0001-0003-000000000006', 'a1b2c3d4-0006-0001-0001-000000000006',
|
||||
'Deodorant', 'Apply it. Future you says thanks.', 'generic', 1, 3),
|
||||
('b2c3d4e5-0006-0001-0004-000000000006', 'a1b2c3d4-0006-0001-0001-000000000006',
|
||||
'Clean clothes', 'Swap into something fresh. Doesn''t need to match.', 'generic', 2, 4),
|
||||
('b2c3d4e5-0006-0001-0005-000000000006', 'a1b2c3d4-0006-0001-0001-000000000006',
|
||||
'Water + snack', 'Drink something. Eat something. Your body is running on empty.', 'generic', 2, 5);
|
||||
|
||||
|
||||
-- ================================================================
|
||||
-- 7. Unstuck
|
||||
-- ================================================================
|
||||
-- For when you're paralyzed and can't start anything. Pure
|
||||
-- two-minute-rule: the smallest possible actions to build momentum.
|
||||
|
||||
INSERT INTO routine_templates (id, name, description, icon, created_by_admin) VALUES
|
||||
('a1b2c3d4-0007-0001-0001-000000000007',
|
||||
'Unstuck',
|
||||
'Can''t start anything? Start here. Tiny steps, real momentum.',
|
||||
'🔓', true);
|
||||
|
||||
INSERT INTO routine_template_steps (id, template_id, name, instructions, step_type, duration_minutes, position) VALUES
|
||||
('b2c3d4e5-0007-0001-0001-000000000007', 'a1b2c3d4-0007-0001-0001-000000000007',
|
||||
'Stand up', 'That''s it. Just stand. You can sit back down after.', 'generic', 1, 1),
|
||||
('b2c3d4e5-0007-0001-0002-000000000007', 'a1b2c3d4-0007-0001-0001-000000000007',
|
||||
'Move to a different spot', 'Walk to another room. Change your scenery.', 'generic', 1, 2),
|
||||
('b2c3d4e5-0007-0001-0003-000000000007', 'a1b2c3d4-0007-0001-0001-000000000007',
|
||||
'Drink water', 'Fill a glass. Drink it slowly.', 'generic', 1, 3),
|
||||
('b2c3d4e5-0007-0001-0004-000000000007', 'a1b2c3d4-0007-0001-0001-000000000007',
|
||||
'Name one thing', 'Say out loud: "The next thing I will do is ___."', 'generic', 1, 4),
|
||||
('b2c3d4e5-0007-0001-0005-000000000007', 'a1b2c3d4-0007-0001-0001-000000000007',
|
||||
'Do two minutes of it', 'Set a timer. Just two minutes. Stop after if you want.', 'timer', 2, 5);
|
||||
|
||||
|
||||
-- ================================================================
|
||||
-- 8. Evening Reset
|
||||
-- ================================================================
|
||||
-- End-of-day prep so tomorrow morning isn't harder than it needs
|
||||
-- to be. Externalizes "things to remember" before sleep.
|
||||
|
||||
INSERT INTO routine_templates (id, name, description, icon, created_by_admin) VALUES
|
||||
('a1b2c3d4-0008-0001-0001-000000000008',
|
||||
'Evening Reset',
|
||||
'Set tomorrow up to be a little easier.',
|
||||
'🌆', true);
|
||||
|
||||
INSERT INTO routine_template_steps (id, template_id, name, instructions, step_type, duration_minutes, position) VALUES
|
||||
('b2c3d4e5-0008-0001-0001-000000000008', 'a1b2c3d4-0008-0001-0001-000000000008',
|
||||
'Quick kitchen pass', 'Dishes in the sink. Wipe the counter. Done.', 'timer', 5, 1),
|
||||
('b2c3d4e5-0008-0001-0002-000000000008', 'a1b2c3d4-0008-0001-0001-000000000008',
|
||||
'Lay out tomorrow', 'Pick clothes for tomorrow. Put them where you''ll see them.', 'generic', 3, 2),
|
||||
('b2c3d4e5-0008-0001-0003-000000000008', 'a1b2c3d4-0008-0001-0001-000000000008',
|
||||
'Bag check', 'Pack your bag for tomorrow. Keys, wallet, whatever you need.', 'generic', 2, 3),
|
||||
('b2c3d4e5-0008-0001-0004-000000000008', 'a1b2c3d4-0008-0001-0001-000000000008',
|
||||
'Brain dump', 'Write down anything still in your head. Paper, phone, whatever. Get it out.', 'generic', 3, 4),
|
||||
('b2c3d4e5-0008-0001-0005-000000000008', 'a1b2c3d4-0008-0001-0001-000000000008',
|
||||
'Charge devices', 'Phone, headphones, laptop — plug them in now.', 'generic', 1, 5);
|
||||
|
||||
|
||||
-- ================================================================
|
||||
-- 9. Move Your Body
|
||||
-- ================================================================
|
||||
-- Not "exercise." Movement. The entry point is putting shoes on,
|
||||
-- not "work out for 30 minutes." Anything beyond standing counts.
|
||||
|
||||
INSERT INTO routine_templates (id, name, description, icon, created_by_admin) VALUES
|
||||
('a1b2c3d4-0009-0001-0001-000000000009',
|
||||
'Move Your Body',
|
||||
'Not a workout plan. Just movement. Any amount counts.',
|
||||
'🏃', true);
|
||||
|
||||
INSERT INTO routine_template_steps (id, template_id, name, instructions, step_type, duration_minutes, position) VALUES
|
||||
('b2c3d4e5-0009-0001-0001-000000000009', 'a1b2c3d4-0009-0001-0001-000000000009',
|
||||
'Shoes on', 'Put on shoes you can move in. This is the commitment step.', 'generic', 1, 1),
|
||||
('b2c3d4e5-0009-0001-0002-000000000009', 'a1b2c3d4-0009-0001-0001-000000000009',
|
||||
'Step outside', 'Open the door. Stand outside for a second. Feel the air.', 'generic', 1, 2),
|
||||
('b2c3d4e5-0009-0001-0003-000000000009', 'a1b2c3d4-0009-0001-0001-000000000009',
|
||||
'Move for 10 minutes', 'Walk, jog, dance, stretch — whatever your body wants.', 'timer', 10, 3),
|
||||
('b2c3d4e5-0009-0001-0004-000000000009', 'a1b2c3d4-0009-0001-0001-000000000009',
|
||||
'Cool down', 'Slow walk or gentle stretching. Let your heart rate settle.', 'timer', 3, 4),
|
||||
('b2c3d4e5-0009-0001-0005-000000000009', 'a1b2c3d4-0009-0001-0001-000000000009',
|
||||
'Water', 'Drink a full glass. You earned it.', 'generic', 1, 5);
|
||||
|
||||
|
||||
-- ================================================================
|
||||
-- 10. Sunday Reset
|
||||
-- ================================================================
|
||||
-- Weekly prep. Slightly longer, but broken into small concrete
|
||||
-- chunks. Prevents the "where did the week go" spiral.
|
||||
|
||||
INSERT INTO routine_templates (id, name, description, icon, created_by_admin) VALUES
|
||||
('a1b2c3d4-0010-0001-0001-000000000010',
|
||||
'Sunday Reset',
|
||||
'Set up the week ahead so Monday doesn''t ambush you.',
|
||||
'📋', true);
|
||||
|
||||
INSERT INTO routine_template_steps (id, template_id, name, instructions, step_type, duration_minutes, position) VALUES
|
||||
('b2c3d4e5-0010-0001-0001-000000000010', 'a1b2c3d4-0010-0001-0001-000000000010',
|
||||
'Check the calendar', 'Open your calendar. What''s happening this week? Anything you need to prepare for?', 'generic', 3, 1),
|
||||
('b2c3d4e5-0010-0001-0002-000000000010', 'a1b2c3d4-0010-0001-0001-000000000010',
|
||||
'Laundry', 'Start a load. You can fold it later — just get it in the machine.', 'generic', 3, 2),
|
||||
('b2c3d4e5-0010-0001-0003-000000000010', 'a1b2c3d4-0010-0001-0001-000000000010',
|
||||
'Restock check', 'Meds, groceries, toiletries — anything running low?', 'generic', 3, 3),
|
||||
('b2c3d4e5-0010-0001-0004-000000000010', 'a1b2c3d4-0010-0001-0001-000000000010',
|
||||
'Tidy living space', 'Flat surfaces first. Then floor. Good enough is good enough.', 'timer', 10, 4),
|
||||
('b2c3d4e5-0010-0001-0005-000000000010', 'a1b2c3d4-0010-0001-0001-000000000010',
|
||||
'Plan meals', 'Even "Monday: leftovers, Tuesday: pasta" counts. Don''t overthink it.', 'generic', 5, 5),
|
||||
('b2c3d4e5-0010-0001-0006-000000000010', 'a1b2c3d4-0010-0001-0001-000000000010',
|
||||
'Rest', 'Done. The week is a little more ready for you now.', 'generic', 1, 6);
|
||||
|
||||
|
||||
-- ================================================================
|
||||
-- 11. Cooking a Meal
|
||||
-- ================================================================
|
||||
-- Not "meal prep for the week." One meal. Broken down so the
|
||||
-- activation energy is low and the sequence is externalized.
|
||||
|
||||
INSERT INTO routine_templates (id, name, description, icon, created_by_admin) VALUES
|
||||
('a1b2c3d4-0011-0001-0001-000000000011',
|
||||
'Cook a Meal',
|
||||
'One meal, start to finish. Just follow the steps.',
|
||||
'🍳', true);
|
||||
|
||||
INSERT INTO routine_template_steps (id, template_id, name, instructions, step_type, duration_minutes, position) VALUES
|
||||
('b2c3d4e5-0011-0001-0001-000000000011', 'a1b2c3d4-0011-0001-0001-000000000011',
|
||||
'Pick something to make', 'Choose one thing. Keep it simple. Eggs, pasta, a sandwich — anything.', 'generic', 1, 1),
|
||||
('b2c3d4e5-0011-0001-0002-000000000011', 'a1b2c3d4-0011-0001-0001-000000000011',
|
||||
'Get ingredients out', 'Pull everything onto the counter before you start.', 'generic', 2, 2),
|
||||
('b2c3d4e5-0011-0001-0003-000000000011', 'a1b2c3d4-0011-0001-0001-000000000011',
|
||||
'Prep', 'Chop, measure, open cans — get everything ready to cook.', 'generic', 5, 3),
|
||||
('b2c3d4e5-0011-0001-0004-000000000011', 'a1b2c3d4-0011-0001-0001-000000000011',
|
||||
'Cook', 'Heat it, combine it, let it do its thing.', 'timer', 15, 4),
|
||||
('b2c3d4e5-0011-0001-0005-000000000011', 'a1b2c3d4-0011-0001-0001-000000000011',
|
||||
'Eat', 'Sit down. Eat the food. You made this.', 'generic', 10, 5),
|
||||
('b2c3d4e5-0011-0001-0006-000000000011', 'a1b2c3d4-0011-0001-0001-000000000011',
|
||||
'Rinse dishes', 'Just rinse and stack. Full cleaning can wait.', 'generic', 2, 6);
|
||||
|
||||
|
||||
-- ================================================================
|
||||
-- 12. Errand Run
|
||||
-- ================================================================
|
||||
-- Externalizes the "I have errands but I keep not doing them"
|
||||
-- problem. Forces the plan into concrete sequential steps.
|
||||
|
||||
INSERT INTO routine_templates (id, name, description, icon, created_by_admin) VALUES
|
||||
('a1b2c3d4-0012-0001-0001-000000000012',
|
||||
'Errand Run',
|
||||
'Get out, do the things, come home. One trip.',
|
||||
'🛒', true);
|
||||
|
||||
INSERT INTO routine_template_steps (id, template_id, name, instructions, step_type, duration_minutes, position) VALUES
|
||||
('b2c3d4e5-0012-0001-0001-000000000012', 'a1b2c3d4-0012-0001-0001-000000000012',
|
||||
'Write the list', 'Every stop, every item. Write it all down right now.', 'generic', 3, 1),
|
||||
('b2c3d4e5-0012-0001-0002-000000000012', 'a1b2c3d4-0012-0001-0001-000000000012',
|
||||
'Plan the route', 'Put the stops in order so you''re not zigzagging.', 'generic', 2, 2),
|
||||
('b2c3d4e5-0012-0001-0003-000000000012', 'a1b2c3d4-0012-0001-0001-000000000012',
|
||||
'Grab essentials', 'Keys, wallet, phone, bags, list. Ready?', 'generic', 2, 3),
|
||||
('b2c3d4e5-0012-0001-0004-000000000012', 'a1b2c3d4-0012-0001-0001-000000000012',
|
||||
'Do the errands', 'Follow the list. Check things off as you go.', 'generic', 30, 4),
|
||||
('b2c3d4e5-0012-0001-0005-000000000012', 'a1b2c3d4-0012-0001-0001-000000000012',
|
||||
'Put things away', 'Groceries away, returns sorted, bags emptied. Then you''re done.', 'generic', 5, 5);
|
||||
@@ -8,6 +8,7 @@ that can be called from API routes, bot commands, or scheduler.
|
||||
import uuid
|
||||
from datetime import datetime, date
|
||||
import core.postgres as postgres
|
||||
import core.tz as tz
|
||||
|
||||
|
||||
def start_session(routine_id, user_uuid):
|
||||
@@ -159,9 +160,13 @@ def clone_template(template_id, user_uuid):
|
||||
return routine
|
||||
|
||||
|
||||
STREAK_MILESTONES = {3, 7, 14, 21, 30, 60, 90, 100, 365}
|
||||
|
||||
|
||||
def _update_streak(user_uuid, routine_id):
|
||||
"""Update streak after completing a session. Resets if day was missed."""
|
||||
today = date.today()
|
||||
"""Update streak after completing a session. Resets if day was missed.
|
||||
Returns the updated streak dict with optional 'milestone' key."""
|
||||
today = tz.user_today()
|
||||
|
||||
streak = postgres.select_one(
|
||||
"routine_streaks",
|
||||
@@ -169,6 +174,7 @@ def _update_streak(user_uuid, routine_id):
|
||||
)
|
||||
|
||||
if not streak:
|
||||
new_streak_val = 1
|
||||
new_streak = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"user_uuid": user_uuid,
|
||||
@@ -177,7 +183,10 @@ def _update_streak(user_uuid, routine_id):
|
||||
"longest_streak": 1,
|
||||
"last_completed_date": today.isoformat(),
|
||||
}
|
||||
return postgres.insert("routine_streaks", new_streak)
|
||||
result = postgres.insert("routine_streaks", new_streak)
|
||||
if new_streak_val in STREAK_MILESTONES:
|
||||
result["milestone"] = new_streak_val
|
||||
return result
|
||||
|
||||
last_completed = streak.get("last_completed_date")
|
||||
if last_completed:
|
||||
@@ -187,24 +196,28 @@ def _update_streak(user_uuid, routine_id):
|
||||
if days_diff == 0:
|
||||
return streak
|
||||
elif days_diff == 1:
|
||||
new_streak = streak["current_streak"] + 1
|
||||
new_streak_val = streak["current_streak"] + 1
|
||||
else:
|
||||
new_streak = 1
|
||||
new_streak_val = 1
|
||||
else:
|
||||
new_streak = 1
|
||||
new_streak_val = 1
|
||||
|
||||
longest = max(streak["longest_streak"], new_streak)
|
||||
longest = max(streak["longest_streak"], new_streak_val)
|
||||
|
||||
postgres.update(
|
||||
"routine_streaks",
|
||||
{
|
||||
"current_streak": new_streak,
|
||||
"current_streak": new_streak_val,
|
||||
"longest_streak": longest,
|
||||
"last_completed_date": today.isoformat(),
|
||||
},
|
||||
{"id": streak["id"]}
|
||||
)
|
||||
return streak
|
||||
|
||||
result = {**streak, "current_streak": new_streak_val, "longest_streak": longest}
|
||||
if new_streak_val in STREAK_MILESTONES:
|
||||
result["milestone"] = new_streak_val
|
||||
return result
|
||||
|
||||
|
||||
def calculate_streak(user_uuid, routine_id):
|
||||
@@ -217,8 +230,14 @@ def calculate_streak(user_uuid, routine_id):
|
||||
|
||||
|
||||
def get_active_session(user_uuid):
|
||||
"""Get user's currently active session."""
|
||||
return postgres.select_one(
|
||||
"""Get user's currently active or paused session."""
|
||||
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"}
|
||||
)
|
||||
return session
|
||||
|
||||
39
core/tz.py
Normal file
39
core/tz.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
core/tz.py - Timezone-aware date/time helpers
|
||||
|
||||
The frontend sends X-Timezone-Offset (minutes from UTC, same sign as
|
||||
JavaScript's getTimezoneOffset — positive means behind UTC).
|
||||
These helpers convert server UTC to the user's local date/time.
|
||||
"""
|
||||
|
||||
from datetime import datetime, date, timezone, timedelta
|
||||
|
||||
|
||||
def _get_offset_minutes():
|
||||
"""Read the timezone offset header from the current Flask request.
|
||||
Returns 0 if outside a request context or header is absent."""
|
||||
try:
|
||||
import flask
|
||||
return int(flask.request.headers.get("X-Timezone-Offset", 0))
|
||||
except (ValueError, TypeError, RuntimeError):
|
||||
# RuntimeError: outside of request context
|
||||
return 0
|
||||
|
||||
|
||||
def _offset_to_tz(offset_minutes):
|
||||
"""Convert JS-style offset (positive = behind UTC) to a timezone object."""
|
||||
return timezone(timedelta(minutes=-offset_minutes))
|
||||
|
||||
|
||||
def user_now(offset_minutes=None):
|
||||
"""Current datetime in the user's timezone.
|
||||
If offset_minutes is provided, uses that instead of the request header."""
|
||||
if offset_minutes is None:
|
||||
offset_minutes = _get_offset_minutes()
|
||||
tz = _offset_to_tz(offset_minutes)
|
||||
return datetime.now(tz)
|
||||
|
||||
|
||||
def user_today(offset_minutes=None):
|
||||
"""Current date in the user's timezone."""
|
||||
return user_now(offset_minutes).date()
|
||||
@@ -11,6 +11,7 @@ services:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./config/schema.sql:/docker-entrypoint-initdb.d/schema.sql
|
||||
- ./config/seed_templates.sql:/docker-entrypoint-initdb.d/seed_templates.sql
|
||||
- ./config/seed_rewards.sql:/docker-entrypoint-initdb.d/seed_rewards.sql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U app"]
|
||||
interval: 5s
|
||||
|
||||
@@ -7,7 +7,7 @@ Override poll_callback() with your domain-specific logic.
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
import core.postgres as postgres
|
||||
import core.notifications as notifications
|
||||
@@ -18,60 +18,81 @@ logger = logging.getLogger(__name__)
|
||||
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", 60))
|
||||
|
||||
|
||||
def _user_now_for(user_uuid):
|
||||
"""Get current datetime in a user's timezone using their stored offset."""
|
||||
prefs = postgres.select_one("user_preferences", {"user_uuid": user_uuid})
|
||||
offset_minutes = 0
|
||||
if prefs and prefs.get("timezone_offset") is not None:
|
||||
offset_minutes = prefs["timezone_offset"]
|
||||
# JS getTimezoneOffset: positive = behind UTC, so negate
|
||||
tz_obj = timezone(timedelta(minutes=-offset_minutes))
|
||||
return datetime.now(tz_obj)
|
||||
|
||||
|
||||
def check_medication_reminders():
|
||||
"""Check for medications due now and send notifications."""
|
||||
try:
|
||||
from datetime import date as date_type
|
||||
meds = postgres.select("medications", where={"active": True})
|
||||
now = datetime.now()
|
||||
current_time = now.strftime("%H:%M")
|
||||
current_day = now.strftime("%a").lower() # "mon","tue", etc.
|
||||
today = now.date()
|
||||
today_str = today.isoformat()
|
||||
|
||||
# Group by user so we only look up timezone once per user
|
||||
user_meds = {}
|
||||
for med in meds:
|
||||
freq = med.get("frequency", "daily")
|
||||
uid = med.get("user_uuid")
|
||||
if uid not in user_meds:
|
||||
user_meds[uid] = []
|
||||
user_meds[uid].append(med)
|
||||
|
||||
# Skip as_needed -- no scheduled reminders for PRN
|
||||
if freq == "as_needed":
|
||||
continue
|
||||
for user_uuid, user_med_list in user_meds.items():
|
||||
now = _user_now_for(user_uuid)
|
||||
current_time = now.strftime("%H:%M")
|
||||
current_day = now.strftime("%a").lower()
|
||||
today = now.date()
|
||||
today_str = today.isoformat()
|
||||
|
||||
# Day-of-week check for specific_days
|
||||
if freq == "specific_days":
|
||||
days = med.get("days_of_week", [])
|
||||
if current_day not in days:
|
||||
for med in user_med_list:
|
||||
freq = med.get("frequency", "daily")
|
||||
|
||||
# Skip as_needed -- no scheduled reminders for PRN
|
||||
if freq == "as_needed":
|
||||
continue
|
||||
|
||||
# Interval check for every_n_days
|
||||
if freq == "every_n_days":
|
||||
start = med.get("start_date")
|
||||
interval = med.get("interval_days")
|
||||
if start and interval:
|
||||
start_d = start if isinstance(start, date_type) else datetime.strptime(str(start), "%Y-%m-%d").date()
|
||||
if (today - start_d).days < 0 or (today - start_d).days % interval != 0:
|
||||
# Day-of-week check for specific_days
|
||||
if freq == "specific_days":
|
||||
med_days = med.get("days_of_week", [])
|
||||
if current_day not in med_days:
|
||||
continue
|
||||
else:
|
||||
|
||||
# Interval check for every_n_days
|
||||
if freq == "every_n_days":
|
||||
start = med.get("start_date")
|
||||
interval = med.get("interval_days")
|
||||
if start and interval:
|
||||
start_d = start if isinstance(start, date_type) else datetime.strptime(str(start), "%Y-%m-%d").date()
|
||||
if (today - start_d).days < 0 or (today - start_d).days % interval != 0:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
|
||||
# Time check
|
||||
times = med.get("times", [])
|
||||
if current_time not in times:
|
||||
continue
|
||||
|
||||
# Time check
|
||||
times = med.get("times", [])
|
||||
if current_time not in times:
|
||||
continue
|
||||
# Already taken today? Check by created_at date
|
||||
logs = postgres.select("med_logs", where={"medication_id": med["id"], "action": "taken"})
|
||||
already_taken = any(
|
||||
log.get("scheduled_time") == current_time
|
||||
and str(log.get("created_at", ""))[:10] == today_str
|
||||
for log in logs
|
||||
)
|
||||
if already_taken:
|
||||
continue
|
||||
|
||||
# Already taken today? Check by created_at date
|
||||
logs = postgres.select("med_logs", where={"medication_id": med["id"], "action": "taken"})
|
||||
already_taken = any(
|
||||
log.get("scheduled_time") == current_time
|
||||
and str(log.get("created_at", ""))[:10] == today_str
|
||||
for log in logs
|
||||
)
|
||||
if already_taken:
|
||||
continue
|
||||
|
||||
user_settings = notifications.getNotificationSettings(med["user_uuid"])
|
||||
if user_settings:
|
||||
msg = f"Time to take {med['name']} ({med['dosage']} {med['unit']})"
|
||||
notifications._sendToEnabledChannels(user_settings, msg, user_uuid=med["user_uuid"])
|
||||
user_settings = notifications.getNotificationSettings(user_uuid)
|
||||
if user_settings:
|
||||
msg = f"Time to take {med['name']} ({med['dosage']} {med['unit']})"
|
||||
notifications._sendToEnabledChannels(user_settings, msg, user_uuid=user_uuid)
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking medication reminders: {e}")
|
||||
|
||||
@@ -79,22 +100,23 @@ def check_medication_reminders():
|
||||
def check_routine_reminders():
|
||||
"""Check for scheduled routines due now and send notifications."""
|
||||
try:
|
||||
now = datetime.now()
|
||||
current_time = now.strftime("%H:%M")
|
||||
current_day = now.strftime("%a").lower()
|
||||
schedules = postgres.select("routine_schedules", where={"remind": True})
|
||||
|
||||
for schedule in schedules:
|
||||
routine = postgres.select_one("routines", {"id": schedule["routine_id"]})
|
||||
if not routine:
|
||||
continue
|
||||
|
||||
now = _user_now_for(routine["user_uuid"])
|
||||
current_time = now.strftime("%H:%M")
|
||||
current_day = now.strftime("%a").lower()
|
||||
|
||||
if current_time != schedule.get("time"):
|
||||
continue
|
||||
days = schedule.get("days", [])
|
||||
if current_day not in days:
|
||||
continue
|
||||
|
||||
routine = postgres.select_one("routines", {"id": schedule["routine_id"]})
|
||||
if not routine:
|
||||
continue
|
||||
|
||||
user_settings = notifications.getNotificationSettings(routine["user_uuid"])
|
||||
if user_settings:
|
||||
msg = f"Time to start your routine: {routine['name']}"
|
||||
|
||||
6749
synculous-client/package-lock.json
generated
Normal file
6749
synculous-client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { useAuth } from '@/components/auth/AuthProvider';
|
||||
import api from '@/lib/api';
|
||||
import {
|
||||
HomeIcon,
|
||||
ListIcon,
|
||||
@@ -34,12 +35,23 @@ export default function DashboardLayout({
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const tzSynced = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [isAuthenticated, isLoading, router]);
|
||||
|
||||
// Sync timezone offset to backend once per session
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && !tzSynced.current) {
|
||||
tzSynced.current = true;
|
||||
const offset = new Date().getTimezoneOffset();
|
||||
api.preferences.update({ timezone_offset: offset }).catch(() => {});
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
@@ -55,6 +67,13 @@ export default function DashboardLayout({
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hide chrome during active session run
|
||||
const isRunMode = pathname.includes('/run');
|
||||
|
||||
if (isRunMode) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
||||
@@ -65,12 +84,17 @@ export default function DashboardLayout({
|
||||
</div>
|
||||
<span className="font-bold text-gray-900">Synculous</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="p-2 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<LogOutIcon size={20} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/dashboard/settings" className="p-2 text-gray-500 hover:text-gray-700">
|
||||
<SettingsIcon size={20} />
|
||||
</Link>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="p-2 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<LogOutIcon size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -81,15 +105,15 @@ export default function DashboardLayout({
|
||||
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 safe-area-bottom">
|
||||
<div className="flex justify-around py-2">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href ||
|
||||
const isActive = pathname === item.href ||
|
||||
(item.href !== '/dashboard' && pathname.startsWith(item.href));
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex flex-col items-center gap-1 px-3 py-2 rounded-lg transition-colors ${
|
||||
isActive
|
||||
? 'text-indigo-600'
|
||||
isActive
|
||||
? 'text-indigo-600'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -45,26 +45,37 @@ interface WeeklySummary {
|
||||
}[];
|
||||
}
|
||||
|
||||
interface Streak {
|
||||
routine_id: string;
|
||||
routine_name: string;
|
||||
current_streak: number;
|
||||
longest_streak: number;
|
||||
last_completed_date?: string;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user } = useAuth();
|
||||
const router = useRouter();
|
||||
const [routines, setRoutines] = useState<Routine[]>([]);
|
||||
const [activeSession, setActiveSession] = useState<ActiveSession | null>(null);
|
||||
const [weeklySummary, setWeeklySummary] = useState<WeeklySummary | null>(null);
|
||||
const [streaks, setStreaks] = useState<Streak[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [routinesData, activeData, summaryData] = await Promise.all([
|
||||
const [routinesData, activeData, summaryData, streaksData] = await Promise.all([
|
||||
api.routines.list().catch(() => []),
|
||||
api.sessions.getActive().catch(() => null),
|
||||
api.stats.getWeeklySummary().catch(() => null),
|
||||
api.stats.getStreaks().catch(() => []),
|
||||
]);
|
||||
setRoutines(routinesData);
|
||||
setActiveSession(activeData);
|
||||
setWeeklySummary(summaryData);
|
||||
setStreaks(streaksData);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch dashboard data:', err);
|
||||
} finally {
|
||||
@@ -75,12 +86,12 @@ export default function DashboardPage() {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleStartRoutine = async (routineId: string) => {
|
||||
try {
|
||||
await api.sessions.start(routineId);
|
||||
router.push(`/dashboard/routines/${routineId}/run`);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
const handleStartRoutine = (routineId: string) => {
|
||||
// If there's an active session, go straight to run
|
||||
if (activeSession) {
|
||||
router.push(`/dashboard/routines/${activeSession.routine.id}/run`);
|
||||
} else {
|
||||
router.push(`/dashboard/routines/${routineId}/launch`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -104,6 +115,17 @@ export default function DashboardPage() {
|
||||
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||
};
|
||||
|
||||
// Find routines with broken streaks for "never miss twice" recovery
|
||||
const getRecoveryRoutines = () => {
|
||||
const today = new Date();
|
||||
return streaks.filter(s => {
|
||||
if (!s.last_completed_date || s.current_streak > 0) return false;
|
||||
const last = new Date(s.last_completed_date);
|
||||
const daysSince = Math.floor((today.getTime() - last.getTime()) / (1000 * 60 * 60 * 24));
|
||||
return daysSince >= 2 && daysSince <= 14;
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]">
|
||||
@@ -112,14 +134,16 @@ export default function DashboardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const recoveryRoutines = getRecoveryRoutines();
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Active Session Banner */}
|
||||
{activeSession && activeSession.session.status === 'active' && (
|
||||
<div className="bg-gradient-to-r from-indigo-500 to-purple-600 rounded-2xl p-4 text-white">
|
||||
<div className="bg-gradient-to-r from-indigo-500 to-purple-600 rounded-2xl p-4 text-white ring-2 ring-indigo-400/50 ring-offset-2 ring-offset-gray-50 animate-gentle-pulse">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-white/80 text-sm">Continue your routine</p>
|
||||
<p className="text-white/80 text-sm">Continue where you left off</p>
|
||||
<h2 className="text-xl font-bold">{activeSession.routine.name}</h2>
|
||||
<p className="text-white/80 text-sm mt-1">
|
||||
Step {activeSession.session.current_step_index + 1}: {activeSession.current_step?.name}
|
||||
@@ -138,32 +162,59 @@ export default function DashboardPage() {
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{getGreeting()}, {user?.username}!</h1>
|
||||
<p className="text-gray-500 mt-1">Let's build some great habits today.</p>
|
||||
<p className="text-gray-500 mt-1">Let's build some great habits today.</p>
|
||||
</div>
|
||||
|
||||
{/* Weekly Stats */}
|
||||
{weeklySummary && (
|
||||
{/* "Never miss twice" Recovery Cards */}
|
||||
{recoveryRoutines.map(recovery => {
|
||||
const routine = routines.find(r => r.id === recovery.routine_id);
|
||||
if (!routine) return null;
|
||||
return (
|
||||
<div key={recovery.routine_id} className="bg-amber-50 border border-amber-200 rounded-2xl p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-amber-800 text-sm font-medium mb-1">Welcome back</p>
|
||||
<p className="text-amber-700 text-sm">
|
||||
It's been a couple days since {routine.icon} {routine.name}. That's completely okay — picking it back up today is what matters most.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleStartRoutine(routine.id)}
|
||||
className="bg-amber-600 text-white px-4 py-2 rounded-lg font-medium text-sm shrink-0 ml-3"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Weekly Stats — identity-based language */}
|
||||
{weeklySummary && weeklySummary.total_completed > 0 && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-indigo-600 mb-1">
|
||||
<StarIcon size={18} />
|
||||
<span className="text-xs font-medium">Completed</span>
|
||||
<span className="text-xs font-medium">Done</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-900">{weeklySummary.total_completed}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">this week</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-purple-600 mb-1">
|
||||
<ClockIcon size={18} />
|
||||
<span className="text-xs font-medium">Time</span>
|
||||
<span className="text-xs font-medium">Invested</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-900">{formatTime(weeklySummary.total_time_minutes)}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">in yourself</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-pink-600 mb-1">
|
||||
<ActivityIcon size={18} />
|
||||
<span className="text-xs font-medium">Started</span>
|
||||
<span className="text-xs font-medium">Active</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-900">{weeklySummary.routines_started}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">routines</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -171,7 +222,7 @@ export default function DashboardPage() {
|
||||
{/* Quick Start Routines */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-3">Your Routines</h2>
|
||||
|
||||
|
||||
{routines.length === 0 ? (
|
||||
<div className="bg-white rounded-xl p-8 shadow-sm text-center">
|
||||
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
@@ -188,30 +239,38 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{routines.map((routine) => (
|
||||
<div
|
||||
key={routine.id}
|
||||
className="bg-white rounded-xl p-4 shadow-sm flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-indigo-100 to-pink-100 rounded-xl flex items-center justify-center">
|
||||
<span className="text-2xl">{routine.icon || '✨'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{routine.name}</h3>
|
||||
{routine.description && (
|
||||
<p className="text-gray-500 text-sm">{routine.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleStartRoutine(routine.id)}
|
||||
className="bg-indigo-600 text-white p-3 rounded-full"
|
||||
{routines.map((routine) => {
|
||||
const streak = streaks.find(s => s.routine_id === routine.id);
|
||||
return (
|
||||
<div
|
||||
key={routine.id}
|
||||
className="bg-white rounded-xl p-4 shadow-sm flex items-center justify-between"
|
||||
>
|
||||
<PlayIcon size={20} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-indigo-100 to-pink-100 rounded-xl flex items-center justify-center">
|
||||
<span className="text-2xl">{routine.icon || '✨'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{routine.name}</h3>
|
||||
{streak && streak.current_streak > 0 ? (
|
||||
<p className="text-sm text-orange-500 flex items-center gap-1">
|
||||
<FlameIcon size={14} />
|
||||
{streak.current_streak} day{streak.current_streak !== 1 ? 's' : ''}
|
||||
</p>
|
||||
) : routine.description ? (
|
||||
<p className="text-gray-500 text-sm">{routine.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleStartRoutine(routine.id)}
|
||||
className="bg-indigo-600 text-white p-3 rounded-full"
|
||||
>
|
||||
<PlayIcon size={20} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -233,7 +292,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 px-4 py-3 rounded-lg text-sm">
|
||||
<div className="bg-amber-50 text-amber-700 px-4 py-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
224
synculous-client/src/app/dashboard/routines/[id]/launch/page.tsx
Normal file
224
synculous-client/src/app/dashboard/routines/[id]/launch/page.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import api from '@/lib/api';
|
||||
import { ArrowLeftIcon, PlayIcon, CheckIcon, ClockIcon } from '@/components/ui/Icons';
|
||||
|
||||
interface Routine {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
location?: string;
|
||||
environment_prompts?: string[];
|
||||
habit_stack_after?: string;
|
||||
}
|
||||
|
||||
interface Step {
|
||||
id: string;
|
||||
name: string;
|
||||
duration_minutes?: number;
|
||||
}
|
||||
|
||||
interface Schedule {
|
||||
days: string[];
|
||||
time: string;
|
||||
}
|
||||
|
||||
const EMOTION_OPTIONS = [
|
||||
{ emoji: '💪', label: 'Accomplished' },
|
||||
{ emoji: '😌', label: 'Relieved' },
|
||||
{ emoji: '🌟', label: 'Proud' },
|
||||
{ emoji: '😊', label: 'Good' },
|
||||
];
|
||||
|
||||
export default function LaunchScreen() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const routineId = params.id as string;
|
||||
|
||||
const [routine, setRoutine] = useState<Routine | null>(null);
|
||||
const [steps, setSteps] = useState<Step[]>([]);
|
||||
const [schedule, setSchedule] = useState<Schedule | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const [selectedEmotion, setSelectedEmotion] = useState<string | null>(null);
|
||||
const [environmentChecked, setEnvironmentChecked] = useState<Set<number>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [routineData, scheduleData] = await Promise.all([
|
||||
api.routines.get(routineId),
|
||||
api.routines.getSchedule(routineId).catch(() => null),
|
||||
]);
|
||||
setRoutine(routineData.routine as Routine);
|
||||
setSteps(routineData.steps);
|
||||
setSchedule(scheduleData);
|
||||
} catch (err) {
|
||||
console.error('Failed to load routine:', err);
|
||||
router.push('/dashboard');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [routineId, router]);
|
||||
|
||||
const handleStart = async () => {
|
||||
setIsStarting(true);
|
||||
try {
|
||||
await api.sessions.start(routineId);
|
||||
router.push(`/dashboard/routines/${routineId}/run`);
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
if (msg.includes('already have active session')) {
|
||||
router.push(`/dashboard/routines/${routineId}/run`);
|
||||
} else {
|
||||
console.error('Failed to start:', err);
|
||||
setIsStarting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEnvironmentCheck = (index: number) => {
|
||||
setEnvironmentChecked(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(index)) next.delete(index);
|
||||
else next.add(index);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const totalDuration = steps.reduce((acc, s) => acc + (s.duration_minutes || 0), 0);
|
||||
const envPrompts = routine?.environment_prompts || [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]">
|
||||
<div className="w-8 h-8 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!routine) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<button onClick={() => router.back()} className="p-1">
|
||||
<ArrowLeftIcon size={24} />
|
||||
</button>
|
||||
<h1 className="text-xl font-bold text-gray-900">Ready to start</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Routine info */}
|
||||
<div className="text-center py-4">
|
||||
<div className="w-20 h-20 bg-gradient-to-br from-indigo-100 to-pink-100 rounded-2xl flex items-center justify-center mx-auto mb-3">
|
||||
<span className="text-5xl">{routine.icon || '✨'}</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">{routine.name}</h2>
|
||||
<div className="flex items-center justify-center gap-2 text-gray-500 mt-2">
|
||||
<ClockIcon size={16} />
|
||||
<span>~{totalDuration} minutes</span>
|
||||
<span>·</span>
|
||||
<span>{steps.length} steps</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Implementation Intention */}
|
||||
{(routine.habit_stack_after || (schedule && routine.location)) && (
|
||||
<div className="bg-indigo-50 border border-indigo-200 rounded-2xl p-4">
|
||||
{routine.habit_stack_after && (
|
||||
<p className="text-indigo-800 text-sm mb-2">
|
||||
After <span className="font-semibold">{routine.habit_stack_after}</span>, I will start{' '}
|
||||
<span className="font-semibold">{routine.name}</span>
|
||||
</p>
|
||||
)}
|
||||
{schedule && routine.location && (
|
||||
<p className="text-indigo-700 text-sm">
|
||||
at <span className="font-semibold">{schedule.time}</span> in{' '}
|
||||
<span className="font-semibold">{routine.location}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Environment Check */}
|
||||
{envPrompts.length > 0 && (
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Quick check</h3>
|
||||
<div className="space-y-2">
|
||||
{envPrompts.map((prompt, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => toggleEnvironmentCheck(i)}
|
||||
className={`w-full flex items-center gap-3 p-3 rounded-xl transition ${
|
||||
environmentChecked.has(i)
|
||||
? 'bg-green-50 border border-green-200'
|
||||
: 'bg-gray-50 border border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-6 h-6 rounded-full border-2 flex items-center justify-center ${
|
||||
environmentChecked.has(i)
|
||||
? 'border-green-500 bg-green-500'
|
||||
: 'border-gray-300'
|
||||
}`}>
|
||||
{environmentChecked.has(i) && <CheckIcon size={14} className="text-white" />}
|
||||
</div>
|
||||
<span className={`text-sm ${environmentChecked.has(i) ? 'text-green-800' : 'text-gray-700'}`}>
|
||||
{prompt}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Emotion Bridge */}
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<h3 className="font-semibold text-gray-900 mb-1">
|
||||
When you finish in ~{totalDuration} minutes, how will you feel?
|
||||
</h3>
|
||||
<p className="text-gray-500 text-sm mb-3">You <em>get to</em> do this</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{EMOTION_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.label}
|
||||
onClick={() => setSelectedEmotion(option.label)}
|
||||
className={`flex items-center gap-2 p-3 rounded-xl border transition ${
|
||||
selectedEmotion === option.label
|
||||
? 'border-indigo-500 bg-indigo-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span className="text-xl">{option.emoji}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Start Button */}
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={isStarting}
|
||||
className="w-full bg-indigo-600 text-white font-semibold py-4 rounded-2xl flex items-center justify-center gap-2 text-lg disabled:opacity-50 shadow-lg shadow-indigo-500/25"
|
||||
>
|
||||
{isStarting ? (
|
||||
<div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<PlayIcon size={24} />
|
||||
Let's Go
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,9 @@ interface Routine {
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
location?: string;
|
||||
environment_prompts?: string[];
|
||||
habit_stack_after?: string;
|
||||
}
|
||||
|
||||
const ICONS = ['✨', '🌅', '🌙', '☀️', '💪', '🧘', '📚', '🍳', '🏃', '💼', '🎯', '⭐', '🔥', '💤', '🧠'];
|
||||
@@ -36,6 +39,10 @@ export default function RoutineDetailPage() {
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editDescription, setEditDescription] = useState('');
|
||||
const [editIcon, setEditIcon] = useState('✨');
|
||||
const [editLocation, setEditLocation] = useState('');
|
||||
const [editHabitStack, setEditHabitStack] = useState('');
|
||||
const [editEnvPrompts, setEditEnvPrompts] = useState<string[]>([]);
|
||||
const [newEnvPrompt, setNewEnvPrompt] = useState('');
|
||||
const [newStepName, setNewStepName] = useState('');
|
||||
const [newStepDuration, setNewStepDuration] = useState(5);
|
||||
|
||||
@@ -48,6 +55,9 @@ export default function RoutineDetailPage() {
|
||||
setEditName(data.routine.name);
|
||||
setEditDescription(data.routine.description || '');
|
||||
setEditIcon(data.routine.icon || '✨');
|
||||
setEditLocation((data.routine as Routine).location || '');
|
||||
setEditHabitStack((data.routine as Routine).habit_stack_after || '');
|
||||
setEditEnvPrompts((data.routine as Routine).environment_prompts || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch routine:', err);
|
||||
router.push('/dashboard/routines');
|
||||
@@ -58,13 +68,8 @@ export default function RoutineDetailPage() {
|
||||
fetchRoutine();
|
||||
}, [routineId, router]);
|
||||
|
||||
const handleStart = async () => {
|
||||
try {
|
||||
await api.sessions.start(routineId);
|
||||
router.push(`/dashboard/routines/${routineId}/run`);
|
||||
} catch (err) {
|
||||
console.error('Failed to start routine:', err);
|
||||
}
|
||||
const handleStart = () => {
|
||||
router.push(`/dashboard/routines/${routineId}/launch`);
|
||||
};
|
||||
|
||||
const handleSaveBasicInfo = async () => {
|
||||
@@ -73,8 +78,19 @@ export default function RoutineDetailPage() {
|
||||
name: editName,
|
||||
description: editDescription,
|
||||
icon: editIcon,
|
||||
location: editLocation || null,
|
||||
habit_stack_after: editHabitStack || null,
|
||||
environment_prompts: editEnvPrompts,
|
||||
});
|
||||
setRoutine({
|
||||
...routine!,
|
||||
name: editName,
|
||||
description: editDescription,
|
||||
icon: editIcon,
|
||||
location: editLocation || undefined,
|
||||
habit_stack_after: editHabitStack || undefined,
|
||||
environment_prompts: editEnvPrompts,
|
||||
});
|
||||
setRoutine({ ...routine!, name: editName, description: editDescription, icon: editIcon });
|
||||
setIsEditing(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to update routine:', err);
|
||||
@@ -179,6 +195,69 @@ export default function RoutineDetailPage() {
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Location</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editLocation}
|
||||
onChange={(e) => setEditLocation(e.target.value)}
|
||||
placeholder="Where do you do this? e.g., bathroom, kitchen"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Anchor habit</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editHabitStack}
|
||||
onChange={(e) => setEditHabitStack(e.target.value)}
|
||||
placeholder="What do you do right before? e.g., finish breakfast"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Environment prompts</label>
|
||||
<p className="text-xs text-gray-500 mb-2">Quick checklist shown before starting</p>
|
||||
<div className="space-y-2 mb-2">
|
||||
{editEnvPrompts.map((prompt, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="flex-1 text-sm text-gray-700 bg-gray-50 px-3 py-2 rounded-lg">{prompt}</span>
|
||||
<button
|
||||
onClick={() => setEditEnvPrompts(editEnvPrompts.filter((_, j) => j !== i))}
|
||||
className="text-red-500 text-sm px-2"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newEnvPrompt}
|
||||
onChange={(e) => setNewEnvPrompt(e.target.value)}
|
||||
placeholder="e.g., Water bottle nearby?"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && newEnvPrompt.trim()) {
|
||||
setEditEnvPrompts([...editEnvPrompts, newEnvPrompt.trim()]);
|
||||
setNewEnvPrompt('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (newEnvPrompt.trim()) {
|
||||
setEditEnvPrompts([...editEnvPrompts, newEnvPrompt.trim()]);
|
||||
setNewEnvPrompt('');
|
||||
}
|
||||
}}
|
||||
className="px-3 py-2 bg-gray-200 rounded-lg text-sm font-medium"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setIsEditing(false)}
|
||||
@@ -216,9 +295,9 @@ export default function RoutineDetailPage() {
|
||||
</div>
|
||||
<button
|
||||
onClick={handleStart}
|
||||
className="w-full mt-4 bg-indigo-600 text-white font-semibold py-3 rounded-xl flex items-center justify-center gap-2"
|
||||
className="w-full mt-4 bg-gradient-to-r from-indigo-600 to-purple-600 text-white font-bold py-4 rounded-2xl flex items-center justify-center gap-2 text-lg shadow-lg shadow-indigo-500/25 active:scale-[0.98] transition-transform"
|
||||
>
|
||||
<PlayIcon size={20} />
|
||||
<PlayIcon size={24} />
|
||||
Start Routine
|
||||
</button>
|
||||
</div>
|
||||
@@ -226,7 +305,17 @@ export default function RoutineDetailPage() {
|
||||
|
||||
{/* Steps */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-3">Steps</h2>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Steps</h2>
|
||||
{steps.length >= 4 && steps.length <= 7 && (
|
||||
<span className="text-xs text-green-600 bg-green-50 px-2 py-1 rounded-full">Good length</span>
|
||||
)}
|
||||
</div>
|
||||
{steps.length > 7 && (
|
||||
<p className="text-sm text-amber-600 bg-amber-50 px-3 py-2 rounded-lg mb-3">
|
||||
Tip: Routines with 4-7 steps tend to feel more manageable. Consider combining related steps.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm space-y-3 mb-4">
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -4,6 +4,10 @@ import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import api from '@/lib/api';
|
||||
import { ArrowLeftIcon, PauseIcon, PlayIcon, StopIcon, SkipForwardIcon, CheckIcon, XIcon } from '@/components/ui/Icons';
|
||||
import AnimatedCheckmark from '@/components/ui/AnimatedCheckmark';
|
||||
import VisualTimeline from '@/components/session/VisualTimeline';
|
||||
import { playStepComplete, playCelebration } from '@/lib/sounds';
|
||||
import { hapticSuccess, hapticCelebration } from '@/lib/haptics';
|
||||
|
||||
interface Step {
|
||||
id: string;
|
||||
@@ -27,6 +31,15 @@ interface Session {
|
||||
current_step_index: number;
|
||||
}
|
||||
|
||||
interface CelebrationData {
|
||||
streak_current: number;
|
||||
streak_longest: number;
|
||||
session_duration_minutes: number;
|
||||
total_completions: number;
|
||||
steps_completed: number;
|
||||
steps_skipped: number;
|
||||
}
|
||||
|
||||
export default function SessionRunnerPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
@@ -38,12 +51,24 @@ export default function SessionRunnerPage() {
|
||||
const [currentStep, setCurrentStep] = useState<Step | null>(null);
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
const [status, setStatus] = useState<'loading' | 'active' | 'paused' | 'completed'>('loading');
|
||||
|
||||
|
||||
const [timerSeconds, setTimerSeconds] = useState(0);
|
||||
const [isTimerRunning, setIsTimerRunning] = useState(false);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const [swipeDirection, setSwipeDirection] = useState<'left' | 'right' | null>(null);
|
||||
// UI states
|
||||
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
|
||||
|
||||
// Animation states
|
||||
const [completionPhase, setCompletionPhase] = useState<'idle' | 'completing' | 'transitioning' | 'arriving'>('idle');
|
||||
const [skipPhase, setSkipPhase] = useState<'idle' | 'skipping' | 'transitioning' | 'arriving'>('idle');
|
||||
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
|
||||
const [celebrationData, setCelebrationData] = useState<CelebrationData | null>(null);
|
||||
const [celebrationPhase, setCelebrationPhase] = useState(0);
|
||||
const [reward, setReward] = useState<{ content: string; emoji?: string; rarity: string } | null>(null);
|
||||
const [soundEnabled, setSoundEnabled] = useState(false);
|
||||
const [hapticEnabled, setHapticEnabled] = useState(true);
|
||||
|
||||
const touchStartX = useRef<number | null>(null);
|
||||
const touchStartY = useRef<number | null>(null);
|
||||
|
||||
@@ -58,10 +83,23 @@ export default function SessionRunnerPage() {
|
||||
setCurrentStep(sessionData.current_step);
|
||||
setCurrentStepIndex(sessionData.session.current_step_index);
|
||||
setStatus(sessionData.session.status === 'paused' ? 'paused' : 'active');
|
||||
|
||||
|
||||
// Mark previous steps as completed
|
||||
const completed = new Set<number>();
|
||||
for (let i = 0; i < sessionData.session.current_step_index; i++) {
|
||||
completed.add(i);
|
||||
}
|
||||
setCompletedSteps(completed);
|
||||
|
||||
if (sessionData.current_step?.duration_minutes) {
|
||||
setTimerSeconds(sessionData.current_step.duration_minutes * 60);
|
||||
}
|
||||
|
||||
// Fetch user preferences for sound/haptics
|
||||
api.preferences.get().then((prefs: { sound_enabled?: boolean; haptic_enabled?: boolean }) => {
|
||||
if (prefs.sound_enabled !== undefined) setSoundEnabled(prefs.sound_enabled);
|
||||
if (prefs.haptic_enabled !== undefined) setHapticEnabled(prefs.haptic_enabled);
|
||||
}).catch(() => {});
|
||||
} catch (err) {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
@@ -89,25 +127,24 @@ export default function SessionRunnerPage() {
|
||||
|
||||
// Touch handlers for swipe
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
if (completionPhase !== 'idle' || skipPhase !== 'idle') return;
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
touchStartY.current = e.touches[0].clientY;
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e: React.TouchEvent) => {
|
||||
if (touchStartX.current === null || touchStartY.current === null) return;
|
||||
|
||||
if (completionPhase !== 'idle' || skipPhase !== 'idle') return;
|
||||
|
||||
const touchEndX = e.changedTouches[0].clientX;
|
||||
const touchEndY = e.changedTouches[0].clientY;
|
||||
const diffX = touchEndX - touchStartX.current;
|
||||
const diffY = touchEndY - touchStartY.current;
|
||||
|
||||
// Only trigger if horizontal swipe is dominant
|
||||
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
|
||||
if (diffX < 0) {
|
||||
// Swipe left - complete
|
||||
handleComplete();
|
||||
} else {
|
||||
// Swipe right - skip
|
||||
handleSkip();
|
||||
}
|
||||
}
|
||||
@@ -117,46 +154,93 @@ export default function SessionRunnerPage() {
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (!session || !currentStep) return;
|
||||
|
||||
setSwipeDirection('left');
|
||||
setTimeout(() => setSwipeDirection(null), 300);
|
||||
if (!session || !currentStep || completionPhase !== 'idle') return;
|
||||
|
||||
// Auto-resume if paused
|
||||
if (status === 'paused') {
|
||||
setStatus('active');
|
||||
setIsTimerRunning(true);
|
||||
}
|
||||
|
||||
// Phase 1: Instant green glow + pulse on complete button
|
||||
setCompletionPhase('completing');
|
||||
if (soundEnabled) playStepComplete();
|
||||
if (hapticEnabled) hapticSuccess();
|
||||
|
||||
try {
|
||||
const result = await api.sessions.completeStep(session.id, currentStep.id);
|
||||
if (result.next_step) {
|
||||
setCurrentStep(result.next_step);
|
||||
setCurrentStepIndex(result.session.current_step_index!);
|
||||
setTimerSeconds((result.next_step.duration_minutes || 5) * 60);
|
||||
setIsTimerRunning(true);
|
||||
} else {
|
||||
setStatus('completed');
|
||||
setIsTimerRunning(false);
|
||||
}
|
||||
|
||||
// Mark current step as completed for progress bar
|
||||
setCompletedSteps(prev => new Set([...prev, currentStepIndex]));
|
||||
|
||||
// Phase 2: Slide out (after brief glow)
|
||||
setTimeout(() => {
|
||||
setCompletionPhase('transitioning');
|
||||
}, 150);
|
||||
|
||||
// Phase 3: Bring in next step
|
||||
setTimeout(() => {
|
||||
if (result.next_step) {
|
||||
setCurrentStep(result.next_step);
|
||||
setCurrentStepIndex(result.session.current_step_index!);
|
||||
setTimerSeconds((result.next_step.duration_minutes || 5) * 60);
|
||||
setIsTimerRunning(true);
|
||||
setCompletionPhase('arriving');
|
||||
} else {
|
||||
setCelebrationData(result.celebration || null);
|
||||
setStatus('completed');
|
||||
setCompletionPhase('idle');
|
||||
}
|
||||
}, 400);
|
||||
|
||||
// Phase 4: Back to idle
|
||||
setTimeout(() => {
|
||||
setCompletionPhase('idle');
|
||||
}, 700);
|
||||
} catch (err) {
|
||||
console.error('Failed to complete step:', err);
|
||||
setCompletionPhase('idle');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = async () => {
|
||||
if (!session || !currentStep) return;
|
||||
|
||||
setSwipeDirection('right');
|
||||
setTimeout(() => setSwipeDirection(null), 300);
|
||||
if (!session || !currentStep || skipPhase !== 'idle') return;
|
||||
|
||||
// Auto-resume if paused
|
||||
if (status === 'paused') {
|
||||
setStatus('active');
|
||||
setIsTimerRunning(true);
|
||||
}
|
||||
|
||||
setSkipPhase('skipping');
|
||||
|
||||
try {
|
||||
const result = await api.sessions.skipStep(session.id, currentStep.id);
|
||||
if (result.next_step) {
|
||||
setCurrentStep(result.next_step);
|
||||
setCurrentStepIndex(result.session.current_step_index!);
|
||||
setTimerSeconds((result.next_step.duration_minutes || 5) * 60);
|
||||
setIsTimerRunning(true);
|
||||
} else {
|
||||
setStatus('completed');
|
||||
setIsTimerRunning(false);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setSkipPhase('transitioning');
|
||||
}, 150);
|
||||
|
||||
setTimeout(() => {
|
||||
if (result.next_step) {
|
||||
setCurrentStep(result.next_step);
|
||||
setCurrentStepIndex(result.session.current_step_index!);
|
||||
setTimerSeconds((result.next_step.duration_minutes || 5) * 60);
|
||||
setIsTimerRunning(true);
|
||||
setSkipPhase('arriving');
|
||||
} else {
|
||||
setCelebrationData(result.celebration || null);
|
||||
setStatus('completed');
|
||||
setSkipPhase('idle');
|
||||
}
|
||||
}, 400);
|
||||
|
||||
setTimeout(() => {
|
||||
setSkipPhase('idle');
|
||||
}, 700);
|
||||
} catch (err) {
|
||||
console.error('Failed to skip step:', err);
|
||||
setSkipPhase('idle');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -182,7 +266,11 @@ export default function SessionRunnerPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
const handleCancelRequest = () => {
|
||||
setShowCancelConfirm(true);
|
||||
};
|
||||
|
||||
const handleCancelConfirm = async () => {
|
||||
if (!session) return;
|
||||
try {
|
||||
await api.sessions.cancel(session.id);
|
||||
@@ -192,25 +280,131 @@ export default function SessionRunnerPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Celebration phase sequencing + reward fetch
|
||||
useEffect(() => {
|
||||
if (status !== 'completed') return;
|
||||
setCelebrationPhase(0);
|
||||
|
||||
// Play celebration feedback
|
||||
if (soundEnabled) playCelebration();
|
||||
if (hapticEnabled) hapticCelebration();
|
||||
|
||||
// Fetch random reward
|
||||
api.rewards.getRandom('completion').then(data => {
|
||||
if (data.reward) setReward(data.reward);
|
||||
}).catch(() => {});
|
||||
|
||||
const timers = [
|
||||
setTimeout(() => setCelebrationPhase(1), 100), // bg gradient
|
||||
setTimeout(() => setCelebrationPhase(2), 400), // checkmark
|
||||
setTimeout(() => setCelebrationPhase(3), 900), // message
|
||||
setTimeout(() => setCelebrationPhase(4), 1300), // stats + reward
|
||||
setTimeout(() => setCelebrationPhase(5), 1700), // done button
|
||||
];
|
||||
return () => timers.forEach(clearTimeout);
|
||||
}, [status]);
|
||||
|
||||
if (status === 'loading' || !currentStep) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-900">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-950">
|
||||
<div className="w-8 h-8 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Celebration Screen ──────────────────────────────────────
|
||||
if (status === 'completed') {
|
||||
const formatDuration = (mins: number) => {
|
||||
if (mins < 1) return 'less than a minute';
|
||||
if (mins < 60) return `${Math.round(mins)} minute${Math.round(mins) !== 1 ? 's' : ''}`;
|
||||
const h = Math.floor(mins / 60);
|
||||
const m = Math.round(mins % 60);
|
||||
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||
};
|
||||
|
||||
const streakMessage = (streak: number) => {
|
||||
if (streak <= 1) return "You showed up today";
|
||||
if (streak <= 3) return `${streak} days and counting`;
|
||||
if (streak <= 7) return `${streak} days — you're building something real`;
|
||||
return `${streak} days — you're someone who shows up`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-500 flex flex-col items-center justify-center p-6">
|
||||
<div className="w-24 h-24 bg-white rounded-full flex items-center justify-center mb-6">
|
||||
<CheckIcon className="text-green-500" size={48} />
|
||||
<div className={`min-h-screen flex flex-col items-center justify-center p-6 transition-all duration-500 ${
|
||||
celebrationPhase >= 1
|
||||
? 'bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-500'
|
||||
: 'bg-gray-950'
|
||||
}`}>
|
||||
{/* Animated Checkmark */}
|
||||
<div className={`mb-8 ${celebrationPhase >= 2 ? 'animate-celebration-scale' : 'opacity-0'}`}>
|
||||
<div className="w-28 h-28 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center">
|
||||
{celebrationPhase >= 2 && (
|
||||
<AnimatedCheckmark size={64} color="#ffffff" strokeWidth={2.5} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Great job!</h1>
|
||||
<p className="text-white/80 text-lg mb-8">You completed your routine</p>
|
||||
|
||||
{/* Message */}
|
||||
<div className={`text-center mb-8 ${celebrationPhase >= 3 ? 'animate-fade-in-up' : 'opacity-0'}`}>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">
|
||||
{routine?.name || 'Routine'} complete
|
||||
</h1>
|
||||
<p className="text-white/80 text-lg">You showed up for yourself</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{celebrationData && (
|
||||
<div className={`w-full max-w-sm space-y-3 mb-10 ${celebrationPhase >= 4 ? 'animate-fade-in-up' : 'opacity-0'}`}
|
||||
style={{ animationDelay: '100ms' }}>
|
||||
{/* Streak */}
|
||||
<div className="bg-white/15 backdrop-blur-sm rounded-2xl p-4 text-center">
|
||||
<p className="text-white/60 text-sm mb-1">Your streak</p>
|
||||
<p className="text-white text-xl font-semibold">
|
||||
{streakMessage(celebrationData.streak_current)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Duration + Steps */}
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 bg-white/15 backdrop-blur-sm rounded-2xl p-4 text-center">
|
||||
<p className="text-white/60 text-sm mb-1">Time</p>
|
||||
<p className="text-white text-lg font-semibold">
|
||||
{formatDuration(celebrationData.session_duration_minutes)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 bg-white/15 backdrop-blur-sm rounded-2xl p-4 text-center">
|
||||
<p className="text-white/60 text-sm mb-1">Steps done</p>
|
||||
<p className="text-white text-lg font-semibold">
|
||||
{celebrationData.steps_completed}/{celebrationData.steps_completed + celebrationData.steps_skipped}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total completions */}
|
||||
{celebrationData.total_completions > 1 && (
|
||||
<p className="text-white/50 text-sm text-center">
|
||||
That's {celebrationData.total_completions} total completions
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Variable Reward */}
|
||||
{reward && (
|
||||
<div className={`bg-white/10 backdrop-blur-sm rounded-2xl p-4 text-center border ${
|
||||
reward.rarity === 'rare' ? 'border-yellow-400/50' : 'border-white/10'
|
||||
}`}>
|
||||
<span className="text-2xl">{reward.emoji || '✨'}</span>
|
||||
<p className="text-white text-sm mt-1">{reward.content}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Done button */}
|
||||
<button
|
||||
onClick={() => router.push('/dashboard')}
|
||||
className="bg-white text-indigo-600 px-8 py-3 rounded-full font-semibold"
|
||||
className={`bg-white text-indigo-600 px-10 py-3.5 rounded-full font-semibold text-lg shadow-lg ${
|
||||
celebrationPhase >= 5 ? 'animate-fade-in-up' : 'opacity-0'
|
||||
}`}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
@@ -218,20 +412,32 @@ export default function SessionRunnerPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const progress = ((currentStepIndex + 1) / steps.length) * 100;
|
||||
// ── Active Session ──────────────────────────────────────────
|
||||
|
||||
// Card animation class
|
||||
const getCardAnimation = () => {
|
||||
if (completionPhase === 'completing') return 'animate-green-glow';
|
||||
if (completionPhase === 'transitioning') return 'animate-slide-out-left';
|
||||
if (completionPhase === 'arriving') return 'animate-slide-in-right';
|
||||
if (skipPhase === 'skipping') return '';
|
||||
if (skipPhase === 'transitioning') return 'animate-slide-out-right';
|
||||
if (skipPhase === 'arriving') return 'animate-slide-in-right';
|
||||
return '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen bg-gray-900 text-white flex flex-col"
|
||||
<div
|
||||
className="min-h-screen bg-gray-950 text-white flex flex-col"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between px-4 py-4">
|
||||
<button onClick={handleCancel} className="p-2">
|
||||
<button onClick={handleCancelRequest} className="p-2">
|
||||
<XIcon size={24} />
|
||||
</button>
|
||||
<div className="text-center">
|
||||
<p className="text-white/40 text-xs tracking-wide uppercase mb-0.5">Focus Mode</p>
|
||||
<p className="text-white/60 text-sm">{routine?.name}</p>
|
||||
<p className="font-semibold">Step {currentStepIndex + 1} of {steps.length}</p>
|
||||
</div>
|
||||
@@ -240,34 +446,44 @@ export default function SessionRunnerPage() {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="px-4">
|
||||
<div className="h-1 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-indigo-500 transition-all duration-500"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
{/* Segmented Progress Bar */}
|
||||
<div className="px-4 pb-2">
|
||||
<div className="flex gap-1">
|
||||
{steps.map((_, i) => (
|
||||
<div key={i} className="flex-1 h-2 rounded-full overflow-hidden bg-gray-800">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
completedSteps.has(i)
|
||||
? 'bg-indigo-500 w-full'
|
||||
: i === currentStepIndex
|
||||
? 'bg-indigo-400 w-full animate-gentle-pulse'
|
||||
: 'w-0'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual Timeline — collapsible step overview */}
|
||||
<VisualTimeline
|
||||
steps={steps}
|
||||
currentStepIndex={currentStepIndex}
|
||||
completedSteps={completedSteps}
|
||||
/>
|
||||
|
||||
{/* Main Card */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6">
|
||||
<div
|
||||
<div
|
||||
className={`
|
||||
w-full max-w-md bg-gray-800 rounded-3xl p-8 text-center
|
||||
transition-transform duration-300
|
||||
${swipeDirection === 'left' ? 'translate-x-20 opacity-50' : ''}
|
||||
${swipeDirection === 'right' ? '-translate-x-20 opacity-50' : ''}
|
||||
shadow-2xl shadow-indigo-500/10
|
||||
${getCardAnimation()}
|
||||
`}
|
||||
>
|
||||
{/* Step Type Badge */}
|
||||
<div className="inline-block px-3 py-1 bg-indigo-600 rounded-full text-sm mb-4">
|
||||
{currentStep.step_type || 'Generic'}
|
||||
</div>
|
||||
|
||||
{/* Timer */}
|
||||
<div className="mb-6">
|
||||
<div className="text-7xl font-bold font-mono mb-2">
|
||||
<div className="text-7xl font-bold font-mono mb-2" style={{ textShadow: '0 0 20px rgba(99, 102, 241, 0.3)' }}>
|
||||
{formatTime(timerSeconds)}
|
||||
</div>
|
||||
<p className="text-white/60">remaining</p>
|
||||
@@ -281,41 +497,67 @@ export default function SessionRunnerPage() {
|
||||
<p className="text-white/80 text-lg mb-6">{currentStep.instructions}</p>
|
||||
)}
|
||||
|
||||
{/* Timer Controls */}
|
||||
<div className="flex justify-center gap-4 mt-8">
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="w-16 h-16 bg-gray-700 rounded-full flex items-center justify-center hover:bg-gray-600 transition"
|
||||
>
|
||||
<SkipForwardIcon size={28} />
|
||||
</button>
|
||||
<button
|
||||
onClick={status === 'paused' ? handleResume : handlePause}
|
||||
className="w-20 h-20 bg-indigo-600 rounded-full flex items-center justify-center hover:bg-indigo-500 transition"
|
||||
>
|
||||
{status === 'paused' ? <PlayIcon size={32} /> : <PauseIcon size={32} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleComplete}
|
||||
className="w-16 h-16 bg-green-600 rounded-full flex items-center justify-center hover:bg-green-500 transition"
|
||||
>
|
||||
<CheckIcon size={28} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Swipe Hints */}
|
||||
<div className="flex justify-between w-full max-w-md mt-8 text-white/40 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowLeftIcon size={16} />
|
||||
<span>Swipe left to complete</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Swipe right to skip</span>
|
||||
<ArrowLeftIcon size={16} className="rotate-180" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons — pinned to bottom for one-handed use */}
|
||||
<div className="px-6 pb-8 pt-4">
|
||||
<div className="flex justify-center items-center gap-6">
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
disabled={skipPhase !== 'idle' || completionPhase !== 'idle'}
|
||||
className="w-16 h-16 bg-gray-700 rounded-full flex items-center justify-center hover:bg-gray-600 transition disabled:opacity-50"
|
||||
>
|
||||
<SkipForwardIcon size={28} />
|
||||
</button>
|
||||
<button
|
||||
onClick={status === 'paused' ? handleResume : handlePause}
|
||||
className="w-20 h-20 bg-indigo-600 rounded-full flex items-center justify-center hover:bg-indigo-500 transition"
|
||||
>
|
||||
{status === 'paused' ? <PlayIcon size={32} /> : <PauseIcon size={32} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleComplete}
|
||||
disabled={completionPhase !== 'idle' || skipPhase !== 'idle'}
|
||||
className={`w-16 h-16 rounded-full flex items-center justify-center transition disabled:opacity-50 ${
|
||||
completionPhase === 'completing'
|
||||
? 'bg-green-500 animate-step-complete'
|
||||
: 'bg-green-600 hover:bg-green-500'
|
||||
}`}
|
||||
>
|
||||
<CheckIcon size={28} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Swipe Hints */}
|
||||
<div className="flex justify-between mt-4 text-white/30 text-xs">
|
||||
<span>Swipe left to complete</span>
|
||||
<span>Swipe right to skip</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gentle Cancel Confirmation */}
|
||||
{showCancelConfirm && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-6">
|
||||
<div className="bg-gray-800 rounded-2xl p-6 max-w-sm w-full text-center">
|
||||
<p className="text-white text-lg font-semibold mb-2">Step away for now?</p>
|
||||
<p className="text-white/60 text-sm mb-6">You can always come back. Your progress today still counts.</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowCancelConfirm(false)}
|
||||
className="flex-1 py-3 bg-indigo-600 text-white rounded-xl font-medium"
|
||||
>
|
||||
Keep Going
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelConfirm}
|
||||
className="flex-1 py-3 bg-gray-700 text-white/70 rounded-xl font-medium"
|
||||
>
|
||||
Leave
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
172
synculous-client/src/app/dashboard/settings/page.tsx
Normal file
172
synculous-client/src/app/dashboard/settings/page.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '@/lib/api';
|
||||
import { VolumeIcon, VolumeOffIcon, SparklesIcon } from '@/components/ui/Icons';
|
||||
import { playStepComplete } from '@/lib/sounds';
|
||||
import { hapticTap } from '@/lib/haptics';
|
||||
|
||||
interface Preferences {
|
||||
sound_enabled: boolean;
|
||||
haptic_enabled: boolean;
|
||||
show_launch_screen: boolean;
|
||||
celebration_style: string;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [prefs, setPrefs] = useState<Preferences>({
|
||||
sound_enabled: false,
|
||||
haptic_enabled: true,
|
||||
show_launch_screen: true,
|
||||
celebration_style: 'standard',
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.preferences.get()
|
||||
.then((data: Preferences) => setPrefs(data))
|
||||
.catch(() => {})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
const updatePref = async (key: keyof Preferences, value: boolean | string) => {
|
||||
const updated = { ...prefs, [key]: value };
|
||||
setPrefs(updated);
|
||||
try {
|
||||
await api.preferences.update({ [key]: value });
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 1500);
|
||||
} catch {
|
||||
// revert on failure
|
||||
setPrefs(prefs);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]">
|
||||
<div className="w-8 h-8 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
|
||||
{saved && (
|
||||
<span className="text-sm text-green-600 animate-fade-in-up">Saved</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Session Experience */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-3">Session Experience</h2>
|
||||
<div className="bg-white rounded-xl shadow-sm divide-y divide-gray-100">
|
||||
{/* Sound */}
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{prefs.sound_enabled ? (
|
||||
<VolumeIcon size={20} className="text-indigo-500" />
|
||||
) : (
|
||||
<VolumeOffIcon size={20} className="text-gray-400" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Sound effects</p>
|
||||
<p className="text-sm text-gray-500">Subtle audio cues on step completion</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
updatePref('sound_enabled', !prefs.sound_enabled);
|
||||
if (!prefs.sound_enabled) playStepComplete();
|
||||
}}
|
||||
className={`w-12 h-7 rounded-full transition-colors ${
|
||||
prefs.sound_enabled ? 'bg-indigo-500' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
|
||||
prefs.sound_enabled ? 'translate-x-5' : ''
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Haptics */}
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<SparklesIcon size={20} className={prefs.haptic_enabled ? 'text-indigo-500' : 'text-gray-400'} />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Haptic feedback</p>
|
||||
<p className="text-sm text-gray-500">Gentle vibration on actions</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
updatePref('haptic_enabled', !prefs.haptic_enabled);
|
||||
if (!prefs.haptic_enabled) hapticTap();
|
||||
}}
|
||||
className={`w-12 h-7 rounded-full transition-colors ${
|
||||
prefs.haptic_enabled ? 'bg-indigo-500' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
|
||||
prefs.haptic_enabled ? 'translate-x-5' : ''
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Launch Screen */}
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Pre-routine launch screen</p>
|
||||
<p className="text-sm text-gray-500">Environment check and emotion bridge</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updatePref('show_launch_screen', !prefs.show_launch_screen)}
|
||||
className={`w-12 h-7 rounded-full transition-colors ${
|
||||
prefs.show_launch_screen ? 'bg-indigo-500' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
|
||||
prefs.show_launch_screen ? 'translate-x-5' : ''
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Celebration Style */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-3">Celebration Style</h2>
|
||||
<div className="bg-white rounded-xl shadow-sm divide-y divide-gray-100">
|
||||
{[
|
||||
{ value: 'standard', label: 'Standard', desc: 'Full animated celebration with stats and rewards' },
|
||||
{ value: 'quick', label: 'Quick', desc: 'Brief confirmation, then back to dashboard' },
|
||||
{ value: 'none', label: 'None', desc: 'No celebration screen, return immediately' },
|
||||
].map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => updatePref('celebration_style', option.value)}
|
||||
className="w-full flex items-center justify-between p-4 text-left"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{option.label}</p>
|
||||
<p className="text-sm text-gray-500">{option.desc}</p>
|
||||
</div>
|
||||
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||
prefs.celebration_style === option.value
|
||||
? 'border-indigo-500'
|
||||
: 'border-gray-300'
|
||||
}`}>
|
||||
{prefs.celebration_style === option.value && (
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-indigo-500" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,26 +35,50 @@ interface WeeklySummary {
|
||||
}[];
|
||||
}
|
||||
|
||||
function getStreakMessage(streak: number): string {
|
||||
if (streak === 0) return 'Ready for a fresh start';
|
||||
if (streak === 1) return "You're back! Day 1";
|
||||
if (streak <= 3) return `${streak} days of showing up`;
|
||||
if (streak <= 7) return `${streak} days — building momentum`;
|
||||
return `${streak} days — you're someone who shows up`;
|
||||
}
|
||||
|
||||
function getCompletionLabel(rate: number): { label: string; color: string } {
|
||||
if (rate >= 80) return { label: 'Rock solid', color: 'text-green-600' };
|
||||
if (rate >= 60) return { label: 'Strong habit', color: 'text-indigo-600' };
|
||||
if (rate >= 30) return { label: 'Building momentum', color: 'text-amber-600' };
|
||||
return { label: 'Getting started', color: 'text-gray-600' };
|
||||
}
|
||||
|
||||
interface Victory {
|
||||
type: string;
|
||||
message: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
export default function StatsPage() {
|
||||
const [routines, setRoutines] = useState<{ id: string; name: string }[]>([]);
|
||||
const [selectedRoutine, setSelectedRoutine] = useState<string>('');
|
||||
const [routineStats, setRoutineStats] = useState<RoutineStats | null>(null);
|
||||
const [streaks, setStreaks] = useState<Streak[]>([]);
|
||||
const [weeklySummary, setWeeklySummary] = useState<WeeklySummary | null>(null);
|
||||
const [victories, setVictories] = useState<Victory[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [routinesData, streaksData, summaryData] = await Promise.all([
|
||||
const [routinesData, streaksData, summaryData, victoriesData] = await Promise.all([
|
||||
api.routines.list(),
|
||||
api.stats.getStreaks(),
|
||||
api.stats.getWeeklySummary(),
|
||||
api.victories.get(30).catch(() => []),
|
||||
]);
|
||||
setRoutines(routinesData);
|
||||
setStreaks(streaksData);
|
||||
setWeeklySummary(summaryData);
|
||||
|
||||
setVictories(victoriesData);
|
||||
|
||||
if (routinesData.length > 0) {
|
||||
setSelectedRoutine(routinesData[0].id);
|
||||
}
|
||||
@@ -97,7 +121,7 @@ export default function StatsPage() {
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Stats</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Your Progress</h1>
|
||||
|
||||
{/* Weekly Summary */}
|
||||
{weeklySummary && (
|
||||
@@ -110,20 +134,42 @@ export default function StatsPage() {
|
||||
<div className="bg-gradient-to-br from-pink-500 to-rose-600 rounded-2xl p-4 text-white">
|
||||
<ClockIcon className="text-white/80 mb-2" size={24} />
|
||||
<p className="text-3xl font-bold">{formatTime(weeklySummary.total_time_minutes)}</p>
|
||||
<p className="text-white/80 text-sm">Time</p>
|
||||
<p className="text-white/80 text-sm">Invested</p>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-amber-500 to-orange-600 rounded-2xl p-4 text-white">
|
||||
<ActivityIcon className="text-white/80 mb-2" size={24} />
|
||||
<p className="text-3xl font-bold">{weeklySummary.routines_started}</p>
|
||||
<p className="text-white/80 text-sm">Started</p>
|
||||
<p className="text-white/80 text-sm">Active</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streaks */}
|
||||
{/* Wins This Month */}
|
||||
{victories.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-3">Wins This Month</h2>
|
||||
<div className="space-y-2">
|
||||
{victories.map((victory, i) => (
|
||||
<div key={i} className="bg-white rounded-xl p-4 shadow-sm flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-green-100 to-emerald-100 rounded-xl flex items-center justify-center">
|
||||
<span className="text-lg">
|
||||
{victory.type === 'comeback' ? '💪' :
|
||||
victory.type === 'weekend' ? '🎉' :
|
||||
victory.type === 'variety' ? '🌈' :
|
||||
victory.type === 'consistency' ? '🔥' : '⭐'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 flex-1">{victory.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Consistency (formerly Streaks) */}
|
||||
{streaks.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-3">Streaks</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-3">Your Consistency</h2>
|
||||
<div className="space-y-2">
|
||||
{streaks.map((streak) => (
|
||||
<div key={streak.routine_id} className="bg-white rounded-xl p-4 shadow-sm flex items-center gap-4">
|
||||
@@ -133,15 +179,27 @@ export default function StatsPage() {
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900">{streak.routine_name}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Last: {streak.last_completed_date ? new Date(streak.last_completed_date).toLocaleDateString() : 'Never'}
|
||||
{getStreakMessage(streak.current_streak)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-bold text-orange-500">{streak.current_streak}</p>
|
||||
<p className="text-xs text-gray-500">day streak</p>
|
||||
{streak.current_streak > 0 ? (
|
||||
<>
|
||||
<p className="text-2xl font-bold text-orange-500">{streak.current_streak}</p>
|
||||
<p className="text-xs text-gray-500">days</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">Ready</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Longest streak callout */}
|
||||
{streaks.some(s => s.longest_streak > 0) && (
|
||||
<p className="text-sm text-gray-400 text-center mt-2">
|
||||
Your personal best: {Math.max(...streaks.map(s => s.longest_streak))} days — you've done it before
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -149,7 +207,7 @@ export default function StatsPage() {
|
||||
{/* Per-Routine Stats */}
|
||||
{routines.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-3">Routine Stats</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-3">Routine Details</h2>
|
||||
<select
|
||||
value={selectedRoutine}
|
||||
onChange={(e) => setSelectedRoutine(e.target.value)}
|
||||
@@ -166,8 +224,10 @@ export default function StatsPage() {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm">
|
||||
<TargetIcon className="text-indigo-500 mb-2" size={24} />
|
||||
<p className="text-2xl font-bold text-gray-900">{routineStats.completion_rate_percent}%</p>
|
||||
<p className="text-sm text-gray-500">Completion Rate</p>
|
||||
<p className={`text-lg font-bold ${getCompletionLabel(routineStats.completion_rate_percent).color}`}>
|
||||
{getCompletionLabel(routineStats.completion_rate_percent).label}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">{routineStats.completion_rate_percent}%</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm">
|
||||
<ClockIcon className="text-purple-500 mb-2" size={24} />
|
||||
@@ -186,6 +246,15 @@ export default function StatsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plateau messaging */}
|
||||
{routineStats && routineStats.total_sessions >= 5 && (
|
||||
<p className="text-sm text-gray-400 text-center mt-3">
|
||||
{routineStats.completion_rate_percent >= 60
|
||||
? "You're showing up consistently — that's the hard part. The exact rate doesn't matter."
|
||||
: "Life has seasons. The fact that you're checking in shows this matters to you."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -24,3 +24,96 @@ body {
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
/* ── Animation Keyframes ─────────────────────────────────── */
|
||||
|
||||
@keyframes checkmark-draw {
|
||||
0% { stroke-dashoffset: 24; }
|
||||
100% { stroke-dashoffset: 0; }
|
||||
}
|
||||
|
||||
@keyframes step-complete-pulse {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.12); opacity: 0.85; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-out-left {
|
||||
0% { transform: translateX(0); opacity: 1; }
|
||||
100% { transform: translateX(-120%); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes slide-in-right {
|
||||
0% { transform: translateX(80%); opacity: 0; }
|
||||
100% { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-out-right {
|
||||
0% { transform: translateX(0); opacity: 1; }
|
||||
100% { transform: translateX(120%); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes progress-fill {
|
||||
0% { transform: scaleX(0); }
|
||||
100% { transform: scaleX(1); }
|
||||
}
|
||||
|
||||
@keyframes progress-glow {
|
||||
0% { box-shadow: 0 0 4px rgba(99, 102, 241, 0.4); }
|
||||
50% { box-shadow: 0 0 12px rgba(99, 102, 241, 0.8); }
|
||||
100% { box-shadow: 0 0 4px rgba(99, 102, 241, 0.4); }
|
||||
}
|
||||
|
||||
@keyframes celebration-scale {
|
||||
0% { transform: scale(0.3); opacity: 0; }
|
||||
60% { transform: scale(1.1); opacity: 1; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
0% { transform: translateY(20px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes gentle-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
@keyframes green-glow {
|
||||
0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.5); }
|
||||
50% { box-shadow: 0 0 20px 4px rgba(34, 197, 94, 0.3); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
|
||||
}
|
||||
|
||||
/* Utility animation classes */
|
||||
.animate-checkmark-draw {
|
||||
animation: checkmark-draw 0.4s ease-out forwards;
|
||||
}
|
||||
.animate-step-complete {
|
||||
animation: step-complete-pulse 0.3s ease-out;
|
||||
}
|
||||
.animate-slide-out-left {
|
||||
animation: slide-out-left 0.25s ease-in forwards;
|
||||
}
|
||||
.animate-slide-in-right {
|
||||
animation: slide-in-right 0.3s ease-out forwards;
|
||||
}
|
||||
.animate-slide-out-right {
|
||||
animation: slide-out-right 0.25s ease-in forwards;
|
||||
}
|
||||
.animate-celebration-scale {
|
||||
animation: celebration-scale 0.5s ease-out forwards;
|
||||
}
|
||||
.animate-fade-in-up {
|
||||
animation: fade-in-up 0.4s ease-out forwards;
|
||||
}
|
||||
.animate-gentle-pulse {
|
||||
animation: gentle-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
.animate-green-glow {
|
||||
animation: green-glow 0.5s ease-out;
|
||||
}
|
||||
.animate-progress-glow {
|
||||
animation: progress-glow 0.6s ease-out;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '@/lib/api';
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
export default function PushNotificationToggle() {
|
||||
const [supported, setSupported] = useState(false);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const check = async () => {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setSupported(true);
|
||||
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
setEnabled(!!sub);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
check();
|
||||
}, []);
|
||||
|
||||
const toggle = async () => {
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
|
||||
if (enabled) {
|
||||
// Unsubscribe
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
if (sub) {
|
||||
await api.notifications.unsubscribe(sub.endpoint);
|
||||
await sub.unsubscribe();
|
||||
}
|
||||
setEnabled(false);
|
||||
} else {
|
||||
// Subscribe
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { public_key } = await api.notifications.getVapidPublicKey();
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(public_key).buffer as ArrayBuffer,
|
||||
});
|
||||
|
||||
const subJson = sub.toJSON();
|
||||
await api.notifications.subscribe(subJson);
|
||||
setEnabled(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Push notification toggle failed:', err);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
if (!supported) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between bg-white rounded-xl p-4 shadow-sm">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 text-sm">Push Notifications</h3>
|
||||
<p className="text-xs text-gray-500">Get reminders on this device</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggle}
|
||||
disabled={loading}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
enabled ? 'bg-indigo-600' : 'bg-gray-300'
|
||||
} ${loading ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
enabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
synculous-client/src/components/session/VisualTimeline.tsx
Normal file
94
synculous-client/src/components/session/VisualTimeline.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { CheckIcon } from '@/components/ui/Icons';
|
||||
|
||||
interface Step {
|
||||
id: string;
|
||||
name: string;
|
||||
duration_minutes?: number;
|
||||
}
|
||||
|
||||
interface VisualTimelineProps {
|
||||
steps: Step[];
|
||||
currentStepIndex: number;
|
||||
completedSteps: Set<number>;
|
||||
}
|
||||
|
||||
export default function VisualTimeline({ steps, currentStepIndex, completedSteps }: VisualTimelineProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (steps.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="px-4 py-2 cursor-pointer"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? (
|
||||
// Expanded: show step names
|
||||
<div className="flex flex-col gap-1">
|
||||
{steps.map((step, i) => {
|
||||
const isCompleted = completedSteps.has(i);
|
||||
const isCurrent = i === currentStepIndex;
|
||||
const isUpcoming = !isCompleted && !isCurrent;
|
||||
|
||||
return (
|
||||
<div key={step.id} className="flex items-center gap-2">
|
||||
<div className={`w-7 h-7 rounded-full flex items-center justify-center shrink-0 transition-all ${
|
||||
isCompleted
|
||||
? 'bg-indigo-500'
|
||||
: isCurrent
|
||||
? 'bg-indigo-400 ring-2 ring-indigo-400/50 ring-offset-2 ring-offset-gray-950'
|
||||
: 'bg-gray-700'
|
||||
}`}>
|
||||
{isCompleted ? (
|
||||
<CheckIcon size={14} className="text-white" />
|
||||
) : (
|
||||
<span className={`text-xs font-medium ${isCurrent ? 'text-white' : 'text-gray-400'}`}>
|
||||
{i + 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-sm truncate ${
|
||||
isCompleted ? 'text-white/40 line-through' :
|
||||
isCurrent ? 'text-white font-medium' :
|
||||
'text-white/50'
|
||||
}`}>
|
||||
{step.name}
|
||||
</span>
|
||||
{step.duration_minutes && (
|
||||
<span className="text-xs text-white/30 ml-auto shrink-0">
|
||||
{step.duration_minutes}m
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="text-white/30 text-xs text-center mt-1">Tap to collapse</p>
|
||||
</div>
|
||||
) : (
|
||||
// Collapsed: just circles
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
{steps.map((step, i) => {
|
||||
const isCompleted = completedSteps.has(i);
|
||||
const isCurrent = i === currentStepIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`rounded-full transition-all ${
|
||||
isCompleted
|
||||
? 'w-3 h-3 bg-indigo-500'
|
||||
: isCurrent
|
||||
? 'w-4 h-4 bg-indigo-400 animate-gentle-pulse'
|
||||
: 'w-3 h-3 bg-gray-700'
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
synculous-client/src/components/ui/AnimatedCheckmark.tsx
Normal file
45
synculous-client/src/components/ui/AnimatedCheckmark.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
interface AnimatedCheckmarkProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
strokeWidth?: number;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export default function AnimatedCheckmark({
|
||||
size = 48,
|
||||
color = '#22c55e',
|
||||
strokeWidth = 3,
|
||||
delay = 0,
|
||||
}: AnimatedCheckmarkProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
style={{ animationDelay: `${delay}ms` }}
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
opacity={0.2}
|
||||
/>
|
||||
<path
|
||||
d="M7 12.5l3.5 3.5 6.5-7"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeDasharray="24"
|
||||
strokeDashoffset="24"
|
||||
className="animate-checkmark-draw"
|
||||
style={{ animationDelay: `${delay}ms` }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -708,6 +708,135 @@ export function TimerIcon({ className = '', size = 24 }: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function LockIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SparklesIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
|
||||
<path d="M5 3v4" />
|
||||
<path d="M19 17v4" />
|
||||
<path d="M3 5h4" />
|
||||
<path d="M17 19h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrophyIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" />
|
||||
<path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18" />
|
||||
<path d="M4 22h16" />
|
||||
<path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22" />
|
||||
<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22" />
|
||||
<path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MapPinIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function VolumeIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function VolumeOffIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<line x1="22" y1="9" x2="16" y2="15" />
|
||||
<line x1="16" y1="9" x2="22" y2="15" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkipForwardIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -20,6 +20,7 @@ async function request<T>(
|
||||
const token = getToken();
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Timezone-Offset': String(new Date().getTimezoneOffset()),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...options.headers,
|
||||
};
|
||||
@@ -375,6 +376,14 @@ export const api = {
|
||||
duration_minutes?: number;
|
||||
position: number;
|
||||
} | null;
|
||||
celebration?: {
|
||||
streak_current: number;
|
||||
streak_longest: number;
|
||||
session_duration_minutes: number;
|
||||
total_completions: number;
|
||||
steps_completed: number;
|
||||
steps_skipped: number;
|
||||
};
|
||||
}>(`/api/sessions/${sessionId}/complete-step`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ step_id: stepId }),
|
||||
@@ -392,6 +401,14 @@ export const api = {
|
||||
duration_minutes?: number;
|
||||
position: number;
|
||||
} | null;
|
||||
celebration?: {
|
||||
streak_current: number;
|
||||
streak_longest: number;
|
||||
session_duration_minutes: number;
|
||||
total_completions: number;
|
||||
steps_completed: number;
|
||||
steps_skipped: number;
|
||||
};
|
||||
}>(`/api/sessions/${sessionId}/skip-step`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ step_id: stepId }),
|
||||
@@ -546,6 +563,68 @@ export const api = {
|
||||
},
|
||||
},
|
||||
|
||||
// Victories
|
||||
victories: {
|
||||
get: async (days = 30) => {
|
||||
return request<Array<{
|
||||
type: string;
|
||||
message: string;
|
||||
date?: string;
|
||||
}>>(`/api/victories?days=${days}`, { method: 'GET' });
|
||||
},
|
||||
},
|
||||
|
||||
// Rewards
|
||||
rewards: {
|
||||
getRandom: async (context = 'completion') => {
|
||||
return request<{
|
||||
reward: {
|
||||
id: string;
|
||||
category: string;
|
||||
content: string;
|
||||
emoji?: string;
|
||||
rarity: string;
|
||||
} | null;
|
||||
}>(`/api/rewards/random?context=${context}`, { method: 'GET' });
|
||||
},
|
||||
|
||||
getHistory: async () => {
|
||||
return request<Array<{
|
||||
earned_at: string;
|
||||
context?: string;
|
||||
category: string;
|
||||
content: string;
|
||||
emoji?: string;
|
||||
rarity?: string;
|
||||
}>>('/api/rewards/history', { method: 'GET' });
|
||||
},
|
||||
},
|
||||
|
||||
// Preferences
|
||||
preferences: {
|
||||
get: async () => {
|
||||
return request<{
|
||||
sound_enabled: boolean;
|
||||
haptic_enabled: boolean;
|
||||
show_launch_screen: boolean;
|
||||
celebration_style: string;
|
||||
}>('/api/preferences', { method: 'GET' });
|
||||
},
|
||||
|
||||
update: async (data: {
|
||||
sound_enabled?: boolean;
|
||||
haptic_enabled?: boolean;
|
||||
show_launch_screen?: boolean;
|
||||
celebration_style?: string;
|
||||
timezone_offset?: number;
|
||||
}) => {
|
||||
return request<Record<string, unknown>>('/api/preferences', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// Notifications
|
||||
notifications: {
|
||||
getVapidPublicKey: async () => {
|
||||
|
||||
19
synculous-client/src/lib/haptics.ts
Normal file
19
synculous-client/src/lib/haptics.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
function vibrate(pattern: number | number[]): void {
|
||||
if (typeof navigator !== 'undefined' && navigator.vibrate) {
|
||||
navigator.vibrate(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
export function hapticTap() {
|
||||
vibrate(10);
|
||||
}
|
||||
|
||||
export function hapticSuccess() {
|
||||
vibrate([10, 50, 10]);
|
||||
}
|
||||
|
||||
export function hapticCelebration() {
|
||||
vibrate([10, 30, 10, 30, 50]);
|
||||
}
|
||||
48
synculous-client/src/lib/sounds.ts
Normal file
48
synculous-client/src/lib/sounds.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
let audioContext: AudioContext | null = null;
|
||||
|
||||
function getAudioContext(): AudioContext {
|
||||
if (!audioContext) {
|
||||
audioContext = new AudioContext();
|
||||
}
|
||||
return audioContext;
|
||||
}
|
||||
|
||||
function playTone(frequency: number, duration: number, type: OscillatorType = 'sine', volume = 0.15) {
|
||||
try {
|
||||
const ctx = getAudioContext();
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gainNode = ctx.createGain();
|
||||
|
||||
oscillator.type = type;
|
||||
oscillator.frequency.setValueAtTime(frequency, ctx.currentTime);
|
||||
gainNode.gain.setValueAtTime(volume, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + duration);
|
||||
} catch {
|
||||
// Audio not available
|
||||
}
|
||||
}
|
||||
|
||||
export function playStepComplete() {
|
||||
playTone(523, 0.1, 'sine', 0.12);
|
||||
setTimeout(() => playTone(659, 0.15, 'sine', 0.12), 80);
|
||||
}
|
||||
|
||||
export function playCelebration() {
|
||||
const notes = [523, 659, 784, 1047];
|
||||
notes.forEach((freq, i) => {
|
||||
setTimeout(() => playTone(freq, 0.2, 'sine', 0.1), i * 120);
|
||||
});
|
||||
}
|
||||
|
||||
export function playTimerEnd() {
|
||||
playTone(880, 0.15, 'triangle', 0.1);
|
||||
setTimeout(() => playTone(880, 0.15, 'triangle', 0.1), 200);
|
||||
}
|
||||
Reference in New Issue
Block a user