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>
3
synculous-client/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
41
synculous-client/.gitignore
vendored
Normal 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
|
||||
15
synculous-client/Dockerfile
Normal 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"]
|
||||
36
synculous-client/README.md
Normal 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.
|
||||
93
synculous-client/brilli features.txt
Normal 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.
|
||||
18
synculous-client/eslint.config.mjs
Normal 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;
|
||||
15
synculous-client/next.config.ts
Normal 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;
|
||||
26
synculous-client/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
synculous-client/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
synculous-client/public/file.svg
Normal 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 |
1
synculous-client/public/globe.svg
Normal 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 |
BIN
synculous-client/public/icon-192.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
synculous-client/public/icon-512.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
21
synculous-client/public/manifest.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
1
synculous-client/public/next.svg
Normal 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 |
30
synculous-client/public/sw.js
Normal 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))
|
||||
);
|
||||
});
|
||||
1
synculous-client/public/vercel.svg
Normal 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 |
1
synculous-client/public/window.svg
Normal 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 |
157
synculous-client/src/app/dashboard/history/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
synculous-client/src/app/dashboard/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
253
synculous-client/src/app/dashboard/medications/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
268
synculous-client/src/app/dashboard/medications/page.tsx
Normal 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} · {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 — 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>
|
||||
);
|
||||
}
|
||||
242
synculous-client/src/app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
294
synculous-client/src/app/dashboard/routines/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
321
synculous-client/src/app/dashboard/routines/[id]/run/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
236
synculous-client/src/app/dashboard/routines/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
synculous-client/src/app/dashboard/routines/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
193
synculous-client/src/app/dashboard/stats/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
synculous-client/src/app/dashboard/templates/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
BIN
synculous-client/src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
26
synculous-client/src/app/globals.css
Normal 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;
|
||||
}
|
||||
45
synculous-client/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
synculous-client/src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
synculous-client/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
synculous-client/src/components/auth/AuthProvider.tsx
Normal 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;
|
||||
}
|
||||
725
synculous-client/src/components/ui/Icons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
265
synculous-client/src/hooks/useSwipe.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
697
synculous-client/src/lib/api.ts
Normal 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;
|
||||
180
synculous-client/src/types/index.ts
Normal 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;
|
||||
}
|
||||
34
synculous-client/tsconfig.json
Normal 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"]
|
||||
}
|
||||