Files
Synculous-2/api/routes/routines.py
chelsea ecb79af44e Fix bugs, add auto-refresh, quick-complete tasks, and every-N-day routines
- Fix bot auth: merge duplicate on_ready handlers so session restore runs (#13)
- Fix push notifications: pass Uint8Array directly as applicationServerKey (#6)
- Show specific conflict reason on schedule save instead of generic error (#17)
- Add inline checkmark button to complete tasks on routines timeline (#18)
- Add visibility-change + 60s polling auto-refresh to routines, meds, tasks (#15)
- Add every-N-day routine scheduling: schema, API, scheduler, and UI (#16)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:04:52 -06:00

829 lines
33 KiB
Python

"""
Routines API - routine management
Routines have ordered steps. Users start sessions to walk through them.
"""
import os
import uuid
import json
from datetime import datetime, timedelta
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 _make_aware_utc(dt):
"""Ensure a datetime is timezone-aware; assume naive datetimes are UTC."""
if dt.tzinfo is None:
from datetime import timezone as _tz
return dt.replace(tzinfo=_tz.utc)
return dt
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)
last_completed = _make_aware_utc(last_completed)
duration_seconds = max(0, 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)
created_at = _make_aware_utc(created_at)
duration_seconds = max(0, 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)
created_at = _make_aware_utc(created_at)
duration_minutes = max(0, 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/<routine_id>", 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/<routine_id>", 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/<routine_id>", 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/<routine_id>/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/<routine_id>/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/<routine_id>/steps/<step_id>", 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/<routine_id>/steps/<step_id>", 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/<routine_id>/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/<routine_id>/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
# Check if starting now would conflict with medication times
now = tz.user_now()
current_time = now.strftime("%H:%M")
current_day = now.strftime("%a").lower()
routine_dur = _get_routine_duration_minutes(routine_id)
routine_start = _time_str_to_minutes(current_time)
user_meds = postgres.select("medications", {"user_uuid": user_uuid, "active": True})
for med in user_meds:
med_times = med.get("times", [])
if isinstance(med_times, str):
med_times = json.loads(med_times)
med_days = med.get("days_of_week", [])
if isinstance(med_days, str):
med_days = json.loads(med_days)
if med_days and current_day not in med_days:
continue
for mt in med_times:
med_start = _time_str_to_minutes(mt)
if _ranges_overlap(routine_start, routine_dur, med_start, 1):
return flask.jsonify(
{"error": f"Starting now would conflict with {med.get('name', 'medication')} at {mt}"}
), 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/<session_id>/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/<session_id>/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/<session_id>/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/<routine_id>/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/schedules", methods=["GET"])
def api_listAllSchedules():
"""Get all schedules for the user's routines with routine metadata."""
user_uuid = _auth(flask.request)
if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401
routines = postgres.select("routines", where={"user_uuid": user_uuid})
if not routines:
return flask.jsonify([]), 200
result = []
for r in routines:
sched = postgres.select_one("routine_schedules", {"routine_id": r["id"]})
if not sched:
continue
steps = postgres.select("routine_steps", where={"routine_id": r["id"]})
total_duration = sum(s.get("duration_minutes") or 0 for s in steps)
entry = {
"routine_id": r["id"],
"routine_name": r.get("name", ""),
"routine_icon": r.get("icon", ""),
"days": sched.get("days", []),
"time": sched.get("time"),
"remind": sched.get("remind", True),
"total_duration_minutes": total_duration,
"frequency": sched.get("frequency", "weekly"),
}
if sched.get("frequency") == "every_n_days":
entry["interval_days"] = sched.get("interval_days")
entry["start_date"] = str(sched.get("start_date")) if sched.get("start_date") else None
result.append(entry)
return flask.jsonify(result), 200
def _get_routine_duration_minutes(routine_id):
"""Get total duration of a routine from its steps."""
steps = postgres.select("routine_steps", where={"routine_id": routine_id})
total = sum(s.get("duration_minutes", 0) or 0 for s in steps)
return max(total, 1) # At least 1 minute
def _time_str_to_minutes(time_str):
"""Convert 'HH:MM' to minutes since midnight."""
parts = time_str.split(":")
return int(parts[0]) * 60 + int(parts[1])
def _ranges_overlap(start1, dur1, start2, dur2):
"""Check if two time ranges overlap (in minutes since midnight)."""
end1 = start1 + dur1
end2 = start2 + dur2
return start1 < end2 and start2 < end1
def _check_schedule_conflicts(user_uuid, new_days, new_time, exclude_routine_id=None, new_routine_id=None):
"""Check if the proposed schedule conflicts with existing routines or medications.
Returns (has_conflict, conflict_message) tuple.
"""
if not new_days or not new_time:
return False, None
new_start = _time_str_to_minutes(new_time)
# Get duration of the routine being scheduled
if new_routine_id:
new_dur = _get_routine_duration_minutes(new_routine_id)
else:
new_dur = 1
# Check conflicts with other routines
user_routines = postgres.select("routines", {"user_uuid": user_uuid})
for r in user_routines:
if r["id"] == exclude_routine_id:
continue
other_sched = postgres.select_one("routine_schedules", {"routine_id": r["id"]})
if not other_sched or not other_sched.get("time"):
continue
other_days = other_sched.get("days", [])
if isinstance(other_days, str):
other_days = json.loads(other_days)
if not any(d in other_days for d in new_days):
continue
other_start = _time_str_to_minutes(other_sched["time"])
other_dur = _get_routine_duration_minutes(r["id"])
if _ranges_overlap(new_start, new_dur, other_start, other_dur):
return True, f"Time conflicts with routine: {r.get('name', 'Unnamed routine')}"
# Check conflicts with medications
user_meds = postgres.select("medications", {"user_uuid": user_uuid, "active": True})
for med in user_meds:
med_times = med.get("times", [])
if isinstance(med_times, str):
med_times = json.loads(med_times)
med_days = med.get("days_of_week", [])
if isinstance(med_days, str):
med_days = json.loads(med_days)
# If med has no specific days, it runs every day
if med_days and not any(d in med_days for d in new_days):
continue
for mt in med_times:
med_start = _time_str_to_minutes(mt)
# Medication takes ~0 minutes, but check if it falls within routine window
if _ranges_overlap(new_start, new_dur, med_start, 1):
return True, f"Time conflicts with medication: {med.get('name', 'Unnamed medication')}"
return False, None
@app.route("/api/routines/<routine_id>/schedule", methods=["PUT"])
def api_setRoutineSchedule(routine_id):
"""Set when this routine should run.
Body: {days, time, remind, frequency?, interval_days?, start_date?}
frequency: 'weekly' (default, uses days) or 'every_n_days' (uses interval_days + start_date)
"""
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
frequency = data.get("frequency", "weekly")
# Check for schedule conflicts (only for weekly — interval conflicts checked at reminder time)
if frequency == "weekly":
new_days = data.get("days", [])
new_time = data.get("time")
has_conflict, conflict_msg = _check_schedule_conflicts(
user_uuid, new_days, new_time, exclude_routine_id=routine_id,
new_routine_id=routine_id,
)
if has_conflict:
return flask.jsonify({"error": conflict_msg}), 409
existing = postgres.select_one("routine_schedules", {"routine_id": routine_id})
schedule_data = {
"routine_id": routine_id,
"days": json.dumps(data.get("days", [])),
"time": data.get("time"),
"remind": data.get("remind", True),
"frequency": frequency,
"interval_days": data.get("interval_days"),
"start_date": data.get("start_date"),
}
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/<routine_id>/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/<routine_id>/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