AI Coach messages bookmarking. Top bar refined.

This commit is contained in:
AG
2025-12-16 16:41:50 +02:00
parent cb0bd1a55d
commit dd027e1615
26 changed files with 2496 additions and 270 deletions

1471
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,8 +25,10 @@
"playwright-test": "^14.1.12",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.10.1",
"recharts": "^3.4.1"
"recharts": "^3.4.1",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@playwright/test": "^1.57.0",

File diff suppressed because one or more lines are too long

22
server/.env Normal file
View File

@@ -0,0 +1,22 @@
# Generic
# DEV
DATABASE_URL_DEV="file:./prisma/dev.db"
ADMIN_EMAIL_DEV="admin@gymflow.ai"
ADMIN_PASSWORD_DEV="admin123"
# TEST
DATABASE_URL_TEST="file:./prisma/test.db"
ADMIN_EMAIL_TEST="admin@gymflow.ai"
ADMIN_PASSWORD_TEST="admin123"
# PROD
DATABASE_URL_PROD="file:./prisma/prod.db"
ADMIN_EMAIL_PROD="admin-prod@gymflow.ai"
ADMIN_PASSWORD_PROD="secure-prod-password-change-me"
# Fallback for Prisma CLI (Migrate default)
DATABASE_URL="file:./prisma/dev.db"
GEMINI_API_KEY=AIzaSyC88SeFyFYjvSfTqgvEyr7iqLSvEhuadoE
DEFAULT_EXERCISES_CSV_PATH='default_exercises.csv'

Binary file not shown.

View File

@@ -0,0 +1,9 @@
-- CreateTable
CREATE TABLE "SavedMessage" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'model',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SavedMessage_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);

View File

@@ -24,6 +24,16 @@ model User {
exercises Exercise[]
plans WorkoutPlan[]
weightRecords BodyWeightRecord[]
savedMessages SavedMessage[]
}
model SavedMessage {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
content String
role String @default("model")
createdAt DateTime @default(now())
}
model BodyWeightRecord {

Binary file not shown.

View File

@@ -0,0 +1,79 @@
import { Request, Response } from 'express';
import prisma from '../lib/prisma';
export class BookmarksController {
static async getAll(req: Request, res: Response) {
try {
const userId = (req as any).user?.userId;
if (!userId) {
return res.status(401).json({ success: false, error: 'Unauthorized' });
}
const messages = await prisma.savedMessage.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
return res.json({ success: true, data: messages });
} catch (error) {
console.error('Failed to get bookmarks:', error);
return res.status(500).json({ success: false, error: 'Internal server error' });
}
}
static async create(req: Request, res: Response) {
try {
const userId = (req as any).user?.userId;
if (!userId) {
return res.status(401).json({ success: false, error: 'Unauthorized' });
}
const { content, role } = req.body;
if (!content) {
return res.status(400).json({ success: false, error: 'Content is required' });
}
const message = await prisma.savedMessage.create({
data: {
userId,
content,
role: role || 'model',
},
});
return res.json({ success: true, data: message });
} catch (error) {
console.error('Failed to create bookmark:', error);
return res.status(500).json({ success: false, error: 'Internal server error' });
}
}
static async delete(req: Request, res: Response) {
try {
const userId = (req as any).user?.userId;
if (!userId) {
return res.status(401).json({ success: false, error: 'Unauthorized' });
}
const { id } = req.params;
// Verify ownership
const existing = await prisma.savedMessage.findFirst({
where: { id, userId },
});
if (!existing) {
return res.status(404).json({ success: false, error: 'Bookmark not found' });
}
await prisma.savedMessage.delete({
where: { id },
});
return res.json({ success: true });
} catch (error) {
console.error('Failed to delete bookmark:', error);
return res.status(500).json({ success: false, error: 'Internal server error' });
}
}
}

View File

@@ -11,6 +11,7 @@ import sessionRoutes from './routes/sessions';
import planRoutes from './routes/plans';
import aiRoutes from './routes/ai';
import weightRoutes from './routes/weight';
import bookmarksRoutes from './routes/bookmarks';
import bcrypt from 'bcryptjs';
import { PrismaClient } from '@prisma/client';
@@ -88,6 +89,7 @@ app.use('/api/sessions', sessionRoutes);
app.use('/api/plans', planRoutes);
app.use('/api/ai', aiRoutes);
app.use('/api/weight', weightRoutes);
app.use('/api/bookmarks', bookmarksRoutes);
app.get('/', (req, res) => {

View File

@@ -0,0 +1,13 @@
import express from 'express';
import { BookmarksController } from '../controllers/bookmarks.controller';
import { authenticateToken } from '../middleware/auth';
const router = express.Router();
router.use(authenticateToken);
router.get('/', BookmarksController.getAll);
router.post('/', BookmarksController.create);
router.delete('/:id', BookmarksController.delete);
export default router;

Binary file not shown.

View File

@@ -333,23 +333,30 @@ Comprehensive test plan for the GymFlow web application, covering authentication
**Expected Results:**
- All exercises are created successfully with their respective types.
#### 2.14. A. Workout Plans - Create Plan with AI
#### 2.14. A. Workout Plans - Create Plan with AI (Parametrized)
**File:** `tests/workout-management.spec.ts`
**File:** `tests/ai-plan-creation.spec.ts`
**Steps:**
1. Log in as a regular user.
2. Navigate to the 'Plans' section.
3. Click the '+' FAB button.
4. Select 'With AI' option.
5. In the AI Side Sheet, enter a prompt (e.g., 'Create a short leg workout with lunges').
6. Click 'Generate'.
7. Wait for the AI response.
5. **Verify Defaults**: Duration 60, Equipment 'No equipment', Level 'Intermediate', Intensity 'Moderate'.
6. **Modify Inputs**:
- Set Duration to 45 mins.
- Set Equipment to 'Free weights'.
- Set Level to 'Advanced'.
7. Click 'Generate' (mocks AI response).
8. **Iterative Flow**:
- Verify preview table appears.
- Click 'Generate' again.
- Click 'Save Plan'.
**Expected Results:**
- A new plan is created and appears in the plans list.
- If 'Lunges' did not exist in the user's exercise library, it is created automatically.
- The plan contains the exercises described in the prompt.
- A new plan is created with the AI-suggested content.
- The plan appears in the plans list.
- New exercises are created with correct `type` and `isUnilateral` flags.
#### 2.15. B. Tracker - Empty State AI Prompt
@@ -1075,3 +1082,57 @@ Comprehensive test plan for the GymFlow web application, covering authentication
**Expected Results:**
- The volume, set count, and body weight charts resize and re-render correctly, maintaining readability and data integrity across different screen sizes.
### 7. VII. AI Coach Features
**Seed:** `tests/ai-coach.spec.ts`
#### 7.1. A. AI Coach - Basic Conversation & Markdown
**File:** `tests/ai-coach.spec.ts`
**Steps:**
1. Log in as a regular user.
2. Navigate to 'AI Coach'.
3. Type a message (e.g., "How to do a pushup?").
4. Click 'Send'.
5. Verify response appears.
**Expected Results:**
- AI responds with a message.
- Response renders Markdown correctly (e.g., bullet points, bold text).
#### 7.2. A. AI Coach - Bookmark Message
**File:** `tests/ai-coach.spec.ts`
**Steps:**
1. Send a message to AI Coach and receive a response.
2. Click the 'Bookmark' icon on the AI's response.
3. Verify a success notification (Snackbar).
4. Reload the page.
**Expected Results:**
- The message remains bookmarked (icon state persists).
#### 7.3. A. AI Coach - View Saved Messages
**File:** `tests/ai-coach.spec.ts`
**Steps:**
1. Bookmark at least one message.
2. Click the 'Saved Messages' icon in the top bar.
3. Verify the Saved Messages sheet opens.
**Expected Results:**
- The sheet displays the bookmarked message content.
- The content is rendered in Markdown.
#### 7.4. A. AI Coach - Delete Bookmark
**File:** `tests/ai-coach.spec.ts`
**Steps:**
1. Open Saved Messages sheet.
2. Click 'Delete' (trash icon) on a saved message.
3. Confirm if necessary (or verify immediate deletion).
**Expected Results:**
- The message is removed from the list.
- The bookmark icon in the main chat (if message is visible) updates to unbookmarked state.

View File

@@ -68,18 +68,21 @@ Users can structure their training via Plans.
* **Trigger**: "Create with AI" option in Plans FAB Menu, or "Ask your AI coach" link from Tracker (when no plans exist).
* **UI Flow**:
* Opens a dedicated Side Sheet in the Plans view.
* User enters a text prompt describing desired workout (e.g., "Create a 20-minute HIIT workout").
* "Generate" button initiates AI call.
* **Inputs**:
* **Duration**: Slider (5 min to 2+ hours, 5 min step). Default 60 min.
* **Equipment**: Selector (No equipment, Essentials, Free weights, Complete gym). Default "No equipment".
* **Level**: Selector (Beginner, Intermediate, Advanced). Default "Intermediate".
* **Intensity**: Selector (Low, Moderate, High). Default "Moderate".
* **Additional Constraints**: Textarea (optional).
* **Action**: "Generate" button initiates AI call.
* **Preview**: Displays generated plan table. User can "Generate" again to retry, or "Save Plan" to finalize.
* **AI Logic**:
* System sends prompt to AI service (`geminiService`).
* AI returns a structured JSON object containing: `name`, `description`, and `exercises` array.
* Each exercise object contains: `name`, `isWeighted` (boolean), `restTimeSeconds` (number).
* For **new exercises** (not in user's library), AI also provides: `type` ('reps' or 'time'), `unilateral` (boolean).
* **Auto-Creation of Exercises**:
* System parses AI response.
* For each exercise in the response, checks if it exists in the user's exercise library by name.
* If not found, creates a new `Exercise` record with AI-provided attributes (type, unilateral flag) via `saveExercise`.
* Links the new/existing exercise ID to the plan step.
* System sends structured prompt to AI service (`geminiService`) embedding all parameters.
* **Naming Rules**:
* Exercise names must NOT contain "Weighted" (use `isWeighted` flag).
* Exclude variants (e.g. "or ...") and form notes.
* **Structure**: Each item in list = ONE set.
* AI returns JSON with `name`, `description`, `exercises` (with `type`, `unilateral` for new ones).
* **Result**: Saves the generated `WorkoutPlan` to DB and displays it in the Plans list.
### 3.3. Exercise Library
@@ -196,6 +199,15 @@ Accessible only if `User.role === 'ADMIN'`.
* **Delete User**: Permanent removal.
* **Reset Password**: Admin can manually trigger password reset flows.
### 3.8. AI Coach
- **Conversational Interface**: Chat-like interface for asking fitness-related questions.
- **Context Awareness**: Access to user's workout history and profile for personalized advice.
- **RAG Integration**: Retrieval Augmented Generation using recent workout logs.
- **Plan Generation**: Ability to generate structured workout plans based on user prompt.
- **Markdown Support**: Rich text formatting for AI responses (bold, lists, code blocks).
- **Bookmarking**: Users can save helpful AI messages for later reference.
- **History Persistence**: Chat history is preserved locally across reloads.
## 4. Technical Constants & Constraints
* **Database**: SQLite (via Prisma).
* **API Schema**: REST-like (JSON).

View File

@@ -1,13 +1,18 @@
import React, { useState, useRef, useEffect } from 'react';
import { Send, Bot, User, Loader2, AlertTriangle } from 'lucide-react';
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Send, MessageSquare, Loader2, AlertTriangle, Bookmark, BookmarkCheck, BookmarkIcon } from 'lucide-react';
import { createFitnessChat } from '../services/geminiService';
import { WorkoutSession, Language, UserProfile, WorkoutPlan } from '../types';
import { Chat, GenerateContentResponse } from '@google/genai';
import { Language } from '../types';
import { GenerateContentResponse } from '@google/genai';
import { t } from '../services/i18n';
import { generateId } from '../utils/uuid';
import { useAuth } from '../context/AuthContext';
import { useSession } from '../context/SessionContext';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { TopBar } from './ui/TopBar';
import SavedMessagesSheet from './SavedMessagesSheet';
import { createBookmark, deleteBookmark, SavedMessage, getBookmarks } from '../services/bookmarks';
import Snackbar from './Snackbar';
interface AICoachProps {
lang: Language;
@@ -17,27 +22,83 @@ interface Message {
id: string;
role: 'user' | 'model';
text: string;
isBookmarked?: boolean;
savedMessageId?: string;
timestamp: number;
}
const STORAGE_KEY_PREFIX = 'ai_coach_history_';
const AICoach: React.FC<AICoachProps> = ({ lang }) => {
const { currentUser } = useAuth();
const { sessions: history, plans } = useSession();
const userProfile = currentUser?.profile;
const userId = currentUser?.id;
// Load initial messages from local storage
const [messages, setMessages] = useState<Message[]>(() => {
if (!userId) return [];
const saved = localStorage.getItem(`${STORAGE_KEY_PREFIX}${userId}`);
if (saved) {
try {
return JSON.parse(saved);
} catch (e) {
console.error("Failed to parse saved chat history", e);
}
}
return [{ id: 'intro', role: 'model', text: t('ai_intro', lang), timestamp: Date.now() }];
});
const [messages, setMessages] = useState<Message[]>([
{ id: 'intro', role: 'model', text: t('ai_intro', lang) }
]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const chatSessionRef = useRef<Chat | null>(null);
const [showSavedMessages, setShowSavedMessages] = useState(false);
const [savedMessages, setSavedMessages] = useState<SavedMessage[]>([]);
const [snackbar, setSnackbar] = useState<{ open: boolean, message: string }>({ open: false, message: '' });
const chatSessionRef = useRef<any>(null); // Type 'Chat' is hard to import perfectly here without errors if not careful
const messagesEndRef = useRef<HTMLDivElement>(null);
// Sync with local storage
useEffect(() => {
if (userId) {
localStorage.setItem(`${STORAGE_KEY_PREFIX}${userId}`, JSON.stringify(messages));
}
}, [messages, userId]);
// Load bookmarks on mount to sync status
useEffect(() => {
if (!userId) return;
const loadBookmarks = async () => {
try {
const bookmarks = await getBookmarks();
setSavedMessages(bookmarks);
// Update bookmarked status in local messages
setMessages(prev => prev.map(msg => {
const found = bookmarks.find(b => b.content === msg.text);
if (found) {
return { ...msg, isBookmarked: true, savedMessageId: found.id };
}
return msg;
}));
} catch (e: any) {
if (e.message !== 'Unauthorized') {
console.warn("Failed to load bookmarks:", e);
}
}
};
loadBookmarks();
}, [userId, showSavedMessages]);
useEffect(() => {
try {
const chat = createFitnessChat(history, lang, userProfile, plans);
if (chat) {
chatSessionRef.current = chat;
// Restore history context
// Note: Gemini SDK doesn't easily allow "restoring" state without re-sending history
// This is a simplification; for full context restoration we'd need to rebuild history
// For now, we start fresh session context but display old UI messages
} else {
setError(t('ai_error', lang));
}
@@ -57,7 +118,7 @@ const AICoach: React.FC<AICoachProps> = ({ lang }) => {
const handleSend = async () => {
if (!input.trim() || !chatSessionRef.current || loading) return;
const userMsg: Message = { id: generateId(), role: 'user', text: input };
const userMsg: Message = { id: generateId(), role: 'user', text: input, timestamp: Date.now() };
setMessages(prev => [...prev, userMsg]);
setInput('');
setLoading(true);
@@ -69,27 +130,42 @@ const AICoach: React.FC<AICoachProps> = ({ lang }) => {
const aiMsg: Message = {
id: generateId(),
role: 'model',
text: text || "Error generating response."
text: text || "Error generating response.",
timestamp: Date.now()
};
setMessages(prev => [...prev, aiMsg]);
} catch (err) {
console.error(err);
let errorText = 'Connection error.';
if (err instanceof Error) {
try {
const json = JSON.parse(err.message);
if (json.error) errorText = json.error;
else errorText = err.message;
} catch {
errorText = err.message;
}
errorText = err.message;
}
setMessages(prev => [...prev, { id: generateId(), role: 'model', text: errorText }]);
setMessages(prev => [...prev, { id: generateId(), role: 'model', text: errorText, timestamp: Date.now() }]);
} finally {
setLoading(false);
}
};
const toggleBookmark = async (msg: Message) => {
if (msg.role !== 'model') return;
if (msg.isBookmarked && msg.savedMessageId) {
// Unbookmark
const success = await deleteBookmark(msg.savedMessageId);
if (success) {
setMessages(prev => prev.map(m => m.id === msg.id ? { ...m, isBookmarked: false, savedMessageId: undefined } : m));
setSnackbar({ open: true, message: 'Bookmark removed' });
}
} else {
// Bookmark
const newBookmark = await createBookmark(msg.text, msg.role);
if (newBookmark) {
setMessages(prev => prev.map(m => m.id === msg.id ? { ...m, isBookmarked: true, savedMessageId: newBookmark.id } : m));
setSnackbar({ open: true, message: 'Message saved' });
}
}
};
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full p-6 text-center text-on-surface-variant">
@@ -101,23 +177,46 @@ const AICoach: React.FC<AICoachProps> = ({ lang }) => {
return (
<div className="flex flex-col h-full bg-surface">
{/* Header */}
<div className="p-4 bg-surface-container shadow-elevation-1 flex items-center gap-3 z-10">
<div className="w-10 h-10 rounded-full bg-secondary-container flex items-center justify-center">
<Bot size={20} className="text-on-secondary-container" />
</div>
<h2 className="text-xl font-normal text-on-surface">{t('ai_expert', lang)}</h2>
</div>
<TopBar
title={t('ai_expert', lang)}
icon={MessageSquare}
actions={
<button
onClick={() => setShowSavedMessages(true)}
className="p-2 text-on-surface hover:bg-surface-container-high rounded-full transition-colors"
title="Saved Messages"
>
<BookmarkIcon size={20} />
</button>
}
/>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 pb-4">
{messages.map((msg) => (
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] p-4 rounded-[20px] text-sm leading-relaxed shadow-sm ${msg.role === 'user'
? 'bg-primary text-on-primary rounded-br-none'
: 'bg-surface-container-high text-on-surface border border-outline-variant/20 rounded-bl-none'
}`}>
{msg.text}
<div className={`max-w-[90%] sm:max-w-[85%] relative group`}>
<div className={`p-4 rounded-[20px] text-sm leading-relaxed shadow-sm ${msg.role === 'user'
? 'bg-primary text-on-primary rounded-br-none'
: 'bg-surface-container-high text-on-surface border border-outline-variant/20 rounded-bl-none'
}`}>
<div className="prose prose-invert prose-sm max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{msg.text}
</ReactMarkdown>
</div>
</div>
{/* Bookmark Action */}
{msg.role === 'model' && (
<button
onClick={() => toggleBookmark(msg)}
className={`absolute -right-8 top-2 p-1.5 rounded-full hover:bg-surface-container-high transition-colors text-on-surface-variant ${msg.isBookmarked ? 'text-primary opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
title={msg.isBookmarked ? "Remove Bookmark" : "Bookmark Message"}
>
{msg.isBookmarked ? <BookmarkCheck size={16} /> : <Bookmark size={16} />}
</button>
)}
</div>
</div>
))}
@@ -152,6 +251,21 @@ const AICoach: React.FC<AICoachProps> = ({ lang }) => {
</button>
</div>
</div>
<SavedMessagesSheet
isOpen={showSavedMessages}
onClose={() => setShowSavedMessages(false)}
onUnbookmark={(id) => {
setMessages(prev => prev.map(m => m.savedMessageId === id ? { ...m, isBookmarked: false, savedMessageId: undefined } : m));
}}
/>
<Snackbar
isOpen={snackbar.open}
message={snackbar.message}
onClose={() => setSnackbar({ ...snackbar, open: false })}
type="success"
/>
</div>
);
};

View File

@@ -1,5 +1,7 @@
import React, { useState } from 'react';
import { Calendar, Clock, TrendingUp, Gauge, Pencil, Trash2, X, Save, ArrowRight, ArrowUp, Timer, Activity, Dumbbell, Percent } from 'lucide-react';
import { Trash2, Calendar, Clock, ChevronDown, ChevronUp, History as HistoryIcon, Dumbbell, Ruler, Timer, Weight, Edit2, Gauge, Pencil, Save } from 'lucide-react';
import { TopBar } from './ui/TopBar';
import { WorkoutSession, ExerciseType, WorkoutSet, Language } from '../types';
import { t } from '../services/i18n';
import { formatSetMetrics } from '../utils/setFormatting';
@@ -84,7 +86,7 @@ const History: React.FC<HistoryProps> = ({ lang }) => {
const formatDateForInput = (timestamp: number) => {
const d = new Date(timestamp);
const pad = (n: number) => n < 10 ? '0' + n : n;
const pad = (n: number) => (n < 10 ? '0' + n : n);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
@@ -170,10 +172,7 @@ const History: React.FC<HistoryProps> = ({ lang }) => {
return (
<div className="h-full flex flex-col bg-surface">
<div className="p-4 bg-surface-container shadow-elevation-1 z-10 shrink-0">
<h2 className="text-2xl font-normal text-on-surface">{t('tab_history', lang)}</h2>
</div>
<TopBar title={t('tab_history', lang)} icon={HistoryIcon} />
<div className="flex-1 overflow-y-auto p-4 pb-20">
<div className="max-w-2xl mx-auto space-y-4">
{/* Regular Workout Sessions */}

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Plus, Trash2, PlayCircle, Dumbbell, Save, X, List, ArrowUp, Pencil, User, Flame, Timer as TimerIcon, Ruler, Footprints, Percent, CheckCircle, GripVertical, Bot, Loader2 } from 'lucide-react';
import { Plus, Trash2, PlayCircle, Dumbbell, Save, X, List, ArrowUp, Pencil, User, Flame, Timer as TimerIcon, Ruler, Footprints, Percent, CheckCircle, GripVertical, Bot, Loader2, ClipboardList } from 'lucide-react';
import { TopBar } from './ui/TopBar';
import {
DndContext,
closestCenter,
@@ -517,20 +518,24 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
if (isEditing) {
return (
<div className="h-full flex flex-col bg-surface">
<div className="px-4 py-3 bg-surface-container border-b border-outline-variant flex justify-between items-center shrink-0">
<Button
onClick={() => {
setIsEditing(false);
localStorage.removeItem('gymflow_plan_draft');
}}
variant="ghost" size="icon">
<X size={20} />
</Button>
<h2 className="text-title-medium font-medium text-on-surface">{t('plan_editor', lang)}</h2>
<Button onClick={handleSave} variant="ghost" className="text-primary font-medium hover:bg-primary-container/10">
{t('save', lang)}
</Button>
</div>
<TopBar
title={t('plan_editor', lang)}
actions={
<div className="flex items-center gap-1">
<Button
onClick={() => {
setIsEditing(false);
localStorage.removeItem('gymflow_plan_draft');
}}
variant="ghost" size="icon">
<X size={20} />
</Button>
<Button onClick={handleSave} variant="ghost" className="text-primary font-medium hover:bg-primary-container/10">
{t('save', lang)}
</Button>
</div>
}
/>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
<FilledInput
@@ -669,9 +674,7 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
return (
<div className="h-full flex flex-col bg-surface relative">
<div className="p-4 bg-surface-container shadow-elevation-1 z-10 shrink-0">
<h2 className="text-2xl font-normal text-on-surface">{t('my_plans', lang)}</h2>
</div>
<TopBar title={t('my_plans', lang)} icon={ClipboardList} />
<div className="flex-1 p-4 overflow-y-auto pb-24">
{plans.length === 0 ? (

View File

@@ -11,6 +11,8 @@ import { t } from '../services/i18n';
import Snackbar from './Snackbar';
import { Button } from './ui/Button';
import { Card } from './ui/Card';
import { TopBar } from './ui/TopBar';
import { Modal } from './ui/Modal';
import { SideSheet } from './ui/SideSheet';
import { Checkbox } from './ui/Checkbox';
@@ -267,15 +269,15 @@ const Profile: React.FC<ProfileProps> = ({ user, onLogout, lang, onLanguageChang
return (
<div className="h-full flex flex-col bg-surface">
<div className="p-4 bg-surface-container shadow-elevation-1 flex items-center justify-between z-10 shrink-0">
<h2 className="text-xl font-normal text-on-surface flex items-center gap-2">
<UserIcon size={20} />
{t('profile_title', lang)}
</h2>
<Button onClick={onLogout} variant="ghost" size="sm" className="text-error hover:bg-error-container/10">
<LogOut size={16} className="mr-1" /> {t('logout', lang)}
</Button>
</div>
<TopBar
title={t('profile_title', lang)}
icon={UserIcon}
actions={
<Button onClick={onLogout} variant="ghost" size="sm" className="text-error hover:bg-error-container/10">
<LogOut size={16} className="mr-1" /> {t('logout', lang)}
</Button>
}
/>
<div className="flex-1 overflow-y-auto p-4 space-y-6 pb-24">
<div className="max-w-2xl mx-auto space-y-6">

View File

@@ -0,0 +1,114 @@
import React, { useEffect, useState } from 'react';
import { SideSheet } from './ui/SideSheet';
import { SavedMessage, getBookmarks, deleteBookmark } from '../services/bookmarks';
import { Trash2, AlertCircle, BookmarkX, Loader2 } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
interface SavedMessagesSheetProps {
isOpen: boolean;
onClose: () => void;
onUnbookmark?: (id: string) => void;
}
const SavedMessagesSheet: React.FC<SavedMessagesSheetProps> = ({ isOpen, onClose, onUnbookmark }) => {
const [bookmarks, setBookmarks] = useState<SavedMessage[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchBookmarks = async () => {
setLoading(true);
setError(null);
try {
const data = await getBookmarks();
setBookmarks(data);
} catch (err: any) {
if (err.message !== 'Unauthorized') {
console.warn("Failed to fetch bookmarks:", err);
}
// Fallback to empty if API fails
setBookmarks([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (isOpen) {
fetchBookmarks();
}
}, [isOpen]);
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
try {
const success = await deleteBookmark(id);
if (success) {
setBookmarks(prev => prev.filter(b => b.id !== id));
if (onUnbookmark) {
onUnbookmark(id);
}
} else {
// Optimistic update fallback
setBookmarks(prev => prev.filter(b => b.id !== id));
if (onUnbookmark) {
onUnbookmark(id);
}
}
} catch (error) {
console.error("Delete failed", error);
}
};
return (
<SideSheet
isOpen={isOpen}
onClose={onClose}
title="Saved Messages"
width="md"
>
<div className="flex flex-col h-full">
{loading ? (
<div className="flex flex-col items-center justify-center p-8 text-on-surface-variant">
<Loader2 size={32} className="animate-spin mb-2" />
<p>Loading...</p>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center p-8 text-error">
<AlertCircle size={32} className="mb-2" />
<p>{error}</p>
</div>
) : bookmarks.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-on-surface-variant opacity-60">
<BookmarkX size={48} className="mb-4" />
<p>No saved messages yet.</p>
</div>
) : (
<div className="space-y-4 p-1">
{bookmarks.map((msg) => (
<div key={msg.id} className="bg-surface-container-high rounded-xl p-4 shadow-sm border border-outline-variant/20 relative group">
<div className="pr-8 prose prose-invert prose-sm max-w-none text-on-surface">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{msg.content}
</ReactMarkdown>
</div>
<div className="text-xs text-on-surface-variant mt-3 pt-3 border-t border-outline-variant/10 flex justify-between items-center">
<span>{new Date(msg.createdAt).toLocaleDateString()}</span>
<button
onClick={(e) => handleDelete(msg.id, e)}
className="p-2 text-on-surface-variant hover:text-error hover:bg-error/10 rounded-full transition-colors absolute top-2 right-2 opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
title="Remove bookmark"
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
</div>
)}
</div>
</SideSheet>
);
};
export default SavedMessagesSheet;

View File

@@ -4,6 +4,8 @@ import { getWeightHistory } from '../services/weight';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts';
import { t } from '../services/i18n';
import { useSession } from '../context/SessionContext';
import { TopBar } from './ui/TopBar';
import { BarChart2 } from 'lucide-react';
interface StatsProps {
lang: Language;
@@ -112,89 +114,91 @@ const Stats: React.FC<StatsProps> = ({ lang }) => {
}
return (
<div className="h-full overflow-y-auto p-4 space-y-6 pb-24 bg-surface">
<h2 className="text-3xl font-normal text-on-surface mb-2 pl-2">{t('progress', lang)}</h2>
<div className="h-full flex flex-col bg-surface">
<TopBar title={t('progress', lang)} icon={BarChart2} />
{/* Volume Chart */}
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
<div className="flex justify-between items-end mb-6">
<div>
<h3 className="text-title-medium font-medium text-on-surface">{t('volume_title', lang)}</h3>
<p className="text-xs text-on-surface-variant mt-1">{t('volume_subtitle', lang)}</p>
<div className="flex-1 overflow-y-auto p-4 space-y-6 pb-24 bg-surface">
{/* Volume Chart */}
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
<div className="flex justify-between items-end mb-6">
<div>
<h3 className="text-title-medium font-medium text-on-surface">{t('volume_title', lang)}</h3>
<p className="text-xs text-on-surface-variant mt-1">{t('volume_subtitle', lang)}</p>
</div>
</div>
<div className="h-64 min-h-64 w-full">
<ResponsiveContainer width="100%" height={256}>
<LineChart data={volumeData}>
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
<YAxis stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} tickFormatter={(val) => `${(val / 1000).toFixed(1)}k`} />
<Tooltip
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
itemStyle={{ color: '#D0BCFF' }}
formatter={(val: number) => [`${val.toLocaleString()} kg`, t('volume_title', lang)]}
/>
<Line type="monotone" dataKey="work" stroke="#D0BCFF" strokeWidth={3} dot={{ r: 4, fill: '#D0BCFF' }} activeDot={{ r: 6 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
<div className="h-64 min-h-64 w-full">
<ResponsiveContainer width="100%" height={256}>
<LineChart data={volumeData}>
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
<YAxis stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} tickFormatter={(val) => `${(val / 1000).toFixed(1)}k`} />
<Tooltip
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
itemStyle={{ color: '#D0BCFF' }}
formatter={(val: number) => [`${val.toLocaleString()} kg`, t('volume_title', lang)]}
/>
<Line type="monotone" dataKey="work" stroke="#D0BCFF" strokeWidth={3} dot={{ r: 4, fill: '#D0BCFF' }} activeDot={{ r: 6 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
{/* Sessions Count Chart */}
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
<h3 className="text-title-medium font-medium text-on-surface mb-6">{t('sessions_count_title', lang)}</h3>
<div className="h-64 min-h-64 w-full">
<ResponsiveContainer width="100%" height={256}>
<BarChart data={sessionsCountData}>
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
<YAxis stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} allowDecimals={false} />
<Tooltip
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
cursor={{ fill: 'rgba(255,255,255,0.05)' }}
/>
<Bar dataKey="sessions" fill="#4FD1C5" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
{/* Sessions Count Chart */}
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
<h3 className="text-title-medium font-medium text-on-surface mb-6">{t('sessions_count_title', lang)}</h3>
<div className="h-64 min-h-64 w-full">
<ResponsiveContainer width="100%" height={256}>
<BarChart data={sessionsCountData}>
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
<YAxis stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} allowDecimals={false} />
<Tooltip
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
cursor={{ fill: 'rgba(255,255,255,0.05)' }}
/>
<Bar dataKey="sessions" fill="#4FD1C5" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Sets Chart */}
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
<h3 className="text-title-medium font-medium text-on-surface mb-6">{t('sets_title', lang)}</h3>
<div className="h-64 min-h-64 w-full">
<ResponsiveContainer width="100%" height={256}>
<BarChart data={setsData}>
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
<YAxis stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
cursor={{ fill: 'rgba(255,255,255,0.05)' }}
/>
<Bar dataKey="sets" fill="#CCC2DC" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
{/* Sets Chart */}
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
<h3 className="text-title-medium font-medium text-on-surface mb-6">{t('sets_title', lang)}</h3>
<div className="h-64 min-h-64 w-full">
<ResponsiveContainer width="100%" height={256}>
<BarChart data={setsData}>
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
<YAxis stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
cursor={{ fill: 'rgba(255,255,255,0.05)' }}
/>
<Bar dataKey="sets" fill="#CCC2DC" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Body Weight Chart */}
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
<h3 className="text-title-medium font-medium text-on-surface mb-6">{t('weight_title', lang)}</h3>
<div className="h-64 min-h-64 w-full">
<ResponsiveContainer width="100%" height={256}>
<LineChart data={weightData}>
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
<YAxis domain={['auto', 'auto']} stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
itemStyle={{ color: '#6EE7B7' }}
formatter={(val: number) => [`${val} kg`, t('weight_kg', lang)]}
/>
<Line type="monotone" dataKey="weight" stroke="#6EE7B7" strokeWidth={3} dot={{ r: 4, fill: '#6EE7B7' }} activeDot={{ r: 6 }} />
</LineChart>
</ResponsiveContainer>
{/* Body Weight Chart */}
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
<h3 className="text-title-medium font-medium text-on-surface mb-6">{t('weight_title', lang)}</h3>
<div className="h-64 min-h-64 w-full">
<ResponsiveContainer width="100%" height={256}>
<LineChart data={weightData}>
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
<YAxis domain={['auto', 'auto']} stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
itemStyle={{ color: '#6EE7B7' }}
formatter={(val: number) => [`${val} kg`, t('weight_kg', lang)]}
/>
<Line type="monotone" dataKey="weight" stroke="#6EE7B7" strokeWidth={3} dot={{ r: 4, fill: '#6EE7B7' }} activeDot={{ r: 6 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Dumbbell, User, PlayCircle, Plus, ArrowRight } from 'lucide-react';
import { TopBar } from '../ui/TopBar';
import { Language } from '../../types';
import { t } from '../../services/i18n';
import { useTracker } from './useTracker';
@@ -77,110 +78,113 @@ const IdleView: React.FC<IdleViewProps> = ({ tracker, lang }) => {
const content = getDaysOffContent();
return (
<div className="flex flex-col h-full p-4 md:p-8 overflow-y-auto relative">
<div className="flex-1 flex flex-col items-center justify-center space-y-12">
<div className="flex flex-col items-center gap-4">
<div className="w-24 h-24 rounded-full bg-surface-container-high flex items-center justify-center text-primary shadow-elevation-1">
<Dumbbell size={40} />
</div>
<div className="text-center">
<h1 className={`text-3xl font-normal ${content.colorClass}`}>{content.title}</h1>
<p className="text-on-surface-variant text-sm">{content.subtitle}</p>
</div>
</div>
<div className="w-full max-w-sm bg-surface-container rounded-2xl p-6 flex flex-col items-center gap-4 shadow-elevation-1">
<label className="text-xs text-on-surface-variant font-bold tracking-wide flex items-center gap-2">
<User size={14} />
{t('my_weight', lang)}
</label>
<div className="flex items-center gap-4">
<input
type="number"
step="0.1"
className="text-5xl font-normal text-on-surface tabular-nums bg-transparent text-center w-full focus:outline-none"
value={userBodyWeight}
onChange={(e) => setUserBodyWeight(e.target.value)}
/>
</div>
<p className="text-[10px] text-on-surface-variant">{t('change_in_profile', lang)}</p>
</div>
<div className="w-full max-w-xs space-y-3">
<button
onClick={() => handleStart()}
className="w-full h-16 rounded-full bg-primary text-on-primary font-medium text-lg shadow-elevation-2 hover:shadow-elevation-3 active:shadow-elevation-1 transition-all flex items-center justify-center gap-2"
>
<PlayCircle size={24} />
{t('free_workout', lang)}
</button>
<button
onClick={() => setIsSporadicMode(true)}
className="w-full h-12 rounded-full bg-surface-container-high text-on-surface font-medium text-base hover:bg-surface-container-highest transition-all flex items-center justify-center gap-2"
>
<Plus size={20} />
{t('quick_log', lang)}
</button>
</div>
{plans.length > 0 ? (
<div className="w-full max-w-md mt-8">
<h3 className="text-sm text-on-surface-variant font-medium px-4 mb-3">{t('or_choose_plan', lang)}</h3>
<div className="grid grid-cols-1 gap-3">
{plans.map(plan => (
<button
key={plan.id}
onClick={() => handleStart(plan)}
className="flex items-center justify-between p-4 bg-surface-container rounded-xl hover:bg-surface-container-high transition-colors border border-outline-variant/20"
>
<div className="text-left">
<div className="text-base font-medium text-on-surface">{plan.name}</div>
<div className="text-xs text-on-surface-variant">{plan.steps.length} {t('exercises_count', lang)}</div>
</div>
<div className="w-10 h-10 rounded-full bg-secondary-container text-on-secondary-container flex items-center justify-center">
<ArrowRight size={20} />
</div>
</button>
))}
<div className="flex flex-col h-full bg-surface">
<TopBar title="Tracker" icon={Dumbbell} />
<div className="flex-1 p-4 md:p-8 overflow-y-auto relative">
<div className="flex-1 flex flex-col items-center justify-center space-y-12">
<div className="flex flex-col items-center gap-4">
<div className="w-24 h-24 rounded-full bg-surface-container-high flex items-center justify-center text-primary shadow-elevation-1">
<Dumbbell size={40} />
</div>
<div className="text-center">
<h1 className={`text-3xl font-normal ${content.colorClass}`}>{content.title}</h1>
<p className="text-on-surface-variant text-sm">{content.subtitle}</p>
</div>
</div>
) : (
<div className="w-full max-w-md mt-8 text-center p-6 bg-surface-container rounded-2xl border border-outline-variant/20">
<p className="text-on-surface-variant mb-4">{t('no_plans_yet', lang)}</p>
<div className="flex flex-col gap-2">
<a
href="/plans?aiPrompt=true"
className="text-primary font-medium hover:underline"
>
{t('ask_ai_to_create', lang)}
</a>
<a
href="/plans?create=true"
className="text-primary font-medium hover:underline"
>
{t('create_manually', lang)}
</a>
<div className="w-full max-w-sm bg-surface-container rounded-2xl p-6 flex flex-col items-center gap-4 shadow-elevation-1">
<label className="text-xs text-on-surface-variant font-bold tracking-wide flex items-center gap-2">
<User size={14} />
{t('my_weight', lang)}
</label>
<div className="flex items-center gap-4">
<input
type="number"
step="0.1"
className="text-5xl font-normal text-on-surface tabular-nums bg-transparent text-center w-full focus:outline-none"
value={userBodyWeight}
onChange={(e) => setUserBodyWeight(e.target.value)}
/>
</div>
<p className="text-[10px] text-on-surface-variant">{t('change_in_profile', lang)}</p>
</div>
<div className="w-full max-w-xs space-y-3">
<button
onClick={() => handleStart()}
className="w-full h-16 rounded-full bg-primary text-on-primary font-medium text-lg shadow-elevation-2 hover:shadow-elevation-3 active:shadow-elevation-1 transition-all flex items-center justify-center gap-2"
>
<PlayCircle size={24} />
{t('free_workout', lang)}
</button>
<button
onClick={() => setIsSporadicMode(true)}
className="w-full h-12 rounded-full bg-surface-container-high text-on-surface font-medium text-base hover:bg-surface-container-highest transition-all flex items-center justify-center gap-2"
>
<Plus size={20} />
{t('quick_log', lang)}
</button>
</div>
{plans.length > 0 ? (
<div className="w-full max-w-md mt-8">
<h3 className="text-sm text-on-surface-variant font-medium px-4 mb-3">{t('or_choose_plan', lang)}</h3>
<div className="grid grid-cols-1 gap-3">
{plans.map(plan => (
<button
key={plan.id}
onClick={() => handleStart(plan)}
className="flex items-center justify-between p-4 bg-surface-container rounded-xl hover:bg-surface-container-high transition-colors border border-outline-variant/20"
>
<div className="text-left">
<div className="text-base font-medium text-on-surface">{plan.name}</div>
<div className="text-xs text-on-surface-variant">{plan.steps.length} {t('exercises_count', lang)}</div>
</div>
<div className="w-10 h-10 rounded-full bg-secondary-container text-on-secondary-container flex items-center justify-center">
<ArrowRight size={20} />
</div>
</button>
))}
</div>
</div>
) : (
<div className="w-full max-w-md mt-8 text-center p-6 bg-surface-container rounded-2xl border border-outline-variant/20">
<p className="text-on-surface-variant mb-4">{t('no_plans_yet', lang)}</p>
<div className="flex flex-col gap-2">
<a
href="/plans?aiPrompt=true"
className="text-primary font-medium hover:underline"
>
{t('ask_ai_to_create', lang)}
</a>
<a
href="/plans?create=true"
className="text-primary font-medium hover:underline"
>
{t('create_manually', lang)}
</a>
</div>
</div>
)}
</div>
{showPlanPrep && (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-6 backdrop-blur-sm">
<div className="w-full max-w-sm bg-surface-container rounded-[28px] p-6 shadow-elevation-3">
<h3 className="text-2xl font-normal text-on-surface mb-4">{showPlanPrep.name}</h3>
<div className="bg-surface-container-high p-4 rounded-xl text-on-surface-variant text-sm mb-8">
<div className="text-xs font-bold text-primary mb-2">{t('prep_title', lang)}</div>
{showPlanPrep.description || t('prep_no_instructions', lang)}
</div>
<div className="flex justify-end gap-2">
<button onClick={() => setShowPlanPrep(null)} className="px-6 py-2.5 rounded-full text-primary font-medium hover:bg-white/5">{t('cancel', lang)}</button>
<button onClick={confirmPlanStart} className="px-6 py-2.5 rounded-full bg-primary text-on-primary font-medium">{t('start', lang)}</button>
</div>
</div>
</div>
)}
</div>
{showPlanPrep && (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-6 backdrop-blur-sm">
<div className="w-full max-w-sm bg-surface-container rounded-[28px] p-6 shadow-elevation-3">
<h3 className="text-2xl font-normal text-on-surface mb-4">{showPlanPrep.name}</h3>
<div className="bg-surface-container-high p-4 rounded-xl text-on-surface-variant text-sm mb-8">
<div className="text-xs font-bold text-primary mb-2">{t('prep_title', lang)}</div>
{showPlanPrep.description || t('prep_no_instructions', lang)}
</div>
<div className="flex justify-end gap-2">
<button onClick={() => setShowPlanPrep(null)} className="px-6 py-2.5 rounded-full text-primary font-medium hover:bg-white/5">{t('cancel', lang)}</button>
<button onClick={confirmPlanStart} className="px-6 py-2.5 rounded-full bg-primary text-on-primary font-medium">{t('start', lang)}</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
interface TopBarProps {
title: string;
icon?: LucideIcon;
actions?: React.ReactNode;
}
export const TopBar: React.FC<TopBarProps> = ({ title, icon: Icon, actions }) => {
return (
<div className="p-4 bg-surface-container shadow-elevation-1 flex items-center gap-3 z-10 shrink-0">
{Icon && (
<div className="w-10 h-10 rounded-full bg-secondary-container flex items-center justify-center">
<Icon size={20} className="text-on-secondary-container" />
</div>
)}
<h2 className="text-xl font-normal text-on-surface flex-1">{title}</h2>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
);
};

View File

@@ -12,11 +12,30 @@ const headers = () => {
};
};
const handleResponse = async (res: Response) => {
if (res.status === 401) {
removeAuthToken();
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
throw new Error('Unauthorized');
}
if (!res.ok) {
const text = await res.text();
try {
const json = JSON.parse(text);
throw new Error(json.message || json.error || text);
} catch {
throw new Error(text);
}
}
return res.json();
};
export const api = {
get: async <T = any>(endpoint: string): Promise<T> => {
const res = await fetch(`${API_URL}${endpoint}`, { headers: headers() });
if (!res.ok) throw new Error(await res.text());
return res.json();
return handleResponse(res);
},
post: async <T = any>(endpoint: string, data: any): Promise<T> => {
const res = await fetch(`${API_URL}${endpoint}`, {
@@ -24,8 +43,7 @@ export const api = {
headers: headers(),
body: JSON.stringify(data)
});
if (!res.ok) throw new Error(await res.text());
return res.json();
return handleResponse(res);
},
put: async <T = any>(endpoint: string, data: any): Promise<T> => {
const res = await fetch(`${API_URL}${endpoint}`, {
@@ -33,16 +51,14 @@ export const api = {
headers: headers(),
body: JSON.stringify(data)
});
if (!res.ok) throw new Error(await res.text());
return res.json();
return handleResponse(res);
},
delete: async <T = any>(endpoint: string): Promise<T> => {
const res = await fetch(`${API_URL}${endpoint}`, {
method: 'DELETE',
headers: headers()
});
if (!res.ok) throw new Error(await res.text());
return res.json();
return handleResponse(res);
},
patch: async <T = any>(endpoint: string, data: any): Promise<T> => {
const res = await fetch(`${API_URL}${endpoint}`, {
@@ -50,7 +66,6 @@ export const api = {
headers: headers(),
body: JSON.stringify(data)
});
if (!res.ok) throw new Error(await res.text());
return res.json();
return handleResponse(res);
}
};

44
src/services/bookmarks.ts Normal file
View File

@@ -0,0 +1,44 @@
import { api } from './api';
export interface SavedMessage {
id: string;
content: string;
role: string;
createdAt: string;
}
interface ApiResponse<T> {
success: boolean;
data: T;
error?: string;
}
export const getBookmarks = async (): Promise<SavedMessage[]> => {
try {
const res = await api.get<ApiResponse<SavedMessage[]>>('/bookmarks');
return res.data || [];
} catch (e) {
console.error('Failed to fetch bookmarks:', e);
return [];
}
};
export const createBookmark = async (content: string, role: string = 'model'): Promise<SavedMessage | null> => {
try {
const res = await api.post<ApiResponse<SavedMessage>>('/bookmarks', { content, role });
return res.data || null;
} catch (e) {
console.error('Failed to create bookmark:', e);
return null;
}
};
export const deleteBookmark = async (id: string): Promise<boolean> => {
try {
await api.delete(`/bookmarks/${id}`);
return true;
} catch (e) {
console.error('Failed to delete bookmark:', e);
return false;
}
};

92
tests/ai-coach.spec.ts Normal file
View File

@@ -0,0 +1,92 @@
import { test, expect } from './fixtures';
test.describe('VII. AI Coach Features', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
// Helper to handle first login if needed (copied from core-auth)
async function handleFirstLogin(page: any) {
try {
const heading = page.getByRole('heading', { name: /Change Password/i });
const dashboard = page.getByText('Free Workout');
await expect(heading).toBeVisible({ timeout: 5000 });
await page.getByLabel('New Password').fill('StrongNewPass123!');
await page.getByRole('button', { name: /Save|Change/i }).click();
await expect(dashboard).toBeVisible();
} catch (e) {
if (await page.getByText('Free Workout').isVisible()) return;
}
}
test('7.1 AI Coach - Basic Conversation & Markdown', async ({ page, createUniqueUser }) => {
const user = await createUniqueUser();
// Login
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password').fill(user.password);
await page.getByRole('button', { name: 'Login' }).click();
await handleFirstLogin(page);
// Navigate to AI Coach
await page.getByText('AI Coach').click();
// Type message
const input = page.getByPlaceholder(/Ask your AI coach/i);
await input.fill('How to do a pushup?');
await page.getByRole('button', { name: /Send/i }).click();
// Verify response (Mocked or Real - expecting Real from previous context)
// Since we can't easily mock backend without more setup, we wait for *any* response
await expect(page.locator('.prose')).toBeVisible({ timeout: 15000 });
// Check for markdown rendering (e.g., strong tags or list items if AI returns them)
// This is a bit flaky with real AI, but checking for visibility is a good start.
});
test('7.2, 7.3, 7.4 AI Coach - Bookmark Flow', async ({ page, createUniqueUser }) => {
const user = await createUniqueUser();
// Login
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password').fill(user.password);
await page.getByRole('button', { name: 'Login' }).click();
await handleFirstLogin(page);
await page.getByText('AI Coach').click();
// Send message
await page.getByPlaceholder(/Ask your AI coach/i).fill('Tell me a short fitness tip');
await page.getByRole('button', { name: /Send/i }).click();
// Wait for response bubble
const responseBubble = page.locator('.prose').first();
await expect(responseBubble).toBeVisible({ timeout: 15000 });
// 7.2 Bookmark
// Find bookmark button within the message container.
// Assuming the layout puts actions near the message.
// We look for the Bookmark icon button.
const bookmarkBtn = page.getByRole('button', { name: /Bookmark/i }).first();
await bookmarkBtn.click();
// Expect success snackbar
await expect(page.getByText(/Message saved/i)).toBeVisible();
// 7.3 View Saved
await page.getByRole('button', { name: /Saved/i }).click(); // The TopBar action
await expect(page.getByText('Saved Messages')).toBeVisible(); // Sheet title
// Verify content is there
await expect(page.getByText(/fitness tip/i)).toBeVisible(); // Part of our prompt/response context usually
// 7.4 Delete Bookmark
const deleteBtn = page.getByRole('button', { name: /Delete/i }).first();
await deleteBtn.click();
// Verify removal
await expect(deleteBtn).not.toBeVisible();
});
});

View File

@@ -0,0 +1,136 @@
import { test, expect } from './fixtures';
test.describe('Reproduction - Edit Modal Fields', () => {
test('Verify Edit Fields for different Exercise Types', async ({ page, createUniqueUser, request }) => {
const user = await createUniqueUser();
// Login
await page.goto('/');
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password').fill(user.password);
await page.getByRole('button', { name: 'Login' }).click();
// Wait for dashboard or password change
try {
const heading = page.getByRole('heading', { name: /Change Password/i });
const dashboard = page.getByText('Free Workout');
await expect(heading.or(dashboard)).toBeVisible({ timeout: 10000 });
if (await heading.isVisible()) {
await page.getByLabel('New Password').fill('StrongNewPass123!');
await page.getByRole('button', { name: /Save|Change/i }).click();
await expect(dashboard).toBeVisible();
}
} catch (e) {
console.log('Login flow exception (might be benign if already logged in):', e);
}
// Seed exercises of different types
const types = [
{ type: 'PLYOMETRIC', name: 'Plyo Test', expectedFields: ['Reps'] },
{ type: 'STRENGTH', name: 'Strength Test', expectedFields: ['Weight', 'Reps'] },
{ type: 'CARDIO', name: 'Cardio Test', expectedFields: ['Time', 'Distance'] },
{ type: 'STATIC', name: 'Static Test', expectedFields: ['Time', 'Weight', 'Body Weight'] }, // Check if Weight is expected based on History.tsx analysis
{ type: 'BODYWEIGHT', name: 'Bodyweight Test', expectedFields: ['Reps', 'Body Weight', 'Weight'] },
{ type: 'HIGH_JUMP', name: 'High Jump Test', expectedFields: ['Height'] },
{ type: 'LONG_JUMP', name: 'Long Jump Test', expectedFields: ['Distance'] },
];
const exIds: Record<string, string> = {};
for (const t of types) {
const resp = await request.post('/api/exercises', {
data: { name: t.name, type: t.type },
headers: { 'Authorization': `Bearer ${user.token}` }
});
expect(resp.ok()).toBeTruthy();
const created = await resp.json();
// Adjust if the response structure is different (e.g. created.exercise)
exIds[t.name] = created.id || created.exercise?.id || created.data?.id;
}
await page.reload();
// Construct a session payload
const now = Date.now();
const setsStub = types.map(t => {
const set: any = {
exerciseId: exIds[t.name],
timestamp: now + 1000,
completed: true
};
if (t.type === 'STRENGTH' || t.type === 'BODYWEIGHT' || t.type === 'PLYOMETRIC') set.reps = 10;
if (t.type === 'STRENGTH' || t.type === 'BODYWEIGHT' || t.type === 'STATIC') set.weight = 50;
if (t.type === 'BODYWEIGHT' || t.type === 'STATIC') set.bodyWeightPercentage = 100;
if (t.type === 'CARDIO' || t.type === 'STATIC') set.durationSeconds = 60;
if (t.type === 'CARDIO' || t.type === 'LONG_JUMP') set.distanceMeters = 100;
if (t.type === 'HIGH_JUMP') set.height = 150;
return set;
});
const sessionResp = await request.post('/api/sessions', {
data: {
startTime: now,
endTime: now + 3600000,
type: 'STANDARD', // History shows STANDARD sessions differently than QUICK_LOG
sets: setsStub
},
headers: { 'Authorization': `Bearer ${user.token}` }
});
if (!sessionResp.ok()) {
console.log('Session Create Error:', await sessionResp.text());
}
expect(sessionResp.ok()).toBeTruthy();
// Go to History
await page.getByRole('button', { name: 'History' }).first().click();
// Find the session card and click Edit (Pencil icon)
// There should be only one session
await page.locator('.lucide-pencil').first().click();
await expect(page.getByText('Edit', { exact: true })).toBeVisible();
// Now verify fields for each exercise in the modal
for (const t of types) {
const exRow = page.locator('div').filter({ hasText: t.name }).last(); // Find the row for this exercise
// This locator might be tricky if the row structure is complex.
// In History.tsx:
// {editingSession.sets.map((set, idx) => (
// <div key={set.id} ...>
// ... <span>{set.exerciseName}</span> ...
// <div className="grid ..."> inputs here </div>
// </div>
// ))}
// So we find the container that has the exercise name, then look for inputs inside it.
const row = page.locator('.bg-surface-container-low').filter({ hasText: t.name }).first();
await expect(row).toBeVisible();
console.log(`Checking fields for ${t.type} (${t.name})...`);
for (const field of t.expectedFields) {
// Map field name to label text actually used in History.tsx
// t('weight_kg', lang) -> "Weight" (assuming en)
// t('reps', lang) -> "Reps"
// t('time_sec', lang) -> "Time"
// t('dist_m', lang) -> "Distance"
// t('height_cm', lang) -> "Height"
// t('body_weight_percent', lang) -> "Body Weight %"
let labelPattern: RegExp;
if (field === 'Weight') labelPattern = /Weight/i;
else if (field === 'Reps') labelPattern = /Reps/i;
else if (field === 'Time') labelPattern = /Time/i;
else if (field === 'Distance') labelPattern = /Distance|Dist/i;
else if (field === 'Height') labelPattern = /Height/i;
else if (field === 'Body Weight') labelPattern = /Body Weight/i;
else labelPattern = new RegExp(field, 'i');
await expect(row.getByLabel(labelPattern).first()).toBeVisible({ timeout: 2000 })
.catch(() => { throw new Error(`Missing field '${field}' for type '${t.type}'`); });
}
}
});
});