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_stats as routine_stats_routes
|
||||||
import api.routes.routine_tags as routine_tags_routes
|
import api.routes.routine_tags as routine_tags_routes
|
||||||
import api.routes.notifications as notifications_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__)
|
app = flask.Flask(__name__)
|
||||||
CORS(app)
|
CORS(app)
|
||||||
@@ -31,6 +34,9 @@ ROUTE_MODULES = [
|
|||||||
routine_stats_routes,
|
routine_stats_routes,
|
||||||
routine_tags_routes,
|
routine_tags_routes,
|
||||||
notifications_routes,
|
notifications_routes,
|
||||||
|
preferences_routes,
|
||||||
|
rewards_routes,
|
||||||
|
victories_routes,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import jwt
|
|||||||
from psycopg2.extras import Json
|
from psycopg2.extras import Json
|
||||||
import core.auth as auth
|
import core.auth as auth
|
||||||
import core.postgres as postgres
|
import core.postgres as postgres
|
||||||
|
import core.tz as tz
|
||||||
|
|
||||||
|
|
||||||
def _get_user_uuid(token):
|
def _get_user_uuid(token):
|
||||||
@@ -72,7 +73,7 @@ def _compute_next_dose_date(med):
|
|||||||
interval = med.get("interval_days")
|
interval = med.get("interval_days")
|
||||||
if not interval:
|
if not interval:
|
||||||
return None
|
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):
|
def _count_expected_doses(med, period_start, days):
|
||||||
@@ -162,7 +163,7 @@ def register(app):
|
|||||||
# Compute next_dose_date for interval meds
|
# Compute next_dose_date for interval meds
|
||||||
if data.get("frequency") == "every_n_days" and data.get("start_date") and data.get("interval_days"):
|
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()
|
start = datetime.strptime(data["start_date"], "%Y-%m-%d").date()
|
||||||
today = date.today()
|
today = tz.user_today()
|
||||||
if start > today:
|
if start > today:
|
||||||
row["next_dose_date"] = data["start_date"]
|
row["next_dose_date"] = data["start_date"]
|
||||||
else:
|
else:
|
||||||
@@ -322,8 +323,8 @@ def register(app):
|
|||||||
return flask.jsonify({"error": "unauthorized"}), 401
|
return flask.jsonify({"error": "unauthorized"}), 401
|
||||||
|
|
||||||
meds = postgres.select("medications", where={"user_uuid": user_uuid, "active": True})
|
meds = postgres.select("medications", where={"user_uuid": user_uuid, "active": True})
|
||||||
now = datetime.now()
|
now = tz.user_now()
|
||||||
today = date.today()
|
today = now.date()
|
||||||
today_str = today.isoformat()
|
today_str = today.isoformat()
|
||||||
current_day = now.strftime("%a").lower() # "mon","tue", etc.
|
current_day = now.strftime("%a").lower() # "mon","tue", etc.
|
||||||
current_hour = now.hour
|
current_hour = now.hour
|
||||||
@@ -361,7 +362,7 @@ def register(app):
|
|||||||
if current_hour >= 22:
|
if current_hour >= 22:
|
||||||
tomorrow = today + timedelta(days=1)
|
tomorrow = today + timedelta(days=1)
|
||||||
tomorrow_str = tomorrow.isoformat()
|
tomorrow_str = tomorrow.isoformat()
|
||||||
tomorrow_day = (now + timedelta(days=1)).strftime("%a").lower()
|
tomorrow_day = tomorrow.strftime("%a").lower()
|
||||||
for med in meds:
|
for med in meds:
|
||||||
if med["id"] in seen_med_ids:
|
if med["id"] in seen_med_ids:
|
||||||
continue
|
continue
|
||||||
@@ -398,7 +399,7 @@ def register(app):
|
|||||||
if current_hour < 2:
|
if current_hour < 2:
|
||||||
yesterday = today - timedelta(days=1)
|
yesterday = today - timedelta(days=1)
|
||||||
yesterday_str = yesterday.isoformat()
|
yesterday_str = yesterday.isoformat()
|
||||||
yesterday_day = (now - timedelta(days=1)).strftime("%a").lower()
|
yesterday_day = yesterday.strftime("%a").lower()
|
||||||
for med in meds:
|
for med in meds:
|
||||||
if med["id"] in seen_med_ids:
|
if med["id"] in seen_med_ids:
|
||||||
continue
|
continue
|
||||||
@@ -443,7 +444,7 @@ def register(app):
|
|||||||
return flask.jsonify({"error": "unauthorized"}), 401
|
return flask.jsonify({"error": "unauthorized"}), 401
|
||||||
num_days = flask.request.args.get("days", 30, type=int)
|
num_days = flask.request.args.get("days", 30, type=int)
|
||||||
meds = postgres.select("medications", where={"user_uuid": user_uuid, "active": True})
|
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 = today - timedelta(days=num_days)
|
||||||
period_start_str = period_start.isoformat()
|
period_start_str = period_start.isoformat()
|
||||||
|
|
||||||
@@ -485,7 +486,7 @@ def register(app):
|
|||||||
if not med:
|
if not med:
|
||||||
return flask.jsonify({"error": "not found"}), 404
|
return flask.jsonify({"error": "not found"}), 404
|
||||||
num_days = flask.request.args.get("days", 30, type=int)
|
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 = today - timedelta(days=num_days)
|
||||||
period_start_str = period_start.isoformat()
|
period_start_str = period_start.isoformat()
|
||||||
|
|
||||||
@@ -542,7 +543,7 @@ def register(app):
|
|||||||
if not user_uuid:
|
if not user_uuid:
|
||||||
return flask.jsonify({"error": "unauthorized"}), 401
|
return flask.jsonify({"error": "unauthorized"}), 401
|
||||||
days_ahead = flask.request.args.get("days_ahead", 7, type=int)
|
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(
|
meds = postgres.select(
|
||||||
"medications",
|
"medications",
|
||||||
where={"user_uuid": user_uuid},
|
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 jwt
|
||||||
import core.auth as auth
|
import core.auth as auth
|
||||||
import core.postgres as postgres
|
import core.postgres as postgres
|
||||||
|
import core.tz as tz
|
||||||
|
|
||||||
|
|
||||||
def _get_user_uuid(token):
|
def _get_user_uuid(token):
|
||||||
@@ -45,7 +46,7 @@ def register(app):
|
|||||||
return flask.jsonify({"error": "session not active"}), 400
|
return flask.jsonify({"error": "session not active"}), 400
|
||||||
result = postgres.update(
|
result = postgres.update(
|
||||||
"routine_sessions",
|
"routine_sessions",
|
||||||
{"status": "paused", "paused_at": datetime.now().isoformat()},
|
{"status": "paused", "paused_at": tz.user_now().isoformat()},
|
||||||
{"id": session_id}
|
{"id": session_id}
|
||||||
)
|
)
|
||||||
return flask.jsonify({"status": "paused"}), 200
|
return flask.jsonify({"status": "paused"}), 200
|
||||||
@@ -81,7 +82,7 @@ def register(app):
|
|||||||
reason = data.get("reason", "Aborted by user")
|
reason = data.get("reason", "Aborted by user")
|
||||||
result = postgres.update(
|
result = postgres.update(
|
||||||
"routine_sessions",
|
"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}
|
{"id": session_id}
|
||||||
)
|
)
|
||||||
return flask.jsonify({"status": "aborted", "reason": reason}), 200
|
return flask.jsonify({"status": "aborted", "reason": reason}), 200
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import flask
|
|||||||
import jwt
|
import jwt
|
||||||
import core.auth as auth
|
import core.auth as auth
|
||||||
import core.postgres as postgres
|
import core.postgres as postgres
|
||||||
|
import core.tz as tz
|
||||||
|
|
||||||
|
|
||||||
def _get_user_uuid(token):
|
def _get_user_uuid(token):
|
||||||
@@ -128,7 +129,7 @@ def register(app):
|
|||||||
"routines_started": 0,
|
"routines_started": 0,
|
||||||
"routines": [],
|
"routines": [],
|
||||||
}), 200
|
}), 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})
|
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]
|
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"]
|
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 os
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
import flask
|
import flask
|
||||||
import jwt
|
import jwt
|
||||||
import core.auth as auth
|
import core.auth as auth
|
||||||
import core.postgres as postgres
|
import core.postgres as postgres
|
||||||
|
import core.routines as routines_core
|
||||||
|
import core.tz as tz
|
||||||
|
|
||||||
|
|
||||||
def _get_user_uuid(token):
|
def _get_user_uuid(token):
|
||||||
@@ -32,6 +35,111 @@ def _auth(request):
|
|||||||
return user_uuid
|
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):
|
def register(app):
|
||||||
|
|
||||||
# ── Routines CRUD ─────────────────────────────────────────────
|
# ── Routines CRUD ─────────────────────────────────────────────
|
||||||
@@ -89,7 +197,7 @@ def register(app):
|
|||||||
existing = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid})
|
existing = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid})
|
||||||
if not existing:
|
if not existing:
|
||||||
return flask.jsonify({"error": "not found"}), 404
|
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}
|
updates = {k: v for k, v in data.items() if k in allowed}
|
||||||
if not updates:
|
if not updates:
|
||||||
return flask.jsonify({"error": "no valid fields to update"}), 400
|
return flask.jsonify({"error": "no valid fields to update"}), 400
|
||||||
@@ -257,6 +365,8 @@ def register(app):
|
|||||||
if not user_uuid:
|
if not user_uuid:
|
||||||
return flask.jsonify({"error": "unauthorized"}), 401
|
return flask.jsonify({"error": "unauthorized"}), 401
|
||||||
session = postgres.select_one("routine_sessions", {"user_uuid": user_uuid, "status": "active"})
|
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:
|
if not session:
|
||||||
return flask.jsonify({"error": "no active session"}), 404
|
return flask.jsonify({"error": "no active session"}), 404
|
||||||
routine = postgres.select_one("routines", {"id": session["routine_id"]})
|
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})
|
session = postgres.select_one("routine_sessions", {"id": session_id, "user_uuid": user_uuid})
|
||||||
if not session:
|
if not session:
|
||||||
return flask.jsonify({"error": "not found"}), 404
|
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
|
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 {}
|
data = flask.request.get_json() or {}
|
||||||
steps = postgres.select(
|
steps = postgres.select(
|
||||||
"routine_steps",
|
"routine_steps",
|
||||||
where={"routine_id": session["routine_id"]},
|
where={"routine_id": session["routine_id"]},
|
||||||
order_by="position",
|
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):
|
if next_index >= len(steps):
|
||||||
postgres.update("routine_sessions", {"status": "completed"}, {"id": session_id})
|
# Session complete — compute celebration data
|
||||||
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})
|
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
|
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})
|
session = postgres.select_one("routine_sessions", {"id": session_id, "user_uuid": user_uuid})
|
||||||
if not session:
|
if not session:
|
||||||
return flask.jsonify({"error": "not found"}), 404
|
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
|
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(
|
steps = postgres.select(
|
||||||
"routine_steps",
|
"routine_steps",
|
||||||
where={"routine_id": session["routine_id"]},
|
where={"routine_id": session["routine_id"]},
|
||||||
order_by="position",
|
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):
|
if next_index >= len(steps):
|
||||||
postgres.update("routine_sessions", {"status": "completed"}, {"id": session_id})
|
celebration = _complete_session_with_celebration(session_id, user_uuid, session)
|
||||||
return flask.jsonify({"session": {"status": "completed"}, "next_step": None}), 200
|
return flask.jsonify({
|
||||||
|
"session": {"status": "completed"},
|
||||||
|
"next_step": None,
|
||||||
|
"celebration": celebration,
|
||||||
|
}), 200
|
||||||
postgres.update("routine_sessions", {"current_step_index": next_index}, {"id": session_id})
|
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
|
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,
|
name VARCHAR(255) NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
icon VARCHAR(100),
|
icon VARCHAR(100),
|
||||||
|
location VARCHAR(255),
|
||||||
|
environment_prompts JSON DEFAULT '[]',
|
||||||
|
habit_stack_after VARCHAR(255),
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -122,6 +125,49 @@ CREATE TABLE IF NOT EXISTS routine_streaks (
|
|||||||
last_completed_date DATE
|
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 ─────────────────────────────────────────────
|
-- ── Medications ─────────────────────────────────────────────
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS 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
|
import uuid
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
import core.postgres as postgres
|
import core.postgres as postgres
|
||||||
|
import core.tz as tz
|
||||||
|
|
||||||
|
|
||||||
def start_session(routine_id, user_uuid):
|
def start_session(routine_id, user_uuid):
|
||||||
@@ -159,9 +160,13 @@ def clone_template(template_id, user_uuid):
|
|||||||
return routine
|
return routine
|
||||||
|
|
||||||
|
|
||||||
|
STREAK_MILESTONES = {3, 7, 14, 21, 30, 60, 90, 100, 365}
|
||||||
|
|
||||||
|
|
||||||
def _update_streak(user_uuid, routine_id):
|
def _update_streak(user_uuid, routine_id):
|
||||||
"""Update streak after completing a session. Resets if day was missed."""
|
"""Update streak after completing a session. Resets if day was missed.
|
||||||
today = date.today()
|
Returns the updated streak dict with optional 'milestone' key."""
|
||||||
|
today = tz.user_today()
|
||||||
|
|
||||||
streak = postgres.select_one(
|
streak = postgres.select_one(
|
||||||
"routine_streaks",
|
"routine_streaks",
|
||||||
@@ -169,6 +174,7 @@ def _update_streak(user_uuid, routine_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not streak:
|
if not streak:
|
||||||
|
new_streak_val = 1
|
||||||
new_streak = {
|
new_streak = {
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
"user_uuid": user_uuid,
|
"user_uuid": user_uuid,
|
||||||
@@ -177,7 +183,10 @@ def _update_streak(user_uuid, routine_id):
|
|||||||
"longest_streak": 1,
|
"longest_streak": 1,
|
||||||
"last_completed_date": today.isoformat(),
|
"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")
|
last_completed = streak.get("last_completed_date")
|
||||||
if last_completed:
|
if last_completed:
|
||||||
@@ -187,24 +196,28 @@ def _update_streak(user_uuid, routine_id):
|
|||||||
if days_diff == 0:
|
if days_diff == 0:
|
||||||
return streak
|
return streak
|
||||||
elif days_diff == 1:
|
elif days_diff == 1:
|
||||||
new_streak = streak["current_streak"] + 1
|
new_streak_val = streak["current_streak"] + 1
|
||||||
else:
|
else:
|
||||||
new_streak = 1
|
new_streak_val = 1
|
||||||
else:
|
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(
|
postgres.update(
|
||||||
"routine_streaks",
|
"routine_streaks",
|
||||||
{
|
{
|
||||||
"current_streak": new_streak,
|
"current_streak": new_streak_val,
|
||||||
"longest_streak": longest,
|
"longest_streak": longest,
|
||||||
"last_completed_date": today.isoformat(),
|
"last_completed_date": today.isoformat(),
|
||||||
},
|
},
|
||||||
{"id": streak["id"]}
|
{"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):
|
def calculate_streak(user_uuid, routine_id):
|
||||||
@@ -217,8 +230,14 @@ def calculate_streak(user_uuid, routine_id):
|
|||||||
|
|
||||||
|
|
||||||
def get_active_session(user_uuid):
|
def get_active_session(user_uuid):
|
||||||
"""Get user's currently active session."""
|
"""Get user's currently active or paused session."""
|
||||||
return postgres.select_one(
|
session = postgres.select_one(
|
||||||
"routine_sessions",
|
"routine_sessions",
|
||||||
{"user_uuid": user_uuid, "status": "active"}
|
{"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
|
- pgdata:/var/lib/postgresql/data
|
||||||
- ./config/schema.sql:/docker-entrypoint-initdb.d/schema.sql
|
- ./config/schema.sql:/docker-entrypoint-initdb.d/schema.sql
|
||||||
- ./config/seed_templates.sql:/docker-entrypoint-initdb.d/seed_templates.sql
|
- ./config/seed_templates.sql:/docker-entrypoint-initdb.d/seed_templates.sql
|
||||||
|
- ./config/seed_rewards.sql:/docker-entrypoint-initdb.d/seed_rewards.sql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U app"]
|
test: ["CMD-SHELL", "pg_isready -U app"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Override poll_callback() with your domain-specific logic.
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
import core.postgres as postgres
|
import core.postgres as postgres
|
||||||
import core.notifications as notifications
|
import core.notifications as notifications
|
||||||
@@ -18,60 +18,81 @@ logger = logging.getLogger(__name__)
|
|||||||
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", 60))
|
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():
|
def check_medication_reminders():
|
||||||
"""Check for medications due now and send notifications."""
|
"""Check for medications due now and send notifications."""
|
||||||
try:
|
try:
|
||||||
from datetime import date as date_type
|
from datetime import date as date_type
|
||||||
meds = postgres.select("medications", where={"active": True})
|
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:
|
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
|
for user_uuid, user_med_list in user_meds.items():
|
||||||
if freq == "as_needed":
|
now = _user_now_for(user_uuid)
|
||||||
continue
|
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
|
for med in user_med_list:
|
||||||
if freq == "specific_days":
|
freq = med.get("frequency", "daily")
|
||||||
days = med.get("days_of_week", [])
|
|
||||||
if current_day not in days:
|
# Skip as_needed -- no scheduled reminders for PRN
|
||||||
|
if freq == "as_needed":
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Interval check for every_n_days
|
# Day-of-week check for specific_days
|
||||||
if freq == "every_n_days":
|
if freq == "specific_days":
|
||||||
start = med.get("start_date")
|
med_days = med.get("days_of_week", [])
|
||||||
interval = med.get("interval_days")
|
if current_day not in med_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
|
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
|
continue
|
||||||
|
|
||||||
# Time check
|
# Already taken today? Check by created_at date
|
||||||
times = med.get("times", [])
|
logs = postgres.select("med_logs", where={"medication_id": med["id"], "action": "taken"})
|
||||||
if current_time not in times:
|
already_taken = any(
|
||||||
continue
|
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
|
user_settings = notifications.getNotificationSettings(user_uuid)
|
||||||
logs = postgres.select("med_logs", where={"medication_id": med["id"], "action": "taken"})
|
if user_settings:
|
||||||
already_taken = any(
|
msg = f"Time to take {med['name']} ({med['dosage']} {med['unit']})"
|
||||||
log.get("scheduled_time") == current_time
|
notifications._sendToEnabledChannels(user_settings, msg, user_uuid=user_uuid)
|
||||||
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"])
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking medication reminders: {e}")
|
logger.error(f"Error checking medication reminders: {e}")
|
||||||
|
|
||||||
@@ -79,22 +100,23 @@ def check_medication_reminders():
|
|||||||
def check_routine_reminders():
|
def check_routine_reminders():
|
||||||
"""Check for scheduled routines due now and send notifications."""
|
"""Check for scheduled routines due now and send notifications."""
|
||||||
try:
|
try:
|
||||||
now = datetime.now()
|
|
||||||
current_time = now.strftime("%H:%M")
|
|
||||||
current_day = now.strftime("%a").lower()
|
|
||||||
schedules = postgres.select("routine_schedules", where={"remind": True})
|
schedules = postgres.select("routine_schedules", where={"remind": True})
|
||||||
|
|
||||||
for schedule in schedules:
|
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"):
|
if current_time != schedule.get("time"):
|
||||||
continue
|
continue
|
||||||
days = schedule.get("days", [])
|
days = schedule.get("days", [])
|
||||||
if current_day not in days:
|
if current_day not in days:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
routine = postgres.select_one("routines", {"id": schedule["routine_id"]})
|
|
||||||
if not routine:
|
|
||||||
continue
|
|
||||||
|
|
||||||
user_settings = notifications.getNotificationSettings(routine["user_uuid"])
|
user_settings = notifications.getNotificationSettings(routine["user_uuid"])
|
||||||
if user_settings:
|
if user_settings:
|
||||||
msg = f"Time to start your routine: {routine['name']}"
|
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';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useRouter, usePathname } from 'next/navigation';
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
import { useAuth } from '@/components/auth/AuthProvider';
|
import { useAuth } from '@/components/auth/AuthProvider';
|
||||||
|
import api from '@/lib/api';
|
||||||
import {
|
import {
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
ListIcon,
|
ListIcon,
|
||||||
@@ -34,12 +35,23 @@ export default function DashboardLayout({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const tzSynced = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && !isAuthenticated) {
|
if (!isLoading && !isAuthenticated) {
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, isLoading, router]);
|
}, [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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
@@ -55,6 +67,13 @@ export default function DashboardLayout({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide chrome during active session run
|
||||||
|
const isRunMode = pathname.includes('/run');
|
||||||
|
|
||||||
|
if (isRunMode) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
||||||
@@ -65,12 +84,17 @@ export default function DashboardLayout({
|
|||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-gray-900">Synculous</span>
|
<span className="font-bold text-gray-900">Synculous</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={logout}
|
<Link href="/dashboard/settings" className="p-2 text-gray-500 hover:text-gray-700">
|
||||||
className="p-2 text-gray-500 hover:text-gray-700"
|
<SettingsIcon size={20} />
|
||||||
>
|
</Link>
|
||||||
<LogOutIcon size={20} />
|
<button
|
||||||
</button>
|
onClick={logout}
|
||||||
|
className="p-2 text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
<LogOutIcon size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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">
|
<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">
|
<div className="flex justify-around py-2">
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const isActive = pathname === item.href ||
|
const isActive = pathname === item.href ||
|
||||||
(item.href !== '/dashboard' && pathname.startsWith(item.href));
|
(item.href !== '/dashboard' && pathname.startsWith(item.href));
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={`flex flex-col items-center gap-1 px-3 py-2 rounded-lg transition-colors ${
|
className={`flex flex-col items-center gap-1 px-3 py-2 rounded-lg transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? 'text-indigo-600'
|
? 'text-indigo-600'
|
||||||
: 'text-gray-500 hover:text-gray-700'
|
: '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() {
|
export default function DashboardPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [routines, setRoutines] = useState<Routine[]>([]);
|
const [routines, setRoutines] = useState<Routine[]>([]);
|
||||||
const [activeSession, setActiveSession] = useState<ActiveSession | null>(null);
|
const [activeSession, setActiveSession] = useState<ActiveSession | null>(null);
|
||||||
const [weeklySummary, setWeeklySummary] = useState<WeeklySummary | null>(null);
|
const [weeklySummary, setWeeklySummary] = useState<WeeklySummary | null>(null);
|
||||||
|
const [streaks, setStreaks] = useState<Streak[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const [routinesData, activeData, summaryData] = await Promise.all([
|
const [routinesData, activeData, summaryData, streaksData] = await Promise.all([
|
||||||
api.routines.list().catch(() => []),
|
api.routines.list().catch(() => []),
|
||||||
api.sessions.getActive().catch(() => null),
|
api.sessions.getActive().catch(() => null),
|
||||||
api.stats.getWeeklySummary().catch(() => null),
|
api.stats.getWeeklySummary().catch(() => null),
|
||||||
|
api.stats.getStreaks().catch(() => []),
|
||||||
]);
|
]);
|
||||||
setRoutines(routinesData);
|
setRoutines(routinesData);
|
||||||
setActiveSession(activeData);
|
setActiveSession(activeData);
|
||||||
setWeeklySummary(summaryData);
|
setWeeklySummary(summaryData);
|
||||||
|
setStreaks(streaksData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch dashboard data:', err);
|
console.error('Failed to fetch dashboard data:', err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -75,12 +86,12 @@ export default function DashboardPage() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleStartRoutine = async (routineId: string) => {
|
const handleStartRoutine = (routineId: string) => {
|
||||||
try {
|
// If there's an active session, go straight to run
|
||||||
await api.sessions.start(routineId);
|
if (activeSession) {
|
||||||
router.push(`/dashboard/routines/${routineId}/run`);
|
router.push(`/dashboard/routines/${activeSession.routine.id}/run`);
|
||||||
} catch (err) {
|
} else {
|
||||||
setError((err as Error).message);
|
router.push(`/dashboard/routines/${routineId}/launch`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,6 +115,17 @@ export default function DashboardPage() {
|
|||||||
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[50vh]">
|
<div className="flex items-center justify-center min-h-[50vh]">
|
||||||
@@ -112,14 +134,16 @@ export default function DashboardPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recoveryRoutines = getRecoveryRoutines();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-6">
|
<div className="p-4 space-y-6">
|
||||||
{/* Active Session Banner */}
|
{/* Active Session Banner */}
|
||||||
{activeSession && activeSession.session.status === 'active' && (
|
{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 className="flex items-center justify-between">
|
||||||
<div>
|
<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>
|
<h2 className="text-xl font-bold">{activeSession.routine.name}</h2>
|
||||||
<p className="text-white/80 text-sm mt-1">
|
<p className="text-white/80 text-sm mt-1">
|
||||||
Step {activeSession.session.current_step_index + 1}: {activeSession.current_step?.name}
|
Step {activeSession.session.current_step_index + 1}: {activeSession.current_step?.name}
|
||||||
@@ -138,32 +162,59 @@ export default function DashboardPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">{getGreeting()}, {user?.username}!</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Weekly Stats */}
|
{/* "Never miss twice" Recovery Cards */}
|
||||||
{weeklySummary && (
|
{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="grid grid-cols-3 gap-3">
|
||||||
<div className="bg-white rounded-xl p-4 shadow-sm">
|
<div className="bg-white rounded-xl p-4 shadow-sm">
|
||||||
<div className="flex items-center gap-2 text-indigo-600 mb-1">
|
<div className="flex items-center gap-2 text-indigo-600 mb-1">
|
||||||
<StarIcon size={18} />
|
<StarIcon size={18} />
|
||||||
<span className="text-xs font-medium">Completed</span>
|
<span className="text-xs font-medium">Done</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-gray-900">{weeklySummary.total_completed}</p>
|
<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>
|
||||||
<div className="bg-white rounded-xl p-4 shadow-sm">
|
<div className="bg-white rounded-xl p-4 shadow-sm">
|
||||||
<div className="flex items-center gap-2 text-purple-600 mb-1">
|
<div className="flex items-center gap-2 text-purple-600 mb-1">
|
||||||
<ClockIcon size={18} />
|
<ClockIcon size={18} />
|
||||||
<span className="text-xs font-medium">Time</span>
|
<span className="text-xs font-medium">Invested</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-gray-900">{formatTime(weeklySummary.total_time_minutes)}</p>
|
<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>
|
||||||
<div className="bg-white rounded-xl p-4 shadow-sm">
|
<div className="bg-white rounded-xl p-4 shadow-sm">
|
||||||
<div className="flex items-center gap-2 text-pink-600 mb-1">
|
<div className="flex items-center gap-2 text-pink-600 mb-1">
|
||||||
<ActivityIcon size={18} />
|
<ActivityIcon size={18} />
|
||||||
<span className="text-xs font-medium">Started</span>
|
<span className="text-xs font-medium">Active</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-gray-900">{weeklySummary.routines_started}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -171,7 +222,7 @@ export default function DashboardPage() {
|
|||||||
{/* Quick Start Routines */}
|
{/* Quick Start Routines */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-3">Your Routines</h2>
|
<h2 className="text-lg font-semibold text-gray-900 mb-3">Your Routines</h2>
|
||||||
|
|
||||||
{routines.length === 0 ? (
|
{routines.length === 0 ? (
|
||||||
<div className="bg-white rounded-xl p-8 shadow-sm text-center">
|
<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">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{routines.map((routine) => (
|
{routines.map((routine) => {
|
||||||
<div
|
const streak = streaks.find(s => s.routine_id === routine.id);
|
||||||
key={routine.id}
|
return (
|
||||||
className="bg-white rounded-xl p-4 shadow-sm flex items-center justify-between"
|
<div
|
||||||
>
|
key={routine.id}
|
||||||
<div className="flex items-center gap-3">
|
className="bg-white rounded-xl p-4 shadow-sm flex items-center justify-between"
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
<PlayIcon size={20} />
|
<div className="flex items-center gap-3">
|
||||||
</button>
|
<div className="w-12 h-12 bg-gradient-to-br from-indigo-100 to-pink-100 rounded-xl flex items-center justify-center">
|
||||||
</div>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -233,7 +292,7 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</div>
|
</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;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
location?: string;
|
||||||
|
environment_prompts?: string[];
|
||||||
|
habit_stack_after?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ICONS = ['✨', '🌅', '🌙', '☀️', '💪', '🧘', '📚', '🍳', '🏃', '💼', '🎯', '⭐', '🔥', '💤', '🧠'];
|
const ICONS = ['✨', '🌅', '🌙', '☀️', '💪', '🧘', '📚', '🍳', '🏃', '💼', '🎯', '⭐', '🔥', '💤', '🧠'];
|
||||||
@@ -36,6 +39,10 @@ export default function RoutineDetailPage() {
|
|||||||
const [editName, setEditName] = useState('');
|
const [editName, setEditName] = useState('');
|
||||||
const [editDescription, setEditDescription] = useState('');
|
const [editDescription, setEditDescription] = useState('');
|
||||||
const [editIcon, setEditIcon] = 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 [newStepName, setNewStepName] = useState('');
|
||||||
const [newStepDuration, setNewStepDuration] = useState(5);
|
const [newStepDuration, setNewStepDuration] = useState(5);
|
||||||
|
|
||||||
@@ -48,6 +55,9 @@ export default function RoutineDetailPage() {
|
|||||||
setEditName(data.routine.name);
|
setEditName(data.routine.name);
|
||||||
setEditDescription(data.routine.description || '');
|
setEditDescription(data.routine.description || '');
|
||||||
setEditIcon(data.routine.icon || '✨');
|
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) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch routine:', err);
|
console.error('Failed to fetch routine:', err);
|
||||||
router.push('/dashboard/routines');
|
router.push('/dashboard/routines');
|
||||||
@@ -58,13 +68,8 @@ export default function RoutineDetailPage() {
|
|||||||
fetchRoutine();
|
fetchRoutine();
|
||||||
}, [routineId, router]);
|
}, [routineId, router]);
|
||||||
|
|
||||||
const handleStart = async () => {
|
const handleStart = () => {
|
||||||
try {
|
router.push(`/dashboard/routines/${routineId}/launch`);
|
||||||
await api.sessions.start(routineId);
|
|
||||||
router.push(`/dashboard/routines/${routineId}/run`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to start routine:', err);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveBasicInfo = async () => {
|
const handleSaveBasicInfo = async () => {
|
||||||
@@ -73,8 +78,19 @@ export default function RoutineDetailPage() {
|
|||||||
name: editName,
|
name: editName,
|
||||||
description: editDescription,
|
description: editDescription,
|
||||||
icon: editIcon,
|
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);
|
setIsEditing(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to update routine:', 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"
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsEditing(false)}
|
onClick={() => setIsEditing(false)}
|
||||||
@@ -216,9 +295,9 @@ export default function RoutineDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleStart}
|
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
|
Start Routine
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,7 +305,17 @@ export default function RoutineDetailPage() {
|
|||||||
|
|
||||||
{/* Steps */}
|
{/* Steps */}
|
||||||
<div>
|
<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="bg-white rounded-xl p-4 shadow-sm space-y-3 mb-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { useEffect, useState, useCallback, useRef } from 'react';
|
|||||||
import { useRouter, useParams } from 'next/navigation';
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { ArrowLeftIcon, PauseIcon, PlayIcon, StopIcon, SkipForwardIcon, CheckIcon, XIcon } from '@/components/ui/Icons';
|
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 {
|
interface Step {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,6 +31,15 @@ interface Session {
|
|||||||
current_step_index: number;
|
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() {
|
export default function SessionRunnerPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -38,12 +51,24 @@ export default function SessionRunnerPage() {
|
|||||||
const [currentStep, setCurrentStep] = useState<Step | null>(null);
|
const [currentStep, setCurrentStep] = useState<Step | null>(null);
|
||||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||||
const [status, setStatus] = useState<'loading' | 'active' | 'paused' | 'completed'>('loading');
|
const [status, setStatus] = useState<'loading' | 'active' | 'paused' | 'completed'>('loading');
|
||||||
|
|
||||||
const [timerSeconds, setTimerSeconds] = useState(0);
|
const [timerSeconds, setTimerSeconds] = useState(0);
|
||||||
const [isTimerRunning, setIsTimerRunning] = useState(false);
|
const [isTimerRunning, setIsTimerRunning] = useState(false);
|
||||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
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 touchStartX = useRef<number | null>(null);
|
||||||
const touchStartY = useRef<number | null>(null);
|
const touchStartY = useRef<number | null>(null);
|
||||||
|
|
||||||
@@ -58,10 +83,23 @@ export default function SessionRunnerPage() {
|
|||||||
setCurrentStep(sessionData.current_step);
|
setCurrentStep(sessionData.current_step);
|
||||||
setCurrentStepIndex(sessionData.session.current_step_index);
|
setCurrentStepIndex(sessionData.session.current_step_index);
|
||||||
setStatus(sessionData.session.status === 'paused' ? 'paused' : 'active');
|
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) {
|
if (sessionData.current_step?.duration_minutes) {
|
||||||
setTimerSeconds(sessionData.current_step.duration_minutes * 60);
|
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) {
|
} catch (err) {
|
||||||
router.push('/dashboard');
|
router.push('/dashboard');
|
||||||
}
|
}
|
||||||
@@ -89,25 +127,24 @@ export default function SessionRunnerPage() {
|
|||||||
|
|
||||||
// Touch handlers for swipe
|
// Touch handlers for swipe
|
||||||
const handleTouchStart = (e: React.TouchEvent) => {
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
if (completionPhase !== 'idle' || skipPhase !== 'idle') return;
|
||||||
touchStartX.current = e.touches[0].clientX;
|
touchStartX.current = e.touches[0].clientX;
|
||||||
touchStartY.current = e.touches[0].clientY;
|
touchStartY.current = e.touches[0].clientY;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTouchEnd = (e: React.TouchEvent) => {
|
const handleTouchEnd = (e: React.TouchEvent) => {
|
||||||
if (touchStartX.current === null || touchStartY.current === null) return;
|
if (touchStartX.current === null || touchStartY.current === null) return;
|
||||||
|
if (completionPhase !== 'idle' || skipPhase !== 'idle') return;
|
||||||
|
|
||||||
const touchEndX = e.changedTouches[0].clientX;
|
const touchEndX = e.changedTouches[0].clientX;
|
||||||
const touchEndY = e.changedTouches[0].clientY;
|
const touchEndY = e.changedTouches[0].clientY;
|
||||||
const diffX = touchEndX - touchStartX.current;
|
const diffX = touchEndX - touchStartX.current;
|
||||||
const diffY = touchEndY - touchStartY.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 (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
|
||||||
if (diffX < 0) {
|
if (diffX < 0) {
|
||||||
// Swipe left - complete
|
|
||||||
handleComplete();
|
handleComplete();
|
||||||
} else {
|
} else {
|
||||||
// Swipe right - skip
|
|
||||||
handleSkip();
|
handleSkip();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,46 +154,93 @@ export default function SessionRunnerPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleComplete = async () => {
|
const handleComplete = async () => {
|
||||||
if (!session || !currentStep) return;
|
if (!session || !currentStep || completionPhase !== 'idle') return;
|
||||||
|
|
||||||
setSwipeDirection('left');
|
// Auto-resume if paused
|
||||||
setTimeout(() => setSwipeDirection(null), 300);
|
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 {
|
try {
|
||||||
const result = await api.sessions.completeStep(session.id, currentStep.id);
|
const result = await api.sessions.completeStep(session.id, currentStep.id);
|
||||||
if (result.next_step) {
|
|
||||||
setCurrentStep(result.next_step);
|
// Mark current step as completed for progress bar
|
||||||
setCurrentStepIndex(result.session.current_step_index!);
|
setCompletedSteps(prev => new Set([...prev, currentStepIndex]));
|
||||||
setTimerSeconds((result.next_step.duration_minutes || 5) * 60);
|
|
||||||
setIsTimerRunning(true);
|
// Phase 2: Slide out (after brief glow)
|
||||||
} else {
|
setTimeout(() => {
|
||||||
setStatus('completed');
|
setCompletionPhase('transitioning');
|
||||||
setIsTimerRunning(false);
|
}, 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) {
|
} catch (err) {
|
||||||
console.error('Failed to complete step:', err);
|
console.error('Failed to complete step:', err);
|
||||||
|
setCompletionPhase('idle');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSkip = async () => {
|
const handleSkip = async () => {
|
||||||
if (!session || !currentStep) return;
|
if (!session || !currentStep || skipPhase !== 'idle') return;
|
||||||
|
|
||||||
setSwipeDirection('right');
|
// Auto-resume if paused
|
||||||
setTimeout(() => setSwipeDirection(null), 300);
|
if (status === 'paused') {
|
||||||
|
setStatus('active');
|
||||||
|
setIsTimerRunning(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSkipPhase('skipping');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.sessions.skipStep(session.id, currentStep.id);
|
const result = await api.sessions.skipStep(session.id, currentStep.id);
|
||||||
if (result.next_step) {
|
|
||||||
setCurrentStep(result.next_step);
|
setTimeout(() => {
|
||||||
setCurrentStepIndex(result.session.current_step_index!);
|
setSkipPhase('transitioning');
|
||||||
setTimerSeconds((result.next_step.duration_minutes || 5) * 60);
|
}, 150);
|
||||||
setIsTimerRunning(true);
|
|
||||||
} else {
|
setTimeout(() => {
|
||||||
setStatus('completed');
|
if (result.next_step) {
|
||||||
setIsTimerRunning(false);
|
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) {
|
} catch (err) {
|
||||||
console.error('Failed to skip step:', 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;
|
if (!session) return;
|
||||||
try {
|
try {
|
||||||
await api.sessions.cancel(session.id);
|
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) {
|
if (status === 'loading' || !currentStep) {
|
||||||
return (
|
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 className="w-8 h-8 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Celebration Screen ──────────────────────────────────────
|
||||||
if (status === 'completed') {
|
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 (
|
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={`min-h-screen flex flex-col items-center justify-center p-6 transition-all duration-500 ${
|
||||||
<div className="w-24 h-24 bg-white rounded-full flex items-center justify-center mb-6">
|
celebrationPhase >= 1
|
||||||
<CheckIcon className="text-green-500" size={48} />
|
? '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>
|
</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
|
<button
|
||||||
onClick={() => router.push('/dashboard')}
|
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
|
Done
|
||||||
</button>
|
</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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="min-h-screen bg-gray-900 text-white flex flex-col"
|
className="min-h-screen bg-gray-950 text-white flex flex-col"
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="flex items-center justify-between px-4 py-4">
|
<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} />
|
<XIcon size={24} />
|
||||||
</button>
|
</button>
|
||||||
<div className="text-center">
|
<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="text-white/60 text-sm">{routine?.name}</p>
|
||||||
<p className="font-semibold">Step {currentStepIndex + 1} of {steps.length}</p>
|
<p className="font-semibold">Step {currentStepIndex + 1} of {steps.length}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,34 +446,44 @@ export default function SessionRunnerPage() {
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Segmented Progress Bar */}
|
||||||
<div className="px-4">
|
<div className="px-4 pb-2">
|
||||||
<div className="h-1 bg-gray-700 rounded-full overflow-hidden">
|
<div className="flex gap-1">
|
||||||
<div
|
{steps.map((_, i) => (
|
||||||
className="h-full bg-indigo-500 transition-all duration-500"
|
<div key={i} className="flex-1 h-2 rounded-full overflow-hidden bg-gray-800">
|
||||||
style={{ width: `${progress}%` }}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Visual Timeline — collapsible step overview */}
|
||||||
|
<VisualTimeline
|
||||||
|
steps={steps}
|
||||||
|
currentStepIndex={currentStepIndex}
|
||||||
|
completedSteps={completedSteps}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Main Card */}
|
{/* Main Card */}
|
||||||
<div className="flex-1 flex flex-col items-center justify-center p-6">
|
<div className="flex-1 flex flex-col items-center justify-center p-6">
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
w-full max-w-md bg-gray-800 rounded-3xl p-8 text-center
|
w-full max-w-md bg-gray-800 rounded-3xl p-8 text-center
|
||||||
transition-transform duration-300
|
shadow-2xl shadow-indigo-500/10
|
||||||
${swipeDirection === 'left' ? 'translate-x-20 opacity-50' : ''}
|
${getCardAnimation()}
|
||||||
${swipeDirection === 'right' ? '-translate-x-20 opacity-50' : ''}
|
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{/* 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 */}
|
{/* Timer */}
|
||||||
<div className="mb-6">
|
<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)}
|
{formatTime(timerSeconds)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-white/60">remaining</p>
|
<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>
|
<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>
|
||||||
</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>
|
</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() {
|
export default function StatsPage() {
|
||||||
const [routines, setRoutines] = useState<{ id: string; name: string }[]>([]);
|
const [routines, setRoutines] = useState<{ id: string; name: string }[]>([]);
|
||||||
const [selectedRoutine, setSelectedRoutine] = useState<string>('');
|
const [selectedRoutine, setSelectedRoutine] = useState<string>('');
|
||||||
const [routineStats, setRoutineStats] = useState<RoutineStats | null>(null);
|
const [routineStats, setRoutineStats] = useState<RoutineStats | null>(null);
|
||||||
const [streaks, setStreaks] = useState<Streak[]>([]);
|
const [streaks, setStreaks] = useState<Streak[]>([]);
|
||||||
const [weeklySummary, setWeeklySummary] = useState<WeeklySummary | null>(null);
|
const [weeklySummary, setWeeklySummary] = useState<WeeklySummary | null>(null);
|
||||||
|
const [victories, setVictories] = useState<Victory[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const [routinesData, streaksData, summaryData] = await Promise.all([
|
const [routinesData, streaksData, summaryData, victoriesData] = await Promise.all([
|
||||||
api.routines.list(),
|
api.routines.list(),
|
||||||
api.stats.getStreaks(),
|
api.stats.getStreaks(),
|
||||||
api.stats.getWeeklySummary(),
|
api.stats.getWeeklySummary(),
|
||||||
|
api.victories.get(30).catch(() => []),
|
||||||
]);
|
]);
|
||||||
setRoutines(routinesData);
|
setRoutines(routinesData);
|
||||||
setStreaks(streaksData);
|
setStreaks(streaksData);
|
||||||
setWeeklySummary(summaryData);
|
setWeeklySummary(summaryData);
|
||||||
|
setVictories(victoriesData);
|
||||||
|
|
||||||
if (routinesData.length > 0) {
|
if (routinesData.length > 0) {
|
||||||
setSelectedRoutine(routinesData[0].id);
|
setSelectedRoutine(routinesData[0].id);
|
||||||
}
|
}
|
||||||
@@ -97,7 +121,7 @@ export default function StatsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-6">
|
<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 */}
|
{/* Weekly Summary */}
|
||||||
{weeklySummary && (
|
{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">
|
<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} />
|
<ClockIcon className="text-white/80 mb-2" size={24} />
|
||||||
<p className="text-3xl font-bold">{formatTime(weeklySummary.total_time_minutes)}</p>
|
<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>
|
||||||
<div className="bg-gradient-to-br from-amber-500 to-orange-600 rounded-2xl p-4 text-white">
|
<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} />
|
<ActivityIcon className="text-white/80 mb-2" size={24} />
|
||||||
<p className="text-3xl font-bold">{weeklySummary.routines_started}</p>
|
<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>
|
||||||
</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 && (
|
{streaks.length > 0 && (
|
||||||
<div>
|
<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">
|
<div className="space-y-2">
|
||||||
{streaks.map((streak) => (
|
{streaks.map((streak) => (
|
||||||
<div key={streak.routine_id} className="bg-white rounded-xl p-4 shadow-sm flex items-center gap-4">
|
<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">
|
<div className="flex-1">
|
||||||
<p className="font-medium text-gray-900">{streak.routine_name}</p>
|
<p className="font-medium text-gray-900">{streak.routine_name}</p>
|
||||||
<p className="text-sm text-gray-500">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-2xl font-bold text-orange-500">{streak.current_streak}</p>
|
{streak.current_streak > 0 ? (
|
||||||
<p className="text-xs text-gray-500">day streak</p>
|
<>
|
||||||
|
<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>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -149,7 +207,7 @@ export default function StatsPage() {
|
|||||||
{/* Per-Routine Stats */}
|
{/* Per-Routine Stats */}
|
||||||
{routines.length > 0 && (
|
{routines.length > 0 && (
|
||||||
<div>
|
<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
|
<select
|
||||||
value={selectedRoutine}
|
value={selectedRoutine}
|
||||||
onChange={(e) => setSelectedRoutine(e.target.value)}
|
onChange={(e) => setSelectedRoutine(e.target.value)}
|
||||||
@@ -166,8 +224,10 @@ export default function StatsPage() {
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="bg-white rounded-xl p-4 shadow-sm">
|
<div className="bg-white rounded-xl p-4 shadow-sm">
|
||||||
<TargetIcon className="text-indigo-500 mb-2" size={24} />
|
<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-lg font-bold ${getCompletionLabel(routineStats.completion_rate_percent).color}`}>
|
||||||
<p className="text-sm text-gray-500">Completion Rate</p>
|
{getCompletionLabel(routineStats.completion_rate_percent).label}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-400">{routineStats.completion_rate_percent}%</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-xl p-4 shadow-sm">
|
<div className="bg-white rounded-xl p-4 shadow-sm">
|
||||||
<ClockIcon className="text-purple-500 mb-2" size={24} />
|
<ClockIcon className="text-purple-500 mb-2" size={24} />
|
||||||
@@ -186,6 +246,15 @@ export default function StatsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,3 +24,96 @@ body {
|
|||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
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) {
|
export function SkipForwardIcon({ className = '', size = 24 }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ async function request<T>(
|
|||||||
const token = getToken();
|
const token = getToken();
|
||||||
const headers: HeadersInit = {
|
const headers: HeadersInit = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
'X-Timezone-Offset': String(new Date().getTimezoneOffset()),
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
...options.headers,
|
...options.headers,
|
||||||
};
|
};
|
||||||
@@ -375,6 +376,14 @@ export const api = {
|
|||||||
duration_minutes?: number;
|
duration_minutes?: number;
|
||||||
position: number;
|
position: number;
|
||||||
} | null;
|
} | 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`, {
|
}>(`/api/sessions/${sessionId}/complete-step`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ step_id: stepId }),
|
body: JSON.stringify({ step_id: stepId }),
|
||||||
@@ -392,6 +401,14 @@ export const api = {
|
|||||||
duration_minutes?: number;
|
duration_minutes?: number;
|
||||||
position: number;
|
position: number;
|
||||||
} | null;
|
} | 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`, {
|
}>(`/api/sessions/${sessionId}/skip-step`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ step_id: stepId }),
|
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
|
||||||
notifications: {
|
notifications: {
|
||||||
getVapidPublicKey: async () => {
|
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