Compare commits
3 Commits
684481fd2c
...
fc12cecd30
| Author | SHA1 | Date | |
|---|---|---|---|
| fc12cecd30 | |||
| 803c205994 | |||
| 28b5b52a8f |
Binary file not shown.
49
apps/admin/scripts/create-fitness-goals.js
Normal file
49
apps/admin/scripts/create-fitness-goals.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '../data/fitai.db');
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Creating fitness_goals table...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS fitness_goals (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
fitness_profile_id TEXT,
|
||||||
|
goal_type TEXT NOT NULL CHECK(goal_type IN ('weight_target', 'strength_milestone', 'endurance_target', 'flexibility_goal', 'habit_building', 'custom')),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
target_value REAL,
|
||||||
|
current_value REAL,
|
||||||
|
unit TEXT,
|
||||||
|
start_date INTEGER NOT NULL,
|
||||||
|
target_date INTEGER,
|
||||||
|
completed_date INTEGER,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'completed', 'abandoned', 'paused')),
|
||||||
|
progress REAL DEFAULT 0,
|
||||||
|
priority TEXT DEFAULT 'medium' CHECK(priority IN ('low', 'medium', 'high')),
|
||||||
|
notes TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (fitness_profile_id) REFERENCES fitness_profiles(userId) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create indexes for better query performance
|
||||||
|
db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fitness_goals_user_id ON fitness_goals(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fitness_goals_status ON fitness_goals(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fitness_goals_profile_id ON fitness_goals(fitness_profile_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('✅ fitness_goals table created successfully');
|
||||||
|
console.log('✅ Indexes created successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Migration failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
39
apps/admin/src/app/api/fitness-goals/[id]/complete/route.ts
Normal file
39
apps/admin/src/app/api/fitness-goals/[id]/complete/route.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { auth } from '@clerk/nextjs/server';
|
||||||
|
import { getDatabase } from '@/lib/database';
|
||||||
|
|
||||||
|
// POST - Mark goal as complete
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { userId } = await auth();
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const db = await getDatabase();
|
||||||
|
|
||||||
|
// Verify goal exists and user owns it
|
||||||
|
const existingGoal = await db.getFitnessGoalById(id);
|
||||||
|
if (!existingGoal) {
|
||||||
|
return NextResponse.json({ error: 'Goal not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (existingGoal.userId !== userId) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as completed
|
||||||
|
const completedGoal = await db.completeGoal(id);
|
||||||
|
|
||||||
|
return NextResponse.json(completedGoal);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error completing fitness goal:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
119
apps/admin/src/app/api/fitness-goals/[id]/route.ts
Normal file
119
apps/admin/src/app/api/fitness-goals/[id]/route.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { auth } from '@clerk/nextjs/server';
|
||||||
|
import { getDatabase } from '@/lib/database';
|
||||||
|
|
||||||
|
// GET - Get specific goal
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { userId } = await auth();
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const db = await getDatabase();
|
||||||
|
|
||||||
|
const goal = await db.getFitnessGoalById(id);
|
||||||
|
|
||||||
|
if (!goal) {
|
||||||
|
return NextResponse.json({ error: 'Goal not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
if (goal.userId !== userId) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(goal);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching fitness goal:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT - Update goal
|
||||||
|
export async function PUT(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { userId } = await auth();
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const db = await getDatabase();
|
||||||
|
|
||||||
|
// Verify goal exists and user owns it
|
||||||
|
const existingGoal = await db.getFitnessGoalById(id);
|
||||||
|
if (!existingGoal) {
|
||||||
|
return NextResponse.json({ error: 'Goal not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (existingGoal.userId !== userId) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = await req.json();
|
||||||
|
|
||||||
|
// Don't allow changing userId or id
|
||||||
|
delete updates.userId;
|
||||||
|
delete updates.id;
|
||||||
|
delete updates.createdAt;
|
||||||
|
|
||||||
|
const updatedGoal = await db.updateFitnessGoal(id, updates);
|
||||||
|
|
||||||
|
return NextResponse.json(updatedGoal);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating fitness goal:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE - Delete goal
|
||||||
|
export async function DELETE(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { userId } = await auth();
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const db = await getDatabase();
|
||||||
|
|
||||||
|
// Verify goal exists and user owns it
|
||||||
|
const existingGoal = await db.getFitnessGoalById(id);
|
||||||
|
if (!existingGoal) {
|
||||||
|
return NextResponse.json({ error: 'Goal not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (existingGoal.userId !== userId) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await db.deleteFitnessGoal(id);
|
||||||
|
|
||||||
|
if (deleted) {
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: 'Failed to delete goal' }, { status: 500 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting fitness goal:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
apps/admin/src/app/api/fitness-goals/route.ts
Normal file
95
apps/admin/src/app/api/fitness-goals/route.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { auth } from '@clerk/nextjs/server';
|
||||||
|
import { getDatabase } from '@/lib/database';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
|
||||||
|
// GET - List user's fitness goals
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { userId } = await auth();
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const targetUserId = searchParams.get('userId') || userId;
|
||||||
|
const status = searchParams.get('status'); // active, completed, all
|
||||||
|
|
||||||
|
const db = await getDatabase();
|
||||||
|
|
||||||
|
// Fetch goals with optional status filter
|
||||||
|
const goals = await db.getFitnessGoalsByUserId(
|
||||||
|
targetUserId,
|
||||||
|
status && status !== 'all' ? status : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(goals);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching fitness goals:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST - Create new fitness goal
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { userId } = await auth();
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const {
|
||||||
|
goalType,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
targetValue,
|
||||||
|
currentValue,
|
||||||
|
unit,
|
||||||
|
targetDate,
|
||||||
|
priority,
|
||||||
|
notes,
|
||||||
|
fitnessProfileId
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!goalType || !title) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'goalType and title are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getDatabase();
|
||||||
|
|
||||||
|
// Create the goal
|
||||||
|
const goal = await db.createFitnessGoal({
|
||||||
|
id: randomBytes(16).toString('hex'),
|
||||||
|
userId,
|
||||||
|
fitnessProfileId: fitnessProfileId || undefined,
|
||||||
|
goalType,
|
||||||
|
title,
|
||||||
|
description: description || undefined,
|
||||||
|
targetValue: targetValue || undefined,
|
||||||
|
currentValue: currentValue || 0,
|
||||||
|
unit: unit || undefined,
|
||||||
|
startDate: new Date(),
|
||||||
|
targetDate: targetDate ? new Date(targetDate) : undefined,
|
||||||
|
status: 'active',
|
||||||
|
progress: 0,
|
||||||
|
priority: priority || 'medium',
|
||||||
|
notes: notes || undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(goal, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating fitness goal:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getDatabase } from "@/lib/database";
|
import { getDatabase } from "@/lib/database";
|
||||||
|
import { buildAIContext } from "@/lib/ai/ai-context";
|
||||||
|
import { buildEnhancedPrompt, buildBasicPrompt } from "@/lib/ai/prompt-builder";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
@ -21,26 +23,17 @@ export async function POST(req: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construct prompt
|
// Build AI context with goals and recommendations
|
||||||
const prompt = `
|
let prompt: string;
|
||||||
You are a professional fitness trainer and nutritionist.
|
try {
|
||||||
Generate a detailed daily recommendation for a user with the following profile:
|
const context = await buildAIContext(userId);
|
||||||
- Height: ${profile.height} cm
|
prompt = buildEnhancedPrompt(context);
|
||||||
- Weight: ${profile.weight} kg
|
console.log('Using enhanced AI context with goals and history');
|
||||||
- Age: ${profile.age}
|
} catch (error) {
|
||||||
- Gender: ${profile.gender}
|
// Fallback to basic prompt if context building fails
|
||||||
- Goal: ${profile.fitnessGoals.join(", ")}
|
console.warn('Failed to build AI context, using basic prompt:', error);
|
||||||
- Activity Level: ${profile.activityLevel}
|
prompt = buildBasicPrompt(profile);
|
||||||
- Medical Conditions: ${profile.medicalConditions || "None"}
|
|
||||||
- Injuries: ${profile.injuries || "None"}
|
|
||||||
|
|
||||||
Please provide the response in the following JSON format ONLY, no other text. Do not use markdown formatting or code blocks:
|
|
||||||
{
|
|
||||||
"recommendationText": "General advice and motivation for today.",
|
|
||||||
"activityPlan": "Detailed workout or activity plan for today.",
|
|
||||||
"dietPlan": "Detailed meal plan for today."
|
|
||||||
}
|
}
|
||||||
`;
|
|
||||||
|
|
||||||
let parsedResponse;
|
let parsedResponse;
|
||||||
|
|
||||||
|
|||||||
88
apps/admin/src/lib/ai/ai-context.ts
Normal file
88
apps/admin/src/lib/ai/ai-context.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { getDatabase } from '@/lib/database';
|
||||||
|
import type { FitnessGoal } from '@/lib/database/types';
|
||||||
|
|
||||||
|
// Import types from database package
|
||||||
|
import type {
|
||||||
|
FitnessProfile as DBFitnessProfile,
|
||||||
|
Recommendation as DBRecommendation,
|
||||||
|
} from '@fitai/database';
|
||||||
|
|
||||||
|
export interface AIContext {
|
||||||
|
profile: DBFitnessProfile;
|
||||||
|
activeGoals: FitnessGoal[];
|
||||||
|
completedGoals: FitnessGoal[];
|
||||||
|
recentRecommendations: DBRecommendation[];
|
||||||
|
progressSummary: {
|
||||||
|
goalsCompleted: number;
|
||||||
|
goalsActive: number;
|
||||||
|
averageProgress: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build comprehensive AI context for a user
|
||||||
|
* Aggregates fitness profile, goals, and recommendation history
|
||||||
|
*/
|
||||||
|
export async function buildAIContext(userId: string): Promise<AIContext> {
|
||||||
|
const db = await getDatabase();
|
||||||
|
|
||||||
|
// Fetch fitness profile
|
||||||
|
const profile = await db.getFitnessProfileByUserId(userId);
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
throw new Error(`Fitness profile not found for user ${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all fitness goals
|
||||||
|
const allGoals = await db.getFitnessGoalsByUserId(userId);
|
||||||
|
|
||||||
|
// Separate active and completed goals
|
||||||
|
const activeGoals = allGoals.filter((g) => g.status === 'active');
|
||||||
|
const completedGoals = allGoals.filter((g) => g.status === 'completed');
|
||||||
|
|
||||||
|
// Get all recommendations
|
||||||
|
const allRecommendations = await db.getRecommendationsByUserId(userId);
|
||||||
|
|
||||||
|
// Filter recent recommendations (last 7 days)
|
||||||
|
const sevenDaysAgo = new Date();
|
||||||
|
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||||
|
|
||||||
|
const recentRecommendations = allRecommendations
|
||||||
|
.filter((r) => new Date(r.createdAt) > sevenDaysAgo)
|
||||||
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
// Calculate progress summary
|
||||||
|
const progressSummary = {
|
||||||
|
goalsCompleted: completedGoals.length,
|
||||||
|
goalsActive: activeGoals.length,
|
||||||
|
averageProgress:
|
||||||
|
activeGoals.length > 0
|
||||||
|
? activeGoals.reduce((sum, g) => sum + (g.progress || 0), 0) / activeGoals.length
|
||||||
|
: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
profile,
|
||||||
|
activeGoals,
|
||||||
|
completedGoals,
|
||||||
|
recentRecommendations,
|
||||||
|
progressSummary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format goal for display in AI prompt
|
||||||
|
*/
|
||||||
|
export function formatGoalForPrompt(goal: FitnessGoal): string {
|
||||||
|
const progress = goal.progress || 0;
|
||||||
|
const current = goal.currentValue || 0;
|
||||||
|
const target = goal.targetValue;
|
||||||
|
const unit = goal.unit || '';
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
return `${goal.title}: ${current}/${target} ${unit} (${progress.toFixed(1)}% complete)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${goal.title}: ${progress.toFixed(1)}% complete`;
|
||||||
|
}
|
||||||
91
apps/admin/src/lib/ai/prompt-builder.ts
Normal file
91
apps/admin/src/lib/ai/prompt-builder.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import type { AIContext } from './ai-context';
|
||||||
|
import { formatGoalForPrompt } from './ai-context';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build enhanced AI prompt with comprehensive user context
|
||||||
|
* Includes profile, goals, progress, and recommendation history
|
||||||
|
*/
|
||||||
|
export function buildEnhancedPrompt(context: AIContext): string {
|
||||||
|
const { profile, activeGoals, completedGoals, recentRecommendations, progressSummary } = context;
|
||||||
|
|
||||||
|
// Build goals section
|
||||||
|
const activeGoalsText =
|
||||||
|
activeGoals.length > 0
|
||||||
|
? activeGoals.map((g) => `- ${formatGoalForPrompt(g)}`).join('\n')
|
||||||
|
: '- No active goals set';
|
||||||
|
|
||||||
|
const completedGoalsText =
|
||||||
|
completedGoals.length > 0
|
||||||
|
? completedGoals.slice(0, 3).map((g) => g.title).join(', ')
|
||||||
|
: 'None yet';
|
||||||
|
|
||||||
|
// Build context about recent recommendations
|
||||||
|
const recommendationContextText =
|
||||||
|
recentRecommendations.length > 0
|
||||||
|
? `The user has received ${recentRecommendations.length} recommendations in the past week. Consider their progress and avoid repetitive advice.`
|
||||||
|
: 'This is a new user or they haven\'t received recent recommendations. Provide comprehensive guidance.';
|
||||||
|
|
||||||
|
return `You are a professional fitness trainer and nutritionist with access to the user's complete fitness journey.
|
||||||
|
|
||||||
|
## User Profile
|
||||||
|
- Height: ${profile.height || 'Not specified'} cm
|
||||||
|
- Weight: ${profile.weight || 'Not specified'} kg
|
||||||
|
- Age: ${profile.age || 'Not specified'}
|
||||||
|
- Gender: ${profile.gender || 'Not specified'}
|
||||||
|
- Primary Goal: ${profile.fitnessGoal || 'General fitness'}
|
||||||
|
- Activity Level: ${profile.activityLevel || 'Not specified'}
|
||||||
|
- Medical Conditions: ${profile.medicalConditions || 'None'}
|
||||||
|
- Allergies: ${profile.allergies || 'None'}
|
||||||
|
- Injuries: ${profile.injuries || 'None'}
|
||||||
|
|
||||||
|
## Active Goals (${activeGoals.length})
|
||||||
|
${activeGoalsText}
|
||||||
|
|
||||||
|
## Recent Progress
|
||||||
|
- Goals Completed: ${progressSummary.goalsCompleted}
|
||||||
|
- Active Goals: ${progressSummary.goalsActive}
|
||||||
|
- Average Progress on Active Goals: ${progressSummary.averageProgress.toFixed(1)}%
|
||||||
|
${completedGoals.length > 0 ? `- Recently Completed: ${completedGoalsText}` : ''}
|
||||||
|
|
||||||
|
## Recommendation History
|
||||||
|
${recommendationContextText}
|
||||||
|
|
||||||
|
## Task
|
||||||
|
Generate a personalized daily recommendation that:
|
||||||
|
1. Acknowledges their progress on active goals
|
||||||
|
2. Provides specific, actionable steps toward goal completion
|
||||||
|
3. Builds on previous recommendations without being repetitive
|
||||||
|
4. Adapts to their current progress level
|
||||||
|
5. Takes into account their medical conditions, allergies, and injuries
|
||||||
|
|
||||||
|
Respond in the following JSON format ONLY, no other text. Do not use markdown formatting or code blocks:
|
||||||
|
{
|
||||||
|
"recommendationText": "Personalized motivation and progress acknowledgment (2-3 sentences)",
|
||||||
|
"activityPlan": "Specific workout plan aligned with active goals (detailed, 3-5 exercises or activities)",
|
||||||
|
"dietPlan": "Nutrition plan supporting their goals and restrictions (detailed, include meals and portions)"
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build basic prompt for users without complete context
|
||||||
|
* Fallback when goals or other data is missing
|
||||||
|
*/
|
||||||
|
export function buildBasicPrompt(profile: any): string {
|
||||||
|
return `You are a professional fitness trainer and nutritionist.
|
||||||
|
Generate a detailed daily recommendation for a user with the following profile:
|
||||||
|
- Height: ${profile.height} cm
|
||||||
|
- Weight: ${profile.weight} kg
|
||||||
|
- Age: ${profile.age}
|
||||||
|
- Gender: ${profile.gender}
|
||||||
|
- Goal: ${profile.fitnessGoal}
|
||||||
|
- Activity Level: ${profile.activityLevel}
|
||||||
|
- Medical Conditions: ${profile.medicalConditions || "None"}
|
||||||
|
- Injuries: ${profile.injuries || "None"}
|
||||||
|
|
||||||
|
Please provide the response in the following JSON format ONLY, no other text. Do not use markdown formatting or code blocks:
|
||||||
|
{
|
||||||
|
"recommendationText": "General advice and motivation for today.",
|
||||||
|
"activityPlan": "Detailed workout or activity plan for today.",
|
||||||
|
"dietPlan": "Detailed meal plan for today."
|
||||||
|
}`;
|
||||||
|
}
|
||||||
@ -614,6 +614,174 @@ export class SQLiteDatabase implements IDatabase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fitness Goals operations
|
||||||
|
async createFitnessGoal(goalData: Omit<import('./types').FitnessGoal, 'createdAt' | 'updatedAt'>): Promise<import('./types').FitnessGoal> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const goal: import('./types').FitnessGoal = {
|
||||||
|
...goalData,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
}
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(
|
||||||
|
`INSERT INTO fitness_goals (
|
||||||
|
id, user_id, fitness_profile_id, goal_type, title, description,
|
||||||
|
target_value, current_value, unit, start_date, target_date,
|
||||||
|
completed_date, status, progress, priority, notes, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
|
||||||
|
stmt.run(
|
||||||
|
goal.id,
|
||||||
|
goal.userId,
|
||||||
|
goal.fitnessProfileId || null,
|
||||||
|
goal.goalType,
|
||||||
|
goal.title,
|
||||||
|
goal.description || null,
|
||||||
|
goal.targetValue || null,
|
||||||
|
goal.currentValue || null,
|
||||||
|
goal.unit || null,
|
||||||
|
goal.startDate.toISOString(),
|
||||||
|
goal.targetDate ? goal.targetDate.toISOString() : null,
|
||||||
|
goal.completedDate ? goal.completedDate.toISOString() : null,
|
||||||
|
goal.status,
|
||||||
|
goal.progress,
|
||||||
|
goal.priority,
|
||||||
|
goal.notes || null,
|
||||||
|
goal.createdAt.toISOString(),
|
||||||
|
goal.updatedAt.toISOString()
|
||||||
|
)
|
||||||
|
|
||||||
|
return goal
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFitnessGoalById(id: string): Promise<import('./types').FitnessGoal | null> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const stmt = this.db.prepare('SELECT * FROM fitness_goals WHERE id = ?')
|
||||||
|
const row = stmt.get(id)
|
||||||
|
|
||||||
|
return row ? this.mapRowToFitnessGoal(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFitnessGoalsByUserId(userId: string, status?: string): Promise<import('./types').FitnessGoal[]> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
let query = 'SELECT * FROM fitness_goals WHERE user_id = ?'
|
||||||
|
const params: any[] = [userId]
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
query += ' AND status = ?'
|
||||||
|
params.push(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY created_at DESC'
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(query)
|
||||||
|
const rows = stmt.all(...params)
|
||||||
|
|
||||||
|
return rows.map(row => this.mapRowToFitnessGoal(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateFitnessGoal(id: string, updates: Partial<import('./types').FitnessGoal>): Promise<import('./types').FitnessGoal | null> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const fields = Object.keys(updates).filter(key => key !== 'id' && key !== 'createdAt')
|
||||||
|
if (fields.length === 0) return this.getFitnessGoalById(id)
|
||||||
|
|
||||||
|
// Map camelCase to snake_case for database columns
|
||||||
|
const columnMap: Record<string, string> = {
|
||||||
|
userId: 'user_id',
|
||||||
|
fitnessProfileId: 'fitness_profile_id',
|
||||||
|
goalType: 'goal_type',
|
||||||
|
targetValue: 'target_value',
|
||||||
|
currentValue: 'current_value',
|
||||||
|
startDate: 'start_date',
|
||||||
|
targetDate: 'target_date',
|
||||||
|
completedDate: 'completed_date',
|
||||||
|
updatedAt: 'updated_at'
|
||||||
|
}
|
||||||
|
|
||||||
|
const setClause = fields.map(field => `${columnMap[field] || field} = ?`).join(', ')
|
||||||
|
const values = fields.map(field => {
|
||||||
|
const val = (updates as any)[field]
|
||||||
|
return val instanceof Date ? val.toISOString() : val
|
||||||
|
})
|
||||||
|
values.push(new Date().toISOString()) // updatedAt
|
||||||
|
values.push(id)
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(`UPDATE fitness_goals SET ${setClause}, updated_at = ? WHERE id = ?`)
|
||||||
|
stmt.run(values)
|
||||||
|
|
||||||
|
return this.getFitnessGoalById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFitnessGoal(id: string): Promise<boolean> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const stmt = this.db.prepare('DELETE FROM fitness_goals WHERE id = ?')
|
||||||
|
const result = stmt.run(id)
|
||||||
|
return (result.changes || 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateGoalProgress(id: string, currentValue: number): Promise<import('./types').FitnessGoal | null> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
// Get the goal to calculate progress
|
||||||
|
const goal = await this.getFitnessGoalById(id)
|
||||||
|
if (!goal) return null
|
||||||
|
|
||||||
|
let progress = goal.progress
|
||||||
|
if (goal.targetValue && goal.targetValue > 0) {
|
||||||
|
progress = Math.min(100, (currentValue / goal.targetValue) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(
|
||||||
|
'UPDATE fitness_goals SET current_value = ?, progress = ?, updated_at = ? WHERE id = ?'
|
||||||
|
)
|
||||||
|
stmt.run(currentValue, progress, new Date().toISOString(), id)
|
||||||
|
|
||||||
|
return this.getFitnessGoalById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeGoal(id: string): Promise<import('./types').FitnessGoal | null> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const stmt = this.db.prepare(
|
||||||
|
'UPDATE fitness_goals SET status = ?, progress = ?, completed_date = ?, updated_at = ? WHERE id = ?'
|
||||||
|
)
|
||||||
|
stmt.run('completed', 100, now.toISOString(), now.toISOString(), id)
|
||||||
|
|
||||||
|
return this.getFitnessGoalById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapRowToFitnessGoal(row: any): import('./types').FitnessGoal {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
userId: row.user_id,
|
||||||
|
fitnessProfileId: row.fitness_profile_id,
|
||||||
|
goalType: row.goal_type,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description,
|
||||||
|
targetValue: row.target_value,
|
||||||
|
currentValue: row.current_value,
|
||||||
|
unit: row.unit,
|
||||||
|
startDate: new Date(row.start_date),
|
||||||
|
targetDate: row.target_date ? new Date(row.target_date) : undefined,
|
||||||
|
completedDate: row.completed_date ? new Date(row.completed_date) : undefined,
|
||||||
|
status: row.status,
|
||||||
|
progress: row.progress,
|
||||||
|
priority: row.priority,
|
||||||
|
notes: row.notes,
|
||||||
|
createdAt: new Date(row.created_at),
|
||||||
|
updatedAt: new Date(row.updated_at)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getDashboardStats(): Promise<{
|
async getDashboardStats(): Promise<{
|
||||||
totalUsers: number;
|
totalUsers: number;
|
||||||
activeClients: number;
|
activeClients: number;
|
||||||
|
|||||||
@ -61,6 +61,27 @@ export interface Recommendation {
|
|||||||
approvedBy?: string;
|
approvedBy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FitnessGoal {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
fitnessProfileId?: string;
|
||||||
|
goalType: "weight_target" | "strength_milestone" | "endurance_target" | "flexibility_goal" | "habit_building" | "custom";
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
targetValue?: number;
|
||||||
|
currentValue?: number;
|
||||||
|
unit?: string;
|
||||||
|
startDate: Date;
|
||||||
|
targetDate?: Date;
|
||||||
|
completedDate?: Date;
|
||||||
|
status: "active" | "completed" | "abandoned" | "paused";
|
||||||
|
progress: number;
|
||||||
|
priority: "low" | "medium" | "high";
|
||||||
|
notes?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
// Database Interface - allows us to swap implementations
|
// Database Interface - allows us to swap implementations
|
||||||
export interface IDatabase {
|
export interface IDatabase {
|
||||||
// Connection management
|
// Connection management
|
||||||
@ -121,6 +142,20 @@ export interface IDatabase {
|
|||||||
): Promise<Recommendation | null>;
|
): Promise<Recommendation | null>;
|
||||||
deleteRecommendation(id: string): Promise<boolean>;
|
deleteRecommendation(id: string): Promise<boolean>;
|
||||||
|
|
||||||
|
// Fitness Goals operations
|
||||||
|
createFitnessGoal(
|
||||||
|
goal: Omit<FitnessGoal, "createdAt" | "updatedAt">
|
||||||
|
): Promise<FitnessGoal>;
|
||||||
|
getFitnessGoalById(id: string): Promise<FitnessGoal | null>;
|
||||||
|
getFitnessGoalsByUserId(userId: string, status?: string): Promise<FitnessGoal[]>;
|
||||||
|
updateFitnessGoal(
|
||||||
|
id: string,
|
||||||
|
updates: Partial<FitnessGoal>
|
||||||
|
): Promise<FitnessGoal | null>;
|
||||||
|
deleteFitnessGoal(id: string): Promise<boolean>;
|
||||||
|
updateGoalProgress(id: string, currentValue: number): Promise<FitnessGoal | null>;
|
||||||
|
completeGoal(id: string): Promise<FitnessGoal | null>;
|
||||||
|
|
||||||
// Dashboard operations
|
// Dashboard operations
|
||||||
getDashboardStats(): Promise<{
|
getDashboardStats(): Promise<{
|
||||||
totalUsers: number;
|
totalUsers: number;
|
||||||
|
|||||||
24
apps/mobile/package-lock.json
generated
24
apps/mobile/package-lock.json
generated
@ -11,6 +11,7 @@
|
|||||||
"@clerk/clerk-expo": "^2.18.3",
|
"@clerk/clerk-expo": "^2.18.3",
|
||||||
"@expo/vector-icons": "^15.0.0",
|
"@expo/vector-icons": "^15.0.0",
|
||||||
"@hookform/resolvers": "^3.3.0",
|
"@hookform/resolvers": "^3.3.0",
|
||||||
|
"@react-native-community/datetimepicker": "8.4.4",
|
||||||
"@react-native-picker/picker": "2.11.1",
|
"@react-native-picker/picker": "2.11.1",
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"ajv": "^8.12.0",
|
"ajv": "^8.12.0",
|
||||||
@ -4029,6 +4030,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-native-community/datetimepicker": {
|
||||||
|
"version": "8.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-8.4.4.tgz",
|
||||||
|
"integrity": "sha512-bc4ZixEHxZC9/qf5gbdYvIJiLZ5CLmEsC3j+Yhe1D1KC/3QhaIfGDVdUcid0PdlSoGOSEq4VlB93AWyetEyBSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"invariant": "^2.2.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": ">=52.0.0",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*",
|
||||||
|
"react-native-windows": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"expo": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-native-windows": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@react-native-picker/picker": {
|
"node_modules/@react-native-picker/picker": {
|
||||||
"version": "2.11.1",
|
"version": "2.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.1.tgz",
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
"@clerk/clerk-expo": "^2.18.3",
|
"@clerk/clerk-expo": "^2.18.3",
|
||||||
"@expo/vector-icons": "^15.0.0",
|
"@expo/vector-icons": "^15.0.0",
|
||||||
"@hookform/resolvers": "^3.3.0",
|
"@hookform/resolvers": "^3.3.0",
|
||||||
|
"@react-native-community/datetimepicker": "8.4.4",
|
||||||
"@react-native-picker/picker": "2.11.1",
|
"@react-native-picker/picker": "2.11.1",
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"ajv": "^8.12.0",
|
"ajv": "^8.12.0",
|
||||||
|
|||||||
@ -11,44 +11,26 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useAuth } from "@clerk/clerk-expo";
|
import { useAuth } from "@clerk/clerk-expo";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { API_BASE_URL, API_ENDPOINTS } from "../../config/api";
|
import { fitnessGoalsService, type FitnessGoal, type CreateGoalData } from "../../services/fitnessGoals";
|
||||||
|
import { GoalProgressCard } from "../../components/GoalProgressCard";
|
||||||
interface Recommendation {
|
import { GoalCreationModal } from "../../components/GoalCreationModal";
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
type: "short_term" | "medium_term" | "long_term";
|
|
||||||
content: string;
|
|
||||||
status: "pending" | "completed";
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function GoalsScreen() {
|
export default function GoalsScreen() {
|
||||||
const { userId, getToken } = useAuth();
|
const { userId, getToken } = useAuth();
|
||||||
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
|
const [goals, setGoals] = useState<FitnessGoal[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
|
||||||
const fetchRecommendations = async () => {
|
const fetchGoals = async () => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
try {
|
try {
|
||||||
const token = await getToken();
|
const token = await getToken();
|
||||||
const response = await fetch(
|
const data = await fitnessGoalsService.getGoals(userId, token);
|
||||||
`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}?userId=${userId}`,
|
setGoals(data);
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setRecommendations(data);
|
|
||||||
} else {
|
|
||||||
console.error("Failed to fetch recommendations");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching recommendations:", error);
|
console.error("Error fetching fitness goals:", error);
|
||||||
|
Alert.alert("Error", "Failed to load goals. Please try again.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
@ -56,99 +38,53 @@ export default function GoalsScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRecommendations();
|
fetchGoals();
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
const onRefresh = () => {
|
const onRefresh = () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
fetchRecommendations();
|
fetchGoals();
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleStatus = async (id: string, currentStatus: string) => {
|
const handleCreateGoal = async (goalData: CreateGoalData) => {
|
||||||
const newStatus = currentStatus === "pending" ? "completed" : "pending";
|
|
||||||
|
|
||||||
// Optimistic update
|
|
||||||
setRecommendations(prev =>
|
|
||||||
prev.map(r => r.id === id ? { ...r, status: newStatus } : r)
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = await getToken();
|
const token = await getToken();
|
||||||
const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}`, {
|
const newGoal = await fitnessGoalsService.createGoal(goalData, token);
|
||||||
method: "PUT",
|
setGoals(prev => [newGoal, ...prev]);
|
||||||
headers: {
|
Alert.alert("Success", "Goal created successfully!");
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
id,
|
|
||||||
status: newStatus,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
// Revert on failure
|
|
||||||
setRecommendations(prev =>
|
|
||||||
prev.map(r => r.id === id ? { ...r, status: currentStatus as "pending" | "completed" } : r)
|
|
||||||
);
|
|
||||||
Alert.alert("Error", "Failed to update status");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error("Error creating goal:", error);
|
||||||
// Revert on error
|
throw error;
|
||||||
setRecommendations(prev =>
|
|
||||||
prev.map(r => r.id === id ? { ...r, status: currentStatus as "pending" | "completed" } : r)
|
|
||||||
);
|
|
||||||
Alert.alert("Error", "Failed to update status");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderSection = (
|
const handleCompleteGoal = async (goalId: string) => {
|
||||||
title: string,
|
try {
|
||||||
type: "short_term" | "medium_term" | "long_term"
|
const token = await getToken();
|
||||||
) => {
|
const updatedGoal = await fitnessGoalsService.completeGoal(goalId, token);
|
||||||
const items = recommendations.filter((r) => r.type === type);
|
setGoals(prev => prev.map(g => g.id === goalId ? updatedGoal : g));
|
||||||
|
Alert.alert("Success", "Goal completed! 🎉");
|
||||||
return (
|
} catch (error) {
|
||||||
<View style={styles.section}>
|
console.error("Error completing goal:", error);
|
||||||
<Text style={styles.sectionTitle}>{title}</Text>
|
Alert.alert("Error", "Failed to complete goal. Please try again.");
|
||||||
{items.length === 0 ? (
|
}
|
||||||
<Text style={styles.emptyText}>No goals set yet.</Text>
|
|
||||||
) : (
|
|
||||||
items.map((rec) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={rec.id}
|
|
||||||
style={[
|
|
||||||
styles.card,
|
|
||||||
rec.status === "completed" && styles.cardCompleted,
|
|
||||||
]}
|
|
||||||
onPress={() => toggleStatus(rec.id, rec.status)}
|
|
||||||
>
|
|
||||||
<View style={styles.checkbox}>
|
|
||||||
{rec.status === "completed" && (
|
|
||||||
<Ionicons name="checkmark" size={16} color="#fff" />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<View style={styles.cardContent}>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.cardText,
|
|
||||||
rec.status === "completed" && styles.cardTextCompleted,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{rec.content}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.dateText}>
|
|
||||||
{new Date(rec.createdAt).toLocaleDateString()}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteGoal = async (goalId: string) => {
|
||||||
|
try {
|
||||||
|
const token = await getToken();
|
||||||
|
await fitnessGoalsService.deleteGoal(goalId, token);
|
||||||
|
setGoals(prev => prev.filter(g => g.id !== goalId));
|
||||||
|
Alert.alert("Success", "Goal deleted");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting goal:", error);
|
||||||
|
Alert.alert("Error", "Failed to delete goal. Please try again.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeGoals = goals.filter(g => g.status === 'active');
|
||||||
|
const completedGoals = goals.filter(g => g.status === 'completed');
|
||||||
|
|
||||||
if (loading && !refreshing) {
|
if (loading && !refreshing) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.center}>
|
<View style={styles.center}>
|
||||||
@ -158,25 +94,105 @@ export default function GoalsScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.container}
|
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.headerTitle}>My Goals</Text>
|
<View>
|
||||||
|
<Text style={styles.headerTitle}>My Fitness Goals</Text>
|
||||||
<Text style={styles.headerSubtitle}>
|
<Text style={styles.headerSubtitle}>
|
||||||
Track your fitness journey progress
|
Track your fitness journey progress
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
{renderSection("Short Term Goals", "short_term")}
|
{/* Stats Summary */}
|
||||||
{renderSection("Medium Term Goals", "medium_term")}
|
{goals.length > 0 && (
|
||||||
{renderSection("Long Term Goals", "long_term")}
|
<View style={styles.statsContainer}>
|
||||||
|
<View style={styles.statCard}>
|
||||||
|
<Text style={styles.statValue}>{activeGoals.length}</Text>
|
||||||
|
<Text style={styles.statLabel}>Active</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statCard}>
|
||||||
|
<Text style={styles.statValue}>{completedGoals.length}</Text>
|
||||||
|
<Text style={styles.statLabel}>Completed</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statCard}>
|
||||||
|
<Text style={styles.statValue}>
|
||||||
|
{activeGoals.length > 0
|
||||||
|
? Math.round(
|
||||||
|
activeGoals.reduce((sum, g) => sum + g.progress, 0) /
|
||||||
|
activeGoals.length
|
||||||
|
)
|
||||||
|
: 0}%
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.statLabel}>Avg Progress</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active Goals */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>
|
||||||
|
Active Goals ({activeGoals.length})
|
||||||
|
</Text>
|
||||||
|
{activeGoals.length === 0 ? (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Ionicons name="flag-outline" size={48} color="#d1d5db" />
|
||||||
|
<Text style={styles.emptyText}>No active goals yet</Text>
|
||||||
|
<Text style={styles.emptySubtext}>
|
||||||
|
Tap the + button to create your first goal
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
activeGoals.map((goal) => (
|
||||||
|
<GoalProgressCard
|
||||||
|
key={goal.id}
|
||||||
|
goal={goal}
|
||||||
|
onComplete={() => handleCompleteGoal(goal.id)}
|
||||||
|
onDelete={() => handleDeleteGoal(goal.id)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Completed Goals */}
|
||||||
|
{completedGoals.length > 0 && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>
|
||||||
|
Completed Goals ({completedGoals.length})
|
||||||
|
</Text>
|
||||||
|
{completedGoals.map((goal) => (
|
||||||
|
<GoalProgressCard
|
||||||
|
key={goal.id}
|
||||||
|
goal={goal}
|
||||||
|
onDelete={() => handleDeleteGoal(goal.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<View style={styles.footer} />
|
<View style={styles.footer} />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Floating Action Button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.fab}
|
||||||
|
onPress={() => setShowCreateModal(true)}
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={28} color="#fff" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Create Goal Modal */}
|
||||||
|
<GoalCreationModal
|
||||||
|
visible={showCreateModal}
|
||||||
|
onClose={() => setShowCreateModal(false)}
|
||||||
|
onSubmit={handleCreateGoal}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -205,6 +221,34 @@ const styles = StyleSheet.create({
|
|||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
},
|
},
|
||||||
|
statsContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
padding: 16,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
statCard: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
statValue: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#2563eb",
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
statLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#6b7280",
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
section: {
|
section: {
|
||||||
padding: 20,
|
padding: 20,
|
||||||
paddingTop: 10,
|
paddingTop: 10,
|
||||||
@ -215,58 +259,38 @@ const styles = StyleSheet.create({
|
|||||||
color: "#374151",
|
color: "#374151",
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
emptyText: {
|
emptyState: {
|
||||||
fontStyle: "italic",
|
|
||||||
color: "#9ca3af",
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
padding: 16,
|
|
||||||
borderRadius: 12,
|
|
||||||
marginBottom: 12,
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "flex-start",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOffset: { width: 0, height: 1 },
|
|
||||||
shadowOpacity: 0.05,
|
|
||||||
shadowRadius: 2,
|
|
||||||
elevation: 2,
|
|
||||||
},
|
|
||||||
cardCompleted: {
|
|
||||||
backgroundColor: "#f0fdf4", // light green
|
|
||||||
borderColor: "#bbf7d0",
|
|
||||||
borderWidth: 1,
|
|
||||||
},
|
|
||||||
checkbox: {
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
borderRadius: 6,
|
|
||||||
borderWidth: 2,
|
|
||||||
borderColor: "#d1d5db",
|
|
||||||
marginRight: 12,
|
|
||||||
marginTop: 2,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
backgroundColor: "#fff",
|
paddingVertical: 40,
|
||||||
},
|
},
|
||||||
cardContent: {
|
emptyText: {
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
cardText: {
|
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: "#1f2937",
|
fontWeight: "500",
|
||||||
lineHeight: 24,
|
color: "#6b7280",
|
||||||
|
marginTop: 12,
|
||||||
},
|
},
|
||||||
cardTextCompleted: {
|
emptySubtext: {
|
||||||
textDecorationLine: "line-through",
|
fontSize: 14,
|
||||||
color: "#9ca3af",
|
|
||||||
},
|
|
||||||
dateText: {
|
|
||||||
fontSize: 12,
|
|
||||||
color: "#9ca3af",
|
color: "#9ca3af",
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
height: 40,
|
height: 100,
|
||||||
|
},
|
||||||
|
fab: {
|
||||||
|
position: "absolute",
|
||||||
|
right: 20,
|
||||||
|
bottom: 20,
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
backgroundColor: "#2563eb",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 8,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { View, Text, FlatList, ActivityIndicator, StyleSheet, RefreshControl } from "react-native";
|
import { View, Text, FlatList, ActivityIndicator, StyleSheet, RefreshControl } from "react-native";
|
||||||
import { useAuth } from "@clerk/clerk-expo";
|
import { useAuth } from "@clerk/clerk-expo";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { API_BASE_URL, API_ENDPOINTS } from "../../config/api";
|
import { API_BASE_URL, API_ENDPOINTS } from "../../config/api";
|
||||||
|
|
||||||
interface Recommendation {
|
interface Recommendation {
|
||||||
@ -77,6 +78,15 @@ export default function RecommendationsScreen() {
|
|||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Text style={styles.header}>AI Recommendations</Text>
|
<Text style={styles.header}>AI Recommendations</Text>
|
||||||
|
|
||||||
|
{/* AI Context Info Banner */}
|
||||||
|
<View style={styles.infoBanner}>
|
||||||
|
<Ionicons name="sparkles" size={20} color="#2563eb" />
|
||||||
|
<Text style={styles.infoBannerText}>
|
||||||
|
Personalized based on your active fitness goals and progress
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
<FlatList
|
<FlatList
|
||||||
data={recommendations}
|
data={recommendations}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
@ -129,6 +139,24 @@ const styles = StyleSheet.create({
|
|||||||
paddingBottom: 12,
|
paddingBottom: 12,
|
||||||
color: '#1a1a1a',
|
color: '#1a1a1a',
|
||||||
},
|
},
|
||||||
|
infoBanner: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#eff6ff',
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
borderLeftColor: '#2563eb',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
infoBannerText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#1e40af',
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
centered: {
|
centered: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
414
apps/mobile/src/components/GoalCreationModal.tsx
Normal file
414
apps/mobile/src/components/GoalCreationModal.tsx
Normal file
@ -0,0 +1,414 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
Modal,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
ScrollView,
|
||||||
|
Platform,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||||
|
import type { CreateGoalData } from '../services/fitnessGoals';
|
||||||
|
|
||||||
|
interface GoalCreationModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (goalData: CreateGoalData) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GOAL_TYPES = [
|
||||||
|
{ value: 'weight_target', label: 'Weight Target' },
|
||||||
|
{ value: 'strength_milestone', label: 'Strength Milestone' },
|
||||||
|
{ value: 'endurance_target', label: 'Endurance Target' },
|
||||||
|
{ value: 'flexibility_goal', label: 'Flexibility Goal' },
|
||||||
|
{ value: 'habit_building', label: 'Habit Building' },
|
||||||
|
{ value: 'custom', label: 'Custom Goal' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const PRIORITIES = [
|
||||||
|
{ value: 'low', label: 'Low', color: '#10b981' },
|
||||||
|
{ value: 'medium', label: 'Medium', color: '#f59e0b' },
|
||||||
|
{ value: 'high', label: 'High', color: '#ef4444' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function GoalCreationModal({ visible, onClose, onSubmit }: GoalCreationModalProps) {
|
||||||
|
const [goalType, setGoalType] = useState<CreateGoalData['goalType']>('weight_target');
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [targetValue, setTargetValue] = useState('');
|
||||||
|
const [currentValue, setCurrentValue] = useState('');
|
||||||
|
const [unit, setUnit] = useState('');
|
||||||
|
const [priority, setPriority] = useState<'low' | 'medium' | 'high'>('medium');
|
||||||
|
const [targetDate, setTargetDate] = useState<Date | undefined>();
|
||||||
|
const [showDatePicker, setShowDatePicker] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setGoalType('weight_target');
|
||||||
|
setTitle('');
|
||||||
|
setDescription('');
|
||||||
|
setTargetValue('');
|
||||||
|
setCurrentValue('');
|
||||||
|
setUnit('');
|
||||||
|
setPriority('medium');
|
||||||
|
setTargetDate(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!title.trim()) {
|
||||||
|
alert('Please enter a goal title');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const goalData: CreateGoalData = {
|
||||||
|
goalType,
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
targetValue: targetValue ? parseFloat(targetValue) : undefined,
|
||||||
|
currentValue: currentValue ? parseFloat(currentValue) : undefined,
|
||||||
|
unit: unit.trim() || undefined,
|
||||||
|
targetDate: targetDate?.toISOString(),
|
||||||
|
priority,
|
||||||
|
};
|
||||||
|
|
||||||
|
await onSubmit(goalData);
|
||||||
|
resetForm();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating goal:', error);
|
||||||
|
alert('Failed to create goal. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
resetForm();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
animationType="slide"
|
||||||
|
presentationStyle="pageSheet"
|
||||||
|
onRequestClose={handleClose}
|
||||||
|
>
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.headerTitle}>Create Fitness Goal</Text>
|
||||||
|
<TouchableOpacity onPress={handleClose} style={styles.closeButton}>
|
||||||
|
<Ionicons name="close" size={28} color="#111827" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
||||||
|
{/* Goal Type */}
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>Goal Type *</Text>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.typeScroll}>
|
||||||
|
{GOAL_TYPES.map((type) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={type.value}
|
||||||
|
style={[
|
||||||
|
styles.typeButton,
|
||||||
|
goalType === type.value && styles.typeButtonActive,
|
||||||
|
]}
|
||||||
|
onPress={() => setGoalType(type.value)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.typeButtonText,
|
||||||
|
goalType === type.value && styles.typeButtonTextActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{type.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>Title *</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={title}
|
||||||
|
onChangeText={setTitle}
|
||||||
|
placeholder="e.g., Lose 5kg"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>Description</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, styles.textArea]}
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
placeholder="Optional description"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
multiline
|
||||||
|
numberOfLines={3}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Target Value & Unit */}
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={[styles.field, styles.flex1]}>
|
||||||
|
<Text style={styles.label}>Target Value</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={targetValue}
|
||||||
|
onChangeText={setTargetValue}
|
||||||
|
placeholder="e.g., 70"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
keyboardType="numeric"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.field, styles.flex1]}>
|
||||||
|
<Text style={styles.label}>Unit</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={unit}
|
||||||
|
onChangeText={setUnit}
|
||||||
|
placeholder="e.g., kg"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Current Value */}
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>Current Value</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={currentValue}
|
||||||
|
onChangeText={setCurrentValue}
|
||||||
|
placeholder="Starting value (optional)"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
keyboardType="numeric"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Target Date */}
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>Target Date</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.dateButton}
|
||||||
|
onPress={() => setShowDatePicker(true)}
|
||||||
|
>
|
||||||
|
<Text style={targetDate ? styles.dateText : styles.datePlaceholder}>
|
||||||
|
{targetDate ? targetDate.toLocaleDateString() : 'Select target date'}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="calendar-outline" size={20} color="#6b7280" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{showDatePicker && (
|
||||||
|
<DateTimePicker
|
||||||
|
value={targetDate || new Date()}
|
||||||
|
mode="date"
|
||||||
|
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
|
||||||
|
onChange={(event, selectedDate) => {
|
||||||
|
setShowDatePicker(Platform.OS === 'ios');
|
||||||
|
if (selectedDate) {
|
||||||
|
setTargetDate(selectedDate);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
minimumDate={new Date()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Priority */}
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>Priority</Text>
|
||||||
|
<View style={styles.priorityContainer}>
|
||||||
|
{PRIORITIES.map((p) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={p.value}
|
||||||
|
style={[
|
||||||
|
styles.priorityButton,
|
||||||
|
priority === p.value && { backgroundColor: p.color },
|
||||||
|
]}
|
||||||
|
onPress={() => setPriority(p.value)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.priorityButtonText,
|
||||||
|
priority === p.value && styles.priorityButtonTextActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.submitButton, submitting && styles.submitButtonDisabled]}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
<Text style={styles.submitButtonText}>
|
||||||
|
{submitting ? 'Creating...' : 'Create Goal'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#f9fafb',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 20,
|
||||||
|
paddingTop: Platform.OS === 'ios' ? 60 : 20,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#e5e7eb',
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#111827',
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
field: {
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#111827',
|
||||||
|
},
|
||||||
|
textArea: {
|
||||||
|
height: 80,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
flex1: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
typeScroll: {
|
||||||
|
flexGrow: 0,
|
||||||
|
},
|
||||||
|
typeButton: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
typeButtonActive: {
|
||||||
|
backgroundColor: '#2563eb',
|
||||||
|
borderColor: '#2563eb',
|
||||||
|
},
|
||||||
|
typeButtonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#374151',
|
||||||
|
},
|
||||||
|
typeButtonTextActive: {
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
dateButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
|
dateText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#111827',
|
||||||
|
},
|
||||||
|
datePlaceholder: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#9ca3af',
|
||||||
|
},
|
||||||
|
priorityContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
priorityButton: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
priorityButtonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#374151',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
priorityButtonTextActive: {
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
padding: 20,
|
||||||
|
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#e5e7eb',
|
||||||
|
},
|
||||||
|
submitButton: {
|
||||||
|
backgroundColor: '#2563eb',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
submitButtonDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
submitButtonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
});
|
||||||
251
apps/mobile/src/components/GoalProgressCard.tsx
Normal file
251
apps/mobile/src/components/GoalProgressCard.tsx
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import type { FitnessGoal } from '../services/fitnessGoals';
|
||||||
|
|
||||||
|
interface GoalProgressCardProps {
|
||||||
|
goal: FitnessGoal;
|
||||||
|
onPress?: () => void;
|
||||||
|
onComplete?: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GoalProgressCard({ goal, onPress, onComplete, onDelete }: GoalProgressCardProps) {
|
||||||
|
const isCompleted = goal.status === 'completed';
|
||||||
|
const progress = goal.progress || 0;
|
||||||
|
|
||||||
|
// Calculate days remaining
|
||||||
|
const daysRemaining = goal.targetDate
|
||||||
|
? Math.ceil((new Date(goal.targetDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const getGoalTypeIcon = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'weight_target': return 'scale-outline';
|
||||||
|
case 'strength_milestone': return 'barbell-outline';
|
||||||
|
case 'endurance_target': return 'bicycle-outline';
|
||||||
|
case 'flexibility_goal': return 'body-outline';
|
||||||
|
case 'habit_building': return 'calendar-outline';
|
||||||
|
default: return 'flag-outline';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityColor = (priority: string) => {
|
||||||
|
switch (priority) {
|
||||||
|
case 'high': return '#ef4444';
|
||||||
|
case 'medium': return '#f59e0b';
|
||||||
|
case 'low': return '#10b981';
|
||||||
|
default: return '#6b7280';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
Alert.alert(
|
||||||
|
'Delete Goal',
|
||||||
|
'Are you sure you want to delete this goal?',
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{ text: 'Delete', style: 'destructive', onPress: onDelete },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.card, isCompleted && styles.cardCompleted]}
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.titleRow}>
|
||||||
|
<Ionicons
|
||||||
|
name={getGoalTypeIcon(goal.goalType) as any}
|
||||||
|
size={24}
|
||||||
|
color={isCompleted ? '#9ca3af' : '#2563eb'}
|
||||||
|
/>
|
||||||
|
<View style={styles.titleContainer}>
|
||||||
|
<Text style={[styles.title, isCompleted && styles.titleCompleted]}>
|
||||||
|
{goal.title}
|
||||||
|
</Text>
|
||||||
|
{goal.description && (
|
||||||
|
<Text style={styles.description} numberOfLines={2}>
|
||||||
|
{goal.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
{!isCompleted && onComplete && (
|
||||||
|
<TouchableOpacity onPress={onComplete} style={styles.actionButton}>
|
||||||
|
<Ionicons name="checkmark-circle-outline" size={24} color="#10b981" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<TouchableOpacity onPress={handleDelete} style={styles.actionButton}>
|
||||||
|
<Ionicons name="trash-outline" size={22} color="#ef4444" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{goal.targetValue && (
|
||||||
|
<View style={styles.progressSection}>
|
||||||
|
<View style={styles.progressInfo}>
|
||||||
|
<Text style={styles.progressText}>
|
||||||
|
{goal.currentValue || 0} / {goal.targetValue} {goal.unit || ''}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.progressPercentage}>{progress.toFixed(0)}%</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.progressBarContainer}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.progressBar,
|
||||||
|
{ width: `${Math.min(progress, 100)}%` },
|
||||||
|
isCompleted && styles.progressBarCompleted
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<View style={[styles.priorityBadge, { backgroundColor: getPriorityColor(goal.priority) }]}>
|
||||||
|
<Text style={styles.priorityText}>{goal.priority.toUpperCase()}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{daysRemaining !== null && !isCompleted && (
|
||||||
|
<Text style={[styles.daysRemaining, daysRemaining < 0 && styles.overdue]}>
|
||||||
|
{daysRemaining < 0
|
||||||
|
? `${Math.abs(daysRemaining)} days overdue`
|
||||||
|
: `${daysRemaining} days remaining`
|
||||||
|
}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCompleted && goal.completedDate && (
|
||||||
|
<Text style={styles.completedDate}>
|
||||||
|
Completed {new Date(goal.completedDate).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
cardCompleted: {
|
||||||
|
backgroundColor: '#f0fdf4',
|
||||||
|
borderColor: '#bbf7d0',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
titleRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
titleContainer: {
|
||||||
|
marginLeft: 12,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#111827',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
titleCompleted: {
|
||||||
|
color: '#9ca3af',
|
||||||
|
textDecorationLine: 'line-through',
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6b7280',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
progressSection: {
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
progressInfo: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
progressText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#374151',
|
||||||
|
},
|
||||||
|
progressPercentage: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#2563eb',
|
||||||
|
},
|
||||||
|
progressBarContainer: {
|
||||||
|
height: 8,
|
||||||
|
backgroundColor: '#e5e7eb',
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
progressBar: {
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: '#2563eb',
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
progressBarCompleted: {
|
||||||
|
backgroundColor: '#10b981',
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
priorityBadge: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
priorityText: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
daysRemaining: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6b7280',
|
||||||
|
},
|
||||||
|
overdue: {
|
||||||
|
color: '#ef4444',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
completedDate: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#10b981',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -18,4 +18,12 @@ export const API_ENDPOINTS = {
|
|||||||
HISTORY: '/api/attendance/history',
|
HISTORY: '/api/attendance/history',
|
||||||
},
|
},
|
||||||
RECOMMENDATIONS: '/api/recommendations',
|
RECOMMENDATIONS: '/api/recommendations',
|
||||||
|
FITNESS_GOALS: {
|
||||||
|
LIST: '/api/fitness-goals',
|
||||||
|
CREATE: '/api/fitness-goals',
|
||||||
|
GET: (id: string) => `/api/fitness-goals/${id}`,
|
||||||
|
UPDATE: (id: string) => `/api/fitness-goals/${id}`,
|
||||||
|
DELETE: (id: string) => `/api/fitness-goals/${id}`,
|
||||||
|
COMPLETE: (id: string) => `/api/fitness-goals/${id}/complete`,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
153
apps/mobile/src/services/fitnessGoals.ts
Normal file
153
apps/mobile/src/services/fitnessGoals.ts
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import { API_BASE_URL, API_ENDPOINTS } from '../config/api';
|
||||||
|
|
||||||
|
export interface FitnessGoal {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
fitnessProfileId?: string;
|
||||||
|
goalType: 'weight_target' | 'strength_milestone' | 'endurance_target' | 'flexibility_goal' | 'habit_building' | 'custom';
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
targetValue?: number;
|
||||||
|
currentValue?: number;
|
||||||
|
unit?: string;
|
||||||
|
startDate: string;
|
||||||
|
targetDate?: string;
|
||||||
|
completedDate?: string;
|
||||||
|
status: 'active' | 'completed' | 'abandoned' | 'paused';
|
||||||
|
progress: number;
|
||||||
|
priority: 'low' | 'medium' | 'high';
|
||||||
|
notes?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateGoalData {
|
||||||
|
goalType: FitnessGoal['goalType'];
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
targetValue?: number;
|
||||||
|
currentValue?: number;
|
||||||
|
unit?: string;
|
||||||
|
targetDate?: string;
|
||||||
|
priority?: FitnessGoal['priority'];
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FitnessGoalsService {
|
||||||
|
private async getAuthHeaders(token: string | null): Promise<HeadersInit> {
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGoals(userId: string, token: string | null, status?: string): Promise<FitnessGoal[]> {
|
||||||
|
try {
|
||||||
|
const headers = await this.getAuthHeaders(token);
|
||||||
|
let url = `${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.LIST}?userId=${userId}`;
|
||||||
|
|
||||||
|
if (status && status !== 'all') {
|
||||||
|
url += `&status=${status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, { headers });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch goals: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching fitness goals:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createGoal(goalData: CreateGoalData, token: string | null): Promise<FitnessGoal> {
|
||||||
|
try {
|
||||||
|
const headers = await this.getAuthHeaders(token);
|
||||||
|
const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.CREATE}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(goalData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Failed to create goal');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating fitness goal:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateGoal(id: string, updates: Partial<FitnessGoal>, token: string | null): Promise<FitnessGoal> {
|
||||||
|
try {
|
||||||
|
const headers = await this.getAuthHeaders(token);
|
||||||
|
const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.UPDATE(id)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to update goal');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating fitness goal:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProgress(id: string, currentValue: number, token: string | null): Promise<FitnessGoal> {
|
||||||
|
return this.updateGoal(id, { currentValue }, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeGoal(id: string, token: string | null): Promise<FitnessGoal> {
|
||||||
|
try {
|
||||||
|
const headers = await this.getAuthHeaders(token);
|
||||||
|
const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.COMPLETE(id)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to complete goal');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error completing fitness goal:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteGoal(id: string, token: string | null): Promise<void> {
|
||||||
|
try {
|
||||||
|
const headers = await this.getAuthHeaders(token);
|
||||||
|
const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.DELETE(id)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete goal');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting fitness goal:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fitnessGoalsService = new FitnessGoalsService();
|
||||||
@ -145,6 +145,67 @@ export const fitnessProfiles = sqliteTable("fitness_profiles", {
|
|||||||
.$defaultFn(() => new Date()),
|
.$defaultFn(() => new Date()),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const fitnessGoals = sqliteTable("fitness_goals", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
fitnessProfileId: text("fitness_profile_id").references(
|
||||||
|
() => fitnessProfiles.id,
|
||||||
|
{ onDelete: "cascade" }
|
||||||
|
),
|
||||||
|
|
||||||
|
// Goal details
|
||||||
|
goalType: text("goal_type", {
|
||||||
|
enum: [
|
||||||
|
"weight_target",
|
||||||
|
"strength_milestone",
|
||||||
|
"endurance_target",
|
||||||
|
"flexibility_goal",
|
||||||
|
"habit_building",
|
||||||
|
"custom",
|
||||||
|
],
|
||||||
|
}).notNull(),
|
||||||
|
|
||||||
|
title: text("title").notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
|
||||||
|
// Measurable targets
|
||||||
|
targetValue: real("target_value"), // e.g., 70 (kg), 100 (kg bench press)
|
||||||
|
currentValue: real("current_value"), // Current progress
|
||||||
|
unit: text("unit"), // kg, km, reps, etc.
|
||||||
|
|
||||||
|
// Timeline
|
||||||
|
startDate: integer("start_date", { mode: "timestamp" })
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => new Date()),
|
||||||
|
targetDate: integer("target_date", { mode: "timestamp" }),
|
||||||
|
completedDate: integer("completed_date", { mode: "timestamp" }),
|
||||||
|
|
||||||
|
// Status tracking
|
||||||
|
status: text("status", {
|
||||||
|
enum: ["active", "completed", "abandoned", "paused"],
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
.default("active"),
|
||||||
|
|
||||||
|
progress: real("progress").default(0), // 0-100 percentage
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
priority: text("priority", {
|
||||||
|
enum: ["low", "medium", "high"],
|
||||||
|
}).default("medium"),
|
||||||
|
|
||||||
|
notes: text("notes"),
|
||||||
|
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp" })
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => new Date()),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => new Date()),
|
||||||
|
});
|
||||||
|
|
||||||
export const recommendations = sqliteTable("recommendations", {
|
export const recommendations = sqliteTable("recommendations", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
@ -186,5 +247,8 @@ export type Notification = typeof notifications.$inferSelect;
|
|||||||
export type NewNotification = typeof notifications.$inferInsert;
|
export type NewNotification = typeof notifications.$inferInsert;
|
||||||
export type FitnessProfile = typeof fitnessProfiles.$inferSelect;
|
export type FitnessProfile = typeof fitnessProfiles.$inferSelect;
|
||||||
export type NewFitnessProfile = typeof fitnessProfiles.$inferInsert;
|
export type NewFitnessProfile = typeof fitnessProfiles.$inferInsert;
|
||||||
|
export type FitnessGoal = typeof fitnessGoals.$inferSelect;
|
||||||
|
export type NewFitnessGoal = typeof fitnessGoals.$inferInsert;
|
||||||
export type Recommendation = typeof recommendations.$inferSelect;
|
export type Recommendation = typeof recommendations.$inferSelect;
|
||||||
export type NewRecommendation = typeof recommendations.$inferInsert;
|
export type NewRecommendation = typeof recommendations.$inferInsert;
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user