From 782b1d2931c6d836c003e61c55214dc98c6b8052 Mon Sep 17 00:00:00 2001 From: chelsea Date: Sun, 15 Feb 2026 22:19:48 -0600 Subject: [PATCH] UI fixes --- api/routes/medications.py | 62 ++- api/routes/routines.py | 295 +++++++++---- .../src/app/dashboard/medications/page.tsx | 12 + .../src/app/dashboard/routines/[id]/page.tsx | 361 +++++++++++----- .../src/app/dashboard/routines/new/page.tsx | 142 ++++++- .../src/app/dashboard/routines/page.tsx | 389 +++++++++++++++-- .../src/app/dashboard/templates/page.tsx | 2 +- synculous-client/src/components/ui/Icons.tsx | 395 ++++++++++++++++++ synculous-client/src/lib/api.ts | 11 +- 9 files changed, 1400 insertions(+), 269 deletions(-) diff --git a/api/routes/medications.py b/api/routes/medications.py index da16445..266490e 100644 --- a/api/routes/medications.py +++ b/api/routes/medications.py @@ -4,7 +4,7 @@ Medications API - medication scheduling, logging, and adherence tracking import os import uuid -from datetime import datetime, date, timedelta +from datetime import datetime, date, timedelta, timezone import flask import jwt @@ -109,12 +109,25 @@ def _count_expected_doses(med, period_start, days): return days * times_per_day -def _count_logs_in_period(logs, period_start_str, action): - """Count logs of a given action where created_at >= period_start.""" +def _log_local_date(created_at, user_tz): + """Convert a DB created_at (naive UTC datetime) to a local date string YYYY-MM-DD.""" + if created_at is None: + return "" + if isinstance(created_at, datetime): + # Treat naive datetimes as UTC + if created_at.tzinfo is None: + created_at = created_at.replace(tzinfo=timezone.utc) + return created_at.astimezone(user_tz).date().isoformat() + # Fallback: already a string + return str(created_at)[:10] + + +def _count_logs_in_period(logs, period_start_str, action, user_tz=None): + """Count logs of a given action where created_at (local date) >= period_start.""" return sum( 1 for log in logs if log.get("action") == action - and str(log.get("created_at", ""))[:10] >= period_start_str + and (_log_local_date(log.get("created_at"), user_tz) if user_tz else str(log.get("created_at", ""))[:10]) >= period_start_str ) @@ -324,6 +337,7 @@ def register(app): meds = postgres.select("medications", where={"user_uuid": user_uuid, "active": True}) now = tz.user_now() + user_tz = now.tzinfo today = now.date() today_str = today.isoformat() current_day = now.strftime("%a").lower() # "mon","tue", etc. @@ -345,16 +359,16 @@ def register(app): where={"medication_id": med["id"]}, ) today_taken = [ - log.get("scheduled_time", "") + log.get("scheduled_time") or "" for log in all_logs if log.get("action") == "taken" - and str(log.get("created_at", ""))[:10] == today_str + and _log_local_date(log.get("created_at"), user_tz) == today_str ] today_skipped = [ - log.get("scheduled_time", "") + log.get("scheduled_time") or "" for log in all_logs if log.get("action") == "skipped" - and str(log.get("created_at", ""))[:10] == today_str + and _log_local_date(log.get("created_at"), user_tz) == today_str ] result.append({ @@ -389,16 +403,16 @@ def register(app): where={"medication_id": med["id"]}, ) tomorrow_taken = [ - log.get("scheduled_time", "") + log.get("scheduled_time") or "" for log in all_logs if log.get("action") == "taken" - and str(log.get("created_at", ""))[:10] == tomorrow_str + and _log_local_date(log.get("created_at"), user_tz) == tomorrow_str ] tomorrow_skipped = [ - log.get("scheduled_time", "") + log.get("scheduled_time") or "" for log in all_logs if log.get("action") == "skipped" - and str(log.get("created_at", ""))[:10] == tomorrow_str + and _log_local_date(log.get("created_at"), user_tz) == tomorrow_str ] result.append({ @@ -434,16 +448,16 @@ def register(app): where={"medication_id": med["id"]}, ) yesterday_taken = [ - log.get("scheduled_time", "") + log.get("scheduled_time") or "" for log in all_logs if log.get("action") == "taken" - and str(log.get("created_at", ""))[:10] == yesterday_str + and _log_local_date(log.get("created_at"), user_tz) == yesterday_str ] yesterday_skipped = [ - log.get("scheduled_time", "") + log.get("scheduled_time") or "" for log in all_logs if log.get("action") == "skipped" - and str(log.get("created_at", ""))[:10] == yesterday_str + and _log_local_date(log.get("created_at"), user_tz) == yesterday_str ] result.append({ @@ -468,7 +482,9 @@ 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 = tz.user_today() + now = tz.user_now() + user_tz = now.tzinfo + today = now.date() period_start = today - timedelta(days=num_days) period_start_str = period_start.isoformat() @@ -479,8 +495,8 @@ def register(app): expected = _count_expected_doses(med, period_start, num_days) logs = postgres.select("med_logs", where={"medication_id": med["id"]}) - taken = _count_logs_in_period(logs, period_start_str, "taken") - skipped = _count_logs_in_period(logs, period_start_str, "skipped") + taken = _count_logs_in_period(logs, period_start_str, "taken", user_tz) + skipped = _count_logs_in_period(logs, period_start_str, "skipped", user_tz) if is_prn: adherence_pct = None @@ -510,7 +526,9 @@ def register(app): if not med: return flask.jsonify({"error": "not found"}), 404 num_days = flask.request.args.get("days", 30, type=int) - today = tz.user_today() + now = tz.user_now() + user_tz = now.tzinfo + today = now.date() period_start = today - timedelta(days=num_days) period_start_str = period_start.isoformat() @@ -519,8 +537,8 @@ def register(app): expected = _count_expected_doses(med, period_start, num_days) logs = postgres.select("med_logs", where={"medication_id": med_id}) - taken = _count_logs_in_period(logs, period_start_str, "taken") - skipped = _count_logs_in_period(logs, period_start_str, "skipped") + taken = _count_logs_in_period(logs, period_start_str, "taken", user_tz) + skipped = _count_logs_in_period(logs, period_start_str, "skipped", user_tz) if is_prn: adherence_pct = None diff --git a/api/routes/routines.py b/api/routes/routines.py index 07c2564..cf16c9a 100644 --- a/api/routes/routines.py +++ b/api/routes/routines.py @@ -6,6 +6,7 @@ Routines have ordered steps. Users start sessions to walk through them. import os import uuid +import json from datetime import datetime import flask import jwt @@ -39,6 +40,7 @@ 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 @@ -73,15 +75,18 @@ def _record_step_result(session_id, step_id, step_index, result, session): 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(), - }) + 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 @@ -99,11 +104,15 @@ def _complete_session_with_celebration(session_id, user_uuid, session): 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}) + 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 @@ -115,10 +124,13 @@ def _complete_session_with_celebration(session_id, user_uuid, session): 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"], - }) + 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"] @@ -127,18 +139,23 @@ def _complete_session_with_celebration(session_id, user_uuid, session): pass try: - step_results = postgres.select("routine_step_results", {"session_id": session_id}) + 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", - }) + 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 @@ -157,7 +174,6 @@ def _complete_session_with_celebration(session_id, user_uuid, session): def register(app): - # ── Routines CRUD ───────────────────────────────────────────── @app.route("/api/routines", methods=["GET"]) @@ -166,7 +182,9 @@ def register(app): 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") + routines = postgres.select( + "routines", where={"user_uuid": user_uuid}, order_by="name" + ) return flask.jsonify(routines), 200 @app.route("/api/routines", methods=["POST"]) @@ -191,7 +209,9 @@ def register(app): 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}) + 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( @@ -210,14 +230,25 @@ def register(app): 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}) + 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"] + 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}) + 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"]) @@ -226,7 +257,9 @@ def register(app): 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}) + 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}) @@ -243,7 +276,9 @@ def register(app): 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}) + 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( @@ -259,7 +294,9 @@ def register(app): 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}) + 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() @@ -290,20 +327,26 @@ def register(app): 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}) + 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}) + 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}) + 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"]) @@ -312,10 +355,14 @@ def register(app): 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}) + 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}) + 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}) @@ -327,7 +374,9 @@ def register(app): 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}) + 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() @@ -335,7 +384,11 @@ def register(app): 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}) + postgres.update( + "routine_steps", + {"position": i + 1}, + {"id": step_id, "routine_id": routine_id}, + ) steps = postgres.select( "routine_steps", where={"routine_id": routine_id}, @@ -351,14 +404,22 @@ def register(app): 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}) + 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"}) + 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"}) + 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 + return flask.jsonify( + {"error": "already have active session", "session_id": active["id"]} + ), 409 steps = postgres.select( "routine_steps", where={"routine_id": routine_id}, @@ -382,9 +443,13 @@ def register(app): 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"}) + 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"}) + 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"]}) @@ -393,8 +458,14 @@ def register(app): 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 + 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): @@ -402,14 +473,20 @@ def register(app): 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}) + 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}) + postgres.update( + "routine_sessions", + {"status": "active", "paused_at": None}, + {"id": session_id}, + ) data = flask.request.get_json() or {} steps = postgres.select( "routine_steps", @@ -421,19 +498,32 @@ def register(app): # Record step result if current_step: - _record_step_result(session_id, current_step["id"], current_index, "completed", session) + _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 + 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): @@ -441,14 +531,20 @@ def register(app): 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}) + 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}) + postgres.update( + "routine_sessions", + {"status": "active", "paused_at": None}, + {"id": session_id}, + ) steps = postgres.select( "routine_steps", where={"routine_id": session["routine_id"]}, @@ -459,18 +555,31 @@ def register(app): # Record step result as skipped if current_step: - _record_step_result(session_id, current_step["id"], current_index, "skipped", session) + _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 + 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): @@ -478,7 +587,9 @@ def register(app): 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}) + 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}) @@ -492,7 +603,9 @@ def register(app): 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}) + 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) @@ -523,15 +636,17 @@ def register(app): continue steps = postgres.select("routine_steps", where={"routine_id": r["id"]}) total_duration = sum(s.get("duration_minutes") or 0 for s in steps) - result.append({ - "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, - }) + result.append( + { + "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, + } + ) return flask.jsonify(result), 200 @app.route("/api/routines//schedule", methods=["PUT"]) @@ -540,7 +655,9 @@ def register(app): 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}) + 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() @@ -549,12 +666,14 @@ def register(app): existing = postgres.select_one("routine_schedules", {"routine_id": routine_id}) schedule_data = { "routine_id": routine_id, - "days": data.get("days", []), + "days": json.dumps(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}) + 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()) @@ -567,7 +686,9 @@ def register(app): 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}) + 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}) @@ -581,7 +702,9 @@ def register(app): 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}) + 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}) diff --git a/synculous-client/src/app/dashboard/medications/page.tsx b/synculous-client/src/app/dashboard/medications/page.tsx index 5e46f2c..986b32c 100644 --- a/synculous-client/src/app/dashboard/medications/page.tsx +++ b/synculous-client/src/app/dashboard/medications/page.tsx @@ -156,21 +156,27 @@ export default function MedicationsPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [todayMeds, tick]); + const [error, setError] = useState(null); + const handleTake = async (medId: string, time?: string) => { try { + setError(null); await api.medications.take(medId, time); window.location.reload(); } catch (err) { console.error('Failed to log medication:', err); + setError(err instanceof Error ? err.message : 'Failed to log medication'); } }; const handleSkip = async (medId: string, time?: string) => { try { + setError(null); await api.medications.skip(medId, time); window.location.reload(); } catch (err) { console.error('Failed to skip medication:', err); + setError(err instanceof Error ? err.message : 'Failed to skip medication'); } }; @@ -217,6 +223,12 @@ export default function MedicationsPage() { {/* Push Notification Toggle */} + {error && ( +
+ {error} +
+ )} + {/* Due Now Section */} {dueEntries.length > 0 && (
diff --git a/synculous-client/src/app/dashboard/routines/[id]/page.tsx b/synculous-client/src/app/dashboard/routines/[id]/page.tsx index 136f727..df4ca10 100644 --- a/synculous-client/src/app/dashboard/routines/[id]/page.tsx +++ b/synculous-client/src/app/dashboard/routines/[id]/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; -import { useRouter, useParams } from 'next/navigation'; +import { useRouter, useParams, useSearchParams } from 'next/navigation'; import api from '@/lib/api'; import { ArrowLeftIcon, PlayIcon, PlusIcon, TrashIcon, GripVerticalIcon, ClockIcon } from '@/components/ui/Icons'; import Link from 'next/link'; @@ -31,7 +31,7 @@ interface Schedule { remind: boolean; } -const ICONS = ['✨', '🌅', '🌙', '☀️', '💪', '🧘', '📚', '🍳', '🏃', '💼', '🎯', '⭐', '🔥', '💤', '🧠']; +const ICONS = ['✨', '🌅', '🌙', '☀️', '💪', '🧘', '📚', '🍳', '🏃', '💼', '🎯', '⭐', '🔥', '💤', '🧠', '☕', '🍎', '💧', '🍀', '🎵', '📝', '🚴', '🏋️', '🚶', '👀', '🛡️', '😊', '😔']; const DAY_OPTIONS = [ { value: 'mon', label: 'Mon' }, @@ -53,12 +53,15 @@ function formatDays(days: string[]): string { export default function RoutineDetailPage() { const router = useRouter(); const params = useParams(); + const searchParams = useSearchParams(); const routineId = params.id as string; + const isNewRoutine = searchParams.get('new') === '1'; const [routine, setRoutine] = useState(null); const [steps, setSteps] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isEditing, setIsEditing] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); const [editName, setEditName] = useState(''); const [editDescription, setEditDescription] = useState(''); const [editIcon, setEditIcon] = useState('✨'); @@ -71,9 +74,10 @@ export default function RoutineDetailPage() { // Schedule state const [schedule, setSchedule] = useState(null); - const [editDays, setEditDays] = useState([]); + const [editDays, setEditDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']); const [editTime, setEditTime] = useState('08:00'); const [editRemind, setEditRemind] = useState(true); + const [showScheduleEditor, setShowScheduleEditor] = useState(false); useEffect(() => { const fetchRoutine = async () => { @@ -95,6 +99,11 @@ export default function RoutineDetailPage() { setEditDays(scheduleData.days || []); setEditTime(scheduleData.time || '08:00'); setEditRemind(scheduleData.remind ?? true); + } else { + setEditDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']); + if (isNewRoutine) { + setShowScheduleEditor(true); + } } } catch (err) { console.error('Failed to fetch routine:', err); @@ -104,7 +113,22 @@ export default function RoutineDetailPage() { } }; fetchRoutine(); - }, [routineId, router]); + }, [routineId, router, isNewRoutine]); + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + if (showScheduleEditor) { + setShowScheduleEditor(false); + } else if (isEditing) { + setIsEditing(false); + } + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isEditing, showScheduleEditor]); const handleStart = () => { router.push(`/dashboard/routines/${routineId}/launch`); @@ -121,19 +145,6 @@ export default function RoutineDetailPage() { environment_prompts: editEnvPrompts, }); - // Save or delete schedule - if (editDays.length > 0) { - await api.routines.setSchedule(routineId, { - days: editDays, - time: editTime, - remind: editRemind, - }); - setSchedule({ days: editDays, time: editTime, remind: editRemind }); - } else if (schedule) { - await api.routines.deleteSchedule(routineId); - setSchedule(null); - } - setRoutine({ ...routine!, name: editName, @@ -149,6 +160,26 @@ export default function RoutineDetailPage() { } }; + const handleSaveSchedule = async () => { + try { + if (editDays.length > 0) { + await api.routines.setSchedule(routineId, { + days: editDays, + time: editTime || '08:00', + remind: editRemind, + }); + setSchedule({ days: editDays, time: editTime || '08:00', remind: editRemind }); + } else if (schedule) { + await api.routines.deleteSchedule(routineId); + setSchedule(null); + } + setShowScheduleEditor(false); + } catch (err) { + console.error('Failed to save schedule:', err); + alert('Failed to save schedule. Please try again.'); + } + }; + const handleAddStep = async () => { if (!newStepName.trim()) return; try { @@ -172,6 +203,25 @@ export default function RoutineDetailPage() { } }; + const handleMoveStep = async (stepId: string, direction: 'up' | 'down') => { + const currentIndex = steps.findIndex(s => s.id === stepId); + if (direction === 'up' && currentIndex === 0) return; + if (direction === 'down' && currentIndex === steps.length - 1) return; + + const newSteps = [...steps]; + const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1; + [newSteps[currentIndex], newSteps[targetIndex]] = [newSteps[targetIndex], newSteps[currentIndex]]; + newSteps.forEach((s, i) => s.position = i + 1); + setSteps(newSteps); + + try { + const stepIds = newSteps.map(s => s.id); + await api.routines.reorderSteps(routineId, stepIds); + } catch (err) { + console.error('Failed to reorder steps:', err); + } + }; + const toggleDay = (day: string) => { setEditDays(prev => prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day] @@ -203,12 +253,32 @@ export default function RoutineDetailPage() {
{!isEditing && ( - +
+ + +
)} @@ -319,86 +389,17 @@ export default function RoutineDetailPage() { - {/* Schedule Editor */} -
-

Schedule

-
- -
- {DAY_OPTIONS.map((day) => ( - - ))} -
-
- {editDays.length > 0 && ( - <> -
- - setEditTime(e.target.value)} - className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none" - /> -
-
-
-

Send reminder

-

Get notified when it's time

-
- -
- - )} - {schedule && ( - - )} -
- {/* Save/Cancel */}
{/* Schedule display (view mode) */} - {schedule && schedule.days.length > 0 && ( -
-
+
+
+

Schedule

-

- {formatDays(schedule.days)} at {schedule.time} -

- {schedule.remind && ( -

Reminders on

+ {!showScheduleEditor && ( + )}
- )} + + {showScheduleEditor ? ( + <> + {/* Quick select */} +
+ + + +
+ +
+ +
+ {DAY_OPTIONS.map((day) => ( + + ))} +
+
+
+ + setEditTime(e.target.value)} + className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none" + /> +
+
+
+

Send reminder

+

Get notified when it's time

+
+ +
+
+ + +
+ + ) : schedule && schedule.days.length > 0 ? ( + <> +

+ {formatDays(schedule.days)} at {schedule.time} +

+ {schedule.remind && ( +

Reminders on

+ )} + + ) : ( +

Not scheduled. Click "Add schedule" to set a time.

+ )} +
)} @@ -523,6 +642,22 @@ export default function RoutineDetailPage() {

{step.duration_minutes} min

)}
+
+ + +
)} + + {/* Bottom Save Button - shows when schedule editor is open */} + {showScheduleEditor && !isEditing && ( +
+ +
+ )} ); diff --git a/synculous-client/src/app/dashboard/routines/new/page.tsx b/synculous-client/src/app/dashboard/routines/new/page.tsx index 82073b9..5674d5e 100644 --- a/synculous-client/src/app/dashboard/routines/new/page.tsx +++ b/synculous-client/src/app/dashboard/routines/new/page.tsx @@ -2,8 +2,9 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; +import Link from 'next/link'; import api from '@/lib/api'; -import { ArrowLeftIcon, PlusIcon, TrashIcon, GripVerticalIcon } from '@/components/ui/Icons'; +import { ArrowLeftIcon, PlusIcon, TrashIcon, GripVerticalIcon, CopyIcon } from '@/components/ui/Icons'; interface Step { id: string; @@ -12,7 +13,7 @@ interface Step { position: number; } -const ICONS = ['✨', '🌅', '🌙', '☀️', '💪', '🧘', '📚', '🍳', '🏃', '💼', '🎯', '⭐', '🔥', '💤', '🧠']; +const ICONS = ['✨', '🌅', '🌙', '☀️', '💪', '🧘', '📚', '🍳', '🏃', '💼', '🎯', '⭐', '🔥', '💤', '🧠', '☕', '🍎', '💧', '🍀', '🎵', '📝', '🚴', '🏋️', '🚶', '👀', '🛡️', '😊', '😔']; const STEP_TYPES = [ { value: 'generic', label: 'Generic' }, @@ -22,6 +23,16 @@ const STEP_TYPES = [ { value: 'exercise', label: 'Exercise' }, ]; +const DAY_OPTIONS = [ + { value: 'mon', label: 'Mon' }, + { value: 'tue', label: 'Tue' }, + { value: 'wed', label: 'Wed' }, + { value: 'thu', label: 'Thu' }, + { value: 'fri', label: 'Fri' }, + { value: 'sat', label: 'Sat' }, + { value: 'sun', label: 'Sun' }, +]; + export default function NewRoutinePage() { const router = useRouter(); const [name, setName] = useState(''); @@ -31,6 +42,17 @@ export default function NewRoutinePage() { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); + // Schedule + const [scheduleDays, setScheduleDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']); + const [scheduleTime, setScheduleTime] = useState('08:00'); + const [scheduleRemind, setScheduleRemind] = useState(true); + + const toggleDay = (day: string) => { + setScheduleDays(prev => + prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day] + ); + }; + const handleAddStep = () => { const newStep: Step = { id: `temp-${Date.now()}`, @@ -69,7 +91,7 @@ export default function NewRoutinePage() { try { const routine = await api.routines.create({ name, description, icon }); - + for (const step of validSteps) { await api.routines.addStep(routine.id, { name: step.name, @@ -77,7 +99,15 @@ export default function NewRoutinePage() { }); } - router.push('/dashboard/routines'); + if (scheduleDays.length > 0) { + await api.routines.setSchedule(routine.id, { + days: scheduleDays, + time: scheduleTime, + remind: scheduleRemind, + }); + } + + router.push(`/dashboard/routines/${routine.id}?new=1`); } catch (err) { setError((err as Error).message || 'Failed to create routine'); } finally { @@ -96,6 +126,22 @@ export default function NewRoutinePage() { + +
+ +
+
+

Start from a template

+

Browse pre-made routines

+
+
+ Recommended +
+ +
{error && (
@@ -114,8 +160,8 @@ export default function NewRoutinePage() { type="button" onClick={() => setIcon(i)} className={`w-10 h-10 rounded-lg text-xl flex items-center justify-center transition ${ - icon === i - ? 'bg-indigo-100 ring-2 ring-indigo-600' + icon === i + ? 'bg-indigo-100 ring-2 ring-indigo-600' : 'bg-gray-100 hover:bg-gray-200' }`} > @@ -148,6 +194,90 @@ export default function NewRoutinePage() {
+ {/* Schedule */} +
+

Schedule (optional)

+ + {/* Quick select buttons */} +
+ + + +
+ +
+ +
+ {DAY_OPTIONS.map((day) => ( + + ))} +
+
+ +
+ + setScheduleTime(e.target.value)} + className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none" + /> +
+ +
+
+

Send reminder

+

Get notified when it's time

+
+ +
+
+ {/* Steps */}
diff --git a/synculous-client/src/app/dashboard/routines/page.tsx b/synculous-client/src/app/dashboard/routines/page.tsx index 598c3d6..0edc2ff 100644 --- a/synculous-client/src/app/dashboard/routines/page.tsx +++ b/synculous-client/src/app/dashboard/routines/page.tsx @@ -1,9 +1,9 @@ 'use client'; -import { useEffect, useState, useRef } from 'react'; +import { useEffect, useState, useRef, useMemo } from 'react'; import { useRouter } from 'next/navigation'; import api from '@/lib/api'; -import { PlusIcon, PlayIcon, ClockIcon } from '@/components/ui/Icons'; +import { PlusIcon, PlayIcon, ClockIcon, CheckIcon } from '@/components/ui/Icons'; import Link from 'next/link'; interface Routine { @@ -23,11 +23,69 @@ interface ScheduleEntry { total_duration_minutes: number; } +interface TodaysMedication { + medication: { id: string; name: string; dosage: string; unit: string }; + scheduled_times: string[]; + taken_times: string[]; + skipped_times?: string[]; + is_prn?: boolean; + is_next_day?: boolean; + is_previous_day?: boolean; +} + +interface MedicationTimelineEntry { + routine_id: string; + routine_name: string; + routine_icon: string; + days: string[]; + time: string; + total_duration_minutes: number; + medication_id: string; + scheduled_time: string; + dosage: string; + unit: string; + status: 'taken' | 'pending' | 'overdue' | 'skipped'; +} + +interface GroupedMedEntry { + time: string; + medications: MedicationTimelineEntry[]; + allTaken: boolean; + allSkipped: boolean; + anyOverdue: boolean; +} + const HOUR_HEIGHT = 80; const START_HOUR = 5; const END_HOUR = 23; const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; const DAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; +const MEDICATION_DURATION_MINUTES = 5; + +function getDayKey(date: Date): string { + const day = date.getDay(); + return DAY_KEYS[day === 0 ? 6 : day - 1]; +} + +function getMedicationStatus( + scheduledTime: string, + takenTimes: string[], + skippedTimes: string[], + now: Date +): 'taken' | 'pending' | 'overdue' | 'skipped' { + if (takenTimes.includes(scheduledTime)) return 'taken'; + if (skippedTimes?.includes(scheduledTime)) return 'skipped'; + + const [h, m] = scheduledTime.split(':').map(Number); + const scheduled = new Date(now); + scheduled.setHours(h, m, 0, 0); + + const diffMs = now.getTime() - scheduled.getTime(); + const diffMin = diffMs / 60000; + + if (diffMin > 15) return 'overdue'; + return 'pending'; +} function getWeekDays(anchor: Date): Date[] { const d = new Date(anchor); @@ -73,9 +131,20 @@ function addMinutesToTime(t: string, mins: number): string { return `${String(nh).padStart(2, '0')}:${String(nm).padStart(2, '0')}`; } -function getDayKey(date: Date): string { - const day = date.getDay(); - return DAY_KEYS[day === 0 ? 6 : day - 1]; +function formatMedsList(meds: { routine_name: string }[]): string { + const MAX_CHARS = 25; + if (meds.length === 1) return meds[0].routine_name; + + let result = ''; + for (const med of meds) { + const next = result ? result + ', ' + med.routine_name : med.routine_name; + if (next.length > MAX_CHARS) { + const remaining = meds.length - (result ? result.split(', ').length : 0) - 1; + return result + ` +${remaining} more`; + } + result = next; + } + return result; } export default function RoutinesPage() { @@ -84,12 +153,21 @@ export default function RoutinesPage() { const [allRoutines, setAllRoutines] = useState([]); const [allSchedules, setAllSchedules] = useState([]); + const [todayMeds, setTodayMeds] = useState([]); const [isLoading, setIsLoading] = useState(true); const [selectedDate, setSelectedDate] = useState(() => new Date()); const [nowMinutes, setNowMinutes] = useState(() => { const n = new Date(); return n.getHours() * 60 + n.getMinutes(); }); + const [tick, setTick] = useState(0); + const [undoAction, setUndoAction] = useState<{ + medicationId: string; + scheduledTime: string; + action: 'taken' | 'skipped'; + timestamp: number; + } | null>(null); + const [error, setError] = useState(null); const today = new Date(); const weekDays = getWeekDays(selectedDate); @@ -105,14 +183,130 @@ export default function RoutinesPage() { const nowTopPx = minutesToTop(nowMinutes); + const medEntries = useMemo(() => { + const now = new Date(); + const entries: MedicationTimelineEntry[] = []; + + for (const med of todayMeds) { + if (med.is_prn) continue; + if (med.is_next_day || med.is_previous_day) continue; + + for (const time of med.scheduled_times) { + entries.push({ + routine_id: `med-${med.medication.id}-${time}`, + routine_name: med.medication.name, + routine_icon: '💊', + days: [dayKey], + time, + total_duration_minutes: MEDICATION_DURATION_MINUTES, + medication_id: med.medication.id, + scheduled_time: time, + dosage: med.medication.dosage, + unit: med.medication.unit, + status: getMedicationStatus(time, med.taken_times, med.skipped_times || [], now), + }); + } + } + + return entries.sort((a, b) => timeToMinutes(a.time) - timeToMinutes(b.time)); + }, [todayMeds, dayKey, tick]); + + const groupedMedEntries = useMemo(() => { + const groups: Map = new Map(); + + for (const entry of medEntries) { + if (!groups.has(entry.time)) { + groups.set(entry.time, { + time: entry.time, + medications: [], + allTaken: true, + allSkipped: true, + anyOverdue: false, + }); + } + const group = groups.get(entry.time)!; + group.medications.push(entry); + if (entry.status !== 'taken') group.allTaken = false; + if (entry.status !== 'skipped') group.allSkipped = false; + if (entry.status === 'overdue') group.anyOverdue = true; + } + + return Array.from(groups.values()).sort((a, b) => timeToMinutes(a.time) - timeToMinutes(b.time)); + }, [medEntries]); + + const handleTakeMed = async (medicationId: string, scheduledTime: string) => { + try { + setError(null); + await api.medications.take(medicationId, scheduledTime || undefined); + setTodayMeds(prev => prev.map(med => { + if (med.medication.id !== medicationId) return med; + return { + ...med, + taken_times: [...med.taken_times, scheduledTime], + }; + })); + setUndoAction({ medicationId, scheduledTime, action: 'taken', timestamp: Date.now() }); + setTimeout(() => setUndoAction(null), 5000); + } catch (err) { + console.error('Failed to take medication:', err); + setError(err instanceof Error ? err.message : 'Failed to take medication'); + } + }; + + const handleSkipMed = async (medicationId: string, scheduledTime: string) => { + try { + setError(null); + await api.medications.skip(medicationId, scheduledTime || undefined); + setTodayMeds(prev => prev.map(med => { + if (med.medication.id !== medicationId) return med; + return { + ...med, + skipped_times: [...(med.skipped_times || []), scheduledTime], + }; + })); + setUndoAction({ medicationId, scheduledTime, action: 'skipped', timestamp: Date.now() }); + setTimeout(() => setUndoAction(null), 5000); + } catch (err) { + console.error('Failed to skip medication:', err); + setError(err instanceof Error ? err.message : 'Failed to skip medication'); + } + }; + + const handleUndo = () => { + // Undo works by reverting the local state immediately + // On next refresh, data will sync from server + if (!undoAction) return; + + if (undoAction.action === 'taken') { + setTodayMeds(prev => prev.map(med => { + if (med.medication.id !== undoAction.medicationId) return med; + return { + ...med, + taken_times: med.taken_times.filter(t => t !== undoAction.scheduledTime), + }; + })); + } else if (undoAction.action === 'skipped') { + setTodayMeds(prev => prev.map(med => { + if (med.medication.id !== undoAction.medicationId) return med; + return { + ...med, + skipped_times: (med.skipped_times || []).filter(t => t !== undoAction.scheduledTime), + }; + })); + } + setUndoAction(null); + }; + useEffect(() => { Promise.all([ api.routines.list(), api.routines.listAllSchedules(), + api.medications.getToday().catch(() => []), ]) - .then(([routines, schedules]) => { + .then(([routines, schedules, todayMeds]) => { setAllRoutines(routines); setAllSchedules(schedules); + setTodayMeds(todayMeds); }) .catch(() => {}) .finally(() => setIsLoading(false)); @@ -122,6 +316,7 @@ export default function RoutinesPage() { const timer = setInterval(() => { const n = new Date(); setNowMinutes(n.getHours() * 60 + n.getMinutes()); + setTick(t => t + 1); }, 30_000); return () => clearInterval(timer); }, []); @@ -166,6 +361,32 @@ export default function RoutinesPage() {
+ {/* Undo Toast */} + {undoAction && ( +
+
+ + {undoAction.action === 'taken' ? 'Medication taken' : 'Medication skipped'} + + +
+
+ )} + + {/* Error Toast */} + {error && ( +
+
+ {error} +
+
+ )} + {/* Week Strip */}
{weekDays.map((day, i) => { @@ -293,53 +514,131 @@ export default function RoutinesPage() { ); })} + {/* Medication cards - grouped by time */} + {groupedMedEntries.map((group) => { + const startMin = timeToMinutes(group.time) || 0; + const topPx = minutesToTop(startMin); + const heightPx = Math.max(48, group.medications.length * 24); + + let statusColor = 'bg-blue-50 border-blue-200'; + if (group.allTaken) statusColor = 'bg-green-50 border-green-200'; + else if (group.allSkipped) statusColor = 'bg-gray-50 border-gray-200 opacity-60'; + else if (group.anyOverdue) statusColor = 'bg-amber-50 border-amber-300'; + + return ( +
+
+
+ 💊 +
+

+ {formatMedsList(group.medications)} +

+

+ {formatTime(group.time)} +

+
+
+ {group.allTaken ? ( + + Taken + + ) : group.allSkipped ? ( + Skipped + ) : ( +
+ {group.anyOverdue && ( + ! + )} + + +
+ )} +
+
+ ); + })} + {/* Empty day */} - {scheduledForDay.length === 0 && ( + {scheduledForDay.length === 0 && medEntries.length === 0 && (
-

No routines scheduled for this day

+

No routines or medications for this day

)}
- - {/* Unscheduled routines */} - {unscheduledRoutines.length > 0 && ( -
-

- Unscheduled -

-
- {unscheduledRoutines.map((r) => ( -
- {r.icon || '✨'} -
-

{r.name}

- {r.description && ( -

{r.description}

- )} -
- - - Edit - -
- ))} -
-
- )} )}
+ + {/* Unscheduled routines - outside scrollable area */} + {unscheduledRoutines.length > 0 && !isLoading && ( +
+

+ Unscheduled +

+
+ {unscheduledRoutines.map((r) => ( +
+ {r.icon || '✨'} +
+

{r.name}

+ {r.description && ( +

{r.description}

+ )} +
+ + + Edit + +
+ ))} +
+
+ )} ); } diff --git a/synculous-client/src/app/dashboard/templates/page.tsx b/synculous-client/src/app/dashboard/templates/page.tsx index 0df70cf..6afc04b 100644 --- a/synculous-client/src/app/dashboard/templates/page.tsx +++ b/synculous-client/src/app/dashboard/templates/page.tsx @@ -37,7 +37,7 @@ export default function TemplatesPage() { setCloningId(templateId); try { const routine = await api.templates.clone(templateId); - router.push(`/dashboard/routines/${routine.id}`); + router.push(`/dashboard/routines/${routine.id}?new=1`); } catch (err) { console.error('Failed to clone template:', err); setCloningId(null); diff --git a/synculous-client/src/components/ui/Icons.tsx b/synculous-client/src/components/ui/Icons.tsx index b196c09..07a0c93 100644 --- a/synculous-client/src/components/ui/Icons.tsx +++ b/synculous-client/src/components/ui/Icons.tsx @@ -852,3 +852,398 @@ export function SkipForwardIcon({ className = '', size = 24 }: IconProps) { ); } + +export function SmileIcon({ className = '', size = 24 }: IconProps) { + return ( + + + + + + + ); +} + +export function FrownIcon({ className = '', size = 24 }: IconProps) { + return ( + + + + + + + ); +} + +export function CoffeeIcon({ className = '', size = 24 }: IconProps) { + return ( + + + + + + + + ); +} + +export function LeafIcon({ className = '', size = 24 }: IconProps) { + return ( + + + + + ); +} + +export function DropletIcon({ className = '', size = 24 }: IconProps) { + return ( + + + + ); +} + +export function AppleIcon({ className = '', size = 24 }: IconProps) { + return ( + + + + ); +} + +export function DumbbellIcon({ className = '', size = 24 }: IconProps) { + return ( + + + + + + + + + + ); +} + +export function RunIcon({ className = '', size = 24 }: IconProps) { + return ( + + + + + + ); +} + +export function BikeIcon({ className = '', size = 24 }: IconProps) { + return ( + + + + + + + + + ); +} + +export function BookIcon({ className = '', size = 24 }: IconProps) { + return ( + + + + ); +} + +export function MusicIcon({ className = '', size = 24 }: IconProps) { + return ( + + + + + + ); +} + +export function PenIcon({ className = '', size = 24 }: IconProps) { + return ( + + + + + + ); +} + +export function CoffeeBeanIcon({ className = '', size = 24 }: IconProps) { + return ( + + + + ); +} + +export function WalkIcon({ className = '', size = 24 }: IconProps) { + return ( + + + + + + + + ); +} + +export function WaterBottleIcon({ className = '', size = 24 }: IconProps) { + return ( + + + + ); +} + +export function SaladIcon({ className = '', size = 24 }: IconProps) { + return ( + + + + + ); +} + +export function RestIcon({ className = '', size = 24 }: IconProps) { + return ( + + + + + ); +} + +export function SparkleIcon({ className = '', size = 24 }: IconProps) { + return ( + + + + ); +} + +export function EyeIcon({ className = '', size = 24 }: IconProps) { + return ( + + + + + ); +} + +export function ShieldIcon({ className = '', size = 24 }: IconProps) { + return ( + + + + ); +} diff --git a/synculous-client/src/lib/api.ts b/synculous-client/src/lib/api.ts index a81cb78..a75cac3 100644 --- a/synculous-client/src/lib/api.ts +++ b/synculous-client/src/lib/api.ts @@ -31,8 +31,15 @@ async function request( }); if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Request failed' })); - throw new Error(error.error || 'Request failed'); + const body = await response.text(); + let errorMsg = 'Request failed'; + try { + const error = JSON.parse(body); + errorMsg = error.error || error.message || body; + } catch { + errorMsg = body || errorMsg; + } + throw new Error(errorMsg); } return response.json();