This commit is contained in:
2026-02-15 22:19:48 -06:00
parent 749f734aff
commit 782b1d2931
9 changed files with 1400 additions and 269 deletions

View File

@@ -4,7 +4,7 @@ Medications API - medication scheduling, logging, and adherence tracking
import os import os
import uuid import uuid
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta, timezone
import flask import flask
import jwt import jwt
@@ -109,12 +109,25 @@ def _count_expected_doses(med, period_start, days):
return days * times_per_day return days * times_per_day
def _count_logs_in_period(logs, period_start_str, action): def _log_local_date(created_at, user_tz):
"""Count logs of a given action where created_at >= period_start.""" """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( return sum(
1 for log in logs 1 for log in logs
if log.get("action") == action 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}) meds = postgres.select("medications", where={"user_uuid": user_uuid, "active": True})
now = tz.user_now() now = tz.user_now()
user_tz = now.tzinfo
today = now.date() today = now.date()
today_str = today.isoformat() today_str = today.isoformat()
current_day = now.strftime("%a").lower() # "mon","tue", etc. current_day = now.strftime("%a").lower() # "mon","tue", etc.
@@ -345,16 +359,16 @@ def register(app):
where={"medication_id": med["id"]}, where={"medication_id": med["id"]},
) )
today_taken = [ today_taken = [
log.get("scheduled_time", "") log.get("scheduled_time") or ""
for log in all_logs for log in all_logs
if log.get("action") == "taken" 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 = [ today_skipped = [
log.get("scheduled_time", "") log.get("scheduled_time") or ""
for log in all_logs for log in all_logs
if log.get("action") == "skipped" 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({ result.append({
@@ -389,16 +403,16 @@ def register(app):
where={"medication_id": med["id"]}, where={"medication_id": med["id"]},
) )
tomorrow_taken = [ tomorrow_taken = [
log.get("scheduled_time", "") log.get("scheduled_time") or ""
for log in all_logs for log in all_logs
if log.get("action") == "taken" 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 = [ tomorrow_skipped = [
log.get("scheduled_time", "") log.get("scheduled_time") or ""
for log in all_logs for log in all_logs
if log.get("action") == "skipped" 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({ result.append({
@@ -434,16 +448,16 @@ def register(app):
where={"medication_id": med["id"]}, where={"medication_id": med["id"]},
) )
yesterday_taken = [ yesterday_taken = [
log.get("scheduled_time", "") log.get("scheduled_time") or ""
for log in all_logs for log in all_logs
if log.get("action") == "taken" 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 = [ yesterday_skipped = [
log.get("scheduled_time", "") log.get("scheduled_time") or ""
for log in all_logs for log in all_logs
if log.get("action") == "skipped" 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({ result.append({
@@ -468,7 +482,9 @@ def register(app):
return flask.jsonify({"error": "unauthorized"}), 401 return flask.jsonify({"error": "unauthorized"}), 401
num_days = flask.request.args.get("days", 30, type=int) num_days = flask.request.args.get("days", 30, type=int)
meds = postgres.select("medications", where={"user_uuid": user_uuid, "active": True}) meds = postgres.select("medications", where={"user_uuid": user_uuid, "active": True})
today = tz.user_today() now = tz.user_now()
user_tz = now.tzinfo
today = now.date()
period_start = today - timedelta(days=num_days) period_start = today - timedelta(days=num_days)
period_start_str = period_start.isoformat() period_start_str = period_start.isoformat()
@@ -479,8 +495,8 @@ def register(app):
expected = _count_expected_doses(med, period_start, num_days) expected = _count_expected_doses(med, period_start, num_days)
logs = postgres.select("med_logs", where={"medication_id": med["id"]}) logs = postgres.select("med_logs", where={"medication_id": med["id"]})
taken = _count_logs_in_period(logs, period_start_str, "taken") taken = _count_logs_in_period(logs, period_start_str, "taken", user_tz)
skipped = _count_logs_in_period(logs, period_start_str, "skipped") skipped = _count_logs_in_period(logs, period_start_str, "skipped", user_tz)
if is_prn: if is_prn:
adherence_pct = None adherence_pct = None
@@ -510,7 +526,9 @@ def register(app):
if not med: if not med:
return flask.jsonify({"error": "not found"}), 404 return flask.jsonify({"error": "not found"}), 404
num_days = flask.request.args.get("days", 30, type=int) num_days = flask.request.args.get("days", 30, type=int)
today = tz.user_today() now = tz.user_now()
user_tz = now.tzinfo
today = now.date()
period_start = today - timedelta(days=num_days) period_start = today - timedelta(days=num_days)
period_start_str = period_start.isoformat() period_start_str = period_start.isoformat()
@@ -519,8 +537,8 @@ def register(app):
expected = _count_expected_doses(med, period_start, num_days) expected = _count_expected_doses(med, period_start, num_days)
logs = postgres.select("med_logs", where={"medication_id": med_id}) logs = postgres.select("med_logs", where={"medication_id": med_id})
taken = _count_logs_in_period(logs, period_start_str, "taken") taken = _count_logs_in_period(logs, period_start_str, "taken", user_tz)
skipped = _count_logs_in_period(logs, period_start_str, "skipped") skipped = _count_logs_in_period(logs, period_start_str, "skipped", user_tz)
if is_prn: if is_prn:
adherence_pct = None adherence_pct = None

View File

@@ -6,6 +6,7 @@ Routines have ordered steps. Users start sessions to walk through them.
import os import os
import uuid import uuid
import json
from datetime import datetime from datetime import datetime
import flask import flask
import jwt import jwt
@@ -39,6 +40,7 @@ def _make_aware_utc(dt):
"""Ensure a datetime is timezone-aware; assume naive datetimes are UTC.""" """Ensure a datetime is timezone-aware; assume naive datetimes are UTC."""
if dt.tzinfo is None: if dt.tzinfo is None:
from datetime import timezone as _tz from datetime import timezone as _tz
return dt.replace(tzinfo=_tz.utc) return dt.replace(tzinfo=_tz.utc)
return dt return dt
@@ -73,7 +75,9 @@ def _record_step_result(session_id, step_id, step_index, result, session):
else: else:
duration_seconds = None duration_seconds = None
postgres.insert("routine_step_results", { postgres.insert(
"routine_step_results",
{
"id": str(uuid.uuid4()), "id": str(uuid.uuid4()),
"session_id": session_id, "session_id": session_id,
"step_id": step_id, "step_id": step_id,
@@ -81,7 +85,8 @@ def _record_step_result(session_id, step_id, step_index, result, session):
"result": result, "result": result,
"duration_seconds": duration_seconds, "duration_seconds": duration_seconds,
"completed_at": now.isoformat(), "completed_at": now.isoformat(),
}) },
)
except Exception: except Exception:
pass # Don't fail the step completion if tracking fails 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 duration_minutes = 0
# Update session as completed with duration — this MUST succeed # Update session as completed with duration — this MUST succeed
postgres.update("routine_sessions", { postgres.update(
"routine_sessions",
{
"status": "completed", "status": "completed",
"completed_at": now.isoformat(), "completed_at": now.isoformat(),
"actual_duration_minutes": int(duration_minutes), "actual_duration_minutes": int(duration_minutes),
}, {"id": session_id}) },
{"id": session_id},
)
# Gather celebration stats — failures here should not break completion # Gather celebration stats — failures here should not break completion
streak_current = 1 streak_current = 1
@@ -115,10 +124,13 @@ def _complete_session_with_celebration(session_id, user_uuid, session):
try: try:
streak_result = routines_core._update_streak(user_uuid, session["routine_id"]) streak_result = routines_core._update_streak(user_uuid, session["routine_id"])
streak = postgres.select_one("routine_streaks", { streak = postgres.select_one(
"routine_streaks",
{
"user_uuid": user_uuid, "user_uuid": user_uuid,
"routine_id": session["routine_id"], "routine_id": session["routine_id"],
}) },
)
if streak: if streak:
streak_current = streak["current_streak"] streak_current = streak["current_streak"]
streak_longest = streak["longest_streak"] streak_longest = streak["longest_streak"]
@@ -127,18 +139,23 @@ def _complete_session_with_celebration(session_id, user_uuid, session):
pass pass
try: 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_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") steps_skipped = sum(1 for r in step_results if r.get("result") == "skipped")
except Exception: except Exception:
pass pass
try: try:
all_completed = postgres.select("routine_sessions", { all_completed = postgres.select(
"routine_sessions",
{
"routine_id": session["routine_id"], "routine_id": session["routine_id"],
"user_uuid": user_uuid, "user_uuid": user_uuid,
"status": "completed", "status": "completed",
}) },
)
total_completions = len(all_completed) total_completions = len(all_completed)
except Exception: except Exception:
pass pass
@@ -157,7 +174,6 @@ def _complete_session_with_celebration(session_id, user_uuid, session):
def register(app): def register(app):
# ── Routines CRUD ───────────────────────────────────────────── # ── Routines CRUD ─────────────────────────────────────────────
@app.route("/api/routines", methods=["GET"]) @app.route("/api/routines", methods=["GET"])
@@ -166,7 +182,9 @@ def register(app):
user_uuid = _auth(flask.request) user_uuid = _auth(flask.request)
if not user_uuid: if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401 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 return flask.jsonify(routines), 200
@app.route("/api/routines", methods=["POST"]) @app.route("/api/routines", methods=["POST"])
@@ -191,7 +209,9 @@ def register(app):
user_uuid = _auth(flask.request) user_uuid = _auth(flask.request)
if not user_uuid: if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401 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: if not routine:
return flask.jsonify({"error": "not found"}), 404 return flask.jsonify({"error": "not found"}), 404
steps = postgres.select( steps = postgres.select(
@@ -210,14 +230,25 @@ def register(app):
data = flask.request.get_json() data = flask.request.get_json()
if not data: if not data:
return flask.jsonify({"error": "missing body"}), 400 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: if not existing:
return flask.jsonify({"error": "not found"}), 404 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} updates = {k: v for k, v in data.items() if k in allowed}
if not updates: if not updates:
return flask.jsonify({"error": "no valid fields to update"}), 400 return flask.jsonify({"error": "no valid fields to update"}), 400
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 return flask.jsonify(result[0] if result else {}), 200
@app.route("/api/routines/<routine_id>", methods=["DELETE"]) @app.route("/api/routines/<routine_id>", methods=["DELETE"])
@@ -226,7 +257,9 @@ def register(app):
user_uuid = _auth(flask.request) user_uuid = _auth(flask.request)
if not user_uuid: if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401 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: if not existing:
return flask.jsonify({"error": "not found"}), 404 return flask.jsonify({"error": "not found"}), 404
postgres.delete("routine_sessions", {"routine_id": routine_id}) postgres.delete("routine_sessions", {"routine_id": routine_id})
@@ -243,7 +276,9 @@ def register(app):
user_uuid = _auth(flask.request) user_uuid = _auth(flask.request)
if not user_uuid: if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401 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: if not routine:
return flask.jsonify({"error": "not found"}), 404 return flask.jsonify({"error": "not found"}), 404
steps = postgres.select( steps = postgres.select(
@@ -259,7 +294,9 @@ def register(app):
user_uuid = _auth(flask.request) user_uuid = _auth(flask.request)
if not user_uuid: if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401 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: if not routine:
return flask.jsonify({"error": "not found"}), 404 return flask.jsonify({"error": "not found"}), 404
data = flask.request.get_json() data = flask.request.get_json()
@@ -290,20 +327,26 @@ def register(app):
user_uuid = _auth(flask.request) user_uuid = _auth(flask.request)
if not user_uuid: if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401 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: if not routine:
return flask.jsonify({"error": "not found"}), 404 return flask.jsonify({"error": "not found"}), 404
data = flask.request.get_json() data = flask.request.get_json()
if not data: if not data:
return flask.jsonify({"error": "missing body"}), 400 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: if not existing:
return flask.jsonify({"error": "step not found"}), 404 return flask.jsonify({"error": "step not found"}), 404
allowed = ["name", "duration_minutes", "position"] allowed = ["name", "duration_minutes", "position"]
updates = {k: v for k, v in data.items() if k in allowed} updates = {k: v for k, v in data.items() if k in allowed}
if not updates: if not updates:
return flask.jsonify({"error": "no valid fields to update"}), 400 return flask.jsonify({"error": "no valid fields to update"}), 400
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 return flask.jsonify(result[0] if result else {}), 200
@app.route("/api/routines/<routine_id>/steps/<step_id>", methods=["DELETE"]) @app.route("/api/routines/<routine_id>/steps/<step_id>", methods=["DELETE"])
@@ -312,10 +355,14 @@ def register(app):
user_uuid = _auth(flask.request) user_uuid = _auth(flask.request)
if not user_uuid: if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401 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: if not routine:
return flask.jsonify({"error": "not found"}), 404 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: if not existing:
return flask.jsonify({"error": "step not found"}), 404 return flask.jsonify({"error": "step not found"}), 404
postgres.delete("routine_steps", {"id": step_id, "routine_id": routine_id}) postgres.delete("routine_steps", {"id": step_id, "routine_id": routine_id})
@@ -327,7 +374,9 @@ def register(app):
user_uuid = _auth(flask.request) user_uuid = _auth(flask.request)
if not user_uuid: if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401 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: if not routine:
return flask.jsonify({"error": "not found"}), 404 return flask.jsonify({"error": "not found"}), 404
data = flask.request.get_json() data = flask.request.get_json()
@@ -335,7 +384,11 @@ def register(app):
return flask.jsonify({"error": "missing step_ids"}), 400 return flask.jsonify({"error": "missing step_ids"}), 400
step_ids = data["step_ids"] step_ids = data["step_ids"]
for i, step_id in enumerate(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( steps = postgres.select(
"routine_steps", "routine_steps",
where={"routine_id": routine_id}, where={"routine_id": routine_id},
@@ -351,14 +404,22 @@ def register(app):
user_uuid = _auth(flask.request) user_uuid = _auth(flask.request)
if not user_uuid: if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401 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: if not routine:
return flask.jsonify({"error": "not found"}), 404 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: 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: 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( steps = postgres.select(
"routine_steps", "routine_steps",
where={"routine_id": routine_id}, where={"routine_id": routine_id},
@@ -382,9 +443,13 @@ def register(app):
user_uuid = _auth(flask.request) user_uuid = _auth(flask.request)
if not user_uuid: if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401 return flask.jsonify({"error": "unauthorized"}), 401
session = postgres.select_one("routine_sessions", {"user_uuid": user_uuid, "status": "active"}) session = postgres.select_one(
"routine_sessions", {"user_uuid": user_uuid, "status": "active"}
)
if not session: 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: if not session:
return flask.jsonify({"error": "no active session"}), 404 return flask.jsonify({"error": "no active session"}), 404
routine = postgres.select_one("routines", {"id": session["routine_id"]}) routine = postgres.select_one("routines", {"id": session["routine_id"]})
@@ -393,8 +458,14 @@ def register(app):
where={"routine_id": session["routine_id"]}, where={"routine_id": session["routine_id"]},
order_by="position", order_by="position",
) )
current_step = steps[session["current_step_index"]] if session["current_step_index"] < len(steps) else None current_step = (
return flask.jsonify({"session": session, "routine": routine, "current_step": current_step}), 200 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"]) @app.route("/api/sessions/<session_id>/complete-step", methods=["POST"])
def api_completeStep(session_id): def api_completeStep(session_id):
@@ -402,14 +473,20 @@ def register(app):
user_uuid = _auth(flask.request) user_uuid = _auth(flask.request)
if not user_uuid: if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401 return flask.jsonify({"error": "unauthorized"}), 401
session = postgres.select_one("routine_sessions", {"id": session_id, "user_uuid": user_uuid}) session = postgres.select_one(
"routine_sessions", {"id": session_id, "user_uuid": user_uuid}
)
if not session: if not session:
return flask.jsonify({"error": "not found"}), 404 return flask.jsonify({"error": "not found"}), 404
if session["status"] not in ("active", "paused"): if session["status"] not in ("active", "paused"):
return flask.jsonify({"error": "session not active"}), 400 return flask.jsonify({"error": "session not active"}), 400
# Auto-resume if paused # Auto-resume if paused
if session["status"] == "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 {} data = flask.request.get_json() or {}
steps = postgres.select( steps = postgres.select(
"routine_steps", "routine_steps",
@@ -421,19 +498,32 @@ def register(app):
# Record step result # Record step result
if current_step: 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 next_index = current_index + 1
if next_index >= len(steps): if next_index >= len(steps):
# Session complete — compute celebration data # Session complete — compute celebration data
celebration = _complete_session_with_celebration(session_id, user_uuid, session) celebration = _complete_session_with_celebration(
return flask.jsonify({ session_id, user_uuid, session
)
return flask.jsonify(
{
"session": {"status": "completed"}, "session": {"status": "completed"},
"next_step": None, "next_step": None,
"celebration": celebration, "celebration": celebration,
}), 200 }
postgres.update("routine_sessions", {"current_step_index": next_index}, {"id": session_id}) ), 200
return flask.jsonify({"session": {"current_step_index": next_index}, "next_step": steps[next_index]}), 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"]) @app.route("/api/sessions/<session_id>/skip-step", methods=["POST"])
def api_skipStep(session_id): def api_skipStep(session_id):
@@ -441,14 +531,20 @@ def register(app):
user_uuid = _auth(flask.request) user_uuid = _auth(flask.request)
if not user_uuid: if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401 return flask.jsonify({"error": "unauthorized"}), 401
session = postgres.select_one("routine_sessions", {"id": session_id, "user_uuid": user_uuid}) session = postgres.select_one(
"routine_sessions", {"id": session_id, "user_uuid": user_uuid}
)
if not session: if not session:
return flask.jsonify({"error": "not found"}), 404 return flask.jsonify({"error": "not found"}), 404
if session["status"] not in ("active", "paused"): if session["status"] not in ("active", "paused"):
return flask.jsonify({"error": "session not active"}), 400 return flask.jsonify({"error": "session not active"}), 400
# Auto-resume if paused # Auto-resume if paused
if session["status"] == "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( steps = postgres.select(
"routine_steps", "routine_steps",
where={"routine_id": session["routine_id"]}, where={"routine_id": session["routine_id"]},
@@ -459,18 +555,31 @@ def register(app):
# Record step result as skipped # Record step result as skipped
if current_step: 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 next_index = current_index + 1
if next_index >= len(steps): if next_index >= len(steps):
celebration = _complete_session_with_celebration(session_id, user_uuid, session) celebration = _complete_session_with_celebration(
return flask.jsonify({ session_id, user_uuid, session
)
return flask.jsonify(
{
"session": {"status": "completed"}, "session": {"status": "completed"},
"next_step": None, "next_step": None,
"celebration": celebration, "celebration": celebration,
}), 200 }
postgres.update("routine_sessions", {"current_step_index": next_index}, {"id": session_id}) ), 200
return flask.jsonify({"session": {"current_step_index": next_index}, "next_step": steps[next_index]}), 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"]) @app.route("/api/sessions/<session_id>/cancel", methods=["POST"])
def api_cancelSession(session_id): def api_cancelSession(session_id):
@@ -478,7 +587,9 @@ def register(app):
user_uuid = _auth(flask.request) user_uuid = _auth(flask.request)
if not user_uuid: if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401 return flask.jsonify({"error": "unauthorized"}), 401
session = postgres.select_one("routine_sessions", {"id": session_id, "user_uuid": user_uuid}) session = postgres.select_one(
"routine_sessions", {"id": session_id, "user_uuid": user_uuid}
)
if not session: if not session:
return flask.jsonify({"error": "not found"}), 404 return flask.jsonify({"error": "not found"}), 404
postgres.update("routine_sessions", {"status": "cancelled"}, {"id": session_id}) postgres.update("routine_sessions", {"status": "cancelled"}, {"id": session_id})
@@ -492,7 +603,9 @@ def register(app):
user_uuid = _auth(flask.request) user_uuid = _auth(flask.request)
if not user_uuid: if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401 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: if not routine:
return flask.jsonify({"error": "not found"}), 404 return flask.jsonify({"error": "not found"}), 404
days = flask.request.args.get("days", 7, type=int) days = flask.request.args.get("days", 7, type=int)
@@ -523,7 +636,8 @@ def register(app):
continue continue
steps = postgres.select("routine_steps", where={"routine_id": r["id"]}) steps = postgres.select("routine_steps", where={"routine_id": r["id"]})
total_duration = sum(s.get("duration_minutes") or 0 for s in steps) total_duration = sum(s.get("duration_minutes") or 0 for s in steps)
result.append({ result.append(
{
"routine_id": r["id"], "routine_id": r["id"],
"routine_name": r.get("name", ""), "routine_name": r.get("name", ""),
"routine_icon": r.get("icon", ""), "routine_icon": r.get("icon", ""),
@@ -531,7 +645,8 @@ def register(app):
"time": sched.get("time"), "time": sched.get("time"),
"remind": sched.get("remind", True), "remind": sched.get("remind", True),
"total_duration_minutes": total_duration, "total_duration_minutes": total_duration,
}) }
)
return flask.jsonify(result), 200 return flask.jsonify(result), 200
@app.route("/api/routines/<routine_id>/schedule", methods=["PUT"]) @app.route("/api/routines/<routine_id>/schedule", methods=["PUT"])
@@ -540,7 +655,9 @@ def register(app):
user_uuid = _auth(flask.request) user_uuid = _auth(flask.request)
if not user_uuid: if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401 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: if not routine:
return flask.jsonify({"error": "not found"}), 404 return flask.jsonify({"error": "not found"}), 404
data = flask.request.get_json() data = flask.request.get_json()
@@ -549,12 +666,14 @@ def register(app):
existing = postgres.select_one("routine_schedules", {"routine_id": routine_id}) existing = postgres.select_one("routine_schedules", {"routine_id": routine_id})
schedule_data = { schedule_data = {
"routine_id": routine_id, "routine_id": routine_id,
"days": data.get("days", []), "days": json.dumps(data.get("days", [])),
"time": data.get("time"), "time": data.get("time"),
"remind": data.get("remind", True), "remind": data.get("remind", True),
} }
if existing: 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 return flask.jsonify(result[0] if result else {}), 200
else: else:
schedule_data["id"] = str(uuid.uuid4()) schedule_data["id"] = str(uuid.uuid4())
@@ -567,7 +686,9 @@ def register(app):
user_uuid = _auth(flask.request) user_uuid = _auth(flask.request)
if not user_uuid: if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401 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: if not routine:
return flask.jsonify({"error": "not found"}), 404 return flask.jsonify({"error": "not found"}), 404
schedule = postgres.select_one("routine_schedules", {"routine_id": routine_id}) schedule = postgres.select_one("routine_schedules", {"routine_id": routine_id})
@@ -581,7 +702,9 @@ def register(app):
user_uuid = _auth(flask.request) user_uuid = _auth(flask.request)
if not user_uuid: if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401 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: if not routine:
return flask.jsonify({"error": "not found"}), 404 return flask.jsonify({"error": "not found"}), 404
postgres.delete("routine_schedules", {"routine_id": routine_id}) postgres.delete("routine_schedules", {"routine_id": routine_id})

View File

@@ -156,21 +156,27 @@ export default function MedicationsPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [todayMeds, tick]); }, [todayMeds, tick]);
const [error, setError] = useState<string | null>(null);
const handleTake = async (medId: string, time?: string) => { const handleTake = async (medId: string, time?: string) => {
try { try {
setError(null);
await api.medications.take(medId, time); await api.medications.take(medId, time);
window.location.reload(); window.location.reload();
} catch (err) { } catch (err) {
console.error('Failed to log medication:', 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) => { const handleSkip = async (medId: string, time?: string) => {
try { try {
setError(null);
await api.medications.skip(medId, time); await api.medications.skip(medId, time);
window.location.reload(); window.location.reload();
} catch (err) { } catch (err) {
console.error('Failed to skip medication:', 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 */} {/* Push Notification Toggle */}
<PushNotificationToggle /> <PushNotificationToggle />
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{error}
</div>
)}
{/* Due Now Section */} {/* Due Now Section */}
{dueEntries.length > 0 && ( {dueEntries.length > 0 && (
<div> <div>

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams, useSearchParams } from 'next/navigation';
import api from '@/lib/api'; import api from '@/lib/api';
import { ArrowLeftIcon, PlayIcon, PlusIcon, TrashIcon, GripVerticalIcon, ClockIcon } from '@/components/ui/Icons'; import { ArrowLeftIcon, PlayIcon, PlusIcon, TrashIcon, GripVerticalIcon, ClockIcon } from '@/components/ui/Icons';
import Link from 'next/link'; import Link from 'next/link';
@@ -31,7 +31,7 @@ interface Schedule {
remind: boolean; remind: boolean;
} }
const ICONS = ['✨', '🌅', '🌙', '☀️', '💪', '🧘', '📚', '🍳', '🏃', '💼', '🎯', '⭐', '🔥', '💤', '🧠']; const ICONS = ['✨', '🌅', '🌙', '☀️', '💪', '🧘', '📚', '🍳', '🏃', '💼', '🎯', '⭐', '🔥', '💤', '🧠', '☕', '🍎', '💧', '🍀', '🎵', '📝', '🚴', '🏋️', '🚶', '👀', '🛡️', '😊', '😔'];
const DAY_OPTIONS = [ const DAY_OPTIONS = [
{ value: 'mon', label: 'Mon' }, { value: 'mon', label: 'Mon' },
@@ -53,12 +53,15 @@ function formatDays(days: string[]): string {
export default function RoutineDetailPage() { export default function RoutineDetailPage() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const searchParams = useSearchParams();
const routineId = params.id as string; const routineId = params.id as string;
const isNewRoutine = searchParams.get('new') === '1';
const [routine, setRoutine] = useState<Routine | null>(null); const [routine, setRoutine] = useState<Routine | null>(null);
const [steps, setSteps] = useState<Step[]>([]); const [steps, setSteps] = useState<Step[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [editName, setEditName] = useState(''); const [editName, setEditName] = useState('');
const [editDescription, setEditDescription] = useState(''); const [editDescription, setEditDescription] = useState('');
const [editIcon, setEditIcon] = useState('✨'); const [editIcon, setEditIcon] = useState('✨');
@@ -71,9 +74,10 @@ export default function RoutineDetailPage() {
// Schedule state // Schedule state
const [schedule, setSchedule] = useState<Schedule | null>(null); const [schedule, setSchedule] = useState<Schedule | null>(null);
const [editDays, setEditDays] = useState<string[]>([]); const [editDays, setEditDays] = useState<string[]>(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
const [editTime, setEditTime] = useState('08:00'); const [editTime, setEditTime] = useState('08:00');
const [editRemind, setEditRemind] = useState(true); const [editRemind, setEditRemind] = useState(true);
const [showScheduleEditor, setShowScheduleEditor] = useState(false);
useEffect(() => { useEffect(() => {
const fetchRoutine = async () => { const fetchRoutine = async () => {
@@ -95,6 +99,11 @@ export default function RoutineDetailPage() {
setEditDays(scheduleData.days || []); setEditDays(scheduleData.days || []);
setEditTime(scheduleData.time || '08:00'); setEditTime(scheduleData.time || '08:00');
setEditRemind(scheduleData.remind ?? true); setEditRemind(scheduleData.remind ?? true);
} else {
setEditDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
if (isNewRoutine) {
setShowScheduleEditor(true);
}
} }
} catch (err) { } catch (err) {
console.error('Failed to fetch routine:', err); console.error('Failed to fetch routine:', err);
@@ -104,7 +113,22 @@ export default function RoutineDetailPage() {
} }
}; };
fetchRoutine(); 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 = () => { const handleStart = () => {
router.push(`/dashboard/routines/${routineId}/launch`); router.push(`/dashboard/routines/${routineId}/launch`);
@@ -121,19 +145,6 @@ export default function RoutineDetailPage() {
environment_prompts: editEnvPrompts, 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({ setRoutine({
...routine!, ...routine!,
name: editName, 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 () => { const handleAddStep = async () => {
if (!newStepName.trim()) return; if (!newStepName.trim()) return;
try { 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) => { const toggleDay = (day: string) => {
setEditDays(prev => setEditDays(prev =>
prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day] prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day]
@@ -203,12 +253,32 @@ export default function RoutineDetailPage() {
</h1> </h1>
</div> </div>
{!isEditing && ( {!isEditing && (
<div className="flex items-center gap-2">
<button
onClick={() => {
if (confirm('Are you sure you want to delete this routine?')) {
setIsDeleting(true);
api.routines.delete(routineId).then(() => {
router.push('/dashboard/routines');
}).catch(err => {
console.error('Failed to delete routine:', err);
alert('Failed to delete routine');
setIsDeleting(false);
});
}
}}
disabled={isDeleting}
className="text-red-500 font-medium disabled:opacity-50"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
<button <button
onClick={() => setIsEditing(true)} onClick={() => setIsEditing(true)}
className="text-indigo-600 font-medium" className="text-indigo-600 font-medium"
> >
Edit Edit
</button> </button>
</div>
)} )}
</div> </div>
</header> </header>
@@ -319,86 +389,17 @@ export default function RoutineDetailPage() {
</div> </div>
</div> </div>
{/* Schedule Editor */}
<div className="bg-white rounded-xl p-4 shadow-sm space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Schedule</h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Days</label>
<div className="flex gap-2 flex-wrap">
{DAY_OPTIONS.map((day) => (
<button
key={day.value}
type="button"
onClick={() => toggleDay(day.value)}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
editDays.includes(day.value)
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-white text-gray-700 border-gray-300'
}`}
>
{day.label}
</button>
))}
</div>
</div>
{editDays.length > 0 && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Time</label>
<input
type="time"
value={editTime}
onChange={(e) => 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"
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">Send reminder</p>
<p className="text-sm text-gray-500">Get notified when it's time</p>
</div>
<button
onClick={() => setEditRemind(!editRemind)}
className={`w-12 h-7 rounded-full transition-colors ${
editRemind ? 'bg-indigo-500' : 'bg-gray-300'
}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
editRemind ? 'translate-x-5' : ''
}`} />
</button>
</div>
</>
)}
{schedule && (
<button
onClick={() => {
setEditDays([]);
setEditTime('08:00');
setEditRemind(true);
}}
className="text-red-500 text-sm font-medium"
>
Remove schedule
</button>
)}
</div>
{/* Save/Cancel */} {/* Save/Cancel */}
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
onClick={() => { onClick={() => {
setIsEditing(false); setIsEditing(false);
// Reset schedule edits setEditName(routine.name);
if (schedule) { setEditDescription(routine.description || '');
setEditDays(schedule.days); setEditIcon(routine.icon || '✨');
setEditTime(schedule.time); setEditLocation(routine.location || '');
setEditRemind(schedule.remind); setEditHabitStack(routine.habit_stack_after || '');
} else { setEditEnvPrompts(routine.environment_prompts || []);
setEditDays([]);
setEditTime('08:00');
setEditRemind(true);
}
}} }}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg font-medium" className="flex-1 px-4 py-2 border border-gray-300 rounded-lg font-medium"
> >
@@ -443,20 +444,138 @@ export default function RoutineDetailPage() {
</div> </div>
{/* Schedule display (view mode) */} {/* Schedule display (view mode) */}
{schedule && schedule.days.length > 0 && (
<div className="bg-white rounded-xl p-4 shadow-sm"> <div className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<ClockIcon size={16} className="text-indigo-500" /> <ClockIcon size={16} className="text-indigo-500" />
<h3 className="font-semibold text-gray-900">Schedule</h3> <h3 className="font-semibold text-gray-900">Schedule</h3>
</div> </div>
{!showScheduleEditor && (
<button
onClick={() => setShowScheduleEditor(true)}
className="text-indigo-600 text-sm font-medium"
>
{schedule ? 'Edit' : 'Add schedule'}
</button>
)}
</div>
{showScheduleEditor ? (
<>
{/* Quick select */}
<div className="flex gap-2 mb-3">
<button
type="button"
onClick={() => setEditDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'])}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
editDays.length === 7 ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-700 border-gray-300'
}`}
>
Every day
</button>
<button
type="button"
onClick={() => setEditDays(['mon', 'tue', 'wed', 'thu', 'fri'])}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
editDays.length === 5 && !editDays.includes('sat') && !editDays.includes('sun') ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-700 border-gray-300'
}`}
>
Weekdays
</button>
<button
type="button"
onClick={() => setEditDays(['sat', 'sun'])}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
editDays.length === 2 && editDays.includes('sat') && editDays.includes('sun') ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-700 border-gray-300'
}`}
>
Weekends
</button>
</div>
<div className="mb-3">
<label className="block text-sm font-medium text-gray-700 mb-2">Days</label>
<div className="flex gap-2 flex-wrap">
{DAY_OPTIONS.map((day) => (
<button
key={day.value}
type="button"
onClick={() => toggleDay(day.value)}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
editDays.includes(day.value)
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-white text-gray-700 border-gray-300'
}`}
>
{day.label}
</button>
))}
</div>
</div>
<div className="mb-3">
<label className="block text-sm font-medium text-gray-700 mb-1">Time</label>
<input
type="time"
value={editTime}
onChange={(e) => 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"
/>
</div>
<div className="flex items-center justify-between mb-3">
<div>
<p className="font-medium text-gray-900">Send reminder</p>
<p className="text-sm text-gray-500">Get notified when it's time</p>
</div>
<button
onClick={() => setEditRemind(!editRemind)}
className={`w-12 h-7 rounded-full transition-colors ${
editRemind ? 'bg-indigo-500' : 'bg-gray-300'
}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
editRemind ? 'translate-x-5' : ''
}`} />
</button>
</div>
<div className="flex gap-2">
<button
onClick={() => {
setShowScheduleEditor(false);
if (schedule) {
setEditDays(schedule.days);
setEditTime(schedule.time);
setEditRemind(schedule.remind);
} else {
setEditDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
setEditTime('08:00');
setEditRemind(true);
}
}}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm font-medium"
>
Cancel
</button>
<button
onClick={handleSaveSchedule}
className="flex-1 px-3 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium"
>
Save Schedule
</button>
</div>
</>
) : schedule && schedule.days.length > 0 ? (
<>
<p className="text-gray-700"> <p className="text-gray-700">
{formatDays(schedule.days)} at {schedule.time} {formatDays(schedule.days)} at {schedule.time}
</p> </p>
{schedule.remind && ( {schedule.remind && (
<p className="text-sm text-gray-500 mt-1">Reminders on</p> <p className="text-sm text-gray-500 mt-1">Reminders on</p>
)} )}
</div> </>
) : (
<p className="text-gray-500 text-sm">Not scheduled. Click "Add schedule" to set a time.</p>
)} )}
</div>
</> </>
)} )}
@@ -523,6 +642,22 @@ export default function RoutineDetailPage() {
<p className="text-sm text-gray-500">{step.duration_minutes} min</p> <p className="text-sm text-gray-500">{step.duration_minutes} min</p>
)} )}
</div> </div>
<div className="flex flex-col">
<button
onClick={() => handleMoveStep(step.id, 'up')}
disabled={index === 0}
className="text-gray-400 p-1 disabled:opacity-30 hover:text-gray-600"
>
</button>
<button
onClick={() => handleMoveStep(step.id, 'down')}
disabled={index === steps.length - 1}
className="text-gray-400 p-1 disabled:opacity-30 hover:text-gray-600"
>
</button>
</div>
<button <button
onClick={() => handleDeleteStep(step.id)} onClick={() => handleDeleteStep(step.id)}
className="text-red-500 p-2" className="text-red-500 p-2"
@@ -534,6 +669,18 @@ export default function RoutineDetailPage() {
</div> </div>
)} )}
</div> </div>
{/* Bottom Save Button - shows when schedule editor is open */}
{showScheduleEditor && !isEditing && (
<div className="fixed bottom-4 left-4 right-4">
<button
onClick={handleSaveSchedule}
className="w-full bg-indigo-600 text-white font-semibold py-4 rounded-xl shadow-lg shadow-indigo-500/25"
>
Save Schedule
</button>
</div>
)}
</div> </div>
</div> </div>
); );

View File

@@ -2,8 +2,9 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link';
import api from '@/lib/api'; 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 { interface Step {
id: string; id: string;
@@ -12,7 +13,7 @@ interface Step {
position: number; position: number;
} }
const ICONS = ['✨', '🌅', '🌙', '☀️', '💪', '🧘', '📚', '🍳', '🏃', '💼', '🎯', '⭐', '🔥', '💤', '🧠']; const ICONS = ['✨', '🌅', '🌙', '☀️', '💪', '🧘', '📚', '🍳', '🏃', '💼', '🎯', '⭐', '🔥', '💤', '🧠', '☕', '🍎', '💧', '🍀', '🎵', '📝', '🚴', '🏋️', '🚶', '👀', '🛡️', '😊', '😔'];
const STEP_TYPES = [ const STEP_TYPES = [
{ value: 'generic', label: 'Generic' }, { value: 'generic', label: 'Generic' },
@@ -22,6 +23,16 @@ const STEP_TYPES = [
{ value: 'exercise', label: 'Exercise' }, { 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() { export default function NewRoutinePage() {
const router = useRouter(); const router = useRouter();
const [name, setName] = useState(''); const [name, setName] = useState('');
@@ -31,6 +42,17 @@ export default function NewRoutinePage() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
// Schedule
const [scheduleDays, setScheduleDays] = useState<string[]>(['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 handleAddStep = () => {
const newStep: Step = { const newStep: Step = {
id: `temp-${Date.now()}`, id: `temp-${Date.now()}`,
@@ -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) { } catch (err) {
setError((err as Error).message || 'Failed to create routine'); setError((err as Error).message || 'Failed to create routine');
} finally { } finally {
@@ -96,6 +126,22 @@ export default function NewRoutinePage() {
</div> </div>
</header> </header>
<Link
href="/dashboard/templates"
className="mx-4 mt-4 flex items-center gap-3 bg-gradient-to-r from-indigo-50 to-purple-50 border-2 border-indigo-200 rounded-xl p-4 hover:border-indigo-400 transition-colors"
>
<div className="w-12 h-12 bg-indigo-100 rounded-xl flex items-center justify-center flex-shrink-0">
<CopyIcon size={24} className="text-indigo-600" />
</div>
<div className="flex-1">
<p className="font-semibold text-gray-900">Start from a template</p>
<p className="text-sm text-gray-500">Browse pre-made routines</p>
</div>
<div className="bg-indigo-600 text-white text-xs font-medium px-2 py-1 rounded-full">
Recommended
</div>
</Link>
<form onSubmit={handleSubmit} className="p-4 space-y-6"> <form onSubmit={handleSubmit} className="p-4 space-y-6">
{error && ( {error && (
<div className="bg-red-50 text-red-600 px-4 py-3 rounded-lg text-sm"> <div className="bg-red-50 text-red-600 px-4 py-3 rounded-lg text-sm">
@@ -148,6 +194,90 @@ export default function NewRoutinePage() {
</div> </div>
</div> </div>
{/* Schedule */}
<div className="bg-white rounded-xl p-4 shadow-sm space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Schedule <span className="text-sm font-normal text-gray-400">(optional)</span></h2>
{/* Quick select buttons */}
<div className="flex gap-2">
<button
type="button"
onClick={() => setScheduleDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'])}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
scheduleDays.length === 7 ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-700 border-gray-300'
}`}
>
Every day
</button>
<button
type="button"
onClick={() => setScheduleDays(['mon', 'tue', 'wed', 'thu', 'fri'])}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
scheduleDays.length === 5 && !scheduleDays.includes('sat') && !scheduleDays.includes('sun') ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-700 border-gray-300'
}`}
>
Weekdays
</button>
<button
type="button"
onClick={() => setScheduleDays(['sat', 'sun'])}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
scheduleDays.length === 2 && scheduleDays.includes('sat') && scheduleDays.includes('sun') ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-700 border-gray-300'
}`}
>
Weekends
</button>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Days</label>
<div className="flex gap-2 flex-wrap">
{DAY_OPTIONS.map((day) => (
<button
key={day.value}
type="button"
onClick={() => toggleDay(day.value)}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
scheduleDays.includes(day.value)
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-white text-gray-700 border-gray-300'
}`}
>
{day.label}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Time</label>
<input
type="time"
value={scheduleTime}
onChange={(e) => 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"
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">Send reminder</p>
<p className="text-sm text-gray-500">Get notified when it's time</p>
</div>
<button
type="button"
onClick={() => setScheduleRemind(!scheduleRemind)}
className={`w-12 h-7 rounded-full transition-colors ${
scheduleRemind ? 'bg-indigo-500' : 'bg-gray-300'
}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
scheduleRemind ? 'translate-x-5' : ''
}`} />
</button>
</div>
</div>
{/* Steps */} {/* Steps */}
<div> <div>
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">

View File

@@ -1,9 +1,9 @@
'use client'; 'use client';
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef, useMemo } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import api from '@/lib/api'; 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'; import Link from 'next/link';
interface Routine { interface Routine {
@@ -23,11 +23,69 @@ interface ScheduleEntry {
total_duration_minutes: number; 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 HOUR_HEIGHT = 80;
const START_HOUR = 5; const START_HOUR = 5;
const END_HOUR = 23; const END_HOUR = 23;
const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const DAY_KEYS = ['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[] { function getWeekDays(anchor: Date): Date[] {
const d = new Date(anchor); 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')}`; return `${String(nh).padStart(2, '0')}:${String(nm).padStart(2, '0')}`;
} }
function getDayKey(date: Date): string { function formatMedsList(meds: { routine_name: string }[]): string {
const day = date.getDay(); const MAX_CHARS = 25;
return DAY_KEYS[day === 0 ? 6 : day - 1]; 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() { export default function RoutinesPage() {
@@ -84,12 +153,21 @@ export default function RoutinesPage() {
const [allRoutines, setAllRoutines] = useState<Routine[]>([]); const [allRoutines, setAllRoutines] = useState<Routine[]>([]);
const [allSchedules, setAllSchedules] = useState<ScheduleEntry[]>([]); const [allSchedules, setAllSchedules] = useState<ScheduleEntry[]>([]);
const [todayMeds, setTodayMeds] = useState<TodaysMedication[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [selectedDate, setSelectedDate] = useState(() => new Date()); const [selectedDate, setSelectedDate] = useState(() => new Date());
const [nowMinutes, setNowMinutes] = useState(() => { const [nowMinutes, setNowMinutes] = useState(() => {
const n = new Date(); const n = new Date();
return n.getHours() * 60 + n.getMinutes(); 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<string | null>(null);
const today = new Date(); const today = new Date();
const weekDays = getWeekDays(selectedDate); const weekDays = getWeekDays(selectedDate);
@@ -105,14 +183,130 @@ export default function RoutinesPage() {
const nowTopPx = minutesToTop(nowMinutes); 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<string, GroupedMedEntry> = 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(() => { useEffect(() => {
Promise.all([ Promise.all([
api.routines.list(), api.routines.list(),
api.routines.listAllSchedules(), api.routines.listAllSchedules(),
api.medications.getToday().catch(() => []),
]) ])
.then(([routines, schedules]) => { .then(([routines, schedules, todayMeds]) => {
setAllRoutines(routines); setAllRoutines(routines);
setAllSchedules(schedules); setAllSchedules(schedules);
setTodayMeds(todayMeds);
}) })
.catch(() => {}) .catch(() => {})
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
@@ -122,6 +316,7 @@ export default function RoutinesPage() {
const timer = setInterval(() => { const timer = setInterval(() => {
const n = new Date(); const n = new Date();
setNowMinutes(n.getHours() * 60 + n.getMinutes()); setNowMinutes(n.getHours() * 60 + n.getMinutes());
setTick(t => t + 1);
}, 30_000); }, 30_000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, []); }, []);
@@ -166,6 +361,32 @@ export default function RoutinesPage() {
</Link> </Link>
</div> </div>
{/* Undo Toast */}
{undoAction && (
<div className="fixed bottom-20 left-4 right-4 z-50 animate-fade-in-up">
<div className="bg-gray-900 text-white px-4 py-3 rounded-xl flex items-center justify-between shadow-lg">
<span className="text-sm">
{undoAction.action === 'taken' ? 'Medication taken' : 'Medication skipped'}
</span>
<button
onClick={handleUndo}
className="text-indigo-400 font-medium text-sm hover:text-indigo-300"
>
Undo
</button>
</div>
</div>
)}
{/* Error Toast */}
{error && (
<div className="fixed bottom-20 left-4 right-4 z-50 animate-fade-in-up">
<div className="bg-red-600 text-white px-4 py-3 rounded-xl shadow-lg">
{error}
</div>
</div>
)}
{/* Week Strip */} {/* Week Strip */}
<div className="flex bg-white px-2 pb-3 pt-2 gap-1 border-b border-gray-100"> <div className="flex bg-white px-2 pb-3 pt-2 gap-1 border-b border-gray-100">
{weekDays.map((day, i) => { {weekDays.map((day, i) => {
@@ -293,16 +514,97 @@ export default function RoutinesPage() {
); );
})} })}
{/* Empty day */} {/* Medication cards - grouped by time */}
{scheduledForDay.length === 0 && ( {groupedMedEntries.map((group) => {
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"> const startMin = timeToMinutes(group.time) || 0;
<p className="text-gray-400 text-sm">No routines scheduled for this day</p> 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 (
<div
key={group.time}
style={{
top: `${topPx}px`,
height: `${heightPx}px`,
left: '60px',
right: '8px',
}}
className={`absolute rounded-xl px-3 py-2 text-left shadow-sm border transition-all overflow-hidden ${statusColor}`}
>
<div className="flex items-center justify-between gap-2 h-full">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className="text-lg leading-none flex-shrink-0">💊</span>
<div className="min-w-0 flex-1">
<p className="font-semibold text-gray-900 text-sm truncate">
{formatMedsList(group.medications)}
</p>
<p className="text-xs text-gray-500 truncate">
{formatTime(group.time)}
</p>
</div>
</div>
{group.allTaken ? (
<span className="text-green-600 font-medium flex items-center gap-1 flex-shrink-0">
<CheckIcon size={16} /> Taken
</span>
) : group.allSkipped ? (
<span className="text-gray-400 font-medium flex-shrink-0">Skipped</span>
) : (
<div className="flex gap-1 flex-shrink-0 items-center">
{group.anyOverdue && (
<span className="text-amber-600 font-medium text-xs mr-1">!</span>
)}
<button
onClick={(e) => {
e.stopPropagation();
group.medications.forEach(med => {
if (med.status !== 'taken' && med.status !== 'skipped') {
handleTakeMed(med.medication_id, med.scheduled_time);
}
});
}}
className="bg-green-600 text-white px-2 py-1 rounded-lg text-xs font-medium"
>
Take All
</button>
<button
onClick={(e) => {
e.stopPropagation();
group.medications.forEach(med => {
if (med.status !== 'taken' && med.status !== 'skipped') {
handleSkipMed(med.medication_id, med.scheduled_time);
}
});
}}
className="text-gray-500 px-1 py-1 text-xs"
>
Skip
</button>
</div> </div>
)} )}
</div> </div>
</div>
);
})}
{/* Unscheduled routines */} {/* Empty day */}
{unscheduledRoutines.length > 0 && ( {scheduledForDay.length === 0 && medEntries.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<p className="text-gray-400 text-sm">No routines or medications for this day</p>
</div>
)}
</div>
</>
)}
</div>
{/* Unscheduled routines - outside scrollable area */}
{unscheduledRoutines.length > 0 && !isLoading && (
<div className="border-t border-gray-200 bg-white px-4 pt-3 pb-4"> <div className="border-t border-gray-200 bg-white px-4 pt-3 pb-4">
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2"> <h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
Unscheduled Unscheduled
@@ -337,9 +639,6 @@ export default function RoutinesPage() {
</div> </div>
</div> </div>
)} )}
</>
)}
</div>
</div> </div>
); );
} }

View File

@@ -37,7 +37,7 @@ export default function TemplatesPage() {
setCloningId(templateId); setCloningId(templateId);
try { try {
const routine = await api.templates.clone(templateId); const routine = await api.templates.clone(templateId);
router.push(`/dashboard/routines/${routine.id}`); router.push(`/dashboard/routines/${routine.id}?new=1`);
} catch (err) { } catch (err) {
console.error('Failed to clone template:', err); console.error('Failed to clone template:', err);
setCloningId(null); setCloningId(null);

View File

@@ -852,3 +852,398 @@ export function SkipForwardIcon({ className = '', size = 24 }: IconProps) {
</svg> </svg>
); );
} }
export function SmileIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<circle cx="12" cy="12" r="10" />
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
<line x1="9" y1="9" x2="9.01" y2="9" />
<line x1="15" y1="9" x2="15.01" y2="9" />
</svg>
);
}
export function FrownIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<circle cx="12" cy="12" r="10" />
<path d="M16 16s-1.5-2-4-2-4 2-4 2" />
<line x1="9" y1="9" x2="9.01" y2="9" />
<line x1="15" y1="9" x2="15.01" y2="9" />
</svg>
);
}
export function CoffeeIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M18 8h1a4 4 0 0 1 0 8h-1" />
<path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z" />
<line x1="6" y1="1" x2="6" y2="4" />
<line x1="10" y1="1" x2="10" y2="4" />
<line x1="14" y1="1" x2="14" y2="4" />
</svg>
);
}
export function LeafIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z" />
<path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12" />
</svg>
);
}
export function DropletIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z" />
</svg>
);
}
export function AppleIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
</svg>
);
}
export function DumbbellIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="m6.5 6.5 11 11" />
<path d="m21 21-1-1" />
<path d="m3 3 1 1" />
<path d="m18 22 4-4" />
<path d="m2 6 4-4" />
<path d="m3 10 7-7" />
<path d="m14 21 7-7" />
</svg>
);
}
export function RunIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<circle cx="12" cy="5" r="1" />
<path d="M9 20l-5-4 1.5 1.5L9 15l3 3 4-4-1.5-1.5L15 16" />
<path d="m6 8 6 2 2-4 2 4 4 2" />
</svg>
);
}
export function BikeIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M5.5 10a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7Z" />
<path d="M18.5 10a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7Z" />
<path d="m16 19 2 2-4.5 2.5-2-2" />
<path d="M6.5 13 5 8l8.5-3 2 7" />
<path d="m15.5 11.5-1.5 3.5" />
<circle cx="18.5" cy="10" r="2" />
</svg>
);
}
export function BookIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20" />
</svg>
);
}
export function MusicIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
);
}
export function PenIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="m12 20 9.5-9.5a2.828 2.828 0 1 0-4-4L7 16" />
<path d="m16 16 4 4" />
<path d="m12 12 4 4" />
</svg>
);
}
export function CoffeeBeanIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<path d="M15 8c0-2.21-2.239-4-5-4S5 5.79 5 8s2.239 4 5 4c.577 0 1.1-.1 1.58-.27A3.99 3.99 0 0 1 14 14.73V21h2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S18.88 10 17.5 10H17V8c0-1.1.9-2 2-2s2 .9 2 2v1h1.5c.28 0 .5.22.5.5v.23c1.16.41 2 1.52 2 2.81 0 1.66-1.34 3-3 3-.45 0-.87-.1-1.24-.27A3.975 3.975 0 0 1 15 18.27V21h2v-3c0-.58.19-1.11.5-1.56C18.16 15.41 19 14.32 19 13c0-1.66-1.34-3-3-3-.45 0-.87.1-1.24.27A3.99 3.99 0 0 1 13 11.27V16h2v-1.27c0-.41-.07-.81-.19-1.18C15.34 12.26 16 11.18 16 10c0-1.66-1.34-3-3-3-.45 0-.87.1-1.24.27C11.07 6.19 10.5 5.16 10.18 4.08 9.14 4.82 7.68 5.2 6 5.2c-2.76 0-5 1.79-5 4z" />
</svg>
);
}
export function WalkIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M4 16v-2.38C4 11.5 2.97 10.5 3 8c.03-2.72 1.49-6 4.5-6C9.37 2 11 3.8 11 8c0 1.25-.38 2-1 2.72V16" />
<path d="m20 20-2-2" />
<path d="M15.5 15.5 17 14l2.5 2.5-1.5 1.5" />
<circle cx="9" cy="6" r="1" />
<path d="m18 11-1-4" />
</svg>
);
}
export function WaterBottleIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<path d="M9.5 2c-1.82 0-3.53.5-5 1.35c2.99 1.73 5 4.65 5 7.65 0 1.75-.45 3.41-1.23 4.85l-.23.37c-.56.92-.97 1.95-1.2 3.07-.08.36-.12.72-.12 1.1v.16c0 .41.02.82.06 1.22.15 1.61.91 2.92 2.22 3.25v.2c0 .55.45 1 1 1s1-.45 1-1v-.2c.78-.2 1.31-.68 1.69-1.5.21-.46.38-.97.5-1.51l.08-.37c.23-.99.47-2.09.47-3.35 0-2.33-1.42-4.28-3.5-5.13V4c-.5-.58-.83-1.28-.83-2 0-1.65 1.35-3 3-3 .58 0 1.12.17 1.59.46C12.35 1.08 10.97 2 9.5 2z" />
</svg>
);
}
export function SaladIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<path d="M18 9V6a2 2 0 0 0-2-2 2 2 0 0 0-2 2v3M14 9V6a2 2 0 0 0-2-2 2 2 0 0 0-2 2v3M10 9V6a2 2 0 0 0-2-2 2 2 0 0 0-2 2v3M6 9V6a2 2 0 0 0-2-2 2 2 0 0 0-2 2v3" />
<path d="M2 11v5a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5a2 2 0 0 0-4 0v2H6v-2a2 2 0 0 0-4 0Z" />
</svg>
);
}
export function RestIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
</svg>
);
}
export function SparkleIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<path d="M12 3l1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5L12 3z" />
</svg>
);
}
export function EyeIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
export function ShieldIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
);
}

View File

@@ -31,8 +31,15 @@ async function request<T>(
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Request failed' })); const body = await response.text();
throw new Error(error.error || 'Request failed'); 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(); return response.json();