""" Routines API - routine management 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): 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 _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 — this MUST succeed postgres.update("routine_sessions", { "status": "completed", "completed_at": now.isoformat(), "actual_duration_minutes": int(duration_minutes), }, {"id": session_id}) # Gather celebration stats — failures here should not break completion streak_current = 1 streak_longest = 1 streak_milestone = None steps_completed = 0 steps_skipped = 0 total_completions = 1 try: streak_result = routines_core._update_streak(user_uuid, session["routine_id"]) streak = postgres.select_one("routine_streaks", { "user_uuid": user_uuid, "routine_id": session["routine_id"], }) if streak: streak_current = streak["current_streak"] streak_longest = streak["longest_streak"] streak_milestone = streak_result.get("milestone") if streak_result else None except Exception: pass try: 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") except Exception: pass try: all_completed = postgres.select("routine_sessions", { "routine_id": session["routine_id"], "user_uuid": user_uuid, "status": "completed", }) total_completions = len(all_completed) except Exception: pass result = { "streak_current": streak_current, "streak_longest": streak_longest, "session_duration_minutes": duration_minutes, "total_completions": total_completions, "steps_completed": steps_completed, "steps_skipped": steps_skipped, } if streak_milestone: result["streak_milestone"] = streak_milestone return result def register(app): # ── Routines CRUD ───────────────────────────────────────────── @app.route("/api/routines", methods=["GET"]) def api_listRoutines(): """List all routines for the logged-in user.""" user_uuid = _auth(flask.request) if not user_uuid: return flask.jsonify({"error": "unauthorized"}), 401 routines = postgres.select("routines", where={"user_uuid": user_uuid}, order_by="name") return flask.jsonify(routines), 200 @app.route("/api/routines", methods=["POST"]) def api_createRoutine(): """Create a new routine. Body: {name, description?, icon?}""" 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 if not data.get("name"): return flask.jsonify({"error": "missing required field: name"}), 400 data["id"] = str(uuid.uuid4()) data["user_uuid"] = user_uuid routine = postgres.insert("routines", data) return flask.jsonify(routine), 201 @app.route("/api/routines/", methods=["GET"]) def api_getRoutine(routine_id): """Get a routine with its steps.""" user_uuid = _auth(flask.request) if not user_uuid: return flask.jsonify({"error": "unauthorized"}), 401 routine = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid}) if not routine: return flask.jsonify({"error": "not found"}), 404 steps = postgres.select( "routine_steps", where={"routine_id": routine_id}, order_by="position", ) return flask.jsonify({"routine": routine, "steps": steps}), 200 @app.route("/api/routines/", methods=["PUT"]) def api_updateRoutine(routine_id): """Update routine details. Body: {name?, description?, icon?}""" 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 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", "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 result = postgres.update("routines", updates, {"id": routine_id, "user_uuid": user_uuid}) return flask.jsonify(result[0] if result else {}), 200 @app.route("/api/routines/", methods=["DELETE"]) def api_deleteRoutine(routine_id): """Delete a routine and all its steps/sessions.""" user_uuid = _auth(flask.request) if not user_uuid: return flask.jsonify({"error": "unauthorized"}), 401 existing = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid}) if not existing: return flask.jsonify({"error": "not found"}), 404 postgres.delete("routine_sessions", {"routine_id": routine_id}) postgres.delete("routine_steps", {"routine_id": routine_id}) postgres.delete("routine_schedules", {"routine_id": routine_id}) postgres.delete("routines", {"id": routine_id, "user_uuid": user_uuid}) return flask.jsonify({"deleted": True}), 200 # ── Steps CRUD ──────────────────────────────────────────────── @app.route("/api/routines//steps", methods=["GET"]) def api_listSteps(routine_id): """List steps for a routine, ordered by position.""" user_uuid = _auth(flask.request) if not user_uuid: return flask.jsonify({"error": "unauthorized"}), 401 routine = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid}) if not routine: return flask.jsonify({"error": "not found"}), 404 steps = postgres.select( "routine_steps", where={"routine_id": routine_id}, order_by="position", ) return flask.jsonify(steps), 200 @app.route("/api/routines//steps", methods=["POST"]) def api_addStep(routine_id): """Add a step to a routine. Body: {name, duration_minutes?, position?}""" user_uuid = _auth(flask.request) if not user_uuid: return flask.jsonify({"error": "unauthorized"}), 401 routine = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid}) if not routine: return flask.jsonify({"error": "not found"}), 404 data = flask.request.get_json() if not data: return flask.jsonify({"error": "missing body"}), 400 if not data.get("name"): return flask.jsonify({"error": "missing required field: name"}), 400 max_pos = postgres.select( "routine_steps", where={"routine_id": routine_id}, order_by="position DESC", limit=1, ) next_pos = (max_pos[0]["position"] + 1) if max_pos else 1 step = { "id": str(uuid.uuid4()), "routine_id": routine_id, "name": data["name"], "duration_minutes": data.get("duration_minutes"), "position": data.get("position", next_pos), } result = postgres.insert("routine_steps", step) return flask.jsonify(result), 201 @app.route("/api/routines//steps/", methods=["PUT"]) def api_updateStep(routine_id, step_id): """Update a step. Body: {name?, duration_minutes?, position?}""" user_uuid = _auth(flask.request) if not user_uuid: return flask.jsonify({"error": "unauthorized"}), 401 routine = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid}) if not routine: return flask.jsonify({"error": "not found"}), 404 data = flask.request.get_json() if not data: return flask.jsonify({"error": "missing body"}), 400 existing = postgres.select_one("routine_steps", {"id": step_id, "routine_id": routine_id}) if not existing: return flask.jsonify({"error": "step not found"}), 404 allowed = ["name", "duration_minutes", "position"] 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 result = postgres.update("routine_steps", updates, {"id": step_id, "routine_id": routine_id}) return flask.jsonify(result[0] if result else {}), 200 @app.route("/api/routines//steps/", methods=["DELETE"]) def api_deleteStep(routine_id, step_id): """Delete a step from a routine.""" user_uuid = _auth(flask.request) if not user_uuid: return flask.jsonify({"error": "unauthorized"}), 401 routine = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid}) if not routine: return flask.jsonify({"error": "not found"}), 404 existing = postgres.select_one("routine_steps", {"id": step_id, "routine_id": routine_id}) if not existing: return flask.jsonify({"error": "step not found"}), 404 postgres.delete("routine_steps", {"id": step_id, "routine_id": routine_id}) return flask.jsonify({"deleted": True}), 200 @app.route("/api/routines//steps/reorder", methods=["PUT"]) def api_reorderSteps(routine_id): """Reorder steps. Body: {step_ids: [ordered list of step UUIDs]}""" user_uuid = _auth(flask.request) if not user_uuid: return flask.jsonify({"error": "unauthorized"}), 401 routine = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid}) if not routine: return flask.jsonify({"error": "not found"}), 404 data = flask.request.get_json() if not data or not data.get("step_ids"): return flask.jsonify({"error": "missing step_ids"}), 400 step_ids = data["step_ids"] for i, step_id in enumerate(step_ids): postgres.update("routine_steps", {"position": i + 1}, {"id": step_id, "routine_id": routine_id}) steps = postgres.select( "routine_steps", where={"routine_id": routine_id}, order_by="position", ) return flask.jsonify(steps), 200 # ── Routine Sessions (active run-through) ───────────────────── @app.route("/api/routines//start", methods=["POST"]) def api_startRoutine(routine_id): """Start a routine session. Returns the session with first step.""" user_uuid = _auth(flask.request) if not user_uuid: return flask.jsonify({"error": "unauthorized"}), 401 routine = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid}) if not routine: return flask.jsonify({"error": "not found"}), 404 active = postgres.select_one("routine_sessions", {"user_uuid": user_uuid, "status": "active"}) if not active: active = postgres.select_one("routine_sessions", {"user_uuid": user_uuid, "status": "paused"}) if active: return flask.jsonify({"error": "already have active session", "session_id": active["id"]}), 409 steps = postgres.select( "routine_steps", where={"routine_id": routine_id}, order_by="position", ) if not steps: return flask.jsonify({"error": "no steps in routine"}), 400 session = { "id": str(uuid.uuid4()), "routine_id": routine_id, "user_uuid": user_uuid, "status": "active", "current_step_index": 0, } result = postgres.insert("routine_sessions", session) return flask.jsonify({"session": result, "current_step": steps[0]}), 201 @app.route("/api/sessions/active", methods=["GET"]) def api_getActiveSession(): """Get the user's currently active routine session, if any.""" user_uuid = _auth(flask.request) 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"]}) steps = postgres.select( "routine_steps", where={"routine_id": session["routine_id"]}, order_by="position", ) current_step = steps[session["current_step_index"]] if session["current_step_index"] < len(steps) else None return flask.jsonify({"session": session, "routine": routine, "current_step": current_step}), 200 @app.route("/api/sessions//complete-step", methods=["POST"]) def api_completeStep(session_id): """Mark current step done, advance to next. Body: {step_id}""" user_uuid = _auth(flask.request) if not user_uuid: return flask.jsonify({"error": "unauthorized"}), 401 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"] 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", ) 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): # 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 @app.route("/api/sessions//skip-step", methods=["POST"]) def api_skipStep(session_id): """Skip current step, advance to next. Body: {step_id}""" user_uuid = _auth(flask.request) if not user_uuid: return flask.jsonify({"error": "unauthorized"}), 401 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"] 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", ) 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): 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 @app.route("/api/sessions//cancel", methods=["POST"]) def api_cancelSession(session_id): """Cancel an active routine session.""" user_uuid = _auth(flask.request) if not user_uuid: return flask.jsonify({"error": "unauthorized"}), 401 session = postgres.select_one("routine_sessions", {"id": session_id, "user_uuid": user_uuid}) if not session: return flask.jsonify({"error": "not found"}), 404 postgres.update("routine_sessions", {"status": "cancelled"}, {"id": session_id}) return flask.jsonify({"session": {"status": "cancelled"}}), 200 # ── Routine History / Stats ─────────────────────────────────── @app.route("/api/routines//history", methods=["GET"]) def api_routineHistory(routine_id): """Get past sessions for a routine. Query: ?days=7""" user_uuid = _auth(flask.request) if not user_uuid: return flask.jsonify({"error": "unauthorized"}), 401 routine = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid}) if not routine: return flask.jsonify({"error": "not found"}), 404 days = flask.request.args.get("days", 7, type=int) sessions = postgres.select( "routine_sessions", where={"routine_id": routine_id}, order_by="created_at DESC", limit=days, ) return flask.jsonify(sessions), 200 # ── Routine Scheduling ──────────────────────────────────────── @app.route("/api/routines//schedule", methods=["PUT"]) def api_setRoutineSchedule(routine_id): """Set when this routine should run. Body: {days: ["mon","tue",...], time: "08:00", remind: true}""" user_uuid = _auth(flask.request) if not user_uuid: return flask.jsonify({"error": "unauthorized"}), 401 routine = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid}) if not routine: return flask.jsonify({"error": "not found"}), 404 data = flask.request.get_json() if not data: return flask.jsonify({"error": "missing body"}), 400 existing = postgres.select_one("routine_schedules", {"routine_id": routine_id}) schedule_data = { "routine_id": routine_id, "days": data.get("days", []), "time": data.get("time"), "remind": data.get("remind", True), } if existing: result = postgres.update("routine_schedules", schedule_data, {"routine_id": routine_id}) return flask.jsonify(result[0] if result else {}), 200 else: schedule_data["id"] = str(uuid.uuid4()) result = postgres.insert("routine_schedules", schedule_data) return flask.jsonify(result), 201 @app.route("/api/routines//schedule", methods=["GET"]) def api_getRoutineSchedule(routine_id): """Get the schedule for a routine.""" user_uuid = _auth(flask.request) if not user_uuid: return flask.jsonify({"error": "unauthorized"}), 401 routine = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid}) if not routine: return flask.jsonify({"error": "not found"}), 404 schedule = postgres.select_one("routine_schedules", {"routine_id": routine_id}) if not schedule: return flask.jsonify({"error": "no schedule set"}), 404 return flask.jsonify(schedule), 200 @app.route("/api/routines//schedule", methods=["DELETE"]) def api_deleteRoutineSchedule(routine_id): """Remove the schedule from a routine.""" user_uuid = _auth(flask.request) if not user_uuid: return flask.jsonify({"error": "unauthorized"}), 401 routine = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid}) if not routine: return flask.jsonify({"error": "not found"}), 404 postgres.delete("routine_schedules", {"routine_id": routine_id}) return flask.jsonify({"deleted": True}), 200