AI Coach messages bookmarking. Top bar refined.
This commit is contained in:
1471
package-lock.json
generated
1471
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
22
server/.env
Normal 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.
@@ -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
|
||||
);
|
||||
@@ -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 {
|
||||
|
||||
BIN
server/prod.db
BIN
server/prod.db
Binary file not shown.
79
server/src/controllers/bookmarks.controller.ts
Normal file
79
server/src/controllers/bookmarks.controller.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
13
server/src/routes/bookmarks.ts
Normal file
13
server/src/routes/bookmarks.ts
Normal 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;
|
||||
BIN
server/test.db
BIN
server/test.db
Binary file not shown.
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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">
|
||||
|
||||
114
src/components/SavedMessagesSheet.tsx
Normal file
114
src/components/SavedMessagesSheet.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
22
src/components/ui/TopBar.tsx
Normal file
22
src/components/ui/TopBar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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
44
src/services/bookmarks.ts
Normal 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
92
tests/ai-coach.spec.ts
Normal 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();
|
||||
});
|
||||
|
||||
});
|
||||
136
tests/repro_edit_fields.spec.ts
Normal file
136
tests/repro_edit_fields.spec.ts
Normal 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}'`); });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user