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 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

View File

@@ -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,7 +75,9 @@ def _record_step_result(session_id, step_id, step_index, result, session):
else:
duration_seconds = None
postgres.insert("routine_step_results", {
postgres.insert(
"routine_step_results",
{
"id": str(uuid.uuid4()),
"session_id": session_id,
"step_id": step_id,
@@ -81,7 +85,8 @@ def _record_step_result(session_id, step_id, step_index, result, session):
"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", {
postgres.update(
"routine_sessions",
{
"status": "completed",
"completed_at": now.isoformat(),
"actual_duration_minutes": int(duration_minutes),
}, {"id": session_id})
},
{"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", {
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", {
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/<routine_id>", 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/<routine_id>/steps/<step_id>", 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/<session_id>/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({
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
}
), 200
postgres.update(
"routine_sessions", {"current_step_index": next_index}, {"id": session_id}
)
return flask.jsonify(
{
"session": {"current_step_index": next_index},
"next_step": steps[next_index],
}
), 200
@app.route("/api/sessions/<session_id>/skip-step", methods=["POST"])
def api_skipStep(session_id):
@@ -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({
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
}
), 200
postgres.update(
"routine_sessions", {"current_step_index": next_index}, {"id": session_id}
)
return flask.jsonify(
{
"session": {"current_step_index": next_index},
"next_step": steps[next_index],
}
), 200
@app.route("/api/sessions/<session_id>/cancel", methods=["POST"])
def api_cancelSession(session_id):
@@ -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,7 +636,8 @@ 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({
result.append(
{
"routine_id": r["id"],
"routine_name": r.get("name", ""),
"routine_icon": r.get("icon", ""),
@@ -531,7 +645,8 @@ def register(app):
"time": sched.get("time"),
"remind": sched.get("remind", True),
"total_duration_minutes": total_duration,
})
}
)
return flask.jsonify(result), 200
@app.route("/api/routines/<routine_id>/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})

View File

@@ -156,21 +156,27 @@ export default function MedicationsPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [todayMeds, tick]);
const [error, setError] = useState<string | null>(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 */}
<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 */}
{dueEntries.length > 0 && (
<div>

View File

@@ -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<Routine | null>(null);
const [steps, setSteps] = useState<Step[]>([]);
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<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 [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() {
</h1>
</div>
{!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
onClick={() => setIsEditing(true)}
className="text-indigo-600 font-medium"
>
Edit
</button>
</div>
)}
</div>
</header>
@@ -319,86 +389,17 @@ export default function RoutineDetailPage() {
</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 */}
<div className="flex gap-3">
<button
onClick={() => {
setIsEditing(false);
// Reset schedule edits
if (schedule) {
setEditDays(schedule.days);
setEditTime(schedule.time);
setEditRemind(schedule.remind);
} else {
setEditDays([]);
setEditTime('08:00');
setEditRemind(true);
}
setEditName(routine.name);
setEditDescription(routine.description || '');
setEditIcon(routine.icon || '✨');
setEditLocation(routine.location || '');
setEditHabitStack(routine.habit_stack_after || '');
setEditEnvPrompts(routine.environment_prompts || []);
}}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg font-medium"
>
@@ -443,20 +444,138 @@ export default function RoutineDetailPage() {
</div>
{/* Schedule display (view mode) */}
{schedule && schedule.days.length > 0 && (
<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" />
<h3 className="font-semibold text-gray-900">Schedule</h3>
</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">
{formatDays(schedule.days)} at {schedule.time}
</p>
{schedule.remind && (
<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>
)}
</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
onClick={() => handleDeleteStep(step.id)}
className="text-red-500 p-2"
@@ -534,6 +669,18 @@ export default function RoutineDetailPage() {
</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>
);

View File

@@ -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<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 newStep: Step = {
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) {
setError((err as Error).message || 'Failed to create routine');
} finally {
@@ -96,6 +126,22 @@ export default function NewRoutinePage() {
</div>
</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">
{error && (
<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>
{/* 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 */}
<div>
<div className="flex items-center justify-between mb-3">

View File

@@ -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<Routine[]>([]);
const [allSchedules, setAllSchedules] = useState<ScheduleEntry[]>([]);
const [todayMeds, setTodayMeds] = useState<TodaysMedication[]>([]);
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<string | null>(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<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(() => {
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() {
</Link>
</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 */}
<div className="flex bg-white px-2 pb-3 pt-2 gap-1 border-b border-gray-100">
{weekDays.map((day, i) => {
@@ -293,16 +514,97 @@ export default function RoutinesPage() {
);
})}
{/* Empty day */}
{scheduledForDay.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<p className="text-gray-400 text-sm">No routines scheduled for this day</p>
{/* 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 (
<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>
);
})}
{/* Unscheduled routines */}
{unscheduledRoutines.length > 0 && (
{/* Empty day */}
{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">
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
Unscheduled
@@ -337,9 +639,6 @@ export default function RoutinesPage() {
</div>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

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

View File

@@ -852,3 +852,398 @@ export function SkipForwardIcon({ className = '', size = 24 }: IconProps) {
</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) {
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();