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

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

View File

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

View File

@@ -11,6 +11,7 @@ import jwt
from psycopg2.extras import Json
import core.auth as auth
import core.postgres as postgres
import core.tz as tz
def _get_user_uuid(token):
@@ -72,7 +73,7 @@ def _compute_next_dose_date(med):
interval = med.get("interval_days")
if not interval:
return None
return (date.today() + timedelta(days=interval)).isoformat()
return (tz.user_today() + timedelta(days=interval)).isoformat()
def _count_expected_doses(med, period_start, days):
@@ -162,7 +163,7 @@ def register(app):
# Compute next_dose_date for interval meds
if data.get("frequency") == "every_n_days" and data.get("start_date") and data.get("interval_days"):
start = datetime.strptime(data["start_date"], "%Y-%m-%d").date()
today = date.today()
today = tz.user_today()
if start > today:
row["next_dose_date"] = data["start_date"]
else:
@@ -322,8 +323,8 @@ def register(app):
return flask.jsonify({"error": "unauthorized"}), 401
meds = postgres.select("medications", where={"user_uuid": user_uuid, "active": True})
now = datetime.now()
today = date.today()
now = tz.user_now()
today = now.date()
today_str = today.isoformat()
current_day = now.strftime("%a").lower() # "mon","tue", etc.
current_hour = now.hour
@@ -361,7 +362,7 @@ def register(app):
if current_hour >= 22:
tomorrow = today + timedelta(days=1)
tomorrow_str = tomorrow.isoformat()
tomorrow_day = (now + timedelta(days=1)).strftime("%a").lower()
tomorrow_day = tomorrow.strftime("%a").lower()
for med in meds:
if med["id"] in seen_med_ids:
continue
@@ -398,7 +399,7 @@ def register(app):
if current_hour < 2:
yesterday = today - timedelta(days=1)
yesterday_str = yesterday.isoformat()
yesterday_day = (now - timedelta(days=1)).strftime("%a").lower()
yesterday_day = yesterday.strftime("%a").lower()
for med in meds:
if med["id"] in seen_med_ids:
continue
@@ -443,7 +444,7 @@ def register(app):
return flask.jsonify({"error": "unauthorized"}), 401
num_days = flask.request.args.get("days", 30, type=int)
meds = postgres.select("medications", where={"user_uuid": user_uuid, "active": True})
today = date.today()
today = tz.user_today()
period_start = today - timedelta(days=num_days)
period_start_str = period_start.isoformat()
@@ -485,7 +486,7 @@ def register(app):
if not med:
return flask.jsonify({"error": "not found"}), 404
num_days = flask.request.args.get("days", 30, type=int)
today = date.today()
today = tz.user_today()
period_start = today - timedelta(days=num_days)
period_start_str = period_start.isoformat()
@@ -542,7 +543,7 @@ def register(app):
if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401
days_ahead = flask.request.args.get("days_ahead", 7, type=int)
cutoff = (datetime.now() + timedelta(days=days_ahead)).strftime("%Y-%m-%d")
cutoff = (tz.user_today() + timedelta(days=days_ahead)).isoformat()
meds = postgres.select(
"medications",
where={"user_uuid": user_uuid},

View File

@@ -0,0 +1,94 @@
"""
Notifications API - Web push subscription management and VAPID key endpoint
"""
import os
import uuid
import flask
import jwt
import core.auth as auth
import core.postgres as postgres
def _get_user_uuid(token):
try:
payload = jwt.decode(token, os.getenv("JWT_SECRET"), algorithms=["HS256"])
return payload.get("sub")
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
return None
def _auth(request):
"""Extract and verify token. Returns user_uuid or None."""
header = request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
return None
token = header[7:]
user_uuid = _get_user_uuid(token)
if not user_uuid or not auth.verifyLoginToken(token, userUUID=user_uuid):
return None
return user_uuid
def register(app):
@app.route("/api/notifications/vapid-public-key", methods=["GET"])
def api_vapidPublicKey():
"""Return the VAPID public key for push subscription."""
key = os.environ.get("VAPID_PUBLIC_KEY")
if not key:
return flask.jsonify({"error": "VAPID not configured"}), 503
return flask.jsonify({"public_key": key}), 200
@app.route("/api/notifications/subscribe", methods=["POST"])
def api_pushSubscribe():
"""Register a push subscription. Body: {endpoint, keys: {p256dh, auth}}"""
user_uuid = _auth(flask.request)
if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401
data = flask.request.get_json()
if not data:
return flask.jsonify({"error": "missing body"}), 400
endpoint = data.get("endpoint")
keys = data.get("keys", {})
p256dh = keys.get("p256dh")
auth_key = keys.get("auth")
if not endpoint or not p256dh or not auth_key:
return flask.jsonify({"error": "missing endpoint or keys"}), 400
# Upsert: remove existing subscription with same endpoint for this user
existing = postgres.select("push_subscriptions", where={
"user_uuid": user_uuid,
"endpoint": endpoint,
})
if existing:
postgres.delete("push_subscriptions", {"id": existing[0]["id"]})
row = {
"id": str(uuid.uuid4()),
"user_uuid": user_uuid,
"endpoint": endpoint,
"p256dh": p256dh,
"auth": auth_key,
}
postgres.insert("push_subscriptions", row)
return flask.jsonify({"subscribed": True}), 201
@app.route("/api/notifications/subscribe", methods=["DELETE"])
def api_pushUnsubscribe():
"""Remove a push subscription. Body: {endpoint}"""
user_uuid = _auth(flask.request)
if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401
data = flask.request.get_json()
if not data or not data.get("endpoint"):
return flask.jsonify({"error": "missing endpoint"}), 400
postgres.delete("push_subscriptions", {
"user_uuid": user_uuid,
"endpoint": data["endpoint"],
})
return flask.jsonify({"unsubscribed": True}), 200

74
api/routes/preferences.py Normal file
View File

@@ -0,0 +1,74 @@
"""
Preferences API - user settings
"""
import os
import uuid
import flask
import jwt
import core.auth as auth
import core.postgres as postgres
def _get_user_uuid(token):
try:
payload = jwt.decode(token, os.getenv("JWT_SECRET"), algorithms=["HS256"])
return payload.get("sub")
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
return None
def _auth(request):
header = request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
return None
token = header[7:]
user_uuid = _get_user_uuid(token)
if not user_uuid or not auth.verifyLoginToken(token, userUUID=user_uuid):
return None
return user_uuid
def register(app):
@app.route("/api/preferences", methods=["GET"])
def api_getPreferences():
"""Get user preferences."""
user_uuid = _auth(flask.request)
if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401
prefs = postgres.select_one("user_preferences", {"user_uuid": user_uuid})
if not prefs:
# Return defaults
return flask.jsonify({
"sound_enabled": False,
"haptic_enabled": True,
"show_launch_screen": True,
"celebration_style": "standard",
}), 200
return flask.jsonify(prefs), 200
@app.route("/api/preferences", methods=["PUT"])
def api_updatePreferences():
"""Update user preferences."""
user_uuid = _auth(flask.request)
if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401
data = flask.request.get_json()
if not data:
return flask.jsonify({"error": "missing body"}), 400
allowed = ["sound_enabled", "haptic_enabled", "show_launch_screen", "celebration_style", "timezone_offset"]
updates = {k: v for k, v in data.items() if k in allowed}
if not updates:
return flask.jsonify({"error": "no valid fields"}), 400
existing = postgres.select_one("user_preferences", {"user_uuid": user_uuid})
if existing:
result = postgres.update("user_preferences", updates, {"user_uuid": user_uuid})
return flask.jsonify(result[0] if result else {}), 200
else:
updates["id"] = str(uuid.uuid4())
updates["user_uuid"] = user_uuid
result = postgres.insert("user_preferences", updates)
return flask.jsonify(result), 201

114
api/routes/rewards.py Normal file
View File

@@ -0,0 +1,114 @@
"""
Rewards API - variable reward system for routine completion
"""
import os
import uuid
import random
import flask
import jwt
import core.auth as auth
import core.postgres as postgres
def _get_user_uuid(token):
try:
payload = jwt.decode(token, os.getenv("JWT_SECRET"), algorithms=["HS256"])
return payload.get("sub")
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
return None
def _auth(request):
header = request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
return None
token = header[7:]
user_uuid = _get_user_uuid(token)
if not user_uuid or not auth.verifyLoginToken(token, userUUID=user_uuid):
return None
return user_uuid
def register(app):
@app.route("/api/rewards/random", methods=["GET"])
def api_getRandomReward():
"""Get a weighted random reward. Query: ?context=completion"""
user_uuid = _auth(flask.request)
if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401
context = flask.request.args.get("context", "completion")
# Fetch all rewards from pool
all_rewards = postgres.select("reward_pool", where={})
if not all_rewards:
return flask.jsonify({"reward": None}), 200
# Weight by rarity: common=70%, uncommon=25%, rare=5%
weights = {
"common": 70,
"uncommon": 25,
"rare": 5,
}
weighted = []
for reward in all_rewards:
w = weights.get(reward.get("rarity", "common"), 70)
weighted.extend([reward] * w)
if not weighted:
return flask.jsonify({"reward": None}), 200
selected = random.choice(weighted)
# Record that user earned this reward
try:
postgres.insert("user_rewards", {
"id": str(uuid.uuid4()),
"user_uuid": user_uuid,
"reward_id": selected["id"],
"context": context,
})
except Exception:
pass # Don't fail if recording fails
return flask.jsonify({
"reward": {
"id": selected["id"],
"category": selected["category"],
"content": selected["content"],
"emoji": selected.get("emoji"),
"rarity": selected.get("rarity", "common"),
}
}), 200
@app.route("/api/rewards/history", methods=["GET"])
def api_getRewardHistory():
"""Get user's past earned rewards."""
user_uuid = _auth(flask.request)
if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401
earned = postgres.select(
"user_rewards",
where={"user_uuid": user_uuid},
order_by="earned_at DESC",
limit=50,
)
# Enrich with reward content
result = []
for entry in earned:
reward = postgres.select_one("reward_pool", {"id": entry["reward_id"]})
if reward:
result.append({
"earned_at": entry["earned_at"],
"context": entry.get("context"),
"category": reward["category"],
"content": reward["content"],
"emoji": reward.get("emoji"),
"rarity": reward.get("rarity"),
})
return flask.jsonify(result), 200

View File

@@ -9,6 +9,7 @@ import flask
import jwt
import core.auth as auth
import core.postgres as postgres
import core.tz as tz
def _get_user_uuid(token):
@@ -45,7 +46,7 @@ def register(app):
return flask.jsonify({"error": "session not active"}), 400
result = postgres.update(
"routine_sessions",
{"status": "paused", "paused_at": datetime.now().isoformat()},
{"status": "paused", "paused_at": tz.user_now().isoformat()},
{"id": session_id}
)
return flask.jsonify({"status": "paused"}), 200
@@ -81,7 +82,7 @@ def register(app):
reason = data.get("reason", "Aborted by user")
result = postgres.update(
"routine_sessions",
{"status": "aborted", "abort_reason": reason, "completed_at": datetime.now().isoformat()},
{"status": "aborted", "abort_reason": reason, "completed_at": tz.user_now().isoformat()},
{"id": session_id}
)
return flask.jsonify({"status": "aborted", "reason": reason}), 200

View File

@@ -8,6 +8,7 @@ import flask
import jwt
import core.auth as auth
import core.postgres as postgres
import core.tz as tz
def _get_user_uuid(token):
@@ -128,7 +129,7 @@ def register(app):
"routines_started": 0,
"routines": [],
}), 200
week_ago = (datetime.now() - timedelta(days=7)).isoformat()
week_ago = (tz.user_now() - timedelta(days=7)).isoformat()
sessions = postgres.select("routine_sessions", where={"user_uuid": user_uuid})
week_sessions = [s for s in sessions if s.get("created_at") and str(s["created_at"]) >= week_ago]
completed = [s for s in week_sessions if s.get("status") == "completed"]

View File

@@ -6,10 +6,13 @@ Routines have ordered steps. Users start sessions to walk through them.
import os
import uuid
from datetime import datetime
import flask
import jwt
import core.auth as auth
import core.postgres as postgres
import core.routines as routines_core
import core.tz as tz
def _get_user_uuid(token):
@@ -32,6 +35,111 @@ def _auth(request):
return user_uuid
def _record_step_result(session_id, step_id, step_index, result, session):
"""Record a per-step result (completed or skipped)."""
try:
# Compute duration from previous step completion or session start
prev_results = postgres.select(
"routine_step_results",
where={"session_id": session_id},
order_by="completed_at DESC",
limit=1,
)
now = tz.user_now()
if prev_results:
last_completed = prev_results[0].get("completed_at")
if last_completed:
if isinstance(last_completed, str):
last_completed = datetime.fromisoformat(last_completed)
# Make naive datetimes comparable with aware ones
if last_completed.tzinfo is None:
duration_seconds = int((now.replace(tzinfo=None) - last_completed).total_seconds())
else:
duration_seconds = int((now - last_completed).total_seconds())
else:
duration_seconds = None
else:
created_at = session.get("created_at")
if created_at:
if isinstance(created_at, str):
created_at = datetime.fromisoformat(created_at)
if created_at.tzinfo is None:
duration_seconds = int((now.replace(tzinfo=None) - created_at).total_seconds())
else:
duration_seconds = int((now - created_at).total_seconds())
else:
duration_seconds = None
postgres.insert("routine_step_results", {
"id": str(uuid.uuid4()),
"session_id": session_id,
"step_id": step_id,
"step_index": step_index,
"result": result,
"duration_seconds": duration_seconds,
"completed_at": now.isoformat(),
})
except Exception:
pass # Don't fail the step completion if tracking fails
def _complete_session_with_celebration(session_id, user_uuid, session):
"""Complete a session and return celebration data."""
now = tz.user_now()
created_at = session.get("created_at")
if created_at:
if isinstance(created_at, str):
created_at = datetime.fromisoformat(created_at)
# Handle naive vs aware datetime comparison
if created_at.tzinfo is None:
duration_minutes = round((now.replace(tzinfo=None) - created_at).total_seconds() / 60, 1)
else:
duration_minutes = round((now - created_at).total_seconds() / 60, 1)
else:
duration_minutes = 0
# Update session as completed with duration
postgres.update("routine_sessions", {
"status": "completed",
"completed_at": now.isoformat(),
"actual_duration_minutes": int(duration_minutes),
}, {"id": session_id})
# Update streak (returns streak with optional 'milestone' key)
streak_result = routines_core._update_streak(user_uuid, session["routine_id"])
# Get streak data
streak = postgres.select_one("routine_streaks", {
"user_uuid": user_uuid,
"routine_id": session["routine_id"],
})
streak_milestone = streak_result.get("milestone") if streak_result else None
# Count step results for this session
step_results = postgres.select("routine_step_results", {"session_id": session_id})
steps_completed = sum(1 for r in step_results if r.get("result") == "completed")
steps_skipped = sum(1 for r in step_results if r.get("result") == "skipped")
# Total completions for this routine
all_completed = postgres.select("routine_sessions", {
"routine_id": session["routine_id"],
"user_uuid": user_uuid,
"status": "completed",
})
result = {
"streak_current": streak["current_streak"] if streak else 1,
"streak_longest": streak["longest_streak"] if streak else 1,
"session_duration_minutes": duration_minutes,
"total_completions": len(all_completed),
"steps_completed": steps_completed,
"steps_skipped": steps_skipped,
}
if streak_milestone:
result["streak_milestone"] = streak_milestone
return result
def register(app):
# ── Routines CRUD ─────────────────────────────────────────────
@@ -89,7 +197,7 @@ def register(app):
existing = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid})
if not existing:
return flask.jsonify({"error": "not found"}), 404
allowed = ["name", "description", "icon"]
allowed = ["name", "description", "icon", "location", "environment_prompts", "habit_stack_after"]
updates = {k: v for k, v in data.items() if k in allowed}
if not updates:
return flask.jsonify({"error": "no valid fields to update"}), 400
@@ -257,6 +365,8 @@ def register(app):
if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401
session = postgres.select_one("routine_sessions", {"user_uuid": user_uuid, "status": "active"})
if not session:
session = postgres.select_one("routine_sessions", {"user_uuid": user_uuid, "status": "paused"})
if not session:
return flask.jsonify({"error": "no active session"}), 404
routine = postgres.select_one("routines", {"id": session["routine_id"]})
@@ -277,18 +387,33 @@ def register(app):
session = postgres.select_one("routine_sessions", {"id": session_id, "user_uuid": user_uuid})
if not session:
return flask.jsonify({"error": "not found"}), 404
if session["status"] != "active":
if session["status"] not in ("active", "paused"):
return flask.jsonify({"error": "session not active"}), 400
# Auto-resume if paused
if session["status"] == "paused":
postgres.update("routine_sessions", {"status": "active", "paused_at": None}, {"id": session_id})
data = flask.request.get_json() or {}
steps = postgres.select(
"routine_steps",
where={"routine_id": session["routine_id"]},
order_by="position",
)
next_index = session["current_step_index"] + 1
current_index = session["current_step_index"]
current_step = steps[current_index] if current_index < len(steps) else None
# Record step result
if current_step:
_record_step_result(session_id, current_step["id"], current_index, "completed", session)
next_index = current_index + 1
if next_index >= len(steps):
postgres.update("routine_sessions", {"status": "completed"}, {"id": session_id})
return flask.jsonify({"session": {"status": "completed"}, "next_step": None}), 200
# Session complete — compute celebration data
celebration = _complete_session_with_celebration(session_id, user_uuid, session)
return flask.jsonify({
"session": {"status": "completed"},
"next_step": None,
"celebration": celebration,
}), 200
postgres.update("routine_sessions", {"current_step_index": next_index}, {"id": session_id})
return flask.jsonify({"session": {"current_step_index": next_index}, "next_step": steps[next_index]}), 200
@@ -301,17 +426,31 @@ def register(app):
session = postgres.select_one("routine_sessions", {"id": session_id, "user_uuid": user_uuid})
if not session:
return flask.jsonify({"error": "not found"}), 404
if session["status"] != "active":
if session["status"] not in ("active", "paused"):
return flask.jsonify({"error": "session not active"}), 400
# Auto-resume if paused
if session["status"] == "paused":
postgres.update("routine_sessions", {"status": "active", "paused_at": None}, {"id": session_id})
steps = postgres.select(
"routine_steps",
where={"routine_id": session["routine_id"]},
order_by="position",
)
next_index = session["current_step_index"] + 1
current_index = session["current_step_index"]
current_step = steps[current_index] if current_index < len(steps) else None
# Record step result as skipped
if current_step:
_record_step_result(session_id, current_step["id"], current_index, "skipped", session)
next_index = current_index + 1
if next_index >= len(steps):
postgres.update("routine_sessions", {"status": "completed"}, {"id": session_id})
return flask.jsonify({"session": {"status": "completed"}, "next_step": None}), 200
celebration = _complete_session_with_celebration(session_id, user_uuid, session)
return flask.jsonify({
"session": {"status": "completed"},
"next_step": None,
"celebration": celebration,
}), 200
postgres.update("routine_sessions", {"current_step_index": next_index}, {"id": session_id})
return flask.jsonify({"session": {"current_step_index": next_index}, "next_step": steps[next_index]}), 200

132
api/routes/victories.py Normal file
View File

@@ -0,0 +1,132 @@
"""
Victories API - compute noteworthy achievements from session history
"""
import os
import uuid
from datetime import datetime, timedelta
import flask
import jwt
import core.auth as auth
import core.postgres as postgres
import core.tz as tz
def _get_user_uuid(token):
try:
payload = jwt.decode(token, os.getenv("JWT_SECRET"), algorithms=["HS256"])
return payload.get("sub")
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
return None
def _auth(request):
header = request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
return None
token = header[7:]
user_uuid = _get_user_uuid(token)
if not user_uuid or not auth.verifyLoginToken(token, userUUID=user_uuid):
return None
return user_uuid
def register(app):
@app.route("/api/victories", methods=["GET"])
def api_getVictories():
"""Compute noteworthy achievements. Query: ?days=30"""
user_uuid = _auth(flask.request)
if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401
days = flask.request.args.get("days", 30, type=int)
cutoff = tz.user_now() - timedelta(days=days)
sessions = postgres.select("routine_sessions", {"user_uuid": user_uuid})
recent = [
s for s in sessions
if s.get("created_at") and s["created_at"] >= cutoff
]
completed = [s for s in recent if s.get("status") == "completed"]
victories = []
# Comeback: completed after 2+ day gap
if completed:
sorted_completed = sorted(completed, key=lambda s: s["created_at"])
for i in range(1, len(sorted_completed)):
prev = sorted_completed[i - 1]["created_at"]
curr = sorted_completed[i]["created_at"]
gap = (curr - prev).days
if gap >= 2:
victories.append({
"type": "comeback",
"message": f"Came back after {gap} days — that takes real strength",
"date": curr.isoformat() if hasattr(curr, 'isoformat') else str(curr),
})
# Weekend completion
for s in completed:
created = s["created_at"]
if hasattr(created, 'weekday') and created.weekday() >= 5: # Saturday=5, Sunday=6
victories.append({
"type": "weekend",
"message": "Completed a routine on the weekend",
"date": created.isoformat() if hasattr(created, 'isoformat') else str(created),
})
break # Only show once
# Variety: 3+ different routines in a week
routine_ids_by_week = {}
for s in completed:
created = s["created_at"]
if hasattr(created, 'isocalendar'):
week_key = created.isocalendar()[:2]
if week_key not in routine_ids_by_week:
routine_ids_by_week[week_key] = set()
routine_ids_by_week[week_key].add(s.get("routine_id"))
for week_key, routine_ids in routine_ids_by_week.items():
if len(routine_ids) >= 3:
victories.append({
"type": "variety",
"message": f"Completed {len(routine_ids)} different routines in one week",
"date": None,
})
break
# Full week consistency: completed every day for 7 consecutive days
if completed:
dates_set = set()
for s in completed:
created = s["created_at"]
if hasattr(created, 'date'):
dates_set.add(created.date())
sorted_dates = sorted(dates_set)
max_streak = 1
current_streak = 1
for i in range(1, len(sorted_dates)):
if (sorted_dates[i] - sorted_dates[i-1]).days == 1:
current_streak += 1
max_streak = max(max_streak, current_streak)
else:
current_streak = 1
if max_streak >= 7:
victories.append({
"type": "consistency",
"message": f"Completed routines every day for {max_streak} days straight",
"date": None,
})
# Limit and deduplicate
seen_types = set()
unique_victories = []
for v in victories:
if v["type"] not in seen_types:
unique_victories.append(v)
seen_types.add(v["type"])
return flask.jsonify(unique_victories[:10]), 200

View File

@@ -39,6 +39,9 @@ CREATE TABLE IF NOT EXISTS routines (
name VARCHAR(255) NOT NULL,
description TEXT,
icon VARCHAR(100),
location VARCHAR(255),
environment_prompts JSON DEFAULT '[]',
habit_stack_after VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
@@ -122,6 +125,49 @@ CREATE TABLE IF NOT EXISTS routine_streaks (
last_completed_date DATE
);
-- ── Rewards ───────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS reward_pool (
id UUID PRIMARY KEY,
category VARCHAR(50) NOT NULL,
content TEXT NOT NULL,
emoji VARCHAR(10),
rarity VARCHAR(20) DEFAULT 'common'
);
CREATE TABLE IF NOT EXISTS user_rewards (
id UUID PRIMARY KEY,
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE,
reward_id UUID REFERENCES reward_pool(id) ON DELETE CASCADE,
earned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
context VARCHAR(50)
);
-- ── User Preferences ──────────────────────────────────────
CREATE TABLE IF NOT EXISTS user_preferences (
id UUID PRIMARY KEY,
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE,
sound_enabled BOOLEAN DEFAULT FALSE,
haptic_enabled BOOLEAN DEFAULT TRUE,
show_launch_screen BOOLEAN DEFAULT TRUE,
celebration_style VARCHAR(50) DEFAULT 'standard',
timezone_offset INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ── Step Results (per-step tracking within sessions) ───────
CREATE TABLE IF NOT EXISTS routine_step_results (
id UUID PRIMARY KEY,
session_id UUID REFERENCES routine_sessions(id) ON DELETE CASCADE,
step_id UUID REFERENCES routine_steps(id) ON DELETE CASCADE,
step_index INTEGER NOT NULL,
result VARCHAR(20) NOT NULL,
duration_seconds INTEGER,
completed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ── Medications ─────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS medications (

63
config/seed_rewards.sql Normal file
View 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
View 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);

View File

@@ -8,6 +8,7 @@ that can be called from API routes, bot commands, or scheduler.
import uuid
from datetime import datetime, date
import core.postgres as postgres
import core.tz as tz
def start_session(routine_id, user_uuid):
@@ -159,9 +160,13 @@ def clone_template(template_id, user_uuid):
return routine
STREAK_MILESTONES = {3, 7, 14, 21, 30, 60, 90, 100, 365}
def _update_streak(user_uuid, routine_id):
"""Update streak after completing a session. Resets if day was missed."""
today = date.today()
"""Update streak after completing a session. Resets if day was missed.
Returns the updated streak dict with optional 'milestone' key."""
today = tz.user_today()
streak = postgres.select_one(
"routine_streaks",
@@ -169,6 +174,7 @@ def _update_streak(user_uuid, routine_id):
)
if not streak:
new_streak_val = 1
new_streak = {
"id": str(uuid.uuid4()),
"user_uuid": user_uuid,
@@ -177,7 +183,10 @@ def _update_streak(user_uuid, routine_id):
"longest_streak": 1,
"last_completed_date": today.isoformat(),
}
return postgres.insert("routine_streaks", new_streak)
result = postgres.insert("routine_streaks", new_streak)
if new_streak_val in STREAK_MILESTONES:
result["milestone"] = new_streak_val
return result
last_completed = streak.get("last_completed_date")
if last_completed:
@@ -187,24 +196,28 @@ def _update_streak(user_uuid, routine_id):
if days_diff == 0:
return streak
elif days_diff == 1:
new_streak = streak["current_streak"] + 1
new_streak_val = streak["current_streak"] + 1
else:
new_streak = 1
new_streak_val = 1
else:
new_streak = 1
new_streak_val = 1
longest = max(streak["longest_streak"], new_streak)
longest = max(streak["longest_streak"], new_streak_val)
postgres.update(
"routine_streaks",
{
"current_streak": new_streak,
"current_streak": new_streak_val,
"longest_streak": longest,
"last_completed_date": today.isoformat(),
},
{"id": streak["id"]}
)
return streak
result = {**streak, "current_streak": new_streak_val, "longest_streak": longest}
if new_streak_val in STREAK_MILESTONES:
result["milestone"] = new_streak_val
return result
def calculate_streak(user_uuid, routine_id):
@@ -217,8 +230,14 @@ def calculate_streak(user_uuid, routine_id):
def get_active_session(user_uuid):
"""Get user's currently active session."""
return postgres.select_one(
"""Get user's currently active or paused session."""
session = postgres.select_one(
"routine_sessions",
{"user_uuid": user_uuid, "status": "active"}
)
if not session:
session = postgres.select_one(
"routine_sessions",
{"user_uuid": user_uuid, "status": "paused"}
)
return session

39
core/tz.py Normal file
View 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()

View File

@@ -11,6 +11,7 @@ services:
- pgdata:/var/lib/postgresql/data
- ./config/schema.sql:/docker-entrypoint-initdb.d/schema.sql
- ./config/seed_templates.sql:/docker-entrypoint-initdb.d/seed_templates.sql
- ./config/seed_rewards.sql:/docker-entrypoint-initdb.d/seed_rewards.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s

View File

@@ -7,7 +7,7 @@ Override poll_callback() with your domain-specific logic.
import os
import time
import logging
from datetime import datetime
from datetime import datetime, timezone, timedelta
import core.postgres as postgres
import core.notifications as notifications
@@ -18,60 +18,81 @@ logger = logging.getLogger(__name__)
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", 60))
def _user_now_for(user_uuid):
"""Get current datetime in a user's timezone using their stored offset."""
prefs = postgres.select_one("user_preferences", {"user_uuid": user_uuid})
offset_minutes = 0
if prefs and prefs.get("timezone_offset") is not None:
offset_minutes = prefs["timezone_offset"]
# JS getTimezoneOffset: positive = behind UTC, so negate
tz_obj = timezone(timedelta(minutes=-offset_minutes))
return datetime.now(tz_obj)
def check_medication_reminders():
"""Check for medications due now and send notifications."""
try:
from datetime import date as date_type
meds = postgres.select("medications", where={"active": True})
now = datetime.now()
current_time = now.strftime("%H:%M")
current_day = now.strftime("%a").lower() # "mon","tue", etc.
today = now.date()
today_str = today.isoformat()
# Group by user so we only look up timezone once per user
user_meds = {}
for med in meds:
freq = med.get("frequency", "daily")
uid = med.get("user_uuid")
if uid not in user_meds:
user_meds[uid] = []
user_meds[uid].append(med)
# Skip as_needed -- no scheduled reminders for PRN
if freq == "as_needed":
continue
for user_uuid, user_med_list in user_meds.items():
now = _user_now_for(user_uuid)
current_time = now.strftime("%H:%M")
current_day = now.strftime("%a").lower()
today = now.date()
today_str = today.isoformat()
# Day-of-week check for specific_days
if freq == "specific_days":
days = med.get("days_of_week", [])
if current_day not in days:
for med in user_med_list:
freq = med.get("frequency", "daily")
# Skip as_needed -- no scheduled reminders for PRN
if freq == "as_needed":
continue
# Interval check for every_n_days
if freq == "every_n_days":
start = med.get("start_date")
interval = med.get("interval_days")
if start and interval:
start_d = start if isinstance(start, date_type) else datetime.strptime(str(start), "%Y-%m-%d").date()
if (today - start_d).days < 0 or (today - start_d).days % interval != 0:
# Day-of-week check for specific_days
if freq == "specific_days":
med_days = med.get("days_of_week", [])
if current_day not in med_days:
continue
else:
# Interval check for every_n_days
if freq == "every_n_days":
start = med.get("start_date")
interval = med.get("interval_days")
if start and interval:
start_d = start if isinstance(start, date_type) else datetime.strptime(str(start), "%Y-%m-%d").date()
if (today - start_d).days < 0 or (today - start_d).days % interval != 0:
continue
else:
continue
# Time check
times = med.get("times", [])
if current_time not in times:
continue
# Time check
times = med.get("times", [])
if current_time not in times:
continue
# Already taken today? Check by created_at date
logs = postgres.select("med_logs", where={"medication_id": med["id"], "action": "taken"})
already_taken = any(
log.get("scheduled_time") == current_time
and str(log.get("created_at", ""))[:10] == today_str
for log in logs
)
if already_taken:
continue
# Already taken today? Check by created_at date
logs = postgres.select("med_logs", where={"medication_id": med["id"], "action": "taken"})
already_taken = any(
log.get("scheduled_time") == current_time
and str(log.get("created_at", ""))[:10] == today_str
for log in logs
)
if already_taken:
continue
user_settings = notifications.getNotificationSettings(med["user_uuid"])
if user_settings:
msg = f"Time to take {med['name']} ({med['dosage']} {med['unit']})"
notifications._sendToEnabledChannels(user_settings, msg, user_uuid=med["user_uuid"])
user_settings = notifications.getNotificationSettings(user_uuid)
if user_settings:
msg = f"Time to take {med['name']} ({med['dosage']} {med['unit']})"
notifications._sendToEnabledChannels(user_settings, msg, user_uuid=user_uuid)
except Exception as e:
logger.error(f"Error checking medication reminders: {e}")
@@ -79,22 +100,23 @@ def check_medication_reminders():
def check_routine_reminders():
"""Check for scheduled routines due now and send notifications."""
try:
now = datetime.now()
current_time = now.strftime("%H:%M")
current_day = now.strftime("%a").lower()
schedules = postgres.select("routine_schedules", where={"remind": True})
for schedule in schedules:
routine = postgres.select_one("routines", {"id": schedule["routine_id"]})
if not routine:
continue
now = _user_now_for(routine["user_uuid"])
current_time = now.strftime("%H:%M")
current_day = now.strftime("%a").lower()
if current_time != schedule.get("time"):
continue
days = schedule.get("days", [])
if current_day not in days:
continue
routine = postgres.select_one("routines", {"id": schedule["routine_id"]})
if not routine:
continue
user_settings = notifications.getNotificationSettings(routine["user_uuid"])
if user_settings:
msg = f"Time to start your routine: {routine['name']}"

6749
synculous-client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,9 @@
'use client';
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { useAuth } from '@/components/auth/AuthProvider';
import api from '@/lib/api';
import {
HomeIcon,
ListIcon,
@@ -34,12 +35,23 @@ export default function DashboardLayout({
const router = useRouter();
const pathname = usePathname();
const tzSynced = useRef(false);
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, isLoading, router]);
// Sync timezone offset to backend once per session
useEffect(() => {
if (isAuthenticated && !tzSynced.current) {
tzSynced.current = true;
const offset = new Date().getTimezoneOffset();
api.preferences.update({ timezone_offset: offset }).catch(() => {});
}
}, [isAuthenticated]);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
@@ -55,6 +67,13 @@ export default function DashboardLayout({
return null;
}
// Hide chrome during active session run
const isRunMode = pathname.includes('/run');
if (isRunMode) {
return <>{children}</>;
}
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
@@ -65,12 +84,17 @@ export default function DashboardLayout({
</div>
<span className="font-bold text-gray-900">Synculous</span>
</div>
<button
onClick={logout}
className="p-2 text-gray-500 hover:text-gray-700"
>
<LogOutIcon size={20} />
</button>
<div className="flex items-center gap-2">
<Link href="/dashboard/settings" className="p-2 text-gray-500 hover:text-gray-700">
<SettingsIcon size={20} />
</Link>
<button
onClick={logout}
className="p-2 text-gray-500 hover:text-gray-700"
>
<LogOutIcon size={20} />
</button>
</div>
</div>
</header>
@@ -81,15 +105,15 @@ export default function DashboardLayout({
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 safe-area-bottom">
<div className="flex justify-around py-2">
{navItems.map((item) => {
const isActive = pathname === item.href ||
const isActive = pathname === item.href ||
(item.href !== '/dashboard' && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
className={`flex flex-col items-center gap-1 px-3 py-2 rounded-lg transition-colors ${
isActive
? 'text-indigo-600'
isActive
? 'text-indigo-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>

View File

@@ -45,26 +45,37 @@ interface WeeklySummary {
}[];
}
interface Streak {
routine_id: string;
routine_name: string;
current_streak: number;
longest_streak: number;
last_completed_date?: string;
}
export default function DashboardPage() {
const { user } = useAuth();
const router = useRouter();
const [routines, setRoutines] = useState<Routine[]>([]);
const [activeSession, setActiveSession] = useState<ActiveSession | null>(null);
const [weeklySummary, setWeeklySummary] = useState<WeeklySummary | null>(null);
const [streaks, setStreaks] = useState<Streak[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
const [routinesData, activeData, summaryData] = await Promise.all([
const [routinesData, activeData, summaryData, streaksData] = await Promise.all([
api.routines.list().catch(() => []),
api.sessions.getActive().catch(() => null),
api.stats.getWeeklySummary().catch(() => null),
api.stats.getStreaks().catch(() => []),
]);
setRoutines(routinesData);
setActiveSession(activeData);
setWeeklySummary(summaryData);
setStreaks(streaksData);
} catch (err) {
console.error('Failed to fetch dashboard data:', err);
} finally {
@@ -75,12 +86,12 @@ export default function DashboardPage() {
fetchData();
}, []);
const handleStartRoutine = async (routineId: string) => {
try {
await api.sessions.start(routineId);
router.push(`/dashboard/routines/${routineId}/run`);
} catch (err) {
setError((err as Error).message);
const handleStartRoutine = (routineId: string) => {
// If there's an active session, go straight to run
if (activeSession) {
router.push(`/dashboard/routines/${activeSession.routine.id}/run`);
} else {
router.push(`/dashboard/routines/${routineId}/launch`);
}
};
@@ -104,6 +115,17 @@ export default function DashboardPage() {
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
};
// Find routines with broken streaks for "never miss twice" recovery
const getRecoveryRoutines = () => {
const today = new Date();
return streaks.filter(s => {
if (!s.last_completed_date || s.current_streak > 0) return false;
const last = new Date(s.last_completed_date);
const daysSince = Math.floor((today.getTime() - last.getTime()) / (1000 * 60 * 60 * 24));
return daysSince >= 2 && daysSince <= 14;
});
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
@@ -112,14 +134,16 @@ export default function DashboardPage() {
);
}
const recoveryRoutines = getRecoveryRoutines();
return (
<div className="p-4 space-y-6">
{/* Active Session Banner */}
{activeSession && activeSession.session.status === 'active' && (
<div className="bg-gradient-to-r from-indigo-500 to-purple-600 rounded-2xl p-4 text-white">
<div className="bg-gradient-to-r from-indigo-500 to-purple-600 rounded-2xl p-4 text-white ring-2 ring-indigo-400/50 ring-offset-2 ring-offset-gray-50 animate-gentle-pulse">
<div className="flex items-center justify-between">
<div>
<p className="text-white/80 text-sm">Continue your routine</p>
<p className="text-white/80 text-sm">Continue where you left off</p>
<h2 className="text-xl font-bold">{activeSession.routine.name}</h2>
<p className="text-white/80 text-sm mt-1">
Step {activeSession.session.current_step_index + 1}: {activeSession.current_step?.name}
@@ -138,32 +162,59 @@ export default function DashboardPage() {
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">{getGreeting()}, {user?.username}!</h1>
<p className="text-gray-500 mt-1">Let's build some great habits today.</p>
<p className="text-gray-500 mt-1">Let&apos;s build some great habits today.</p>
</div>
{/* Weekly Stats */}
{weeklySummary && (
{/* "Never miss twice" Recovery Cards */}
{recoveryRoutines.map(recovery => {
const routine = routines.find(r => r.id === recovery.routine_id);
if (!routine) return null;
return (
<div key={recovery.routine_id} className="bg-amber-50 border border-amber-200 rounded-2xl p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-amber-800 text-sm font-medium mb-1">Welcome back</p>
<p className="text-amber-700 text-sm">
It&apos;s been a couple days since {routine.icon} {routine.name}. That&apos;s completely okay picking it back up today is what matters most.
</p>
</div>
<button
onClick={() => handleStartRoutine(routine.id)}
className="bg-amber-600 text-white px-4 py-2 rounded-lg font-medium text-sm shrink-0 ml-3"
>
Start
</button>
</div>
</div>
);
})}
{/* Weekly Stats — identity-based language */}
{weeklySummary && weeklySummary.total_completed > 0 && (
<div className="grid grid-cols-3 gap-3">
<div className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex items-center gap-2 text-indigo-600 mb-1">
<StarIcon size={18} />
<span className="text-xs font-medium">Completed</span>
<span className="text-xs font-medium">Done</span>
</div>
<p className="text-2xl font-bold text-gray-900">{weeklySummary.total_completed}</p>
<p className="text-xs text-gray-400 mt-0.5">this week</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex items-center gap-2 text-purple-600 mb-1">
<ClockIcon size={18} />
<span className="text-xs font-medium">Time</span>
<span className="text-xs font-medium">Invested</span>
</div>
<p className="text-2xl font-bold text-gray-900">{formatTime(weeklySummary.total_time_minutes)}</p>
<p className="text-xs text-gray-400 mt-0.5">in yourself</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex items-center gap-2 text-pink-600 mb-1">
<ActivityIcon size={18} />
<span className="text-xs font-medium">Started</span>
<span className="text-xs font-medium">Active</span>
</div>
<p className="text-2xl font-bold text-gray-900">{weeklySummary.routines_started}</p>
<p className="text-xs text-gray-400 mt-0.5">routines</p>
</div>
</div>
)}
@@ -171,7 +222,7 @@ export default function DashboardPage() {
{/* Quick Start Routines */}
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Your Routines</h2>
{routines.length === 0 ? (
<div className="bg-white rounded-xl p-8 shadow-sm text-center">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
@@ -188,30 +239,38 @@ export default function DashboardPage() {
</div>
) : (
<div className="space-y-3">
{routines.map((routine) => (
<div
key={routine.id}
className="bg-white rounded-xl p-4 shadow-sm flex items-center justify-between"
>
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-indigo-100 to-pink-100 rounded-xl flex items-center justify-center">
<span className="text-2xl">{routine.icon || ''}</span>
</div>
<div>
<h3 className="font-semibold text-gray-900">{routine.name}</h3>
{routine.description && (
<p className="text-gray-500 text-sm">{routine.description}</p>
)}
</div>
</div>
<button
onClick={() => handleStartRoutine(routine.id)}
className="bg-indigo-600 text-white p-3 rounded-full"
{routines.map((routine) => {
const streak = streaks.find(s => s.routine_id === routine.id);
return (
<div
key={routine.id}
className="bg-white rounded-xl p-4 shadow-sm flex items-center justify-between"
>
<PlayIcon size={20} />
</button>
</div>
))}
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-indigo-100 to-pink-100 rounded-xl flex items-center justify-center">
<span className="text-2xl">{routine.icon || '✨'}</span>
</div>
<div>
<h3 className="font-semibold text-gray-900">{routine.name}</h3>
{streak && streak.current_streak > 0 ? (
<p className="text-sm text-orange-500 flex items-center gap-1">
<FlameIcon size={14} />
{streak.current_streak} day{streak.current_streak !== 1 ? 's' : ''}
</p>
) : routine.description ? (
<p className="text-gray-500 text-sm">{routine.description}</p>
) : null}
</div>
</div>
<button
onClick={() => handleStartRoutine(routine.id)}
className="bg-indigo-600 text-white p-3 rounded-full"
>
<PlayIcon size={20} />
</button>
</div>
);
})}
</div>
)}
</div>
@@ -233,7 +292,7 @@ export default function DashboardPage() {
</div>
{error && (
<div className="bg-red-50 text-red-600 px-4 py-3 rounded-lg text-sm">
<div className="bg-amber-50 text-amber-700 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}

View 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&apos;s Go
</>
)}
</button>
</div>
</div>
);
}

View File

@@ -20,6 +20,9 @@ interface Routine {
name: string;
description?: string;
icon?: string;
location?: string;
environment_prompts?: string[];
habit_stack_after?: string;
}
const ICONS = ['✨', '🌅', '🌙', '☀️', '💪', '🧘', '📚', '🍳', '🏃', '💼', '🎯', '⭐', '🔥', '💤', '🧠'];
@@ -36,6 +39,10 @@ export default function RoutineDetailPage() {
const [editName, setEditName] = useState('');
const [editDescription, setEditDescription] = useState('');
const [editIcon, setEditIcon] = useState('✨');
const [editLocation, setEditLocation] = useState('');
const [editHabitStack, setEditHabitStack] = useState('');
const [editEnvPrompts, setEditEnvPrompts] = useState<string[]>([]);
const [newEnvPrompt, setNewEnvPrompt] = useState('');
const [newStepName, setNewStepName] = useState('');
const [newStepDuration, setNewStepDuration] = useState(5);
@@ -48,6 +55,9 @@ export default function RoutineDetailPage() {
setEditName(data.routine.name);
setEditDescription(data.routine.description || '');
setEditIcon(data.routine.icon || '✨');
setEditLocation((data.routine as Routine).location || '');
setEditHabitStack((data.routine as Routine).habit_stack_after || '');
setEditEnvPrompts((data.routine as Routine).environment_prompts || []);
} catch (err) {
console.error('Failed to fetch routine:', err);
router.push('/dashboard/routines');
@@ -58,13 +68,8 @@ export default function RoutineDetailPage() {
fetchRoutine();
}, [routineId, router]);
const handleStart = async () => {
try {
await api.sessions.start(routineId);
router.push(`/dashboard/routines/${routineId}/run`);
} catch (err) {
console.error('Failed to start routine:', err);
}
const handleStart = () => {
router.push(`/dashboard/routines/${routineId}/launch`);
};
const handleSaveBasicInfo = async () => {
@@ -73,8 +78,19 @@ export default function RoutineDetailPage() {
name: editName,
description: editDescription,
icon: editIcon,
location: editLocation || null,
habit_stack_after: editHabitStack || null,
environment_prompts: editEnvPrompts,
});
setRoutine({
...routine!,
name: editName,
description: editDescription,
icon: editIcon,
location: editLocation || undefined,
habit_stack_after: editHabitStack || undefined,
environment_prompts: editEnvPrompts,
});
setRoutine({ ...routine!, name: editName, description: editDescription, icon: editIcon });
setIsEditing(false);
} catch (err) {
console.error('Failed to update routine:', err);
@@ -179,6 +195,69 @@ export default function RoutineDetailPage() {
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Location</label>
<input
type="text"
value={editLocation}
onChange={(e) => setEditLocation(e.target.value)}
placeholder="Where do you do this? e.g., bathroom, kitchen"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Anchor habit</label>
<input
type="text"
value={editHabitStack}
onChange={(e) => setEditHabitStack(e.target.value)}
placeholder="What do you do right before? e.g., finish breakfast"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Environment prompts</label>
<p className="text-xs text-gray-500 mb-2">Quick checklist shown before starting</p>
<div className="space-y-2 mb-2">
{editEnvPrompts.map((prompt, i) => (
<div key={i} className="flex items-center gap-2">
<span className="flex-1 text-sm text-gray-700 bg-gray-50 px-3 py-2 rounded-lg">{prompt}</span>
<button
onClick={() => setEditEnvPrompts(editEnvPrompts.filter((_, j) => j !== i))}
className="text-red-500 text-sm px-2"
>
Remove
</button>
</div>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={newEnvPrompt}
onChange={(e) => setNewEnvPrompt(e.target.value)}
placeholder="e.g., Water bottle nearby?"
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 outline-none"
onKeyDown={(e) => {
if (e.key === 'Enter' && newEnvPrompt.trim()) {
setEditEnvPrompts([...editEnvPrompts, newEnvPrompt.trim()]);
setNewEnvPrompt('');
}
}}
/>
<button
onClick={() => {
if (newEnvPrompt.trim()) {
setEditEnvPrompts([...editEnvPrompts, newEnvPrompt.trim()]);
setNewEnvPrompt('');
}
}}
className="px-3 py-2 bg-gray-200 rounded-lg text-sm font-medium"
>
Add
</button>
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => setIsEditing(false)}
@@ -216,9 +295,9 @@ export default function RoutineDetailPage() {
</div>
<button
onClick={handleStart}
className="w-full mt-4 bg-indigo-600 text-white font-semibold py-3 rounded-xl flex items-center justify-center gap-2"
className="w-full mt-4 bg-gradient-to-r from-indigo-600 to-purple-600 text-white font-bold py-4 rounded-2xl flex items-center justify-center gap-2 text-lg shadow-lg shadow-indigo-500/25 active:scale-[0.98] transition-transform"
>
<PlayIcon size={20} />
<PlayIcon size={24} />
Start Routine
</button>
</div>
@@ -226,7 +305,17 @@ export default function RoutineDetailPage() {
{/* Steps */}
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Steps</h2>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-gray-900">Steps</h2>
{steps.length >= 4 && steps.length <= 7 && (
<span className="text-xs text-green-600 bg-green-50 px-2 py-1 rounded-full">Good length</span>
)}
</div>
{steps.length > 7 && (
<p className="text-sm text-amber-600 bg-amber-50 px-3 py-2 rounded-lg mb-3">
Tip: Routines with 4-7 steps tend to feel more manageable. Consider combining related steps.
</p>
)}
<div className="bg-white rounded-xl p-4 shadow-sm space-y-3 mb-4">
<div className="flex gap-2">

View File

@@ -4,6 +4,10 @@ import { useEffect, useState, useCallback, useRef } from 'react';
import { useRouter, useParams } from 'next/navigation';
import api from '@/lib/api';
import { ArrowLeftIcon, PauseIcon, PlayIcon, StopIcon, SkipForwardIcon, CheckIcon, XIcon } from '@/components/ui/Icons';
import AnimatedCheckmark from '@/components/ui/AnimatedCheckmark';
import VisualTimeline from '@/components/session/VisualTimeline';
import { playStepComplete, playCelebration } from '@/lib/sounds';
import { hapticSuccess, hapticCelebration } from '@/lib/haptics';
interface Step {
id: string;
@@ -27,6 +31,15 @@ interface Session {
current_step_index: number;
}
interface CelebrationData {
streak_current: number;
streak_longest: number;
session_duration_minutes: number;
total_completions: number;
steps_completed: number;
steps_skipped: number;
}
export default function SessionRunnerPage() {
const router = useRouter();
const params = useParams();
@@ -38,12 +51,24 @@ export default function SessionRunnerPage() {
const [currentStep, setCurrentStep] = useState<Step | null>(null);
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [status, setStatus] = useState<'loading' | 'active' | 'paused' | 'completed'>('loading');
const [timerSeconds, setTimerSeconds] = useState(0);
const [isTimerRunning, setIsTimerRunning] = useState(false);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const [swipeDirection, setSwipeDirection] = useState<'left' | 'right' | null>(null);
// UI states
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
// Animation states
const [completionPhase, setCompletionPhase] = useState<'idle' | 'completing' | 'transitioning' | 'arriving'>('idle');
const [skipPhase, setSkipPhase] = useState<'idle' | 'skipping' | 'transitioning' | 'arriving'>('idle');
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
const [celebrationData, setCelebrationData] = useState<CelebrationData | null>(null);
const [celebrationPhase, setCelebrationPhase] = useState(0);
const [reward, setReward] = useState<{ content: string; emoji?: string; rarity: string } | null>(null);
const [soundEnabled, setSoundEnabled] = useState(false);
const [hapticEnabled, setHapticEnabled] = useState(true);
const touchStartX = useRef<number | null>(null);
const touchStartY = useRef<number | null>(null);
@@ -58,10 +83,23 @@ export default function SessionRunnerPage() {
setCurrentStep(sessionData.current_step);
setCurrentStepIndex(sessionData.session.current_step_index);
setStatus(sessionData.session.status === 'paused' ? 'paused' : 'active');
// Mark previous steps as completed
const completed = new Set<number>();
for (let i = 0; i < sessionData.session.current_step_index; i++) {
completed.add(i);
}
setCompletedSteps(completed);
if (sessionData.current_step?.duration_minutes) {
setTimerSeconds(sessionData.current_step.duration_minutes * 60);
}
// Fetch user preferences for sound/haptics
api.preferences.get().then((prefs: { sound_enabled?: boolean; haptic_enabled?: boolean }) => {
if (prefs.sound_enabled !== undefined) setSoundEnabled(prefs.sound_enabled);
if (prefs.haptic_enabled !== undefined) setHapticEnabled(prefs.haptic_enabled);
}).catch(() => {});
} catch (err) {
router.push('/dashboard');
}
@@ -89,25 +127,24 @@ export default function SessionRunnerPage() {
// Touch handlers for swipe
const handleTouchStart = (e: React.TouchEvent) => {
if (completionPhase !== 'idle' || skipPhase !== 'idle') return;
touchStartX.current = e.touches[0].clientX;
touchStartY.current = e.touches[0].clientY;
};
const handleTouchEnd = (e: React.TouchEvent) => {
if (touchStartX.current === null || touchStartY.current === null) return;
if (completionPhase !== 'idle' || skipPhase !== 'idle') return;
const touchEndX = e.changedTouches[0].clientX;
const touchEndY = e.changedTouches[0].clientY;
const diffX = touchEndX - touchStartX.current;
const diffY = touchEndY - touchStartY.current;
// Only trigger if horizontal swipe is dominant
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
if (diffX < 0) {
// Swipe left - complete
handleComplete();
} else {
// Swipe right - skip
handleSkip();
}
}
@@ -117,46 +154,93 @@ export default function SessionRunnerPage() {
};
const handleComplete = async () => {
if (!session || !currentStep) return;
setSwipeDirection('left');
setTimeout(() => setSwipeDirection(null), 300);
if (!session || !currentStep || completionPhase !== 'idle') return;
// Auto-resume if paused
if (status === 'paused') {
setStatus('active');
setIsTimerRunning(true);
}
// Phase 1: Instant green glow + pulse on complete button
setCompletionPhase('completing');
if (soundEnabled) playStepComplete();
if (hapticEnabled) hapticSuccess();
try {
const result = await api.sessions.completeStep(session.id, currentStep.id);
if (result.next_step) {
setCurrentStep(result.next_step);
setCurrentStepIndex(result.session.current_step_index!);
setTimerSeconds((result.next_step.duration_minutes || 5) * 60);
setIsTimerRunning(true);
} else {
setStatus('completed');
setIsTimerRunning(false);
}
// Mark current step as completed for progress bar
setCompletedSteps(prev => new Set([...prev, currentStepIndex]));
// Phase 2: Slide out (after brief glow)
setTimeout(() => {
setCompletionPhase('transitioning');
}, 150);
// Phase 3: Bring in next step
setTimeout(() => {
if (result.next_step) {
setCurrentStep(result.next_step);
setCurrentStepIndex(result.session.current_step_index!);
setTimerSeconds((result.next_step.duration_minutes || 5) * 60);
setIsTimerRunning(true);
setCompletionPhase('arriving');
} else {
setCelebrationData(result.celebration || null);
setStatus('completed');
setCompletionPhase('idle');
}
}, 400);
// Phase 4: Back to idle
setTimeout(() => {
setCompletionPhase('idle');
}, 700);
} catch (err) {
console.error('Failed to complete step:', err);
setCompletionPhase('idle');
}
};
const handleSkip = async () => {
if (!session || !currentStep) return;
setSwipeDirection('right');
setTimeout(() => setSwipeDirection(null), 300);
if (!session || !currentStep || skipPhase !== 'idle') return;
// Auto-resume if paused
if (status === 'paused') {
setStatus('active');
setIsTimerRunning(true);
}
setSkipPhase('skipping');
try {
const result = await api.sessions.skipStep(session.id, currentStep.id);
if (result.next_step) {
setCurrentStep(result.next_step);
setCurrentStepIndex(result.session.current_step_index!);
setTimerSeconds((result.next_step.duration_minutes || 5) * 60);
setIsTimerRunning(true);
} else {
setStatus('completed');
setIsTimerRunning(false);
}
setTimeout(() => {
setSkipPhase('transitioning');
}, 150);
setTimeout(() => {
if (result.next_step) {
setCurrentStep(result.next_step);
setCurrentStepIndex(result.session.current_step_index!);
setTimerSeconds((result.next_step.duration_minutes || 5) * 60);
setIsTimerRunning(true);
setSkipPhase('arriving');
} else {
setCelebrationData(result.celebration || null);
setStatus('completed');
setSkipPhase('idle');
}
}, 400);
setTimeout(() => {
setSkipPhase('idle');
}, 700);
} catch (err) {
console.error('Failed to skip step:', err);
setSkipPhase('idle');
}
};
@@ -182,7 +266,11 @@ export default function SessionRunnerPage() {
}
};
const handleCancel = async () => {
const handleCancelRequest = () => {
setShowCancelConfirm(true);
};
const handleCancelConfirm = async () => {
if (!session) return;
try {
await api.sessions.cancel(session.id);
@@ -192,25 +280,131 @@ export default function SessionRunnerPage() {
}
};
// Celebration phase sequencing + reward fetch
useEffect(() => {
if (status !== 'completed') return;
setCelebrationPhase(0);
// Play celebration feedback
if (soundEnabled) playCelebration();
if (hapticEnabled) hapticCelebration();
// Fetch random reward
api.rewards.getRandom('completion').then(data => {
if (data.reward) setReward(data.reward);
}).catch(() => {});
const timers = [
setTimeout(() => setCelebrationPhase(1), 100), // bg gradient
setTimeout(() => setCelebrationPhase(2), 400), // checkmark
setTimeout(() => setCelebrationPhase(3), 900), // message
setTimeout(() => setCelebrationPhase(4), 1300), // stats + reward
setTimeout(() => setCelebrationPhase(5), 1700), // done button
];
return () => timers.forEach(clearTimeout);
}, [status]);
if (status === 'loading' || !currentStep) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900">
<div className="min-h-screen flex items-center justify-center bg-gray-950">
<div className="w-8 h-8 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
// ── Celebration Screen ──────────────────────────────────────
if (status === 'completed') {
const formatDuration = (mins: number) => {
if (mins < 1) return 'less than a minute';
if (mins < 60) return `${Math.round(mins)} minute${Math.round(mins) !== 1 ? 's' : ''}`;
const h = Math.floor(mins / 60);
const m = Math.round(mins % 60);
return m > 0 ? `${h}h ${m}m` : `${h}h`;
};
const streakMessage = (streak: number) => {
if (streak <= 1) return "You showed up today";
if (streak <= 3) return `${streak} days and counting`;
if (streak <= 7) return `${streak} days — you're building something real`;
return `${streak} days — you're someone who shows up`;
};
return (
<div className="min-h-screen bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-500 flex flex-col items-center justify-center p-6">
<div className="w-24 h-24 bg-white rounded-full flex items-center justify-center mb-6">
<CheckIcon className="text-green-500" size={48} />
<div className={`min-h-screen flex flex-col items-center justify-center p-6 transition-all duration-500 ${
celebrationPhase >= 1
? 'bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-500'
: 'bg-gray-950'
}`}>
{/* Animated Checkmark */}
<div className={`mb-8 ${celebrationPhase >= 2 ? 'animate-celebration-scale' : 'opacity-0'}`}>
<div className="w-28 h-28 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center">
{celebrationPhase >= 2 && (
<AnimatedCheckmark size={64} color="#ffffff" strokeWidth={2.5} />
)}
</div>
</div>
<h1 className="text-3xl font-bold text-white mb-2">Great job!</h1>
<p className="text-white/80 text-lg mb-8">You completed your routine</p>
{/* Message */}
<div className={`text-center mb-8 ${celebrationPhase >= 3 ? 'animate-fade-in-up' : 'opacity-0'}`}>
<h1 className="text-3xl font-bold text-white mb-2">
{routine?.name || 'Routine'} complete
</h1>
<p className="text-white/80 text-lg">You showed up for yourself</p>
</div>
{/* Stats */}
{celebrationData && (
<div className={`w-full max-w-sm space-y-3 mb-10 ${celebrationPhase >= 4 ? 'animate-fade-in-up' : 'opacity-0'}`}
style={{ animationDelay: '100ms' }}>
{/* Streak */}
<div className="bg-white/15 backdrop-blur-sm rounded-2xl p-4 text-center">
<p className="text-white/60 text-sm mb-1">Your streak</p>
<p className="text-white text-xl font-semibold">
{streakMessage(celebrationData.streak_current)}
</p>
</div>
{/* Duration + Steps */}
<div className="flex gap-3">
<div className="flex-1 bg-white/15 backdrop-blur-sm rounded-2xl p-4 text-center">
<p className="text-white/60 text-sm mb-1">Time</p>
<p className="text-white text-lg font-semibold">
{formatDuration(celebrationData.session_duration_minutes)}
</p>
</div>
<div className="flex-1 bg-white/15 backdrop-blur-sm rounded-2xl p-4 text-center">
<p className="text-white/60 text-sm mb-1">Steps done</p>
<p className="text-white text-lg font-semibold">
{celebrationData.steps_completed}/{celebrationData.steps_completed + celebrationData.steps_skipped}
</p>
</div>
</div>
{/* Total completions */}
{celebrationData.total_completions > 1 && (
<p className="text-white/50 text-sm text-center">
That&apos;s {celebrationData.total_completions} total completions
</p>
)}
{/* Variable Reward */}
{reward && (
<div className={`bg-white/10 backdrop-blur-sm rounded-2xl p-4 text-center border ${
reward.rarity === 'rare' ? 'border-yellow-400/50' : 'border-white/10'
}`}>
<span className="text-2xl">{reward.emoji || '✨'}</span>
<p className="text-white text-sm mt-1">{reward.content}</p>
</div>
)}
</div>
)}
{/* Done button */}
<button
onClick={() => router.push('/dashboard')}
className="bg-white text-indigo-600 px-8 py-3 rounded-full font-semibold"
className={`bg-white text-indigo-600 px-10 py-3.5 rounded-full font-semibold text-lg shadow-lg ${
celebrationPhase >= 5 ? 'animate-fade-in-up' : 'opacity-0'
}`}
>
Done
</button>
@@ -218,20 +412,32 @@ export default function SessionRunnerPage() {
);
}
const progress = ((currentStepIndex + 1) / steps.length) * 100;
// ── Active Session ──────────────────────────────────────────
// Card animation class
const getCardAnimation = () => {
if (completionPhase === 'completing') return 'animate-green-glow';
if (completionPhase === 'transitioning') return 'animate-slide-out-left';
if (completionPhase === 'arriving') return 'animate-slide-in-right';
if (skipPhase === 'skipping') return '';
if (skipPhase === 'transitioning') return 'animate-slide-out-right';
if (skipPhase === 'arriving') return 'animate-slide-in-right';
return '';
};
return (
<div
className="min-h-screen bg-gray-900 text-white flex flex-col"
<div
className="min-h-screen bg-gray-950 text-white flex flex-col"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Header */}
<header className="flex items-center justify-between px-4 py-4">
<button onClick={handleCancel} className="p-2">
<button onClick={handleCancelRequest} className="p-2">
<XIcon size={24} />
</button>
<div className="text-center">
<p className="text-white/40 text-xs tracking-wide uppercase mb-0.5">Focus Mode</p>
<p className="text-white/60 text-sm">{routine?.name}</p>
<p className="font-semibold">Step {currentStepIndex + 1} of {steps.length}</p>
</div>
@@ -240,34 +446,44 @@ export default function SessionRunnerPage() {
</button>
</header>
{/* Progress bar */}
<div className="px-4">
<div className="h-1 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 transition-all duration-500"
style={{ width: `${progress}%` }}
/>
{/* Segmented Progress Bar */}
<div className="px-4 pb-2">
<div className="flex gap-1">
{steps.map((_, i) => (
<div key={i} className="flex-1 h-2 rounded-full overflow-hidden bg-gray-800">
<div
className={`h-full rounded-full transition-all duration-500 ${
completedSteps.has(i)
? 'bg-indigo-500 w-full'
: i === currentStepIndex
? 'bg-indigo-400 w-full animate-gentle-pulse'
: 'w-0'
}`}
/>
</div>
))}
</div>
</div>
{/* Visual Timeline — collapsible step overview */}
<VisualTimeline
steps={steps}
currentStepIndex={currentStepIndex}
completedSteps={completedSteps}
/>
{/* Main Card */}
<div className="flex-1 flex flex-col items-center justify-center p-6">
<div
<div
className={`
w-full max-w-md bg-gray-800 rounded-3xl p-8 text-center
transition-transform duration-300
${swipeDirection === 'left' ? 'translate-x-20 opacity-50' : ''}
${swipeDirection === 'right' ? '-translate-x-20 opacity-50' : ''}
shadow-2xl shadow-indigo-500/10
${getCardAnimation()}
`}
>
{/* Step Type Badge */}
<div className="inline-block px-3 py-1 bg-indigo-600 rounded-full text-sm mb-4">
{currentStep.step_type || 'Generic'}
</div>
{/* Timer */}
<div className="mb-6">
<div className="text-7xl font-bold font-mono mb-2">
<div className="text-7xl font-bold font-mono mb-2" style={{ textShadow: '0 0 20px rgba(99, 102, 241, 0.3)' }}>
{formatTime(timerSeconds)}
</div>
<p className="text-white/60">remaining</p>
@@ -281,41 +497,67 @@ export default function SessionRunnerPage() {
<p className="text-white/80 text-lg mb-6">{currentStep.instructions}</p>
)}
{/* Timer Controls */}
<div className="flex justify-center gap-4 mt-8">
<button
onClick={handleSkip}
className="w-16 h-16 bg-gray-700 rounded-full flex items-center justify-center hover:bg-gray-600 transition"
>
<SkipForwardIcon size={28} />
</button>
<button
onClick={status === 'paused' ? handleResume : handlePause}
className="w-20 h-20 bg-indigo-600 rounded-full flex items-center justify-center hover:bg-indigo-500 transition"
>
{status === 'paused' ? <PlayIcon size={32} /> : <PauseIcon size={32} />}
</button>
<button
onClick={handleComplete}
className="w-16 h-16 bg-green-600 rounded-full flex items-center justify-center hover:bg-green-500 transition"
>
<CheckIcon size={28} />
</button>
</div>
</div>
{/* Swipe Hints */}
<div className="flex justify-between w-full max-w-md mt-8 text-white/40 text-sm">
<div className="flex items-center gap-2">
<ArrowLeftIcon size={16} />
<span>Swipe left to complete</span>
</div>
<div className="flex items-center gap-2">
<span>Swipe right to skip</span>
<ArrowLeftIcon size={16} className="rotate-180" />
</div>
</div>
</div>
{/* Action Buttons — pinned to bottom for one-handed use */}
<div className="px-6 pb-8 pt-4">
<div className="flex justify-center items-center gap-6">
<button
onClick={handleSkip}
disabled={skipPhase !== 'idle' || completionPhase !== 'idle'}
className="w-16 h-16 bg-gray-700 rounded-full flex items-center justify-center hover:bg-gray-600 transition disabled:opacity-50"
>
<SkipForwardIcon size={28} />
</button>
<button
onClick={status === 'paused' ? handleResume : handlePause}
className="w-20 h-20 bg-indigo-600 rounded-full flex items-center justify-center hover:bg-indigo-500 transition"
>
{status === 'paused' ? <PlayIcon size={32} /> : <PauseIcon size={32} />}
</button>
<button
onClick={handleComplete}
disabled={completionPhase !== 'idle' || skipPhase !== 'idle'}
className={`w-16 h-16 rounded-full flex items-center justify-center transition disabled:opacity-50 ${
completionPhase === 'completing'
? 'bg-green-500 animate-step-complete'
: 'bg-green-600 hover:bg-green-500'
}`}
>
<CheckIcon size={28} />
</button>
</div>
{/* Swipe Hints */}
<div className="flex justify-between mt-4 text-white/30 text-xs">
<span>Swipe left to complete</span>
<span>Swipe right to skip</span>
</div>
</div>
{/* Gentle Cancel Confirmation */}
{showCancelConfirm && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-6">
<div className="bg-gray-800 rounded-2xl p-6 max-w-sm w-full text-center">
<p className="text-white text-lg font-semibold mb-2">Step away for now?</p>
<p className="text-white/60 text-sm mb-6">You can always come back. Your progress today still counts.</p>
<div className="flex gap-3">
<button
onClick={() => setShowCancelConfirm(false)}
className="flex-1 py-3 bg-indigo-600 text-white rounded-xl font-medium"
>
Keep Going
</button>
<button
onClick={handleCancelConfirm}
className="flex-1 py-3 bg-gray-700 text-white/70 rounded-xl font-medium"
>
Leave
</button>
</div>
</div>
</div>
)}
</div>
);
}

View 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>
);
}

View File

@@ -35,26 +35,50 @@ interface WeeklySummary {
}[];
}
function getStreakMessage(streak: number): string {
if (streak === 0) return 'Ready for a fresh start';
if (streak === 1) return "You're back! Day 1";
if (streak <= 3) return `${streak} days of showing up`;
if (streak <= 7) return `${streak} days — building momentum`;
return `${streak} days — you're someone who shows up`;
}
function getCompletionLabel(rate: number): { label: string; color: string } {
if (rate >= 80) return { label: 'Rock solid', color: 'text-green-600' };
if (rate >= 60) return { label: 'Strong habit', color: 'text-indigo-600' };
if (rate >= 30) return { label: 'Building momentum', color: 'text-amber-600' };
return { label: 'Getting started', color: 'text-gray-600' };
}
interface Victory {
type: string;
message: string;
date?: string;
}
export default function StatsPage() {
const [routines, setRoutines] = useState<{ id: string; name: string }[]>([]);
const [selectedRoutine, setSelectedRoutine] = useState<string>('');
const [routineStats, setRoutineStats] = useState<RoutineStats | null>(null);
const [streaks, setStreaks] = useState<Streak[]>([]);
const [weeklySummary, setWeeklySummary] = useState<WeeklySummary | null>(null);
const [victories, setVictories] = useState<Victory[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const [routinesData, streaksData, summaryData] = await Promise.all([
const [routinesData, streaksData, summaryData, victoriesData] = await Promise.all([
api.routines.list(),
api.stats.getStreaks(),
api.stats.getWeeklySummary(),
api.victories.get(30).catch(() => []),
]);
setRoutines(routinesData);
setStreaks(streaksData);
setWeeklySummary(summaryData);
setVictories(victoriesData);
if (routinesData.length > 0) {
setSelectedRoutine(routinesData[0].id);
}
@@ -97,7 +121,7 @@ export default function StatsPage() {
return (
<div className="p-4 space-y-6">
<h1 className="text-2xl font-bold text-gray-900">Stats</h1>
<h1 className="text-2xl font-bold text-gray-900">Your Progress</h1>
{/* Weekly Summary */}
{weeklySummary && (
@@ -110,20 +134,42 @@ export default function StatsPage() {
<div className="bg-gradient-to-br from-pink-500 to-rose-600 rounded-2xl p-4 text-white">
<ClockIcon className="text-white/80 mb-2" size={24} />
<p className="text-3xl font-bold">{formatTime(weeklySummary.total_time_minutes)}</p>
<p className="text-white/80 text-sm">Time</p>
<p className="text-white/80 text-sm">Invested</p>
</div>
<div className="bg-gradient-to-br from-amber-500 to-orange-600 rounded-2xl p-4 text-white">
<ActivityIcon className="text-white/80 mb-2" size={24} />
<p className="text-3xl font-bold">{weeklySummary.routines_started}</p>
<p className="text-white/80 text-sm">Started</p>
<p className="text-white/80 text-sm">Active</p>
</div>
</div>
)}
{/* Streaks */}
{/* Wins This Month */}
{victories.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Wins This Month</h2>
<div className="space-y-2">
{victories.map((victory, i) => (
<div key={i} className="bg-white rounded-xl p-4 shadow-sm flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-green-100 to-emerald-100 rounded-xl flex items-center justify-center">
<span className="text-lg">
{victory.type === 'comeback' ? '💪' :
victory.type === 'weekend' ? '🎉' :
victory.type === 'variety' ? '🌈' :
victory.type === 'consistency' ? '🔥' : '⭐'}
</span>
</div>
<p className="text-sm text-gray-700 flex-1">{victory.message}</p>
</div>
))}
</div>
</div>
)}
{/* Consistency (formerly Streaks) */}
{streaks.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Streaks</h2>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Your Consistency</h2>
<div className="space-y-2">
{streaks.map((streak) => (
<div key={streak.routine_id} className="bg-white rounded-xl p-4 shadow-sm flex items-center gap-4">
@@ -133,15 +179,27 @@ export default function StatsPage() {
<div className="flex-1">
<p className="font-medium text-gray-900">{streak.routine_name}</p>
<p className="text-sm text-gray-500">
Last: {streak.last_completed_date ? new Date(streak.last_completed_date).toLocaleDateString() : 'Never'}
{getStreakMessage(streak.current_streak)}
</p>
</div>
<div className="text-right">
<p className="text-2xl font-bold text-orange-500">{streak.current_streak}</p>
<p className="text-xs text-gray-500">day streak</p>
{streak.current_streak > 0 ? (
<>
<p className="text-2xl font-bold text-orange-500">{streak.current_streak}</p>
<p className="text-xs text-gray-500">days</p>
</>
) : (
<p className="text-sm text-gray-400">Ready</p>
)}
</div>
</div>
))}
{/* Longest streak callout */}
{streaks.some(s => s.longest_streak > 0) && (
<p className="text-sm text-gray-400 text-center mt-2">
Your personal best: {Math.max(...streaks.map(s => s.longest_streak))} days you&apos;ve done it before
</p>
)}
</div>
</div>
)}
@@ -149,7 +207,7 @@ export default function StatsPage() {
{/* Per-Routine Stats */}
{routines.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Routine Stats</h2>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Routine Details</h2>
<select
value={selectedRoutine}
onChange={(e) => setSelectedRoutine(e.target.value)}
@@ -166,8 +224,10 @@ export default function StatsPage() {
<div className="grid grid-cols-2 gap-3">
<div className="bg-white rounded-xl p-4 shadow-sm">
<TargetIcon className="text-indigo-500 mb-2" size={24} />
<p className="text-2xl font-bold text-gray-900">{routineStats.completion_rate_percent}%</p>
<p className="text-sm text-gray-500">Completion Rate</p>
<p className={`text-lg font-bold ${getCompletionLabel(routineStats.completion_rate_percent).color}`}>
{getCompletionLabel(routineStats.completion_rate_percent).label}
</p>
<p className="text-sm text-gray-400">{routineStats.completion_rate_percent}%</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm">
<ClockIcon className="text-purple-500 mb-2" size={24} />
@@ -186,6 +246,15 @@ export default function StatsPage() {
</div>
</div>
)}
{/* Plateau messaging */}
{routineStats && routineStats.total_sessions >= 5 && (
<p className="text-sm text-gray-400 text-center mt-3">
{routineStats.completion_rate_percent >= 60
? "You're showing up consistently — that's the hard part. The exact rate doesn't matter."
: "Life has seasons. The fact that you're checking in shows this matters to you."}
</p>
)}
</div>
)}
</div>

View File

@@ -24,3 +24,96 @@ body {
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
/* ── Animation Keyframes ─────────────────────────────────── */
@keyframes checkmark-draw {
0% { stroke-dashoffset: 24; }
100% { stroke-dashoffset: 0; }
}
@keyframes step-complete-pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.12); opacity: 0.85; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes slide-out-left {
0% { transform: translateX(0); opacity: 1; }
100% { transform: translateX(-120%); opacity: 0; }
}
@keyframes slide-in-right {
0% { transform: translateX(80%); opacity: 0; }
100% { transform: translateX(0); opacity: 1; }
}
@keyframes slide-out-right {
0% { transform: translateX(0); opacity: 1; }
100% { transform: translateX(120%); opacity: 0; }
}
@keyframes progress-fill {
0% { transform: scaleX(0); }
100% { transform: scaleX(1); }
}
@keyframes progress-glow {
0% { box-shadow: 0 0 4px rgba(99, 102, 241, 0.4); }
50% { box-shadow: 0 0 12px rgba(99, 102, 241, 0.8); }
100% { box-shadow: 0 0 4px rgba(99, 102, 241, 0.4); }
}
@keyframes celebration-scale {
0% { transform: scale(0.3); opacity: 0; }
60% { transform: scale(1.1); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes fade-in-up {
0% { transform: translateY(20px); opacity: 0; }
100% { transform: translateY(0); opacity: 1; }
}
@keyframes gentle-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
@keyframes green-glow {
0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.5); }
50% { box-shadow: 0 0 20px 4px rgba(34, 197, 94, 0.3); }
100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
}
/* Utility animation classes */
.animate-checkmark-draw {
animation: checkmark-draw 0.4s ease-out forwards;
}
.animate-step-complete {
animation: step-complete-pulse 0.3s ease-out;
}
.animate-slide-out-left {
animation: slide-out-left 0.25s ease-in forwards;
}
.animate-slide-in-right {
animation: slide-in-right 0.3s ease-out forwards;
}
.animate-slide-out-right {
animation: slide-out-right 0.25s ease-in forwards;
}
.animate-celebration-scale {
animation: celebration-scale 0.5s ease-out forwards;
}
.animate-fade-in-up {
animation: fade-in-up 0.4s ease-out forwards;
}
.animate-gentle-pulse {
animation: gentle-pulse 2s ease-in-out infinite;
}
.animate-green-glow {
animation: green-glow 0.5s ease-out;
}
.animate-progress-glow {
animation: progress-glow 0.6s ease-out;
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -708,6 +708,135 @@ export function TimerIcon({ className = '', size = 24 }: IconProps) {
);
}
export function LockIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
);
}
export function SparklesIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
<path d="M5 3v4" />
<path d="M19 17v4" />
<path d="M3 5h4" />
<path d="M17 19h4" />
</svg>
);
}
export function TrophyIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" />
<path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18" />
<path d="M4 22h16" />
<path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22" />
<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22" />
<path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" />
</svg>
);
}
export function MapPinIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
);
}
export function VolumeIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
</svg>
);
}
export function VolumeOffIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<line x1="22" y1="9" x2="16" y2="15" />
<line x1="16" y1="9" x2="22" y2="15" />
</svg>
);
}
export function SkipForwardIcon({ className = '', size = 24 }: IconProps) {
return (
<svg

View File

@@ -20,6 +20,7 @@ async function request<T>(
const token = getToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
'X-Timezone-Offset': String(new Date().getTimezoneOffset()),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
};
@@ -375,6 +376,14 @@ export const api = {
duration_minutes?: number;
position: number;
} | null;
celebration?: {
streak_current: number;
streak_longest: number;
session_duration_minutes: number;
total_completions: number;
steps_completed: number;
steps_skipped: number;
};
}>(`/api/sessions/${sessionId}/complete-step`, {
method: 'POST',
body: JSON.stringify({ step_id: stepId }),
@@ -392,6 +401,14 @@ export const api = {
duration_minutes?: number;
position: number;
} | null;
celebration?: {
streak_current: number;
streak_longest: number;
session_duration_minutes: number;
total_completions: number;
steps_completed: number;
steps_skipped: number;
};
}>(`/api/sessions/${sessionId}/skip-step`, {
method: 'POST',
body: JSON.stringify({ step_id: stepId }),
@@ -546,6 +563,68 @@ export const api = {
},
},
// Victories
victories: {
get: async (days = 30) => {
return request<Array<{
type: string;
message: string;
date?: string;
}>>(`/api/victories?days=${days}`, { method: 'GET' });
},
},
// Rewards
rewards: {
getRandom: async (context = 'completion') => {
return request<{
reward: {
id: string;
category: string;
content: string;
emoji?: string;
rarity: string;
} | null;
}>(`/api/rewards/random?context=${context}`, { method: 'GET' });
},
getHistory: async () => {
return request<Array<{
earned_at: string;
context?: string;
category: string;
content: string;
emoji?: string;
rarity?: string;
}>>('/api/rewards/history', { method: 'GET' });
},
},
// Preferences
preferences: {
get: async () => {
return request<{
sound_enabled: boolean;
haptic_enabled: boolean;
show_launch_screen: boolean;
celebration_style: string;
}>('/api/preferences', { method: 'GET' });
},
update: async (data: {
sound_enabled?: boolean;
haptic_enabled?: boolean;
show_launch_screen?: boolean;
celebration_style?: string;
timezone_offset?: number;
}) => {
return request<Record<string, unknown>>('/api/preferences', {
method: 'PUT',
body: JSON.stringify(data),
});
},
},
// Notifications
notifications: {
getVapidPublicKey: async () => {

View 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]);
}

View 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);
}