Fix medication system and rename to Synculous.

- Add all 14 missing database tables (medications, med_logs, routines, etc.)
- Rewrite medication scheduling: support specific days, every N days, as-needed (PRN)
- Fix taken_times matching: match by created_at date, not scheduled_time string
- Fix adherence calculation: taken / expected doses, not taken / (taken + skipped)
- Add formatSchedule() helper for readable display
- Update client types and API layer
- Rename brilli-ins-client → synculous-client
- Make client PWA: add manifest, service worker, icons
- Bind dev server to 0.0.0.0 for network access
- Fix SVG icon bugs in Icons.tsx
- Add .dockerignore for client npm caching

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 03:23:38 -06:00
parent 3e1134575b
commit 97a166f5aa
47 changed files with 5231 additions and 61 deletions

View File

@@ -6,6 +6,7 @@ Domain routes are registered via the routes registry.
import os import os
import flask import flask
from flask_cors import CORS
import core.auth as auth import core.auth as auth
import core.users as users import core.users as users
import core.postgres as postgres import core.postgres as postgres
@@ -18,6 +19,7 @@ import api.routes.routine_stats as routine_stats_routes
import api.routes.routine_tags as routine_tags_routes import api.routes.routine_tags as routine_tags_routes
app = flask.Flask(__name__) app = flask.Flask(__name__)
CORS(app)
ROUTE_MODULES = [ ROUTE_MODULES = [
routines_routes, routines_routes,

View File

@@ -4,8 +4,11 @@ Medications API - medication scheduling, logging, and adherence tracking
import os import os
import uuid import uuid
from datetime import datetime, date, timedelta
import flask import flask
import jwt import jwt
from psycopg2.extras import Json
import core.auth as auth import core.auth as auth
import core.postgres as postgres import core.postgres as postgres
@@ -30,6 +33,85 @@ def _auth(request):
return user_uuid return user_uuid
_JSON_COLS = {"times", "days_of_week"}
def _wrap_json(data):
"""Wrap list/dict values in Json() for psycopg2 when targeting JSON columns."""
return {k: Json(v) if k in _JSON_COLS and isinstance(v, (list, dict)) else v for k, v in data.items()}
def _is_med_due_today(med, today, current_day):
"""Check if a medication is scheduled for today based on its frequency."""
freq = med.get("frequency", "daily")
if freq in ("daily", "twice_daily"):
return True
if freq == "as_needed":
return True # always show PRN, but with no scheduled times
if freq == "specific_days":
days = med.get("days_of_week", [])
return current_day in days
if freq == "every_n_days":
start = med.get("start_date")
interval = med.get("interval_days")
if start and interval:
start_d = start if isinstance(start, date) else datetime.strptime(str(start), "%Y-%m-%d").date()
days_since = (today - start_d).days
return days_since >= 0 and days_since % interval == 0
return False
return True
def _compute_next_dose_date(med):
"""Compute the next dose date for every_n_days medications."""
interval = med.get("interval_days")
if not interval:
return None
return (date.today() + timedelta(days=interval)).isoformat()
def _count_expected_doses(med, period_start, days):
"""Count expected doses in a period based on frequency and schedule."""
freq = med.get("frequency", "daily")
times_per_day = len(med.get("times", [])) or 1
if freq == "as_needed":
return 0 # PRN has no expected doses
if freq in ("daily", "twice_daily"):
return days * times_per_day
if freq == "specific_days":
dow = med.get("days_of_week", [])
count = 0
for d in range(days):
check_date = period_start + timedelta(days=d)
if check_date.strftime("%a").lower() in dow:
count += times_per_day
return count
if freq == "every_n_days":
interval = med.get("interval_days", 1)
start = med.get("start_date")
if not start:
return 0
start_d = start if isinstance(start, date) else datetime.strptime(str(start), "%Y-%m-%d").date()
count = 0
for d in range(days):
check_date = period_start + timedelta(days=d)
diff = (check_date - start_d).days
if diff >= 0 and diff % interval == 0:
count += times_per_day
return count
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."""
return sum(
1 for log in logs
if log.get("action") == action
and str(log.get("created_at", ""))[:10] >= period_start_str
)
def register(app): def register(app):
# ── Medications CRUD ────────────────────────────────────────── # ── Medications CRUD ──────────────────────────────────────────
@@ -45,7 +127,7 @@ def register(app):
@app.route("/api/medications", methods=["POST"]) @app.route("/api/medications", methods=["POST"])
def api_addMedication(): def api_addMedication():
"""Add a medication. Body: {name, dosage, unit, frequency, times: ["08:00","20:00"], notes?}""" """Add a medication. Body: {name, dosage, unit, frequency, times?, days_of_week?, interval_days?, start_date?, notes?}"""
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
@@ -56,11 +138,37 @@ def register(app):
missing = [f for f in required if not data.get(f)] missing = [f for f in required if not data.get(f)]
if missing: if missing:
return flask.jsonify({"error": f"missing required fields: {', '.join(missing)}"}), 400 return flask.jsonify({"error": f"missing required fields: {', '.join(missing)}"}), 400
data["id"] = str(uuid.uuid4())
data["user_uuid"] = user_uuid row = {
data["times"] = data.get("times", []) "id": str(uuid.uuid4()),
data["active"] = True "user_uuid": user_uuid,
med = postgres.insert("medications", data) "name": data["name"],
"dosage": data["dosage"],
"unit": data["unit"],
"frequency": data["frequency"],
"times": data.get("times", []),
"days_of_week": data.get("days_of_week", []),
"interval_days": data.get("interval_days"),
"start_date": data.get("start_date"),
"notes": data.get("notes"),
"active": True,
}
# Compute next_dose_date for interval meds
if data.get("frequency") == "every_n_days" and data.get("start_date") and data.get("interval_days"):
start = datetime.strptime(data["start_date"], "%Y-%m-%d").date()
today = date.today()
if start > today:
row["next_dose_date"] = data["start_date"]
else:
days_since = (today - start).days
remainder = days_since % data["interval_days"]
if remainder == 0:
row["next_dose_date"] = today.isoformat()
else:
row["next_dose_date"] = (today + timedelta(days=data["interval_days"] - remainder)).isoformat()
med = postgres.insert("medications", _wrap_json(row))
return flask.jsonify(med), 201 return flask.jsonify(med), 201
@app.route("/api/medications/<med_id>", methods=["GET"]) @app.route("/api/medications/<med_id>", methods=["GET"])
@@ -76,7 +184,7 @@ def register(app):
@app.route("/api/medications/<med_id>", methods=["PUT"]) @app.route("/api/medications/<med_id>", methods=["PUT"])
def api_updateMedication(med_id): def api_updateMedication(med_id):
"""Update medication details. Body: {name?, dosage?, unit?, frequency?, times?, notes?, active?}""" """Update medication details."""
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
@@ -86,11 +194,14 @@ def register(app):
existing = postgres.select_one("medications", {"id": med_id, "user_uuid": user_uuid}) existing = postgres.select_one("medications", {"id": med_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", "dosage", "unit", "frequency", "times", "notes", "active"] allowed = [
"name", "dosage", "unit", "frequency", "times", "notes", "active",
"days_of_week", "interval_days", "start_date", "next_dose_date",
]
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("medications", updates, {"id": med_id, "user_uuid": user_uuid}) result = postgres.update("medications", _wrap_json(updates), {"id": med_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/medications/<med_id>", methods=["DELETE"]) @app.route("/api/medications/<med_id>", methods=["DELETE"])
@@ -127,6 +238,11 @@ def register(app):
"notes": data.get("notes"), "notes": data.get("notes"),
} }
log = postgres.insert("med_logs", log_entry) log = postgres.insert("med_logs", log_entry)
# Advance next_dose_date for interval meds
if med.get("frequency") == "every_n_days" and med.get("interval_days"):
next_date = _compute_next_dose_date(med)
if next_date:
postgres.update("medications", {"next_dose_date": next_date}, {"id": med_id})
return flask.jsonify(log), 201 return flask.jsonify(log), 201
@app.route("/api/medications/<med_id>/skip", methods=["POST"]) @app.route("/api/medications/<med_id>/skip", methods=["POST"])
@@ -148,6 +264,11 @@ def register(app):
"notes": data.get("reason"), "notes": data.get("reason"),
} }
log = postgres.insert("med_logs", log_entry) log = postgres.insert("med_logs", log_entry)
# Advance next_dose_date for interval meds
if med.get("frequency") == "every_n_days" and med.get("interval_days"):
next_date = _compute_next_dose_date(med)
if next_date:
postgres.update("medications", {"next_dose_date": next_date}, {"id": med_id})
return flask.jsonify(log), 201 return flask.jsonify(log), 201
@app.route("/api/medications/<med_id>/snooze", methods=["POST"]) @app.route("/api/medications/<med_id>/snooze", methods=["POST"])
@@ -189,24 +310,37 @@ 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
meds = postgres.select("medications", where={"user_uuid": user_uuid, "active": True}) meds = postgres.select("medications", where={"user_uuid": user_uuid, "active": True})
from datetime import datetime now = datetime.now()
today = datetime.now().strftime("%Y-%m-%d") today = date.today()
today_str = today.isoformat()
current_day = now.strftime("%a").lower() # "mon","tue", etc.
result = [] result = []
for med in meds: for med in meds:
times = med.get("times", []) if not _is_med_due_today(med, today, current_day):
taken_times = [ continue
log["scheduled_time"]
for log in postgres.select( freq = med.get("frequency", "daily")
is_prn = freq == "as_needed"
# Get today's taken times by filtering on created_at date
all_logs = postgres.select(
"med_logs", "med_logs",
where={"medication_id": med["id"], "action": "taken"}, where={"medication_id": med["id"], "action": "taken"},
) )
if log.get("scheduled_time", "").startswith(today) today_taken = [
log.get("scheduled_time", "")
for log in all_logs
if str(log.get("created_at", ""))[:10] == today_str
] ]
result.append({ result.append({
"medication": med, "medication": med,
"scheduled_times": times, "scheduled_times": [] if is_prn else med.get("times", []),
"taken_times": taken_times, "taken_times": today_taken,
"is_prn": is_prn,
}) })
return flask.jsonify(result), 200 return flask.jsonify(result), 200
@@ -218,25 +352,37 @@ 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
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 = date.today()
period_start = today - timedelta(days=num_days)
period_start_str = period_start.isoformat()
result = [] result = []
for med in meds: for med in meds:
logs = postgres.select( freq = med.get("frequency", "daily")
"med_logs", is_prn = freq == "as_needed"
where={"medication_id": med["id"]}, expected = _count_expected_doses(med, period_start, num_days)
limit=days * 10,
) logs = postgres.select("med_logs", where={"medication_id": med["id"]})
taken = sum(1 for log in logs if log.get("action") == "taken") taken = _count_logs_in_period(logs, period_start_str, "taken")
skipped = sum(1 for log in logs if log.get("action") == "skipped") skipped = _count_logs_in_period(logs, period_start_str, "skipped")
total = taken + skipped
adherence = (taken / total * 100) if total > 0 else 0 if is_prn:
adherence_pct = None
elif expected > 0:
adherence_pct = round(min(taken / expected * 100, 100), 1)
else:
adherence_pct = 0
result.append({ result.append({
"medication_id": med["id"], "medication_id": med["id"],
"name": med["name"], "name": med["name"],
"taken": taken, "taken": taken,
"skipped": skipped, "skipped": skipped,
"adherence_percent": round(adherence, 1), "expected": expected,
"adherence_percent": adherence_pct,
"is_prn": is_prn,
}) })
return flask.jsonify(result), 200 return flask.jsonify(result), 200
@@ -249,22 +395,34 @@ def register(app):
med = postgres.select_one("medications", {"id": med_id, "user_uuid": user_uuid}) med = postgres.select_one("medications", {"id": med_id, "user_uuid": user_uuid})
if not med: if not med:
return flask.jsonify({"error": "not found"}), 404 return flask.jsonify({"error": "not found"}), 404
days = flask.request.args.get("days", 30, type=int) num_days = flask.request.args.get("days", 30, type=int)
logs = postgres.select( today = date.today()
"med_logs", period_start = today - timedelta(days=num_days)
where={"medication_id": med_id}, period_start_str = period_start.isoformat()
limit=days * 10,
) freq = med.get("frequency", "daily")
taken = sum(1 for log in logs if log.get("action") == "taken") is_prn = freq == "as_needed"
skipped = sum(1 for log in logs if log.get("action") == "skipped") expected = _count_expected_doses(med, period_start, num_days)
total = taken + skipped
adherence = (taken / total * 100) if total > 0 else 0 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")
if is_prn:
adherence_pct = None
elif expected > 0:
adherence_pct = round(min(taken / expected * 100, 100), 1)
else:
adherence_pct = 0
return flask.jsonify({ return flask.jsonify({
"medication_id": med_id, "medication_id": med_id,
"name": med["name"], "name": med["name"],
"taken": taken, "taken": taken,
"skipped": skipped, "skipped": skipped,
"adherence_percent": round(adherence, 1), "expected": expected,
"adherence_percent": adherence_pct,
"is_prn": is_prn,
}), 200 }), 200
# ── Refills ─────────────────────────────────────────────────── # ── Refills ───────────────────────────────────────────────────
@@ -295,7 +453,6 @@ def register(app):
if not user_uuid: if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401 return flask.jsonify({"error": "unauthorized"}), 401
days_ahead = flask.request.args.get("days_ahead", 7, type=int) days_ahead = flask.request.args.get("days_ahead", 7, type=int)
from datetime import datetime, timedelta
cutoff = (datetime.now() + timedelta(days=days_ahead)).strftime("%Y-%m-%d") cutoff = (datetime.now() + timedelta(days=days_ahead)).strftime("%Y-%m-%d")
meds = postgres.select( meds = postgres.select(
"medications", "medications",
@@ -307,6 +464,6 @@ def register(app):
refill_date = med.get("refill_date") refill_date = med.get("refill_date")
if qty is not None and qty <= 7: if qty is not None and qty <= 7:
due.append(med) due.append(med)
elif refill_date and refill_date <= cutoff: elif refill_date and str(refill_date) <= cutoff:
due.append(med) due.append(med)
return flask.jsonify(due), 200 return flask.jsonify(due), 200

View File

@@ -1,5 +1,5 @@
""" """
Routines API - Brilli-style routine management Routines API - routine management
Routines have ordered steps. Users start sessions to walk through them. Routines have ordered steps. Users start sessions to walk through them.
""" """

View File

@@ -20,11 +20,125 @@ CREATE TABLE IF NOT EXISTS notifications (
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
-- Add your domain tables below -- ── Routines ────────────────────────────────────────────────
-- Example:
-- CREATE TABLE IF NOT EXISTS examples ( CREATE TABLE IF NOT EXISTS routines (
-- id UUID PRIMARY KEY, id UUID PRIMARY KEY,
-- user_uuid UUID REFERENCES users(id) ON DELETE CASCADE, user_uuid UUID REFERENCES users(id) ON DELETE CASCADE,
-- name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
-- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP description TEXT,
-- ); icon VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS routine_steps (
id UUID PRIMARY KEY,
routine_id UUID REFERENCES routines(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
duration_minutes INTEGER,
position INTEGER NOT NULL,
instructions TEXT,
step_type VARCHAR(50) DEFAULT 'generic',
media_url TEXT
);
CREATE TABLE IF NOT EXISTS routine_sessions (
id UUID PRIMARY KEY,
routine_id UUID REFERENCES routines(id) ON DELETE CASCADE,
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(20) DEFAULT 'active',
current_step_index INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
paused_at TIMESTAMP,
completed_at TIMESTAMP,
abort_reason TEXT,
actual_duration_minutes INTEGER
);
CREATE TABLE IF NOT EXISTS routine_schedules (
id UUID PRIMARY KEY,
routine_id UUID REFERENCES routines(id) ON DELETE CASCADE,
days JSON DEFAULT '[]',
time VARCHAR(5),
remind BOOLEAN DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS routine_session_notes (
id UUID PRIMARY KEY,
session_id UUID REFERENCES routine_sessions(id) ON DELETE CASCADE,
step_index INTEGER,
note TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS routine_templates (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
icon VARCHAR(100),
created_by_admin BOOLEAN DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS routine_template_steps (
id UUID PRIMARY KEY,
template_id UUID REFERENCES routine_templates(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
instructions TEXT,
step_type VARCHAR(50) DEFAULT 'generic',
duration_minutes INTEGER,
media_url TEXT,
position INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS routine_tags (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
color VARCHAR(20) DEFAULT '#888888'
);
CREATE TABLE IF NOT EXISTS routine_routine_tags (
routine_id UUID REFERENCES routines(id) ON DELETE CASCADE,
tag_id UUID REFERENCES routine_tags(id) ON DELETE CASCADE,
PRIMARY KEY (routine_id, tag_id)
);
CREATE TABLE IF NOT EXISTS routine_streaks (
id UUID PRIMARY KEY,
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE,
routine_id UUID REFERENCES routines(id) ON DELETE CASCADE,
current_streak INTEGER DEFAULT 0,
longest_streak INTEGER DEFAULT 0,
last_completed_date DATE
);
-- ── Medications ─────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS medications (
id UUID PRIMARY KEY,
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
dosage VARCHAR(100) NOT NULL,
unit VARCHAR(50) NOT NULL,
frequency VARCHAR(50) NOT NULL,
times JSON DEFAULT '[]',
days_of_week JSON DEFAULT '[]',
interval_days INTEGER,
start_date DATE,
next_dose_date DATE,
notes TEXT,
active BOOLEAN DEFAULT TRUE,
quantity_remaining INTEGER,
refill_date DATE,
pharmacy_notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS med_logs (
id UUID PRIMARY KEY,
medication_id UUID REFERENCES medications(id) ON DELETE CASCADE,
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE,
action VARCHAR(20) NOT NULL,
scheduled_time VARCHAR(5),
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -41,5 +41,16 @@ services:
app: app:
condition: service_started condition: service_started
client:
build:
context: ./synculous-client
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NEXT_PUBLIC_API_URL=http://app:5000
depends_on:
- app
volumes: volumes:
pgdata: pgdata:

View File

@@ -1,4 +1,5 @@
flask>=3.0.0 flask>=3.0.0
flask-cors>=4.0.0
psycopg2-binary>=2.9.0 psycopg2-binary>=2.9.0
bcrypt>=4.1.0 bcrypt>=4.1.0
PyJWT>=2.8.0 PyJWT>=2.8.0

View File

@@ -21,23 +21,48 @@ POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", 60))
def check_medication_reminders(): def check_medication_reminders():
"""Check for medications due now and send notifications.""" """Check for medications due now and send notifications."""
try: try:
from datetime import date as date_type
meds = postgres.select("medications", where={"active": True}) meds = postgres.select("medications", where={"active": True})
now = datetime.now() now = datetime.now()
current_time = now.strftime("%H:%M") current_time = now.strftime("%H:%M")
today = now.strftime("%Y-%m-%d") current_day = now.strftime("%a").lower() # "mon","tue", etc.
today = now.date()
today_str = today.isoformat()
for med in meds: for med in meds:
freq = med.get("frequency", "daily")
# Skip as_needed -- no scheduled reminders for PRN
if freq == "as_needed":
continue
# Day-of-week check for specific_days
if freq == "specific_days":
days = med.get("days_of_week", [])
if current_day not in days:
continue
# Interval check for every_n_days
if freq == "every_n_days":
start = med.get("start_date")
interval = med.get("interval_days")
if start and interval:
start_d = start if isinstance(start, date_type) else datetime.strptime(str(start), "%Y-%m-%d").date()
if (today - start_d).days < 0 or (today - start_d).days % interval != 0:
continue
else:
continue
# Time check
times = med.get("times", []) times = med.get("times", [])
if current_time not in times: if current_time not in times:
continue continue
logs = postgres.select( # Already taken today? Check by created_at date
"med_logs", logs = postgres.select("med_logs", where={"medication_id": med["id"], "action": "taken"})
where={"medication_id": med["id"]},
)
already_taken = any( already_taken = any(
log.get("action") == "taken" log.get("scheduled_time") == current_time
and log.get("scheduled_time", "").startswith(today) and str(log.get("created_at", ""))[:10] == today_str
for log in logs for log in logs
) )
if already_taken: if already_taken:

View File

@@ -0,0 +1,3 @@
node_modules
.next
.git

41
synculous-client/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,15 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --legacy-peer-deps
COPY . .
ENV NEXT_PUBLIC_API_URL=http://app:5000
ENV PORT=3000
EXPOSE 3000
CMD ["npx", "next", "dev", "--hostname", "0.0.0.0"]

View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,93 @@
Brili Routines: Exhaustive Feature Breakdown
Brili Routines is a visual planner and timer app designed primarily for individuals with ADHD, available in both adult and child versions. Below is a comprehensive breakdown of all features organized by category.
Core Functionality
Routine Management
Feature Description
Routine Creator Create unlimited custom routines with unlimited tasks
On-Demand Routines Launch routines anytime, not just scheduled times
Pre-made Templates Routines created by ADHD routine experts
Routine Scheduling Schedule from end time (e.g., school bus departure) or start time (e.g., school return)
Dynamic Free Time Automatically inserted before Finish; expands/contracts based on task completion speed
Routine History View past routine completions and performance
Activity Types
Activities: Tasks or actions to complete (e.g., Get Dressed, Brush Teeth)
Finish: End point defining routine conclusion (e.g., Leave for School, Lights Out)
Dynamic Free Time: Reward activity that adjusts based on efficiency
Activity Reward (Rest): Timed breaks inserted into routines
User Interface & Interaction
Gestures
Gesture Action
Swipe Left Complete task / earn stars
Swipe Right Postpone task
Swipe Up Delete task
Swipe Down Reorder tasks
Visual Elements
Visual Timer: Automatically calculates time remaining for all tasks
Focused Task Cards: Single-task view for better focus
Colorful Calendar: Visual overview of completed routines
Mastered Minutes Counter: Tracks total time spent on completed tasks
"Info Boost": Additional context for tasks (from App Store listing)
Scheduling & Reminders
Notifications
Push notifications for routine start time reminders
Acoustic and visual alerts
Consistent nudges to stay on track
Calendar Features
Daily View: See what's scheduled for today
Weekly Calendar: Overview of entire week
Routine Automation: App automatically presents appropriate routine based on time settings
Gamification & Motivation
Rewards System
Star Rewards: Parents/users assign star values to tasks
Customizable Rewards: Set your own incentives
Achievement System: Earn achievements reflecting real-life improvements and consistency
Progress Tracking
Stats and progress reviews
Motivation and insights on improvement areas
Visual success tracking
Account & Accessibility
Modes of Operation
Parent Mode: For setting up and managing child's routines
Kid Mode: Child-friendly interface for completing routines
Account Options
Email/password signup
Google or Facebook login
Profile picture (optional)
Multiple child profiles support
Multi-Device Support: Access routines from different devices
Platform Availability
iOS app
Android app (Google Play)
Web access via my.brili.com
Specialized Features for Adults with ADHD
Based on the adult app's focus, these features specifically address ADHD needs:
Category Features
Time Management Visual clock, time blindness combat, task transition support
Focus Task cards, calendar view, reduced distractions
Mental Health CBT-based approach, anxiety reduction, mood improvement
Organization Cleaning routines, meal planning, study routines
Self-Care Mindfulness, calming, workout, bedtime routines
Productivity Pomodoro techniques, work routines, weekly chores
Template Categories (Pre-made)
The app offers templates for:
Morning routines
Work routines
Weekly Chores
Self-care routines
Pomodoro routines
Bedtime routines
Mindfulness routines
Workout routines
Cleaning routines
Calming routines
Study routines
Meal planning routines
Additional Features
Task Notes: Add notes to individual tasks
Weekly Tips: Shared tips and techniques from ADHD routine experts
Multiple Kids Support: Manage routines for several children
iOS Permissions: Push notification support for reminders
This breakdown covers all major features available in Brili Routines as of the current version. The app is specifically designed to help users with ADHD manage time, reduce anxiety, build healthy habits, and maintain focus through visual scheduling and gamified task completion.

View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View File

@@ -0,0 +1,15 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: 'standalone',
async rewrites() {
return [
{
source: '/api/:path*',
destination: 'http://app:5000/api/:path*',
},
];
},
};
export default nextConfig;

View File

@@ -0,0 +1,26 @@
{
"name": "synculous-client",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@@ -0,0 +1,21 @@
{
"name": "Synculous",
"short_name": "Synculous",
"description": "Visual routine planner and timer for building healthy habits",
"start_url": "/dashboard",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#4f46e5",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,30 @@
const CACHE_NAME = 'synculous-v1';
self.addEventListener('install', (event) => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((names) =>
Promise.all(names.filter((n) => n !== CACHE_NAME).map((n) => caches.delete(n)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
const url = new URL(event.request.url);
if (url.pathname.startsWith('/api/')) return;
event.respondWith(
fetch(event.request)
.then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
return response;
})
.catch(() => caches.match(event.request))
);
});

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,157 @@
'use client';
import { useEffect, useState } from 'react';
import api from '@/lib/api';
import { CalendarIcon, CheckIcon, XIcon, ClockIcon } from '@/components/ui/Icons';
interface HistorySession {
id: string;
routine_id: string;
status: string;
created_at: string;
completed_at?: string;
}
interface Routine {
id: string;
name: string;
icon?: string;
}
export default function HistoryPage() {
const [routines, setRoutines] = useState<Routine[]>([]);
const [selectedRoutine, setSelectedRoutine] = useState<string>('all');
const [history, setHistory] = useState<HistorySession[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const [routinesData] = await Promise.all([
api.routines.list(),
]);
setRoutines(routinesData);
} catch (err) {
console.error('Failed to fetch data:', err);
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
useEffect(() => {
const fetchHistory = async () => {
setIsLoading(true);
try {
if (selectedRoutine === 'all') {
const allHistory: HistorySession[] = [];
for (const routine of routines) {
const sessions = await api.routines.getHistory(routine.id, 30).catch(() => []);
allHistory.push(...sessions.map(s => ({ ...s, routine_name: routine.name, routine_icon: routine.icon })));
}
allHistory.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
setHistory(allHistory.slice(0, 50));
} else {
const sessions = await api.routines.getHistory(selectedRoutine, 30);
setHistory(sessions);
}
} catch (err) {
console.error('Failed to fetch history:', err);
} finally {
setIsLoading(false);
}
};
if (routines.length > 0) {
fetchHistory();
}
}, [selectedRoutine, routines]);
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
});
};
const isToday = (dateStr: string) => {
const date = new Date(dateStr);
const today = new Date();
return date.toDateString() === today.toDateString();
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<div className="w-8 h-8 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
return (
<div className="p-4 space-y-4">
<h1 className="text-2xl font-bold text-gray-900">History</h1>
{/* Filter */}
<select
value={selectedRoutine}
onChange={(e) => setSelectedRoutine(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-xl bg-white"
>
<option value="all">All Routines</option>
{routines.map((routine) => (
<option key={routine.id} value={routine.id}>
{routine.name}
</option>
))}
</select>
{history.length === 0 ? (
<div className="bg-white rounded-xl p-8 shadow-sm text-center">
<CalendarIcon className="text-gray-400 mx-auto mb-4" size={40} />
<h3 className="font-semibold text-gray-900 mb-1">No history yet</h3>
<p className="text-gray-500 text-sm">Complete a routine to see it here</p>
</div>
) : (
<div className="space-y-3">
{history.map((session) => (
<div
key={session.id}
className="bg-white rounded-xl p-4 shadow-sm flex items-center gap-4"
>
<div className={`
w-10 h-10 rounded-full flex items-center justify-center
${session.status === 'completed' ? 'bg-green-100' : 'bg-red-100'}
`}>
{session.status === 'completed' ? (
<CheckIcon className="text-green-600" size={20} />
) : (
<XIcon className="text-red-600" size={20} />
)}
</div>
<div className="flex-1">
<p className="font-medium text-gray-900">
{(session as any).routine_name || 'Routine'}
</p>
<p className="text-sm text-gray-500">
{formatDate(session.created_at)}
</p>
</div>
<span className={`
text-xs font-medium px-2 py-1 rounded-full
${session.status === 'completed' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}
`}>
{session.status}
</span>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import { useEffect } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { useAuth } from '@/components/auth/AuthProvider';
import {
HomeIcon,
ListIcon,
CalendarIcon,
BarChartIcon,
PillIcon,
SettingsIcon,
LogOutIcon,
CopyIcon,
HeartIcon
} from '@/components/ui/Icons';
import Link from 'next/link';
const navItems = [
{ href: '/dashboard', label: 'Today', icon: HomeIcon },
{ href: '/dashboard/routines', label: 'Routines', icon: ListIcon },
{ href: '/dashboard/templates', label: 'Templates', icon: CopyIcon },
{ href: '/dashboard/history', label: 'History', icon: CalendarIcon },
{ href: '/dashboard/stats', label: 'Stats', icon: BarChartIcon },
{ href: '/dashboard/medications', label: 'Meds', icon: PillIcon },
];
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const { isAuthenticated, isLoading, logout } = useAuth();
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, isLoading, router]);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-pulse flex flex-col items-center">
<div className="w-12 h-12 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
<p className="mt-4 text-gray-600">Loading...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return null;
}
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-gradient-to-br from-indigo-500 to-pink-500 rounded-lg flex items-center justify-center">
<HeartIcon className="text-white" size={16} />
</div>
<span className="font-bold text-gray-900">Synculous</span>
</div>
<button
onClick={logout}
className="p-2 text-gray-500 hover:text-gray-700"
>
<LogOutIcon size={20} />
</button>
</div>
</header>
<main className="pb-20">
{children}
</main>
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 safe-area-bottom">
<div className="flex justify-around py-2">
{navItems.map((item) => {
const isActive = pathname === item.href ||
(item.href !== '/dashboard' && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
className={`flex flex-col items-center gap-1 px-3 py-2 rounded-lg transition-colors ${
isActive
? 'text-indigo-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
<item.icon size={20} />
<span className="text-xs font-medium">{item.label}</span>
</Link>
);
})}
</div>
</nav>
</div>
);
}

View File

@@ -0,0 +1,253 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import api from '@/lib/api';
import { ArrowLeftIcon } from '@/components/ui/Icons';
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 NewMedicationPage() {
const router = useRouter();
const [name, setName] = useState('');
const [dosage, setDosage] = useState('');
const [unit, setUnit] = useState('mg');
const [frequency, setFrequency] = useState('daily');
const [times, setTimes] = useState<string[]>(['08:00']);
const [daysOfWeek, setDaysOfWeek] = useState<string[]>([]);
const [intervalDays, setIntervalDays] = useState(7);
const [startDate, setStartDate] = useState(new Date().toISOString().slice(0, 10));
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const handleAddTime = () => {
setTimes([...times, '12:00']);
};
const handleRemoveTime = (index: number) => {
setTimes(times.filter((_, i) => i !== index));
};
const handleTimeChange = (index: number, value: string) => {
const newTimes = [...times];
newTimes[index] = value;
setTimes(newTimes);
};
const toggleDay = (day: string) => {
setDaysOfWeek(prev =>
prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day]
);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !dosage.trim()) {
setError('Name and dosage are required');
return;
}
if (frequency === 'specific_days' && daysOfWeek.length === 0) {
setError('Select at least one day of the week');
return;
}
setIsLoading(true);
setError('');
try {
await api.medications.create({
name,
dosage,
unit,
frequency,
times: frequency === 'as_needed' ? [] : times,
...(frequency === 'specific_days' && { days_of_week: daysOfWeek }),
...(frequency === 'every_n_days' && { interval_days: intervalDays, start_date: startDate }),
});
router.push('/dashboard/medications');
} catch (err) {
setError((err as Error).message || 'Failed to add medication');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
<div className="flex items-center gap-3 px-4 py-3">
<button onClick={() => router.back()} className="p-1">
<ArrowLeftIcon size={24} />
</button>
<h1 className="text-xl font-bold text-gray-900">Add Medication</h1>
</div>
</header>
<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">
{error}
</div>
)}
<div className="bg-white rounded-xl p-4 shadow-sm space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Medication Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Vitamin D"
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="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Dosage</label>
<input
type="text"
value={dosage}
onChange={(e) => setDosage(e.target.value)}
placeholder="e.g., 1000"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Unit</label>
<select
value={unit}
onChange={(e) => setUnit(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"
>
<option value="mg">mg</option>
<option value="mcg">mcg</option>
<option value="g">g</option>
<option value="ml">ml</option>
<option value="IU">IU</option>
<option value="tablets">tablets</option>
<option value="capsules">capsules</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Frequency</label>
<select
value={frequency}
onChange={(e) => setFrequency(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"
>
<option value="daily">Daily</option>
<option value="twice_daily">Twice Daily</option>
<option value="specific_days">Specific Days of Week</option>
<option value="every_n_days">Every N Days</option>
<option value="as_needed">As Needed (PRN)</option>
</select>
</div>
{/* Day-of-week picker for specific_days */}
{frequency === 'specific_days' && (
<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(({ value, label }) => (
<button
key={value}
type="button"
onClick={() => toggleDay(value)}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
daysOfWeek.includes(value)
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-white text-gray-700 border-gray-300'
}`}
>
{label}
</button>
))}
</div>
</div>
)}
{/* Interval settings for every_n_days */}
{frequency === 'every_n_days' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Every N Days</label>
<input
type="number"
min={1}
value={intervalDays}
onChange={(e) => setIntervalDays(parseInt(e.target.value) || 1)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Starting From</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(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>
)}
{/* Times picker — hidden for as_needed */}
{frequency !== 'as_needed' && (
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-gray-700">Times</label>
<button
type="button"
onClick={handleAddTime}
className="text-indigo-600 text-sm font-medium"
>
+ Add Time
</button>
</div>
<div className="space-y-2">
{times.map((time, index) => (
<div key={index} className="flex gap-2">
<input
type="time"
value={time}
onChange={(e) => handleTimeChange(index, e.target.value)}
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
/>
{times.length > 1 && (
<button
type="button"
onClick={() => handleRemoveTime(index)}
className="text-red-500 px-3"
>
Remove
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-indigo-600 text-white font-semibold py-4 rounded-xl hover:bg-indigo-700 disabled:opacity-50"
>
{isLoading ? 'Adding...' : 'Add Medication'}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,268 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import api from '@/lib/api';
import { PlusIcon, CheckIcon, PillIcon, ClockIcon, TrashIcon } from '@/components/ui/Icons';
import Link from 'next/link';
interface Medication {
id: string;
name: string;
dosage: string;
unit: string;
frequency: string;
times: string[];
days_of_week?: string[];
interval_days?: number;
start_date?: string;
next_dose_date?: string;
notes?: string;
active: boolean;
quantity_remaining?: number;
}
interface TodaysMedication {
medication: {
id: string;
name: string;
dosage: string;
unit: string;
};
scheduled_times: string[];
taken_times: string[];
is_prn?: boolean;
}
interface AdherenceEntry {
medication_id: string;
name: string;
adherence_percent: number | null;
is_prn?: boolean;
}
const formatSchedule = (med: Medication): string => {
if (med.frequency === 'specific_days' && med.days_of_week?.length) {
return med.days_of_week.map(d => d.charAt(0).toUpperCase() + d.slice(1)).join(', ');
}
if (med.frequency === 'every_n_days' && med.interval_days) {
return `Every ${med.interval_days} days`;
}
if (med.frequency === 'as_needed') return 'As needed';
if (med.frequency === 'twice_daily') return 'Twice daily';
return 'Daily';
};
export default function MedicationsPage() {
const router = useRouter();
const [medications, setMedications] = useState<Medication[]>([]);
const [todayMeds, setTodayMeds] = useState<TodaysMedication[]>([]);
const [adherence, setAdherence] = useState<AdherenceEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const [medsData, todayData, adherenceData] = await Promise.all([
api.medications.list(),
api.medications.getToday().catch(() => []),
api.medications.getAdherence(30).catch(() => []),
]);
setMedications(medsData);
setTodayMeds(todayData);
setAdherence(adherenceData);
} catch (err) {
console.error('Failed to fetch medications:', err);
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
const handleTake = async (medId: string, time?: string) => {
try {
await api.medications.take(medId, time);
window.location.reload();
} catch (err) {
console.error('Failed to log medication:', err);
}
};
const handleSkip = async (medId: string, time?: string) => {
try {
await api.medications.skip(medId, time);
window.location.reload();
} catch (err) {
console.error('Failed to skip medication:', err);
}
};
const handleDelete = async (medId: string) => {
try {
await api.medications.delete(medId);
setMedications(medications.filter(m => m.id !== medId));
} catch (err) {
console.error('Failed to delete medication:', err);
}
};
const getAdherenceForMed = (medId: string) => {
const entry = adherence.find(a => a.medication_id === medId);
if (!entry) return { percent: 0, isPrn: false };
return { percent: entry.adherence_percent, isPrn: entry.is_prn || false };
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<div className="w-8 h-8 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
return (
<div className="p-4 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Medications</h1>
<Link href="/dashboard/medications/new" className="bg-indigo-600 text-white p-2 rounded-full">
<PlusIcon size={24} />
</Link>
</div>
{/* Today's Schedule */}
{todayMeds.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Today</h2>
<div className="space-y-3">
{todayMeds.map((item) => (
<div key={item.medication.id} className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="font-semibold text-gray-900">{item.medication.name}</h3>
<p className="text-sm text-gray-500">{item.medication.dosage} {item.medication.unit}</p>
</div>
</div>
<div className="space-y-2">
{item.is_prn ? (
<div className="flex items-center justify-between bg-gray-50 rounded-lg p-3">
<span className="text-gray-500 text-sm">As needed</span>
<button
onClick={() => handleTake(item.medication.id)}
className="bg-green-600 text-white px-3 py-1 rounded-lg text-sm font-medium"
>
Log Dose
</button>
</div>
) : (
item.scheduled_times.map((time) => {
const isTaken = item.taken_times.includes(time);
return (
<div key={time} className="flex items-center justify-between bg-gray-50 rounded-lg p-3">
<div className="flex items-center gap-2">
<ClockIcon size={16} className="text-gray-500" />
<span className="font-medium">{time}</span>
</div>
{isTaken ? (
<span className="text-green-600 font-medium flex items-center gap-1">
<CheckIcon size={16} /> Taken
</span>
) : (
<div className="flex gap-2">
<button
onClick={() => handleTake(item.medication.id, time)}
className="bg-green-600 text-white px-3 py-1 rounded-lg text-sm font-medium"
>
Take
</button>
<button
onClick={() => handleSkip(item.medication.id, time)}
className="text-gray-500 px-2 py-1"
>
Skip
</button>
</div>
)}
</div>
);
})
)}
</div>
</div>
))}
</div>
</div>
)}
{/* All Medications */}
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">All Medications</h2>
{medications.length === 0 ? (
<div className="bg-white rounded-xl p-8 shadow-sm text-center">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<PillIcon className="text-gray-400" size={32} />
</div>
<h3 className="font-semibold text-gray-900 mb-1">No medications yet</h3>
<p className="text-gray-500 text-sm mb-4">Add your medications to track them</p>
<Link href="/dashboard/medications/new" className="inline-block bg-indigo-600 text-white px-4 py-2 rounded-lg font-medium">
Add Medication
</Link>
</div>
) : (
<div className="space-y-3">
{medications.map((med) => {
const { percent: adherencePercent, isPrn } = getAdherenceForMed(med.id);
return (
<div key={med.id} className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900">{med.name}</h3>
{!med.active && (
<span className="text-xs bg-gray-100 text-gray-500 px-2 py-0.5 rounded">Inactive</span>
)}
</div>
<p className="text-gray-500 text-sm">{med.dosage} {med.unit} &middot; {formatSchedule(med)}</p>
{med.times.length > 0 && (
<p className="text-gray-400 text-sm mt-1">Times: {med.times.join(', ')}</p>
)}
</div>
<button
onClick={() => handleDelete(med.id)}
className="text-red-500 p-2"
>
<TrashIcon size={18} />
</button>
</div>
{/* Adherence */}
<div className="mt-3 pt-3 border-t border-gray-100">
{isPrn || adherencePercent === null ? (
<span className="text-sm text-gray-400">PRN &mdash; no adherence tracking</span>
) : (
<>
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-gray-500">30-day adherence</span>
<span className={`font-semibold ${adherencePercent >= 80 ? 'text-green-600' : adherencePercent >= 50 ? 'text-yellow-600' : 'text-red-600'}`}>
{adherencePercent}%
</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full ${adherencePercent >= 80 ? 'bg-green-500' : adherencePercent >= 50 ? 'bg-yellow-500' : 'bg-red-500'}`}
style={{ width: `${adherencePercent}%` }}
/>
</div>
</>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,242 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import api from '@/lib/api';
import { useAuth } from '@/components/auth/AuthProvider';
import { PlayIcon, ClockIcon, FlameIcon, StarIcon, ActivityIcon } from '@/components/ui/Icons';
import Link from 'next/link';
interface Routine {
id: string;
name: string;
description?: string;
icon?: string;
}
interface ActiveSession {
session: {
id: string;
routine_id: string;
status: string;
current_step_index: number;
};
routine: {
id: string;
name: string;
icon?: string;
};
current_step: {
id: string;
name: string;
step_type: string;
duration_minutes?: number;
} | null;
}
interface WeeklySummary {
total_completed: number;
total_time_minutes: number;
routines_started: number;
routines: {
routine_id: string;
name: string;
completed_this_week: number;
}[];
}
export default function DashboardPage() {
const { user } = useAuth();
const router = useRouter();
const [routines, setRoutines] = useState<Routine[]>([]);
const [activeSession, setActiveSession] = useState<ActiveSession | null>(null);
const [weeklySummary, setWeeklySummary] = useState<WeeklySummary | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
const [routinesData, activeData, summaryData] = await Promise.all([
api.routines.list().catch(() => []),
api.sessions.getActive().catch(() => null),
api.stats.getWeeklySummary().catch(() => null),
]);
setRoutines(routinesData);
setActiveSession(activeData);
setWeeklySummary(summaryData);
} catch (err) {
console.error('Failed to fetch dashboard data:', err);
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
const handleStartRoutine = async (routineId: string) => {
try {
await api.sessions.start(routineId);
router.push(`/dashboard/routines/${routineId}/run`);
} catch (err) {
setError((err as Error).message);
}
};
const handleResumeSession = () => {
if (activeSession) {
router.push(`/dashboard/routines/${activeSession.routine.id}/run`);
}
};
const getGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return 'Good morning';
if (hour < 17) return 'Good afternoon';
return 'Good evening';
};
const formatTime = (minutes: number) => {
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<div className="w-8 h-8 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
return (
<div className="p-4 space-y-6">
{/* Active Session Banner */}
{activeSession && activeSession.session.status === 'active' && (
<div className="bg-gradient-to-r from-indigo-500 to-purple-600 rounded-2xl p-4 text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-white/80 text-sm">Continue your routine</p>
<h2 className="text-xl font-bold">{activeSession.routine.name}</h2>
<p className="text-white/80 text-sm mt-1">
Step {activeSession.session.current_step_index + 1}: {activeSession.current_step?.name}
</p>
</div>
<button
onClick={handleResumeSession}
className="bg-white text-indigo-600 px-4 py-2 rounded-lg font-semibold"
>
Resume
</button>
</div>
</div>
)}
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">{getGreeting()}, {user?.username}!</h1>
<p className="text-gray-500 mt-1">Let's build some great habits today.</p>
</div>
{/* Weekly Stats */}
{weeklySummary && (
<div className="grid grid-cols-3 gap-3">
<div className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex items-center gap-2 text-indigo-600 mb-1">
<StarIcon size={18} />
<span className="text-xs font-medium">Completed</span>
</div>
<p className="text-2xl font-bold text-gray-900">{weeklySummary.total_completed}</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex items-center gap-2 text-purple-600 mb-1">
<ClockIcon size={18} />
<span className="text-xs font-medium">Time</span>
</div>
<p className="text-2xl font-bold text-gray-900">{formatTime(weeklySummary.total_time_minutes)}</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex items-center gap-2 text-pink-600 mb-1">
<ActivityIcon size={18} />
<span className="text-xs font-medium">Started</span>
</div>
<p className="text-2xl font-bold text-gray-900">{weeklySummary.routines_started}</p>
</div>
</div>
)}
{/* Quick Start Routines */}
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Your Routines</h2>
{routines.length === 0 ? (
<div className="bg-white rounded-xl p-8 shadow-sm text-center">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<FlameIcon className="text-gray-400" size={32} />
</div>
<h3 className="font-semibold text-gray-900 mb-1">No routines yet</h3>
<p className="text-gray-500 text-sm mb-4">Create your first routine to get started</p>
<Link
href="/dashboard/routines/new"
className="inline-block bg-indigo-600 text-white px-4 py-2 rounded-lg font-medium"
>
Create Routine
</Link>
</div>
) : (
<div className="space-y-3">
{routines.map((routine) => (
<div
key={routine.id}
className="bg-white rounded-xl p-4 shadow-sm flex items-center justify-between"
>
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-indigo-100 to-pink-100 rounded-xl flex items-center justify-center">
<span className="text-2xl">{routine.icon || ''}</span>
</div>
<div>
<h3 className="font-semibold text-gray-900">{routine.name}</h3>
{routine.description && (
<p className="text-gray-500 text-sm">{routine.description}</p>
)}
</div>
</div>
<button
onClick={() => handleStartRoutine(routine.id)}
className="bg-indigo-600 text-white p-3 rounded-full"
>
<PlayIcon size={20} />
</button>
</div>
))}
</div>
)}
</div>
{/* Templates CTA */}
<div className="bg-gradient-to-r from-pink-500 to-rose-500 rounded-2xl p-4 text-white">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">Need inspiration?</h3>
<p className="text-white/80 text-sm">Browse pre-made routines</p>
</div>
<Link
href="/dashboard/templates"
className="bg-white text-pink-600 px-4 py-2 rounded-lg font-medium"
>
Browse
</Link>
</div>
</div>
{error && (
<div className="bg-red-50 text-red-600 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,294 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import api from '@/lib/api';
import { ArrowLeftIcon, PlayIcon, PlusIcon, TrashIcon, GripVerticalIcon, ClockIcon } from '@/components/ui/Icons';
import Link from 'next/link';
interface Step {
id: string;
name: string;
instructions?: string;
step_type: string;
duration_minutes?: number;
position: number;
}
interface Routine {
id: string;
name: string;
description?: string;
icon?: string;
}
const ICONS = ['✨', '🌅', '🌙', '☀️', '💪', '🧘', '📚', '🍳', '🏃', '💼', '🎯', '⭐', '🔥', '💤', '🧠'];
export default function RoutineDetailPage() {
const router = useRouter();
const params = useParams();
const routineId = params.id as string;
const [routine, setRoutine] = useState<Routine | null>(null);
const [steps, setSteps] = useState<Step[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState('');
const [editDescription, setEditDescription] = useState('');
const [editIcon, setEditIcon] = useState('✨');
const [newStepName, setNewStepName] = useState('');
const [newStepDuration, setNewStepDuration] = useState(5);
useEffect(() => {
const fetchRoutine = async () => {
try {
const data = await api.routines.get(routineId);
setRoutine(data.routine);
setSteps(data.steps);
setEditName(data.routine.name);
setEditDescription(data.routine.description || '');
setEditIcon(data.routine.icon || '✨');
} catch (err) {
console.error('Failed to fetch routine:', err);
router.push('/dashboard/routines');
} finally {
setIsLoading(false);
}
};
fetchRoutine();
}, [routineId, router]);
const handleStart = async () => {
try {
await api.sessions.start(routineId);
router.push(`/dashboard/routines/${routineId}/run`);
} catch (err) {
console.error('Failed to start routine:', err);
}
};
const handleSaveBasicInfo = async () => {
try {
await api.routines.update(routineId, {
name: editName,
description: editDescription,
icon: editIcon,
});
setRoutine({ ...routine!, name: editName, description: editDescription, icon: editIcon });
setIsEditing(false);
} catch (err) {
console.error('Failed to update routine:', err);
}
};
const handleAddStep = async () => {
if (!newStepName.trim()) return;
try {
const step = await api.routines.addStep(routineId, {
name: newStepName,
duration_minutes: newStepDuration,
});
setSteps([...steps, { ...step, position: steps.length + 1 }]);
setNewStepName('');
} catch (err) {
console.error('Failed to add step:', err);
}
};
const handleDeleteStep = async (stepId: string) => {
try {
await api.routines.deleteStep(routineId, stepId);
setSteps(steps.filter(s => s.id !== stepId).map((s, i) => ({ ...s, position: i + 1 })));
} catch (err) {
console.error('Failed to delete step:', err);
}
};
const totalDuration = steps.reduce((acc, s) => acc + (s.duration_minutes || 0), 0);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<div className="w-8 h-8 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
if (!routine) return null;
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3">
<button onClick={() => router.back()} className="p-1">
<ArrowLeftIcon size={24} />
</button>
<h1 className="text-xl font-bold text-gray-900">
{isEditing ? 'Edit Routine' : routine.name}
</h1>
</div>
{!isEditing && (
<button
onClick={() => setIsEditing(true)}
className="text-indigo-600 font-medium"
>
Edit
</button>
)}
</div>
</header>
<div className="p-4 space-y-6">
{isEditing ? (
<div className="bg-white rounded-xl p-4 shadow-sm space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Icon</label>
<div className="flex flex-wrap gap-2">
{ICONS.map((i) => (
<button
key={i}
type="button"
onClick={() => setEditIcon(i)}
className={`w-10 h-10 rounded-lg text-xl flex items-center justify-center transition ${
editIcon === i
? 'bg-indigo-100 ring-2 ring-indigo-600'
: 'bg-gray-100 hover:bg-gray-200'
}`}
>
{i}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input
type="text"
value={editName}
onChange={(e) => setEditName(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>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<input
type="text"
value={editDescription}
onChange={(e) => setEditDescription(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 gap-3">
<button
onClick={() => setIsEditing(false)}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg font-medium"
>
Cancel
</button>
<button
onClick={handleSaveBasicInfo}
className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg font-medium"
>
Save
</button>
</div>
</div>
) : (
<div className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex items-center gap-4">
<div className="w-16 h-16 bg-gradient-to-br from-indigo-100 to-pink-100 rounded-2xl flex items-center justify-center">
<span className="text-4xl">{routine.icon || '✨'}</span>
</div>
<div className="flex-1">
<h2 className="text-xl font-bold text-gray-900">{routine.name}</h2>
{routine.description && (
<p className="text-gray-500">{routine.description}</p>
)}
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
<span className="flex items-center gap-1">
<ClockIcon size={14} />
{totalDuration} min
</span>
<span>{steps.length} steps</span>
</div>
</div>
</div>
<button
onClick={handleStart}
className="w-full mt-4 bg-indigo-600 text-white font-semibold py-3 rounded-xl flex items-center justify-center gap-2"
>
<PlayIcon size={20} />
Start Routine
</button>
</div>
)}
{/* Steps */}
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Steps</h2>
<div className="bg-white rounded-xl p-4 shadow-sm space-y-3 mb-4">
<div className="flex gap-2">
<input
type="text"
value={newStepName}
onChange={(e) => setNewStepName(e.target.value)}
placeholder="New step name"
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
/>
<select
value={newStepDuration}
onChange={(e) => setNewStepDuration(Number(e.target.value))}
className="px-3 py-2 border border-gray-300 rounded-lg"
>
<option value={1}>1m</option>
<option value={5}>5m</option>
<option value={10}>10m</option>
<option value={15}>15m</option>
<option value={30}>30m</option>
</select>
<button
onClick={handleAddStep}
className="bg-indigo-600 text-white px-4 py-2 rounded-lg"
>
<PlusIcon size={20} />
</button>
</div>
</div>
{steps.length === 0 ? (
<div className="bg-white rounded-xl p-8 shadow-sm text-center">
<p className="text-gray-500">No steps yet</p>
</div>
) : (
<div className="space-y-2">
{steps.map((step, index) => (
<div
key={step.id}
className="bg-white rounded-xl p-4 shadow-sm flex items-center gap-3"
>
<div className="w-8 h-8 bg-indigo-100 text-indigo-600 rounded-full flex items-center justify-center font-semibold text-sm">
{index + 1}
</div>
<div className="flex-1">
<h3 className="font-medium text-gray-900">{step.name}</h3>
{step.duration_minutes && (
<p className="text-sm text-gray-500">{step.duration_minutes} min</p>
)}
</div>
<button
onClick={() => handleDeleteStep(step.id)}
className="text-red-500 p-2"
>
<TrashIcon size={18} />
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,321 @@
'use client';
import { useEffect, useState, useCallback, useRef } from 'react';
import { useRouter, useParams } from 'next/navigation';
import api from '@/lib/api';
import { ArrowLeftIcon, PauseIcon, PlayIcon, StopIcon, SkipForwardIcon, CheckIcon, XIcon } from '@/components/ui/Icons';
interface Step {
id: string;
name: string;
instructions?: string;
step_type: string;
duration_minutes?: number;
position: number;
}
interface Routine {
id: string;
name: string;
icon?: string;
}
interface Session {
id: string;
routine_id: string;
status: string;
current_step_index: number;
}
export default function SessionRunnerPage() {
const router = useRouter();
const params = useParams();
const routineId = params.id as string;
const [routine, setRoutine] = useState<Routine | null>(null);
const [steps, setSteps] = useState<Step[]>([]);
const [session, setSession] = useState<Session | null>(null);
const [currentStep, setCurrentStep] = useState<Step | null>(null);
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [status, setStatus] = useState<'loading' | 'active' | 'paused' | 'completed'>('loading');
const [timerSeconds, setTimerSeconds] = useState(0);
const [isTimerRunning, setIsTimerRunning] = useState(false);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const [swipeDirection, setSwipeDirection] = useState<'left' | 'right' | null>(null);
const touchStartX = useRef<number | null>(null);
const touchStartY = useRef<number | null>(null);
// Fetch session data
useEffect(() => {
const fetchSession = async () => {
try {
const sessionData = await api.sessions.getActive();
setSession(sessionData.session);
setRoutine(sessionData.routine);
setSteps(await api.routines.getSteps(sessionData.routine.id).then(s => s));
setCurrentStep(sessionData.current_step);
setCurrentStepIndex(sessionData.session.current_step_index);
setStatus(sessionData.session.status === 'paused' ? 'paused' : 'active');
if (sessionData.current_step?.duration_minutes) {
setTimerSeconds(sessionData.current_step.duration_minutes * 60);
}
} catch (err) {
router.push('/dashboard');
}
};
fetchSession();
}, [router]);
// Timer logic
useEffect(() => {
if (isTimerRunning && timerSeconds > 0) {
timerRef.current = setInterval(() => {
setTimerSeconds(s => Math.max(0, s - 1));
}, 1000);
}
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [isTimerRunning, timerSeconds]);
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
// Touch handlers for swipe
const handleTouchStart = (e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX;
touchStartY.current = e.touches[0].clientY;
};
const handleTouchEnd = (e: React.TouchEvent) => {
if (touchStartX.current === null || touchStartY.current === null) return;
const touchEndX = e.changedTouches[0].clientX;
const touchEndY = e.changedTouches[0].clientY;
const diffX = touchEndX - touchStartX.current;
const diffY = touchEndY - touchStartY.current;
// Only trigger if horizontal swipe is dominant
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
if (diffX < 0) {
// Swipe left - complete
handleComplete();
} else {
// Swipe right - skip
handleSkip();
}
}
touchStartX.current = null;
touchStartY.current = null;
};
const handleComplete = async () => {
if (!session || !currentStep) return;
setSwipeDirection('left');
setTimeout(() => setSwipeDirection(null), 300);
try {
const result = await api.sessions.completeStep(session.id, currentStep.id);
if (result.next_step) {
setCurrentStep(result.next_step);
setCurrentStepIndex(result.session.current_step_index!);
setTimerSeconds((result.next_step.duration_minutes || 5) * 60);
setIsTimerRunning(true);
} else {
setStatus('completed');
setIsTimerRunning(false);
}
} catch (err) {
console.error('Failed to complete step:', err);
}
};
const handleSkip = async () => {
if (!session || !currentStep) return;
setSwipeDirection('right');
setTimeout(() => setSwipeDirection(null), 300);
try {
const result = await api.sessions.skipStep(session.id, currentStep.id);
if (result.next_step) {
setCurrentStep(result.next_step);
setCurrentStepIndex(result.session.current_step_index!);
setTimerSeconds((result.next_step.duration_minutes || 5) * 60);
setIsTimerRunning(true);
} else {
setStatus('completed');
setIsTimerRunning(false);
}
} catch (err) {
console.error('Failed to skip step:', err);
}
};
const handlePause = async () => {
if (!session) return;
try {
await api.sessions.pause(session.id);
setStatus('paused');
setIsTimerRunning(false);
} catch (err) {
console.error('Failed to pause:', err);
}
};
const handleResume = async () => {
if (!session) return;
try {
await api.sessions.resume(session.id);
setStatus('active');
setIsTimerRunning(true);
} catch (err) {
console.error('Failed to resume:', err);
}
};
const handleCancel = async () => {
if (!session) return;
try {
await api.sessions.cancel(session.id);
router.push('/dashboard');
} catch (err) {
console.error('Failed to cancel:', err);
}
};
if (status === 'loading' || !currentStep) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900">
<div className="w-8 h-8 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
if (status === 'completed') {
return (
<div className="min-h-screen bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-500 flex flex-col items-center justify-center p-6">
<div className="w-24 h-24 bg-white rounded-full flex items-center justify-center mb-6">
<CheckIcon className="text-green-500" size={48} />
</div>
<h1 className="text-3xl font-bold text-white mb-2">Great job!</h1>
<p className="text-white/80 text-lg mb-8">You completed your routine</p>
<button
onClick={() => router.push('/dashboard')}
className="bg-white text-indigo-600 px-8 py-3 rounded-full font-semibold"
>
Done
</button>
</div>
);
}
const progress = ((currentStepIndex + 1) / steps.length) * 100;
return (
<div
className="min-h-screen bg-gray-900 text-white flex flex-col"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Header */}
<header className="flex items-center justify-between px-4 py-4">
<button onClick={handleCancel} className="p-2">
<XIcon size={24} />
</button>
<div className="text-center">
<p className="text-white/60 text-sm">{routine?.name}</p>
<p className="font-semibold">Step {currentStepIndex + 1} of {steps.length}</p>
</div>
<button onClick={status === 'paused' ? handleResume : handlePause} className="p-2">
{status === 'paused' ? <PlayIcon size={24} /> : <PauseIcon size={24} />}
</button>
</header>
{/* Progress bar */}
<div className="px-4">
<div className="h-1 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
</div>
{/* Main Card */}
<div className="flex-1 flex flex-col items-center justify-center p-6">
<div
className={`
w-full max-w-md bg-gray-800 rounded-3xl p-8 text-center
transition-transform duration-300
${swipeDirection === 'left' ? 'translate-x-20 opacity-50' : ''}
${swipeDirection === 'right' ? '-translate-x-20 opacity-50' : ''}
`}
>
{/* Step Type Badge */}
<div className="inline-block px-3 py-1 bg-indigo-600 rounded-full text-sm mb-4">
{currentStep.step_type || 'Generic'}
</div>
{/* Timer */}
<div className="mb-6">
<div className="text-7xl font-bold font-mono mb-2">
{formatTime(timerSeconds)}
</div>
<p className="text-white/60">remaining</p>
</div>
{/* Step Name */}
<h2 className="text-3xl font-bold mb-4">{currentStep.name}</h2>
{/* Instructions */}
{currentStep.instructions && (
<p className="text-white/80 text-lg mb-6">{currentStep.instructions}</p>
)}
{/* Timer Controls */}
<div className="flex justify-center gap-4 mt-8">
<button
onClick={handleSkip}
className="w-16 h-16 bg-gray-700 rounded-full flex items-center justify-center hover:bg-gray-600 transition"
>
<SkipForwardIcon size={28} />
</button>
<button
onClick={status === 'paused' ? handleResume : handlePause}
className="w-20 h-20 bg-indigo-600 rounded-full flex items-center justify-center hover:bg-indigo-500 transition"
>
{status === 'paused' ? <PlayIcon size={32} /> : <PauseIcon size={32} />}
</button>
<button
onClick={handleComplete}
className="w-16 h-16 bg-green-600 rounded-full flex items-center justify-center hover:bg-green-500 transition"
>
<CheckIcon size={28} />
</button>
</div>
</div>
{/* Swipe Hints */}
<div className="flex justify-between w-full max-w-md mt-8 text-white/40 text-sm">
<div className="flex items-center gap-2">
<ArrowLeftIcon size={16} />
<span>Swipe left to complete</span>
</div>
<div className="flex items-center gap-2">
<span>Swipe right to skip</span>
<ArrowLeftIcon size={16} className="rotate-180" />
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,236 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import api from '@/lib/api';
import { ArrowLeftIcon, PlusIcon, TrashIcon, GripVerticalIcon } from '@/components/ui/Icons';
interface Step {
id: string;
name: string;
duration_minutes?: number;
position: number;
}
const ICONS = ['✨', '🌅', '🌙', '☀️', '💪', '🧘', '📚', '🍳', '🏃', '💼', '🎯', '⭐', '🔥', '💤', '🧠'];
const STEP_TYPES = [
{ value: 'generic', label: 'Generic' },
{ value: 'timer', label: 'Timer' },
{ value: 'checklist', label: 'Checklist' },
{ value: 'meditation', label: 'Meditation' },
{ value: 'exercise', label: 'Exercise' },
];
export default function NewRoutinePage() {
const router = useRouter();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [icon, setIcon] = useState('✨');
const [steps, setSteps] = useState<Step[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const handleAddStep = () => {
const newStep: Step = {
id: `temp-${Date.now()}`,
name: '',
duration_minutes: 5,
position: steps.length + 1,
};
setSteps([...steps, newStep]);
};
const handleUpdateStep = (index: number, updates: Partial<Step>) => {
const newSteps = [...steps];
newSteps[index] = { ...newSteps[index], ...updates };
setSteps(newSteps);
};
const handleDeleteStep = (index: number) => {
const newSteps = steps.filter((_, i) => i !== index);
setSteps(newSteps.map((s, i) => ({ ...s, position: i + 1 })));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
setError('Please enter a routine name');
return;
}
const validSteps = steps.filter(s => s.name.trim());
if (validSteps.length === 0) {
setError('Please add at least one step');
return;
}
setIsLoading(true);
setError('');
try {
const routine = await api.routines.create({ name, description, icon });
for (const step of validSteps) {
await api.routines.addStep(routine.id, {
name: step.name,
duration_minutes: step.duration_minutes,
});
}
router.push('/dashboard/routines');
} catch (err) {
setError((err as Error).message || 'Failed to create routine');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
<div className="flex items-center gap-3 px-4 py-3">
<button onClick={() => router.back()} className="p-1">
<ArrowLeftIcon size={24} />
</button>
<h1 className="text-xl font-bold text-gray-900">New Routine</h1>
</div>
</header>
<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">
{error}
</div>
)}
{/* Basic Info */}
<div className="bg-white rounded-xl p-4 shadow-sm space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Icon</label>
<div className="flex flex-wrap gap-2">
{ICONS.map((i) => (
<button
key={i}
type="button"
onClick={() => setIcon(i)}
className={`w-10 h-10 rounded-lg text-xl flex items-center justify-center transition ${
icon === i
? 'bg-indigo-100 ring-2 ring-indigo-600'
: 'bg-gray-100 hover:bg-gray-200'
}`}
>
{i}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Morning Routine"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description (optional)</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Start your day right"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
</div>
{/* Steps */}
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-gray-900">Steps</h2>
<button
type="button"
onClick={handleAddStep}
className="text-indigo-600 text-sm font-medium flex items-center gap-1"
>
<PlusIcon size={16} />
Add Step
</button>
</div>
{steps.length === 0 ? (
<div className="bg-white rounded-xl p-8 shadow-sm text-center">
<p className="text-gray-500 mb-4">Add steps to your routine</p>
<button
type="button"
onClick={handleAddStep}
className="text-indigo-600 font-medium"
>
+ Add your first step
</button>
</div>
) : (
<div className="space-y-3">
{steps.map((step, index) => (
<div
key={step.id}
className="bg-white rounded-xl p-4 shadow-sm flex items-start gap-3"
>
<div className="pt-3 text-gray-400 cursor-grab">
<GripVerticalIcon size={20} />
</div>
<div className="flex-1 space-y-3">
<input
type="text"
value={step.name}
onChange={(e) => handleUpdateStep(index, { name: e.target.value })}
placeholder="Step name"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
/>
<div className="flex items-center gap-3">
<label className="text-sm text-gray-500">Duration:</label>
<select
value={step.duration_minutes || 5}
onChange={(e) => handleUpdateStep(index, { duration_minutes: Number(e.target.value) })}
className="px-3 py-1 border border-gray-300 rounded-lg text-sm"
>
<option value={1}>1 min</option>
<option value={2}>2 min</option>
<option value={5}>5 min</option>
<option value={10}>10 min</option>
<option value={15}>15 min</option>
<option value={20}>20 min</option>
<option value={30}>30 min</option>
<option value={45}>45 min</option>
<option value={60}>60 min</option>
</select>
</div>
</div>
<button
type="button"
onClick={() => handleDeleteStep(index)}
className="text-red-500 p-2"
>
<TrashIcon size={18} />
</button>
</div>
))}
</div>
)}
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-indigo-600 text-white font-semibold py-4 rounded-xl hover:bg-indigo-700 disabled:opacity-50"
>
{isLoading ? 'Creating...' : 'Create Routine'}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,157 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import api from '@/lib/api';
import { PlusIcon, PlayIcon, EditIcon, TrashIcon, FlameIcon } from '@/components/ui/Icons';
import Link from 'next/link';
interface Routine {
id: string;
name: string;
description?: string;
icon?: string;
}
export default function RoutinesPage() {
const router = useRouter();
const [routines, setRoutines] = useState<Routine[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [deleteModal, setDeleteModal] = useState<string | null>(null);
useEffect(() => {
const fetchRoutines = async () => {
try {
const data = await api.routines.list();
setRoutines(data);
} catch (err) {
console.error('Failed to fetch routines:', err);
} finally {
setIsLoading(false);
}
};
fetchRoutines();
}, []);
const handleDelete = async (routineId: string) => {
try {
await api.routines.delete(routineId);
setRoutines(routines.filter(r => r.id !== routineId));
setDeleteModal(null);
} catch (err) {
console.error('Failed to delete routine:', err);
}
};
const handleStartRoutine = async (routineId: string) => {
try {
await api.sessions.start(routineId);
router.push(`/dashboard/routines/${routineId}/run`);
} catch (err) {
console.error('Failed to start routine:', err);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<div className="w-8 h-8 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
return (
<div className="p-4 space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Routines</h1>
<Link
href="/dashboard/routines/new"
className="bg-indigo-600 text-white p-2 rounded-full"
>
<PlusIcon size={24} />
</Link>
</div>
{routines.length === 0 ? (
<div className="bg-white rounded-xl p-8 shadow-sm text-center mt-4">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<FlameIcon className="text-gray-400" size={32} />
</div>
<h3 className="font-semibold text-gray-900 mb-1">No routines yet</h3>
<p className="text-gray-500 text-sm mb-4">Create your first routine to get started</p>
<Link
href="/dashboard/routines/new"
className="inline-block bg-indigo-600 text-white px-4 py-2 rounded-lg font-medium"
>
Create Routine
</Link>
</div>
) : (
<div className="space-y-3">
{routines.map((routine) => (
<div
key={routine.id}
className="bg-white rounded-xl p-4 shadow-sm"
>
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-indigo-100 to-pink-100 rounded-xl flex items-center justify-center flex-shrink-0">
<span className="text-2xl">{routine.icon || '✨'}</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{routine.name}</h3>
{routine.description && (
<p className="text-gray-500 text-sm truncate">{routine.description}</p>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleStartRoutine(routine.id)}
className="bg-indigo-600 text-white p-2 rounded-full"
>
<PlayIcon size={18} />
</button>
<Link
href={`/dashboard/routines/${routine.id}`}
className="text-gray-500 p-2"
>
<EditIcon size={18} />
</Link>
<button
onClick={() => setDeleteModal(routine.id)}
className="text-red-500 p-2"
>
<TrashIcon size={18} />
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Delete Modal */}
{deleteModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 max-w-sm w-full">
<h3 className="text-lg font-semibold text-gray-900 mb-2">Delete Routine?</h3>
<p className="text-gray-500 mb-4">This action cannot be undone.</p>
<div className="flex gap-3">
<button
onClick={() => setDeleteModal(null)}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg font-medium"
>
Cancel
</button>
<button
onClick={() => handleDelete(deleteModal)}
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg font-medium"
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,193 @@
'use client';
import { useEffect, useState } from 'react';
import api from '@/lib/api';
import { FlameIcon, StarIcon, ClockIcon, ActivityIcon, TargetIcon } from '@/components/ui/Icons';
interface RoutineStats {
routine_id: string;
routine_name: string;
period_days: number;
total_sessions: number;
completed: number;
aborted: number;
completion_rate_percent: number;
avg_duration_minutes: number;
total_time_minutes: number;
}
interface Streak {
routine_id: string;
routine_name: string;
current_streak: number;
longest_streak: number;
last_completed_date?: string;
}
interface WeeklySummary {
total_completed: number;
total_time_minutes: number;
routines_started: number;
routines: {
routine_id: string;
name: string;
completed_this_week: number;
}[];
}
export default function StatsPage() {
const [routines, setRoutines] = useState<{ id: string; name: string }[]>([]);
const [selectedRoutine, setSelectedRoutine] = useState<string>('');
const [routineStats, setRoutineStats] = useState<RoutineStats | null>(null);
const [streaks, setStreaks] = useState<Streak[]>([]);
const [weeklySummary, setWeeklySummary] = useState<WeeklySummary | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const [routinesData, streaksData, summaryData] = await Promise.all([
api.routines.list(),
api.stats.getStreaks(),
api.stats.getWeeklySummary(),
]);
setRoutines(routinesData);
setStreaks(streaksData);
setWeeklySummary(summaryData);
if (routinesData.length > 0) {
setSelectedRoutine(routinesData[0].id);
}
} catch (err) {
console.error('Failed to fetch stats:', err);
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
useEffect(() => {
const fetchRoutineStats = async () => {
if (!selectedRoutine) return;
try {
const stats = await api.routines.getStats(selectedRoutine, 30);
setRoutineStats(stats);
} catch (err) {
console.error('Failed to fetch routine stats:', err);
}
};
fetchRoutineStats();
}, [selectedRoutine]);
const formatTime = (minutes: number) => {
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<div className="w-8 h-8 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
return (
<div className="p-4 space-y-6">
<h1 className="text-2xl font-bold text-gray-900">Stats</h1>
{/* Weekly Summary */}
{weeklySummary && (
<div className="grid grid-cols-3 gap-3">
<div className="bg-gradient-to-br from-indigo-500 to-purple-600 rounded-2xl p-4 text-white">
<StarIcon className="text-white/80 mb-2" size={24} />
<p className="text-3xl font-bold">{weeklySummary.total_completed}</p>
<p className="text-white/80 text-sm">Completed</p>
</div>
<div className="bg-gradient-to-br from-pink-500 to-rose-600 rounded-2xl p-4 text-white">
<ClockIcon className="text-white/80 mb-2" size={24} />
<p className="text-3xl font-bold">{formatTime(weeklySummary.total_time_minutes)}</p>
<p className="text-white/80 text-sm">Time</p>
</div>
<div className="bg-gradient-to-br from-amber-500 to-orange-600 rounded-2xl p-4 text-white">
<ActivityIcon className="text-white/80 mb-2" size={24} />
<p className="text-3xl font-bold">{weeklySummary.routines_started}</p>
<p className="text-white/80 text-sm">Started</p>
</div>
</div>
)}
{/* Streaks */}
{streaks.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Streaks</h2>
<div className="space-y-2">
{streaks.map((streak) => (
<div key={streak.routine_id} className="bg-white rounded-xl p-4 shadow-sm flex items-center gap-4">
<div className="w-12 h-12 bg-gradient-to-br from-orange-100 to-red-100 rounded-xl flex items-center justify-center">
<FlameIcon className="text-orange-500" size={24} />
</div>
<div className="flex-1">
<p className="font-medium text-gray-900">{streak.routine_name}</p>
<p className="text-sm text-gray-500">
Last: {streak.last_completed_date ? new Date(streak.last_completed_date).toLocaleDateString() : 'Never'}
</p>
</div>
<div className="text-right">
<p className="text-2xl font-bold text-orange-500">{streak.current_streak}</p>
<p className="text-xs text-gray-500">day streak</p>
</div>
</div>
))}
</div>
</div>
)}
{/* Per-Routine Stats */}
{routines.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Routine Stats</h2>
<select
value={selectedRoutine}
onChange={(e) => setSelectedRoutine(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-xl bg-white mb-4"
>
{routines.map((routine) => (
<option key={routine.id} value={routine.id}>
{routine.name}
</option>
))}
</select>
{routineStats && (
<div className="grid grid-cols-2 gap-3">
<div className="bg-white rounded-xl p-4 shadow-sm">
<TargetIcon className="text-indigo-500 mb-2" size={24} />
<p className="text-2xl font-bold text-gray-900">{routineStats.completion_rate_percent}%</p>
<p className="text-sm text-gray-500">Completion Rate</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm">
<ClockIcon className="text-purple-500 mb-2" size={24} />
<p className="text-2xl font-bold text-gray-900">{formatTime(routineStats.avg_duration_minutes)}</p>
<p className="text-sm text-gray-500">Avg Duration</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm">
<StarIcon className="text-green-500 mb-2" size={24} />
<p className="text-2xl font-bold text-gray-900">{routineStats.completed}</p>
<p className="text-sm text-gray-500">Completed</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm">
<ActivityIcon className="text-pink-500 mb-2" size={24} />
<p className="text-2xl font-bold text-gray-900">{routineStats.total_sessions}</p>
<p className="text-sm text-gray-500">Total Sessions</p>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,110 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import api from '@/lib/api';
import { CopyIcon, CheckIcon, FlameIcon } from '@/components/ui/Icons';
interface Template {
id: string;
name: string;
description?: string;
icon?: string;
step_count: number;
}
export default function TemplatesPage() {
const router = useRouter();
const [templates, setTemplates] = useState<Template[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [cloningId, setCloningId] = useState<string | null>(null);
useEffect(() => {
const fetchTemplates = async () => {
try {
const data = await api.templates.list();
setTemplates(data);
} catch (err) {
console.error('Failed to fetch templates:', err);
} finally {
setIsLoading(false);
}
};
fetchTemplates();
}, []);
const handleClone = async (templateId: string) => {
setCloningId(templateId);
try {
const routine = await api.templates.clone(templateId);
router.push(`/dashboard/routines/${routine.id}`);
} catch (err) {
console.error('Failed to clone template:', err);
setCloningId(null);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<div className="w-8 h-8 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
return (
<div className="p-4 space-y-4">
<h1 className="text-2xl font-bold text-gray-900">Templates</h1>
<p className="text-gray-500">Start with a pre-made routine</p>
{templates.length === 0 ? (
<div className="bg-white rounded-xl p-8 shadow-sm text-center mt-4">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<FlameIcon className="text-gray-400" size={32} />
</div>
<h3 className="font-semibold text-gray-900 mb-1">No templates yet</h3>
<p className="text-gray-500 text-sm">Templates will appear here when available</p>
</div>
) : (
<div className="grid gap-4">
{templates.map((template) => (
<div
key={template.id}
className="bg-white rounded-xl p-4 shadow-sm"
>
<div className="flex items-center gap-4">
<div className="w-14 h-14 bg-gradient-to-br from-indigo-100 to-pink-100 rounded-xl flex items-center justify-center flex-shrink-0">
<span className="text-3xl">{template.icon || '✨'}</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900">{template.name}</h3>
{template.description && (
<p className="text-gray-500 text-sm truncate">{template.description}</p>
)}
<p className="text-gray-400 text-xs mt-1">{template.step_count} steps</p>
</div>
<button
onClick={() => handleClone(template.id)}
disabled={cloningId === template.id}
className="bg-indigo-600 text-white px-4 py-2 rounded-lg font-medium flex items-center gap-2 disabled:opacity-50"
>
{cloningId === template.id ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>Cloning...</span>
</>
) : (
<>
<CopyIcon size={18} />
<span>Use</span>
</>
)}
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import "./globals.css";
import { AuthProvider } from "@/components/auth/AuthProvider";
export const metadata: Metadata = {
title: "Synculous",
description: "Visual routine planner and timer for building healthy habits",
manifest: "/manifest.json",
themeColor: "#4f46e5",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "Synculous",
},
viewport: {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<head>
<link rel="apple-touch-icon" href="/icon-192.png" />
</head>
<body className="antialiased">
<AuthProvider>
{children}
</AuthProvider>
<script
dangerouslySetInnerHTML={{
__html: `if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js')}`,
}}
/>
</body>
</html>
);
}

View File

@@ -0,0 +1,107 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/auth/AuthProvider';
import { HeartIcon } from '@/components/ui/Icons';
export default function LoginPage() {
const [isLogin, setIsLogin] = useState(true);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { login, register } = useAuth();
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
if (isLogin) {
await login(username, password);
} else {
await register(username, password);
await login(username, password);
}
router.push('/');
} catch (err) {
setError((err as Error).message || 'An error occurred');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-500 p-4">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md p-8">
<div className="flex flex-col items-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-indigo-500 to-pink-500 rounded-2xl flex items-center justify-center mb-4">
<HeartIcon className="text-white" size={32} />
</div>
<h1 className="text-2xl font-bold text-gray-900">Synculous</h1>
<p className="text-gray-500 mt-1">
{isLogin ? 'Welcome back!' : 'Create your account'}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 text-red-600 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Username
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition"
placeholder="Enter your username"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition"
placeholder="Enter your password"
required
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-gradient-to-r from-indigo-500 to-pink-500 text-white font-semibold py-3 px-4 rounded-lg hover:opacity-90 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Loading...' : isLogin ? 'Sign In' : 'Create Account'}
</button>
</form>
<div className="mt-6 text-center">
<button
onClick={() => setIsLogin(!isLogin)}
className="text-indigo-600 hover:text-indigo-800 text-sm font-medium"
>
{isLogin
? "Don't have an account? Sign up"
: 'Already have an account? Sign in'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/auth/AuthProvider';
export default function Home() {
const { isAuthenticated, isLoading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading) {
if (isAuthenticated) {
router.push('/dashboard');
} else {
router.push('/login');
}
}
}, [isAuthenticated, isLoading, router]);
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-pulse flex flex-col items-center">
<div className="w-12 h-12 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
<p className="mt-4 text-gray-600">Loading...</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
'use client';
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import api from '@/lib/api';
import { User } from '@/types';
interface AuthContextType {
user: User | null;
token: string | null;
isLoading: boolean;
isAuthenticated: boolean;
login: (username: string, password: string) => Promise<void>;
register: (username: string, password: string) => Promise<void>;
logout: () => void;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const refreshUser = useCallback(async () => {
const storedToken = api.auth.getToken();
if (!storedToken) {
setIsLoading(false);
return;
}
setToken(storedToken);
try {
const tokenParts = storedToken.split('.');
if (tokenParts.length === 3) {
const payload = JSON.parse(atob(tokenParts[1]));
const userId = payload.sub;
if (userId) {
const userData = await api.user.get(userId);
setUser(userData);
}
}
} catch (error) {
console.error('Failed to refresh user:', error);
api.auth.logout();
setToken(null);
setUser(null);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
refreshUser();
}, [refreshUser]);
const login = async (username: string, password: string) => {
const result = await api.auth.login(username, password);
const storedToken = api.auth.getToken();
setToken(storedToken);
const tokenParts = storedToken!.split('.');
const payload = JSON.parse(atob(tokenParts[1]));
const userId = payload.sub;
if (userId) {
const userData = await api.user.get(userId);
setUser(userData);
}
};
const register = async (username: string, password: string) => {
await api.auth.register(username, password);
};
const logout = () => {
api.auth.logout();
setToken(null);
setUser(null);
};
return (
<AuthContext.Provider
value={{
user,
token,
isLoading,
isAuthenticated: !!user,
login,
register,
logout,
refreshUser,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -0,0 +1,725 @@
'use client';
import React from 'react';
interface IconProps {
className?: string;
size?: number;
}
export function CheckIcon({ 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}
>
<polyline points="20 6 9 17 4 12" />
</svg>
);
}
export function XIcon({ 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}
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
}
export function PlusIcon({ 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}
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
}
export function PlayIcon({ 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}
>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
);
}
export function PauseIcon({ 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}
>
<rect x="6" y="4" width="4" height="16" />
<rect x="14" y="4" width="4" height="16" />
</svg>
);
}
export function StopIcon({ 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}
>
<rect x="4" y="4" width="16" height="16" rx="2" />
</svg>
);
}
export function ClockIcon({ 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" />
<polyline points="12 6 12 12 16 14" />
</svg>
);
}
export function CalendarIcon({ 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}
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
);
}
export function HomeIcon({ 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="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
);
}
export function ListIcon({ 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}
>
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
);
}
export function SettingsIcon({ 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="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
);
}
export function UserIcon({ 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="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
);
}
export function LogOutIcon({ 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 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
);
}
export function TrashIcon({ 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}
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
);
}
export function EditIcon({ 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 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
}
export function ArrowLeftIcon({ 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}
>
<line x1="19" y1="12" x2="5" y2="12" />
<polyline points="12 19 5 12 12 5" />
</svg>
);
}
export function ArrowRightIcon({ 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}
>
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
);
}
export function ChevronDownIcon({ 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}
>
<polyline points="6 9 12 15 18 9" />
</svg>
);
}
export function ChevronUpIcon({ 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}
>
<polyline points="18 15 12 9 6 15" />
</svg>
);
}
export function GripVerticalIcon({ 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="9" cy="12" r="1" />
<circle cx="9" cy="5" r="1" />
<circle cx="9" cy="19" r="1" />
<circle cx="15" cy="12" r="1" />
<circle cx="15" cy="5" r="1" />
<circle cx="15" cy="19" r="1" />
</svg>
);
}
export function FlameIcon({ 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="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z" />
</svg>
);
}
export function StarIcon({ 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}
>
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
);
}
export function TargetIcon({ 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" />
<circle cx="12" cy="12" r="6" />
<circle cx="12" cy="12" r="2" />
</svg>
);
}
export function ActivityIcon({ 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}
>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
);
}
export function PillIcon({ 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="m10.5 20.5 10-10a4.95 4.95 0 1 0-7-7l-10 10a4.95 4.95 0 1 0 7 7Z" />
<path d="m8.5 8.5 7 7" />
</svg>
);
}
export function HeartIcon({ 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="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" />
</svg>
);
}
export function BrainIcon({ 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.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z" />
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z" />
</svg>
);
}
export function MoonIcon({ 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 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
);
}
export function SunIcon({ 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="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>
);
}
export function CopyIcon({ 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}
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
);
}
export function RefreshIcon({ 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="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M8 16H3v5" />
</svg>
);
}
export function BarChartIcon({ 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}
>
<line x1="12" y1="20" x2="12" y2="10" />
<line x1="18" y1="20" x2="18" y2="4" />
<line x1="6" y1="20" x2="6" y2="16" />
</svg>
);
}
export function AlertCircleIcon({ 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" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
);
}
export function InfoIcon({ 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 16v-4" />
<path d="M12 8h.01" />
</svg>
);
}
export function TimerIcon({ 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}
>
<line x1="10" y1="13" x2="14" y2="13" />
<line x1="12" y1="2" x2="12" y2="6" />
<line x1="12" y1="18" x2="12" y2="22" />
<line x1="4.93" y1="4.93" x2="7.76" y2="7.76" />
<line x1="16.24" y1="16.24" x2="19.07" y2="19.07" />
<line x1="2" y1="12" x2="6" y2="12" />
<line x1="18" y1="12" x2="22" y2="12" />
<line x1="4.93" y1="19.07" x2="7.76" y2="16.24" />
<line x1="16.24" y1="7.76" x2="19.07" y2="4.93" />
</svg>
);
}
export function SkipForwardIcon({ 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}
>
<polygon points="5 4 15 12 5 20 5 4" />
<line x1="19" y1="5" x2="19" y2="19" stroke="currentColor" strokeWidth="2" />
</svg>
);
}

View File

@@ -0,0 +1,265 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import api from '@/lib/api';
interface UseSwipeOptions {
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
onSwipeUp?: () => void;
onSwipeDown?: () => void;
threshold?: number;
}
export function useSwipe({
onSwipeLeft,
onSwipeRight,
onSwipeUp,
onSwipeDown,
threshold = 50,
}: UseSwipeOptions) {
const touchStart = useRef<{ x: number; y: number } | null>(null);
const touchEnd = useRef<{ x: number; y: number } | null>(null);
const handleTouchStart = useCallback((e: React.TouchEvent) => {
touchStart.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
}, []);
const handleTouchMove = useCallback((e: React.TouchEvent) => {
touchEnd.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
}, []);
const handleTouchEnd = useCallback(() => {
if (!touchStart.current || !touchEnd.current) return;
const diffX = touchEnd.current.x - touchStart.current.x;
const diffY = touchEnd.current.y - touchStart.current.y;
if (Math.abs(diffX) > Math.abs(diffY)) {
if (Math.abs(diffX) > threshold) {
if (diffX > 0 && onSwipeRight) {
onSwipeRight();
} else if (diffX < 0 && onSwipeLeft) {
onSwipeLeft();
}
}
} else {
if (Math.abs(diffY) > threshold) {
if (diffY > 0 && onSwipeDown) {
onSwipeDown();
} else if (diffY < 0 && onSwipeUp) {
onSwipeUp();
}
}
}
touchStart.current = null;
touchEnd.current = null;
}, [onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeDown, threshold]);
return {
handleTouchStart,
handleTouchMove,
handleTouchEnd,
};
}
export function useTimer(initialMinutes: number = 0) {
const [seconds, setSeconds] = useState(initialMinutes * 60);
const [isRunning, setIsRunning] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (isRunning && !isPaused && seconds > 0) {
intervalRef.current = setInterval(() => {
setSeconds((s) => Math.max(0, s - 1));
}, 1000);
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [isRunning, isPaused, seconds]);
const start = useCallback(() => {
setIsRunning(true);
setIsPaused(false);
}, []);
const pause = useCallback(() => {
setIsPaused(true);
}, []);
const resume = useCallback(() => {
setIsPaused(false);
}, []);
const reset = useCallback((minutes?: number) => {
setSeconds((minutes ?? initialMinutes) * 60);
setIsRunning(false);
setIsPaused(false);
}, [initialMinutes]);
const formatTime = useCallback((totalSeconds: number) => {
const mins = Math.floor(totalSeconds / 60);
const secs = totalSeconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}, []);
return {
seconds,
isRunning,
isPaused,
start,
pause,
resume,
reset,
formattedTime: formatTime(seconds),
};
}
export function useActiveSession() {
const [session, setSession] = useState<{
id: string;
routineId: string;
routineName: string;
routineIcon?: string;
status: string;
currentStepIndex: number;
} | null>(null);
const [currentStep, setCurrentStep] = useState<{
id: string;
name: string;
instructions?: string;
stepType: string;
durationMinutes?: number;
position: number;
} | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchActiveSession = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await api.sessions.getActive();
if (data.session && data.current_step) {
setSession({
id: data.session.id,
routineId: data.session.routine_id,
routineName: data.routine.name,
routineIcon: data.routine.icon,
status: data.session.status,
currentStepIndex: data.session.current_step_index,
});
setCurrentStep({
id: data.current_step.id,
name: data.current_step.name,
instructions: data.current_step.instructions,
stepType: data.current_step.step_type,
durationMinutes: data.current_step.duration_minutes,
position: data.current_step.position,
});
}
} catch (err) {
if ((err as Error).message !== 'no active session') {
setError((err as Error).message);
}
} finally {
setIsLoading(false);
}
}, []);
const completeStep = useCallback(async () => {
if (!session) return;
try {
const result = await api.sessions.completeStep(session.id, currentStep!.id);
if (result.next_step) {
setCurrentStep({
id: result.next_step.id,
name: result.next_step.name,
instructions: result.next_step.instructions,
stepType: result.next_step.step_type,
durationMinutes: result.next_step.duration_minutes,
position: result.next_step.position,
});
setSession((s) => s ? { ...s, currentStepIndex: result.session.current_step_index! } : null);
} else {
setSession(null);
setCurrentStep(null);
}
} catch (err) {
setError((err as Error).message);
}
}, [session, currentStep]);
const skipStep = useCallback(async () => {
if (!session) return;
try {
const result = await api.sessions.skipStep(session.id, currentStep!.id);
if (result.next_step) {
setCurrentStep({
id: result.next_step.id,
name: result.next_step.name,
instructions: result.next_step.instructions,
stepType: result.next_step.step_type,
durationMinutes: result.next_step.duration_minutes,
position: result.next_step.position,
});
setSession((s) => s ? { ...s, currentStepIndex: result.session.current_step_index! } : null);
} else {
setSession(null);
setCurrentStep(null);
}
} catch (err) {
setError((err as Error).message);
}
}, [session, currentStep]);
const pause = useCallback(async () => {
if (!session) return;
try {
await api.sessions.pause(session.id);
setSession((s) => s ? { ...s, status: 'paused' } : null);
} catch (err) {
setError((err as Error).message);
}
}, [session]);
const resume = useCallback(async () => {
if (!session) return;
try {
await api.sessions.resume(session.id);
setSession((s) => s ? { ...s, status: 'active' } : null);
} catch (err) {
setError((err as Error).message);
}
}, [session]);
const cancel = useCallback(async () => {
if (!session) return;
try {
await api.sessions.cancel(session.id);
setSession(null);
setCurrentStep(null);
} catch (err) {
setError((err as Error).message);
}
}, [session]);
return {
session,
currentStep,
isLoading,
error,
fetchActiveSession,
completeStep,
skipStep,
pause,
resume,
cancel,
};
}

View File

@@ -0,0 +1,697 @@
const API_URL = '';
function getToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('token');
}
function setToken(token: string): void {
localStorage.setItem('token', token);
}
function clearToken(): void {
localStorage.removeItem('token');
}
async function request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const token = getToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
};
const response = await fetch(`${API_URL}${endpoint}`, {
...options,
headers,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Request failed' }));
throw new Error(error.error || 'Request failed');
}
return response.json();
}
export const api = {
// Auth
auth: {
login: async (username: string, password: string) => {
const result = await request<{ token: string }>('/api/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
setToken(result.token);
return result;
},
register: async (username: string, password: string) => {
return request<{ success: boolean }>('/api/register', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
},
logout: () => {
clearToken();
},
getToken,
},
// User
user: {
get: async (userId: string) => {
return request<{ id: string; username: string; created_at: string }>(
`/api/user/${userId}`,
{ method: 'GET' }
);
},
getUUID: async (username: string) => {
return request<{ id: string }>(`/api/getUserUUID/${username}`, {
method: 'GET',
});
},
update: async (userId: string, data: Record<string, unknown>) => {
return request<{ success: boolean }>(`/api/user/${userId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
},
},
// Routines
routines: {
list: async () => {
return request<Array<{
id: string;
user_uuid: string;
name: string;
description?: string;
icon?: string;
created_at: string;
}>>('/api/routines', { method: 'GET' });
},
get: async (routineId: string) => {
return request<{
routine: {
id: string;
user_uuid: string;
name: string;
description?: string;
icon?: string;
};
steps: Array<{
id: string;
routine_id: string;
name: string;
instructions?: string;
step_type: string;
duration_minutes?: number;
media_url?: string;
position: number;
}>;
}>(`/api/routines/${routineId}`, { method: 'GET' });
},
create: async (data: { name: string; description?: string; icon?: string }) => {
return request<{ id: string }>('/api/routines', {
method: 'POST',
body: JSON.stringify(data),
});
},
update: async (routineId: string, data: Record<string, unknown>) => {
return request<{ id: string }>(`/api/routines/${routineId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
},
delete: async (routineId: string) => {
return request<{ deleted: boolean }>(`/api/routines/${routineId}`, {
method: 'DELETE',
});
},
// Steps
getSteps: async (routineId: string) => {
return request<Array<{
id: string;
routine_id: string;
name: string;
instructions?: string;
step_type: string;
duration_minutes?: number;
media_url?: string;
position: number;
}>>(`/api/routines/${routineId}/steps`, { method: 'GET' });
},
addStep: async (
routineId: string,
data: { name: string; duration_minutes?: number; position?: number }
) => {
return request<{ id: string }>(`/api/routines/${routineId}/steps`, {
method: 'POST',
body: JSON.stringify(data),
});
},
updateStep: async (
routineId: string,
stepId: string,
data: Record<string, unknown>
) => {
return request<{ id: string }>(`/api/routines/${routineId}/steps/${stepId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
},
deleteStep: async (routineId: string, stepId: string) => {
return request<{ deleted: boolean }>(
`/api/routines/${routineId}/steps/${stepId}`,
{ method: 'DELETE' }
);
},
reorderSteps: async (routineId: string, stepIds: string[]) => {
return request<Array<{ id: string }>>(
`/api/routines/${routineId}/steps/reorder`,
{
method: 'PUT',
body: JSON.stringify({ step_ids: stepIds }),
}
);
},
// Step extended
updateStepInstructions: async (
routineId: string,
stepId: string,
instructions: string
) => {
return request<{ id: string }>(
`/api/routines/${routineId}/steps/${stepId}/instructions`,
{
method: 'PUT',
body: JSON.stringify({ instructions }),
}
);
},
updateStepType: async (
routineId: string,
stepId: string,
stepType: string
) => {
return request<{ id: string }>(
`/api/routines/${routineId}/steps/${stepId}/type`,
{
method: 'PUT',
body: JSON.stringify({ step_type: stepType }),
}
);
},
updateStepMedia: async (
routineId: string,
stepId: string,
mediaUrl: string
) => {
return request<{ id: string }>(
`/api/routines/${routineId}/steps/${stepId}/media`,
{
method: 'PUT',
body: JSON.stringify({ media_url: mediaUrl }),
}
);
},
// Scheduling
getSchedule: async (routineId: string) => {
return request<{
id: string;
routine_id: string;
days: string[];
time: string;
remind: boolean;
}>(`/api/routines/${routineId}/schedule`, { method: 'GET' });
},
setSchedule: async (
routineId: string,
data: { days: string[]; time: string; remind?: boolean }
) => {
return request<{ id: string }>(`/api/routines/${routineId}/schedule`, {
method: 'PUT',
body: JSON.stringify(data),
});
},
deleteSchedule: async (routineId: string) => {
return request<{ deleted: boolean }>(
`/api/routines/${routineId}/schedule`,
{ method: 'DELETE' }
);
},
// History
getHistory: async (routineId: string, days = 7) => {
return request<Array<{
id: string;
routine_id: string;
status: string;
created_at: string;
completed_at?: string;
}>>(`/api/routines/${routineId}/history?days=${days}`, {
method: 'GET',
});
},
// Stats
getStats: async (routineId: string, days = 30) => {
return request<{
routine_id: string;
routine_name: string;
period_days: number;
total_sessions: number;
completed: number;
aborted: number;
completion_rate_percent: number;
avg_duration_minutes: number;
total_time_minutes: number;
}>(`/api/routines/${routineId}/stats?days=${days}`, { method: 'GET' });
},
getStreak: async (routineId: string) => {
return request<{
routine_id: string;
routine_name: string;
current_streak: number;
longest_streak: number;
last_completed_date?: string;
}>(`/api/routines/${routineId}/streak`, { method: 'GET' });
},
// Tags
getTags: async (routineId: string) => {
return request<Array<{ id: string; name: string; color: string }>>(
`/api/routines/${routineId}/tags`,
{ method: 'GET' }
);
},
addTags: async (routineId: string, tagIds: string[]) => {
return request<Array<{ id: string; name: string; color: string }>>(
`/api/routines/${routineId}/tags`,
{
method: 'POST',
body: JSON.stringify({ tag_ids: tagIds }),
}
);
},
removeTag: async (routineId: string, tagId: string) => {
return request<{ removed: boolean }>(
`/api/routines/${routineId}/tags/${tagId}`,
{ method: 'DELETE' }
);
},
},
// Sessions
sessions: {
start: async (routineId: string) => {
return request<{
session: { id: string; status: string };
current_step: {
id: string;
name: string;
instructions?: string;
step_type: string;
duration_minutes?: number;
position: number;
};
}>(`/api/routines/${routineId}/start`, { method: 'POST' });
},
getActive: async () => {
return request<{
session: {
id: string;
routine_id: string;
status: string;
current_step_index: number;
};
routine: { id: string; name: string; icon?: string };
current_step: {
id: string;
name: string;
instructions?: string;
step_type: string;
duration_minutes?: number;
position: number;
} | null;
}>('/api/sessions/active', { method: 'GET' });
},
completeStep: async (sessionId: string, stepId: string) => {
return request<{
session: { status: string; current_step_index?: number };
next_step: {
id: string;
name: string;
instructions?: string;
step_type: string;
duration_minutes?: number;
position: number;
} | null;
}>(`/api/sessions/${sessionId}/complete-step`, {
method: 'POST',
body: JSON.stringify({ step_id: stepId }),
});
},
skipStep: async (sessionId: string, stepId: string) => {
return request<{
session: { status: string; current_step_index?: number };
next_step: {
id: string;
name: string;
instructions?: string;
step_type: string;
duration_minutes?: number;
position: number;
} | null;
}>(`/api/sessions/${sessionId}/skip-step`, {
method: 'POST',
body: JSON.stringify({ step_id: stepId }),
});
},
pause: async (sessionId: string) => {
return request<{ status: string }>(`/api/sessions/${sessionId}/pause`, {
method: 'POST',
});
},
resume: async (sessionId: string) => {
return request<{ status: string }>(`/api/sessions/${sessionId}/resume`, {
method: 'POST',
});
},
cancel: async (sessionId: string) => {
return request<{ status: string }>(`/api/sessions/${sessionId}/cancel`, {
method: 'POST',
});
},
abort: async (sessionId: string, reason?: string) => {
return request<{ status: string; reason: string }>(
`/api/sessions/${sessionId}/abort`,
{
method: 'POST',
body: JSON.stringify({ reason }),
}
);
},
addNote: async (
sessionId: string,
stepIndex: number | undefined,
note: string
) => {
return request<{ id: string }>(`/api/sessions/${sessionId}/note`, {
method: 'POST',
body: JSON.stringify({ step_index: stepIndex, note }),
});
},
setDuration: async (sessionId: string, durationMinutes: number) => {
return request<{ id: string }>(`/api/sessions/${sessionId}/duration`, {
method: 'PUT',
body: JSON.stringify({ actual_duration_minutes: durationMinutes }),
});
},
getDetails: async (sessionId: string) => {
return request<{
session: {
id: string;
routine_id: string;
status: string;
current_step_index: number;
};
routine: { id: string; name: string };
steps: Array<{ id: string; name: string; position: number }>;
notes: Array<{ id: string; step_index?: number; note: string }>;
}>(`/api/sessions/${sessionId}`, { method: 'GET' });
},
},
// Templates
templates: {
list: async () => {
return request<Array<{
id: string;
name: string;
description?: string;
icon?: string;
step_count: number;
}>>('/api/templates', { method: 'GET' });
},
get: async (templateId: string) => {
return request<{
template: {
id: string;
name: string;
description?: string;
icon?: string;
};
steps: Array<{
id: string;
name: string;
instructions?: string;
step_type: string;
duration_minutes?: number;
position: number;
}>;
}>(`/api/templates/${templateId}`, { method: 'GET' });
},
clone: async (templateId: string) => {
return request<{ id: string }>(`/api/templates/${templateId}/clone`, {
method: 'POST',
});
},
},
// Tags
tags: {
list: async () => {
return request<Array<{ id: string; name: string; color: string }>>(
'/api/tags',
{ method: 'GET' }
);
},
create: async (data: { name: string; color?: string }) => {
return request<{ id: string }>('/api/tags', {
method: 'POST',
body: JSON.stringify(data),
});
},
delete: async (tagId: string) => {
return request<{ deleted: boolean }>(`/api/tags/${tagId}`, {
method: 'DELETE',
});
},
},
// Stats
stats: {
getWeeklySummary: async () => {
return request<{
total_completed: number;
total_time_minutes: number;
routines_started: number;
routines: Array<{
routine_id: string;
name: string;
completed_this_week: number;
}>;
}>('/api/routines/weekly-summary', { method: 'GET' });
},
getStreaks: async () => {
return request<Array<{
routine_id: string;
routine_name: string;
current_streak: number;
longest_streak: number;
last_completed_date?: string;
}>>('/api/routines/streaks', { method: 'GET' });
},
},
// Medications
medications: {
list: async () => {
return request<Array<{
id: string;
name: string;
dosage: string;
unit: string;
frequency: string;
times: string[];
notes?: string;
active: boolean;
quantity_remaining?: number;
refill_date?: string;
}>>('/api/medications', { method: 'GET' });
},
get: async (medId: string) => {
return request<{
id: string;
name: string;
dosage: string;
unit: string;
frequency: string;
times: string[];
notes?: string;
active: boolean;
quantity_remaining?: number;
refill_date?: string;
}>(`/api/medications/${medId}`, { method: 'GET' });
},
create: async (data: {
name: string;
dosage: string;
unit: string;
frequency: string;
times?: string[];
days_of_week?: string[];
interval_days?: number;
start_date?: string;
notes?: string;
}) => {
return request<{ id: string }>('/api/medications', {
method: 'POST',
body: JSON.stringify(data),
});
},
update: async (medId: string, data: Record<string, unknown>) => {
return request<{ id: string }>(`/api/medications/${medId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
},
delete: async (medId: string) => {
return request<{ deleted: boolean }>(`/api/medications/${medId}`, {
method: 'DELETE',
});
},
take: async (medId: string, scheduledTime?: string, notes?: string) => {
return request<{ id: string }>(`/api/medications/${medId}/take`, {
method: 'POST',
body: JSON.stringify({ scheduled_time: scheduledTime, notes }),
});
},
skip: async (medId: string, scheduledTime?: string, reason?: string) => {
return request<{ id: string }>(`/api/medications/${medId}/skip`, {
method: 'POST',
body: JSON.stringify({ scheduled_time: scheduledTime, reason }),
});
},
snooze: async (medId: string, minutes = 15) => {
return request<{ snoozed_until_minutes: number }>(
`/api/medications/${medId}/snooze`,
{
method: 'POST',
body: JSON.stringify({ minutes }),
}
);
},
getLog: async (medId: string, days = 30) => {
return request<Array<{
id: string;
medication_id: string;
action: string;
scheduled_time?: string;
notes?: string;
created_at: string;
}>>(`/api/medications/${medId}/log?days=${days}`, { method: 'GET' });
},
getToday: async () => {
return request<Array<{
medication: {
id: string;
name: string;
dosage: string;
unit: string;
};
scheduled_times: string[];
taken_times: string[];
}>>('/api/medications/today', { method: 'GET' });
},
getAdherence: async (days = 30) => {
return request<Array<{
medication_id: string;
name: string;
taken: number;
skipped: number;
adherence_percent: number;
}>>(`/api/medications/adherence?days=${days}`, { method: 'GET' });
},
getRefillsDue: async (daysAhead = 7) => {
return request<Array<{
id: string;
name: string;
dosage: string;
quantity_remaining?: number;
refill_date?: string;
}>>(`/api/medications/refills-due?days_ahead=${daysAhead}`, {
method: 'GET',
});
},
setRefill: async (
medId: string,
data: {
quantity_remaining?: number;
refill_date?: string;
pharmacy_notes?: string;
}
) => {
return request<{ id: string }>(`/api/medications/${medId}/refill`, {
method: 'PUT',
body: JSON.stringify(data),
});
},
},
};
export default api;

View File

@@ -0,0 +1,180 @@
export interface User {
id: string;
username: string;
created_at: string;
}
export interface Routine {
id: string;
user_uuid: string;
name: string;
description?: string;
icon?: string;
created_at: string;
}
export interface RoutineWithSteps extends Routine {
steps: RoutineStep[];
}
export interface RoutineStep {
id: string;
routine_id: string;
name: string;
instructions?: string;
step_type: StepType;
duration_minutes?: number;
media_url?: string;
position: number;
}
export type StepType = 'generic' | 'timer' | 'checklist' | 'meditation' | 'exercise';
export interface RoutineSchedule {
id: string;
routine_id: string;
days: string[];
time: string;
remind: boolean;
}
export interface RoutineSession {
id: string;
routine_id: string;
user_uuid: string;
status: SessionStatus;
current_step_index: number;
created_at: string;
paused_at?: string;
completed_at?: string;
abort_reason?: string;
actual_duration_minutes?: number;
}
export type SessionStatus = 'active' | 'paused' | 'completed' | 'cancelled' | 'aborted';
export interface RoutineTemplate {
id: string;
name: string;
description?: string;
icon?: string;
step_count: number;
}
export interface RoutineTemplateWithSteps extends RoutineTemplate {
steps: RoutineTemplateStep[];
}
export interface RoutineTemplateStep {
id: string;
template_id: string;
name: string;
instructions?: string;
step_type: StepType;
duration_minutes?: number;
media_url?: string;
position: number;
}
export interface RoutineStats {
routine_id: string;
routine_name: string;
period_days: number;
total_sessions: number;
completed: number;
aborted: number;
completion_rate_percent: number;
avg_duration_minutes: number;
total_time_minutes: number;
}
export interface RoutineStreak {
routine_id: string;
routine_name: string;
current_streak: number;
longest_streak: number;
last_completed_date?: string;
}
export interface WeeklySummary {
total_completed: number;
total_time_minutes: number;
routines_started: number;
routines: {
routine_id: string;
name: string;
completed_this_week: number;
}[];
}
export interface RoutineTag {
id: string;
name: string;
color: string;
}
export type MedicationFrequency = 'daily' | 'twice_daily' | 'specific_days' | 'every_n_days' | 'as_needed';
export interface Medication {
id: string;
user_uuid: string;
name: string;
dosage: string;
unit: string;
frequency: MedicationFrequency;
times: string[];
days_of_week?: string[];
interval_days?: number;
start_date?: string;
next_dose_date?: string;
notes?: string;
active: boolean;
quantity_remaining?: number;
refill_date?: string;
pharmacy_notes?: string;
}
export interface MedicationLog {
id: string;
medication_id: string;
user_uuid: string;
action: 'taken' | 'skipped';
scheduled_time?: string;
notes?: string;
created_at: string;
}
export interface TodaysMedication {
medication: Medication;
scheduled_times: string[];
taken_times: string[];
is_prn?: boolean;
}
export interface MedicationAdherence {
medication_id: string;
name: string;
taken: number;
skipped: number;
expected?: number;
adherence_percent: number | null;
is_prn?: boolean;
}
export interface SessionNote {
id: string;
session_id: string;
step_index?: number;
note: string;
created_at: string;
}
export interface AuthResponse {
token?: string;
error?: string;
success?: boolean;
}
export interface ApiError {
error: string;
}

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}