diff --git a/api/main.py b/api/main.py index 534bc24..714b71b 100644 --- a/api/main.py +++ b/api/main.py @@ -6,6 +6,7 @@ Domain routes are registered via the routes registry. import os import flask +from flask_cors import CORS import core.auth as auth import core.users as users 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 app = flask.Flask(__name__) +CORS(app) ROUTE_MODULES = [ routines_routes, diff --git a/api/routes/medications.py b/api/routes/medications.py index 6e73fe4..ce38406 100644 --- a/api/routes/medications.py +++ b/api/routes/medications.py @@ -4,8 +4,11 @@ Medications API - medication scheduling, logging, and adherence tracking import os import uuid +from datetime import datetime, date, timedelta + import flask import jwt +from psycopg2.extras import Json import core.auth as auth import core.postgres as postgres @@ -30,6 +33,85 @@ def _auth(request): 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): # ── Medications CRUD ────────────────────────────────────────── @@ -45,7 +127,7 @@ def register(app): @app.route("/api/medications", methods=["POST"]) 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) if not user_uuid: return flask.jsonify({"error": "unauthorized"}), 401 @@ -56,11 +138,37 @@ def register(app): missing = [f for f in required if not data.get(f)] if missing: return flask.jsonify({"error": f"missing required fields: {', '.join(missing)}"}), 400 - data["id"] = str(uuid.uuid4()) - data["user_uuid"] = user_uuid - data["times"] = data.get("times", []) - data["active"] = True - med = postgres.insert("medications", data) + + row = { + "id": str(uuid.uuid4()), + "user_uuid": user_uuid, + "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 @app.route("/api/medications/", methods=["GET"]) @@ -76,7 +184,7 @@ def register(app): @app.route("/api/medications/", methods=["PUT"]) def api_updateMedication(med_id): - """Update medication details. Body: {name?, dosage?, unit?, frequency?, times?, notes?, active?}""" + """Update medication details.""" user_uuid = _auth(flask.request) if not user_uuid: 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}) if not existing: 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} if not updates: 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 @app.route("/api/medications/", methods=["DELETE"]) @@ -127,6 +238,11 @@ def register(app): "notes": data.get("notes"), } 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 @app.route("/api/medications//skip", methods=["POST"]) @@ -148,6 +264,11 @@ def register(app): "notes": data.get("reason"), } 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 @app.route("/api/medications//snooze", methods=["POST"]) @@ -189,24 +310,37 @@ def register(app): user_uuid = _auth(flask.request) if not user_uuid: return flask.jsonify({"error": "unauthorized"}), 401 + meds = postgres.select("medications", where={"user_uuid": user_uuid, "active": True}) - from datetime import datetime - today = datetime.now().strftime("%Y-%m-%d") + now = datetime.now() + today = date.today() + today_str = today.isoformat() + current_day = now.strftime("%a").lower() # "mon","tue", etc. + result = [] for med in meds: - times = med.get("times", []) - taken_times = [ - log["scheduled_time"] - for log in postgres.select( - "med_logs", - where={"medication_id": med["id"], "action": "taken"}, - ) - if log.get("scheduled_time", "").startswith(today) + if not _is_med_due_today(med, today, current_day): + continue + + 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", + where={"medication_id": med["id"], "action": "taken"}, + ) + today_taken = [ + log.get("scheduled_time", "") + for log in all_logs + if str(log.get("created_at", ""))[:10] == today_str ] + result.append({ "medication": med, - "scheduled_times": times, - "taken_times": taken_times, + "scheduled_times": [] if is_prn else med.get("times", []), + "taken_times": today_taken, + "is_prn": is_prn, }) return flask.jsonify(result), 200 @@ -218,25 +352,37 @@ def register(app): user_uuid = _auth(flask.request) if not user_uuid: 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}) + today = date.today() + period_start = today - timedelta(days=num_days) + period_start_str = period_start.isoformat() + result = [] for med in meds: - logs = postgres.select( - "med_logs", - where={"medication_id": med["id"]}, - limit=days * 10, - ) - taken = sum(1 for log in logs if log.get("action") == "taken") - skipped = sum(1 for log in logs if log.get("action") == "skipped") - total = taken + skipped - adherence = (taken / total * 100) if total > 0 else 0 + freq = med.get("frequency", "daily") + is_prn = freq == "as_needed" + expected = _count_expected_doses(med, period_start, num_days) + + logs = postgres.select("med_logs", where={"medication_id": med["id"]}) + taken = _count_logs_in_period(logs, period_start_str, "taken") + skipped = _count_logs_in_period(logs, period_start_str, "skipped") + + if is_prn: + adherence_pct = None + elif expected > 0: + adherence_pct = round(min(taken / expected * 100, 100), 1) + else: + adherence_pct = 0 + result.append({ "medication_id": med["id"], "name": med["name"], "taken": taken, "skipped": skipped, - "adherence_percent": round(adherence, 1), + "expected": expected, + "adherence_percent": adherence_pct, + "is_prn": is_prn, }) return flask.jsonify(result), 200 @@ -249,22 +395,34 @@ def register(app): med = postgres.select_one("medications", {"id": med_id, "user_uuid": user_uuid}) if not med: return flask.jsonify({"error": "not found"}), 404 - days = flask.request.args.get("days", 30, type=int) - logs = postgres.select( - "med_logs", - where={"medication_id": med_id}, - limit=days * 10, - ) - taken = sum(1 for log in logs if log.get("action") == "taken") - skipped = sum(1 for log in logs if log.get("action") == "skipped") - total = taken + skipped - adherence = (taken / total * 100) if total > 0 else 0 + num_days = flask.request.args.get("days", 30, type=int) + today = date.today() + period_start = today - timedelta(days=num_days) + period_start_str = period_start.isoformat() + + freq = med.get("frequency", "daily") + is_prn = freq == "as_needed" + expected = _count_expected_doses(med, period_start, num_days) + + logs = postgres.select("med_logs", where={"medication_id": med_id}) + taken = _count_logs_in_period(logs, period_start_str, "taken") + skipped = _count_logs_in_period(logs, period_start_str, "skipped") + + 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({ "medication_id": med_id, "name": med["name"], "taken": taken, "skipped": skipped, - "adherence_percent": round(adherence, 1), + "expected": expected, + "adherence_percent": adherence_pct, + "is_prn": is_prn, }), 200 # ── Refills ─────────────────────────────────────────────────── @@ -295,7 +453,6 @@ def register(app): if not user_uuid: return flask.jsonify({"error": "unauthorized"}), 401 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") meds = postgres.select( "medications", @@ -307,6 +464,6 @@ def register(app): refill_date = med.get("refill_date") if qty is not None and qty <= 7: due.append(med) - elif refill_date and refill_date <= cutoff: + elif refill_date and str(refill_date) <= cutoff: due.append(med) return flask.jsonify(due), 200 diff --git a/api/routes/routines.py b/api/routes/routines.py index e28402e..50a0fcc 100644 --- a/api/routes/routines.py +++ b/api/routes/routines.py @@ -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. """ diff --git a/config/schema.sql b/config/schema.sql index f925e0e..7283ec0 100644 --- a/config/schema.sql +++ b/config/schema.sql @@ -20,11 +20,125 @@ CREATE TABLE IF NOT EXISTS notifications ( updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); --- Add your domain tables below --- Example: --- CREATE TABLE IF NOT EXISTS examples ( --- id UUID PRIMARY KEY, --- user_uuid UUID REFERENCES users(id) ON DELETE CASCADE, --- name VARCHAR(255) NOT NULL, --- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP --- ); +-- ── Routines ──────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS routines ( + id UUID PRIMARY KEY, + user_uuid UUID REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + 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 +); diff --git a/docker-compose.yml b/docker-compose.yml index c2552c7..5e9654a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,5 +41,16 @@ services: app: 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: pgdata: diff --git a/requirements.txt b/requirements.txt index 6549e5d..286d50b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ flask>=3.0.0 +flask-cors>=4.0.0 psycopg2-binary>=2.9.0 bcrypt>=4.1.0 PyJWT>=2.8.0 diff --git a/scheduler/daemon.py b/scheduler/daemon.py index d87d3ea..e2118fd 100644 --- a/scheduler/daemon.py +++ b/scheduler/daemon.py @@ -21,23 +21,48 @@ POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", 60)) def check_medication_reminders(): """Check for medications due now and send notifications.""" try: + from datetime import date as date_type meds = postgres.select("medications", where={"active": True}) now = datetime.now() 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: + 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", []) if current_time not in times: continue - logs = postgres.select( - "med_logs", - where={"medication_id": med["id"]}, - ) + # Already taken today? Check by created_at date + logs = postgres.select("med_logs", where={"medication_id": med["id"], "action": "taken"}) already_taken = any( - log.get("action") == "taken" - and log.get("scheduled_time", "").startswith(today) + log.get("scheduled_time") == current_time + and str(log.get("created_at", ""))[:10] == today_str for log in logs ) if already_taken: diff --git a/synculous-client/.dockerignore b/synculous-client/.dockerignore new file mode 100644 index 0000000..8b88447 --- /dev/null +++ b/synculous-client/.dockerignore @@ -0,0 +1,3 @@ +node_modules +.next +.git diff --git a/synculous-client/.gitignore b/synculous-client/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/synculous-client/.gitignore @@ -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 diff --git a/synculous-client/Dockerfile b/synculous-client/Dockerfile new file mode 100644 index 0000000..46350bf --- /dev/null +++ b/synculous-client/Dockerfile @@ -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"] diff --git a/synculous-client/README.md b/synculous-client/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/synculous-client/README.md @@ -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. diff --git a/synculous-client/brilli features.txt b/synculous-client/brilli features.txt new file mode 100644 index 0000000..1ec1db9 --- /dev/null +++ b/synculous-client/brilli features.txt @@ -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. \ No newline at end of file diff --git a/synculous-client/eslint.config.mjs b/synculous-client/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/synculous-client/eslint.config.mjs @@ -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; diff --git a/synculous-client/next.config.ts b/synculous-client/next.config.ts new file mode 100644 index 0000000..9e33cc6 --- /dev/null +++ b/synculous-client/next.config.ts @@ -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; diff --git a/synculous-client/package.json b/synculous-client/package.json new file mode 100644 index 0000000..1b90394 --- /dev/null +++ b/synculous-client/package.json @@ -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" + } +} diff --git a/synculous-client/postcss.config.mjs b/synculous-client/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/synculous-client/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/synculous-client/public/file.svg b/synculous-client/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/synculous-client/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/synculous-client/public/globe.svg b/synculous-client/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/synculous-client/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/synculous-client/public/icon-192.png b/synculous-client/public/icon-192.png new file mode 100644 index 0000000..a5f6935 Binary files /dev/null and b/synculous-client/public/icon-192.png differ diff --git a/synculous-client/public/icon-512.png b/synculous-client/public/icon-512.png new file mode 100644 index 0000000..5055189 Binary files /dev/null and b/synculous-client/public/icon-512.png differ diff --git a/synculous-client/public/manifest.json b/synculous-client/public/manifest.json new file mode 100644 index 0000000..fd9ade4 --- /dev/null +++ b/synculous-client/public/manifest.json @@ -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" + } + ] +} diff --git a/synculous-client/public/next.svg b/synculous-client/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/synculous-client/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/synculous-client/public/sw.js b/synculous-client/public/sw.js new file mode 100644 index 0000000..ca8926f --- /dev/null +++ b/synculous-client/public/sw.js @@ -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)) + ); +}); diff --git a/synculous-client/public/vercel.svg b/synculous-client/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/synculous-client/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/synculous-client/public/window.svg b/synculous-client/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/synculous-client/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/synculous-client/src/app/dashboard/history/page.tsx b/synculous-client/src/app/dashboard/history/page.tsx new file mode 100644 index 0000000..a1b207d --- /dev/null +++ b/synculous-client/src/app/dashboard/history/page.tsx @@ -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([]); + const [selectedRoutine, setSelectedRoutine] = useState('all'); + const [history, setHistory] = useState([]); + 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 ( +
+
+
+ ); + } + + return ( +
+

History

+ + {/* Filter */} + + + {history.length === 0 ? ( +
+ +

No history yet

+

Complete a routine to see it here

+
+ ) : ( +
+ {history.map((session) => ( +
+
+ {session.status === 'completed' ? ( + + ) : ( + + )} +
+
+

+ {(session as any).routine_name || 'Routine'} +

+

+ {formatDate(session.created_at)} +

+
+ + {session.status} + +
+ ))} +
+ )} +
+ ); +} diff --git a/synculous-client/src/app/dashboard/layout.tsx b/synculous-client/src/app/dashboard/layout.tsx new file mode 100644 index 0000000..7ca2352 --- /dev/null +++ b/synculous-client/src/app/dashboard/layout.tsx @@ -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 ( +
+
+
+

Loading...

+
+
+ ); + } + + if (!isAuthenticated) { + return null; + } + + return ( +
+
+
+
+
+ +
+ Synculous +
+ +
+
+ +
+ {children} +
+ + +
+ ); +} diff --git a/synculous-client/src/app/dashboard/medications/new/page.tsx b/synculous-client/src/app/dashboard/medications/new/page.tsx new file mode 100644 index 0000000..f1e4cdb --- /dev/null +++ b/synculous-client/src/app/dashboard/medications/new/page.tsx @@ -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(['08:00']); + const [daysOfWeek, setDaysOfWeek] = useState([]); + 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 ( +
+
+
+ +

Add Medication

+
+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+
+ + 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" + /> +
+ +
+
+ + 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" + /> +
+
+ + +
+
+ +
+ + +
+ + {/* Day-of-week picker for specific_days */} + {frequency === 'specific_days' && ( +
+ +
+ {DAY_OPTIONS.map(({ value, label }) => ( + + ))} +
+
+ )} + + {/* Interval settings for every_n_days */} + {frequency === 'every_n_days' && ( +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ )} + + {/* Times picker — hidden for as_needed */} + {frequency !== 'as_needed' && ( +
+
+ + +
+
+ {times.map((time, index) => ( +
+ 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 && ( + + )} +
+ ))} +
+
+ )} +
+ + +
+
+ ); +} diff --git a/synculous-client/src/app/dashboard/medications/page.tsx b/synculous-client/src/app/dashboard/medications/page.tsx new file mode 100644 index 0000000..a6f16a1 --- /dev/null +++ b/synculous-client/src/app/dashboard/medications/page.tsx @@ -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([]); + const [todayMeds, setTodayMeds] = useState([]); + const [adherence, setAdherence] = useState([]); + 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 ( +
+
+
+ ); + } + + return ( +
+
+

Medications

+ + + +
+ + {/* Today's Schedule */} + {todayMeds.length > 0 && ( +
+

Today

+
+ {todayMeds.map((item) => ( +
+
+
+

{item.medication.name}

+

{item.medication.dosage} {item.medication.unit}

+
+
+
+ {item.is_prn ? ( +
+ As needed + +
+ ) : ( + item.scheduled_times.map((time) => { + const isTaken = item.taken_times.includes(time); + return ( +
+
+ + {time} +
+ {isTaken ? ( + + Taken + + ) : ( +
+ + +
+ )} +
+ ); + }) + )} +
+
+ ))} +
+
+ )} + + {/* All Medications */} +
+

All Medications

+ + {medications.length === 0 ? ( +
+
+ +
+

No medications yet

+

Add your medications to track them

+ + Add Medication + +
+ ) : ( +
+ {medications.map((med) => { + const { percent: adherencePercent, isPrn } = getAdherenceForMed(med.id); + return ( +
+
+
+
+

{med.name}

+ {!med.active && ( + Inactive + )} +
+

{med.dosage} {med.unit} · {formatSchedule(med)}

+ {med.times.length > 0 && ( +

Times: {med.times.join(', ')}

+ )} +
+ +
+ + {/* Adherence */} +
+ {isPrn || adherencePercent === null ? ( + PRN — no adherence tracking + ) : ( + <> +
+ 30-day adherence + = 80 ? 'text-green-600' : adherencePercent >= 50 ? 'text-yellow-600' : 'text-red-600'}`}> + {adherencePercent}% + +
+
+
= 80 ? 'bg-green-500' : adherencePercent >= 50 ? 'bg-yellow-500' : 'bg-red-500'}`} + style={{ width: `${adherencePercent}%` }} + /> +
+ + )} +
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/synculous-client/src/app/dashboard/page.tsx b/synculous-client/src/app/dashboard/page.tsx new file mode 100644 index 0000000..e55ddae --- /dev/null +++ b/synculous-client/src/app/dashboard/page.tsx @@ -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([]); + const [activeSession, setActiveSession] = useState(null); + const [weeklySummary, setWeeklySummary] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+ ); + } + + return ( +
+ {/* Active Session Banner */} + {activeSession && activeSession.session.status === 'active' && ( +
+
+
+

Continue your routine

+

{activeSession.routine.name}

+

+ Step {activeSession.session.current_step_index + 1}: {activeSession.current_step?.name} +

+
+ +
+
+ )} + + {/* Header */} +
+

{getGreeting()}, {user?.username}!

+

Let's build some great habits today.

+
+ + {/* Weekly Stats */} + {weeklySummary && ( +
+
+
+ + Completed +
+

{weeklySummary.total_completed}

+
+
+
+ + Time +
+

{formatTime(weeklySummary.total_time_minutes)}

+
+
+
+ + Started +
+

{weeklySummary.routines_started}

+
+
+ )} + + {/* Quick Start Routines */} +
+

Your Routines

+ + {routines.length === 0 ? ( +
+
+ +
+

No routines yet

+

Create your first routine to get started

+ + Create Routine + +
+ ) : ( +
+ {routines.map((routine) => ( +
+
+
+ {routine.icon || '✨'} +
+
+

{routine.name}

+ {routine.description && ( +

{routine.description}

+ )} +
+
+ +
+ ))} +
+ )} +
+ + {/* Templates CTA */} +
+
+
+

Need inspiration?

+

Browse pre-made routines

+
+ + Browse + +
+
+ + {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/synculous-client/src/app/dashboard/routines/[id]/page.tsx b/synculous-client/src/app/dashboard/routines/[id]/page.tsx new file mode 100644 index 0000000..a3c31a4 --- /dev/null +++ b/synculous-client/src/app/dashboard/routines/[id]/page.tsx @@ -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(null); + const [steps, setSteps] = useState([]); + 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 ( +
+
+
+ ); + } + + if (!routine) return null; + + return ( +
+
+
+
+ +

+ {isEditing ? 'Edit Routine' : routine.name} +

+
+ {!isEditing && ( + + )} +
+
+ +
+ {isEditing ? ( +
+
+ +
+ {ICONS.map((i) => ( + + ))} +
+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + +
+
+ ) : ( +
+
+
+ {routine.icon || '✨'} +
+
+

{routine.name}

+ {routine.description && ( +

{routine.description}

+ )} +
+ + + {totalDuration} min + + {steps.length} steps +
+
+
+ +
+ )} + + {/* Steps */} +
+

Steps

+ +
+
+ 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" + /> + + +
+
+ + {steps.length === 0 ? ( +
+

No steps yet

+
+ ) : ( +
+ {steps.map((step, index) => ( +
+
+ {index + 1} +
+
+

{step.name}

+ {step.duration_minutes && ( +

{step.duration_minutes} min

+ )} +
+ +
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/synculous-client/src/app/dashboard/routines/[id]/run/page.tsx b/synculous-client/src/app/dashboard/routines/[id]/run/page.tsx new file mode 100644 index 0000000..eeca461 --- /dev/null +++ b/synculous-client/src/app/dashboard/routines/[id]/run/page.tsx @@ -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(null); + const [steps, setSteps] = useState([]); + const [session, setSession] = useState(null); + const [currentStep, setCurrentStep] = useState(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(null); + + const [swipeDirection, setSwipeDirection] = useState<'left' | 'right' | null>(null); + const touchStartX = useRef(null); + const touchStartY = useRef(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 ( +
+
+
+ ); + } + + if (status === 'completed') { + return ( +
+
+ +
+

Great job!

+

You completed your routine

+ +
+ ); + } + + const progress = ((currentStepIndex + 1) / steps.length) * 100; + + return ( +
+ {/* Header */} +
+ +
+

{routine?.name}

+

Step {currentStepIndex + 1} of {steps.length}

+
+ +
+ + {/* Progress bar */} +
+
+
+
+
+ + {/* Main Card */} +
+
+ {/* Step Type Badge */} +
+ {currentStep.step_type || 'Generic'} +
+ + {/* Timer */} +
+
+ {formatTime(timerSeconds)} +
+

remaining

+
+ + {/* Step Name */} +

{currentStep.name}

+ + {/* Instructions */} + {currentStep.instructions && ( +

{currentStep.instructions}

+ )} + + {/* Timer Controls */} +
+ + + +
+
+ + {/* Swipe Hints */} +
+
+ + Swipe left to complete +
+
+ Swipe right to skip + +
+
+
+
+ ); +} diff --git a/synculous-client/src/app/dashboard/routines/new/page.tsx b/synculous-client/src/app/dashboard/routines/new/page.tsx new file mode 100644 index 0000000..82073b9 --- /dev/null +++ b/synculous-client/src/app/dashboard/routines/new/page.tsx @@ -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([]); + 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) => { + 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 ( +
+
+
+ +

New Routine

+
+
+ +
+ {error && ( +
+ {error} +
+ )} + + {/* Basic Info */} +
+
+ +
+ {ICONS.map((i) => ( + + ))} +
+
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+
+ + {/* Steps */} +
+
+

Steps

+ +
+ + {steps.length === 0 ? ( +
+

Add steps to your routine

+ +
+ ) : ( +
+ {steps.map((step, index) => ( +
+
+ +
+
+ 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" + /> +
+ + +
+
+ +
+ ))} +
+ )} +
+ + +
+
+ ); +} diff --git a/synculous-client/src/app/dashboard/routines/page.tsx b/synculous-client/src/app/dashboard/routines/page.tsx new file mode 100644 index 0000000..8a64f32 --- /dev/null +++ b/synculous-client/src/app/dashboard/routines/page.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(true); + const [deleteModal, setDeleteModal] = useState(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 ( +
+
+
+ ); + } + + return ( +
+
+

Routines

+ + + +
+ + {routines.length === 0 ? ( +
+
+ +
+

No routines yet

+

Create your first routine to get started

+ + Create Routine + +
+ ) : ( +
+ {routines.map((routine) => ( +
+
+
+ {routine.icon || '✨'} +
+
+

{routine.name}

+ {routine.description && ( +

{routine.description}

+ )} +
+
+ + + + + +
+
+
+ ))} +
+ )} + + {/* Delete Modal */} + {deleteModal && ( +
+
+

Delete Routine?

+

This action cannot be undone.

+
+ + +
+
+
+ )} +
+ ); +} diff --git a/synculous-client/src/app/dashboard/stats/page.tsx b/synculous-client/src/app/dashboard/stats/page.tsx new file mode 100644 index 0000000..17d2505 --- /dev/null +++ b/synculous-client/src/app/dashboard/stats/page.tsx @@ -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(''); + const [routineStats, setRoutineStats] = useState(null); + const [streaks, setStreaks] = useState([]); + const [weeklySummary, setWeeklySummary] = useState(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 ( +
+
+
+ ); + } + + return ( +
+

Stats

+ + {/* Weekly Summary */} + {weeklySummary && ( +
+
+ +

{weeklySummary.total_completed}

+

Completed

+
+
+ +

{formatTime(weeklySummary.total_time_minutes)}

+

Time

+
+
+ +

{weeklySummary.routines_started}

+

Started

+
+
+ )} + + {/* Streaks */} + {streaks.length > 0 && ( +
+

Streaks

+
+ {streaks.map((streak) => ( +
+
+ +
+
+

{streak.routine_name}

+

+ Last: {streak.last_completed_date ? new Date(streak.last_completed_date).toLocaleDateString() : 'Never'} +

+
+
+

{streak.current_streak}

+

day streak

+
+
+ ))} +
+
+ )} + + {/* Per-Routine Stats */} + {routines.length > 0 && ( +
+

Routine Stats

+ + + {routineStats && ( +
+
+ +

{routineStats.completion_rate_percent}%

+

Completion Rate

+
+
+ +

{formatTime(routineStats.avg_duration_minutes)}

+

Avg Duration

+
+
+ +

{routineStats.completed}

+

Completed

+
+
+ +

{routineStats.total_sessions}

+

Total Sessions

+
+
+ )} +
+ )} +
+ ); +} diff --git a/synculous-client/src/app/dashboard/templates/page.tsx b/synculous-client/src/app/dashboard/templates/page.tsx new file mode 100644 index 0000000..0df70cf --- /dev/null +++ b/synculous-client/src/app/dashboard/templates/page.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(true); + const [cloningId, setCloningId] = useState(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 ( +
+
+
+ ); + } + + return ( +
+

Templates

+

Start with a pre-made routine

+ + {templates.length === 0 ? ( +
+
+ +
+

No templates yet

+

Templates will appear here when available

+
+ ) : ( +
+ {templates.map((template) => ( +
+
+
+ {template.icon || '✨'} +
+
+

{template.name}

+ {template.description && ( +

{template.description}

+ )} +

{template.step_count} steps

+
+ +
+
+ ))} +
+ )} +
+ ); +} diff --git a/synculous-client/src/app/favicon.ico b/synculous-client/src/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/synculous-client/src/app/favicon.ico differ diff --git a/synculous-client/src/app/globals.css b/synculous-client/src/app/globals.css new file mode 100644 index 0000000..a2dc41e --- /dev/null +++ b/synculous-client/src/app/globals.css @@ -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; +} diff --git a/synculous-client/src/app/layout.tsx b/synculous-client/src/app/layout.tsx new file mode 100644 index 0000000..63ba995 --- /dev/null +++ b/synculous-client/src/app/layout.tsx @@ -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 ( + + + + + + + {children} + +