added yoga stuff, i18n, workout tracking and a dashboard to track progress

This commit is contained in:
2025-10-04 17:55:30 +03:00
parent 1ed9235451
commit d1bc362fab
32 changed files with 1790 additions and 63 deletions
@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers;
use App\Models\WorkoutSession;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class DashboardController extends Controller
{
public function index(): View
{
$user = Auth::user();
// Basic counts
$workoutsCount = $user->workouts()->count();
$sessionsQuery = WorkoutSession::where('user_id', $user->id);
$sessionsCount = (clone $sessionsQuery)->count();
$completedSessions = (clone $sessionsQuery)->whereNotNull('completed_at')->count();
$completionRate = $sessionsCount > 0 ? round(($completedSessions / $sessionsCount) * 100) : 0;
// Recent sessions with progress
$recentSessions = (clone $sessionsQuery)
->latest('started_at')
->latest('id')
->with(['workout:id,title', 'items:id,session_id,done'])
->limit(5)
->get()
->map(function ($s) {
$total = $s->items->count();
$done = $s->items->where('done', true)->count();
$pct = $total > 0 ? (int) round(($done / $total) * 100) : 0;
return [
'id' => $s->id,
'workout_title' => optional($s->workout)->title ?? 'Workout',
'started_at' => $s->started_at,
'completed_at' => $s->completed_at,
'done' => $done,
'total' => $total,
'pct' => $pct,
];
});
// Activity in the last 30 days (number of sessions per day)
$since = now()->subDays(29)->startOfDay();
$dailyActivity = (clone $sessionsQuery)
->where('created_at', '>=', $since)
->get(['id', 'created_at'])
->groupBy(function ($s) { return $s->created_at->format('Y-m-d'); })
->map->count();
return view('dashboard', compact(
'workoutsCount',
'sessionsCount',
'completedSessions',
'completionRate',
'recentSessions',
'dailyActivity'
));
}
}
@@ -69,8 +69,40 @@ class ExerciseController extends Controller
public function show(Exercise $exercise)
{
$exercise->load(['bodyParts:id,name,slug', 'targetMuscles:id,name,slug', 'secondaryMuscles:id,name,slug', 'equipments:id,name,slug']);
// Find similar exercises by shared target muscles, body parts, or equipment
$targetIds = $exercise->targetMuscles->pluck('id')->all();
$bodyPartIds = $exercise->bodyParts->pluck('id')->all();
$equipmentIds = $exercise->equipments->pluck('id')->all();
$similarQuery = Exercise::query()
->with(['bodyParts:id,name,slug', 'targetMuscles:id,name,slug', 'equipments:id,name,slug'])
->where('id', '!=', $exercise->id)
->where(function ($q) use ($targetIds, $bodyPartIds, $equipmentIds) {
if (!empty($targetIds)) {
$q->orWhereHas('targetMuscles', function ($rel) use ($targetIds) {
$rel->whereIn('id', $targetIds);
});
}
if (!empty($bodyPartIds)) {
$q->orWhereHas('bodyParts', function ($rel) use ($bodyPartIds) {
$rel->whereIn('id', $bodyPartIds);
});
}
if (!empty($equipmentIds)) {
$q->orWhereHas('equipments', function ($rel) use ($equipmentIds) {
$rel->whereIn('id', $equipmentIds);
});
}
})
->orderBy('name')
->limit(9);
$similarExercises = $similarQuery->get();
return view('exercises.show', [
'exercise' => $exercise,
'similarExercises' => $similarExercises,
]);
}
}
@@ -0,0 +1,95 @@
<?php
namespace App\Http\Controllers;
use App\Models\Workout;
use App\Models\WorkoutExercise;
use App\Models\WorkoutSession;
use App\Models\WorkoutSessionItem;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class WorkoutSessionController extends Controller
{
public function store(Request $request, Workout $workout): RedirectResponse
{
$this->authorizeWorkout($workout);
$session = WorkoutSession::create([
'user_id' => Auth::id(),
'workout_id' => $workout->id,
'started_at' => now(),
]);
// Pre-create items for convenience
$workout->loadMissing('items');
foreach ($workout->items as $item) {
WorkoutSessionItem::create([
'session_id' => $session->id,
'workout_exercise_id' => $item->id,
'done' => false,
]);
}
return redirect()->route('sessions.show', $session);
}
public function show(WorkoutSession $session): View
{
$this->authorizeSession($session);
$session->load(['workout.items.exercise', 'items']);
// Index session items by workout_exercise_id for quick lookup
$doneByItemId = $session->items->keyBy('workout_exercise_id');
return view('workouts.session', [
'session' => $session,
'workout' => $session->workout,
'doneByItemId' => $doneByItemId,
]);
}
public function toggleItem(Request $request, WorkoutSession $session, WorkoutExercise $item): RedirectResponse
{
$this->authorizeSession($session);
if ($item->workout_id !== $session->workout_id) {
abort(404);
}
$si = WorkoutSessionItem::firstOrCreate([
'session_id' => $session->id,
'workout_exercise_id' => $item->id,
], [
'done' => false,
]);
$si->done = !$si->done;
$si->save();
return back();
}
public function complete(WorkoutSession $session): RedirectResponse
{
$this->authorizeSession($session);
$session->completed_at = now();
$session->save();
return redirect()->route('workouts.show', $session->workout)->with('status', __('app.sessions.completed'));
}
protected function authorizeWorkout(Workout $workout): void
{
if ($workout->user_id !== Auth::id()) {
abort(403);
}
}
protected function authorizeSession(WorkoutSession $session): void
{
if ($session->user_id !== Auth::id()) {
abort(403);
}
}
}
@@ -0,0 +1,84 @@
<?php
namespace App\Http\Controllers;
use App\Models\YogaCategory;
use App\Models\YogaPose;
use Illuminate\Http\Request;
use Illuminate\View\View;
class YogaPoseController extends Controller
{
public function index(Request $request): View
{
$q = trim((string) $request->query('q', ''));
$category = (int) $request->query('category', 0);
$benefit = trim((string) $request->query('benefit', ''));
$perPage = (int) $request->query('per_page', 12);
if ($perPage < 6 || $perPage > 60) { $perPage = 12; }
$query = YogaPose::query()->with('category')
->when($q !== '', function ($qb) use ($q) {
$qb->where(function ($sub) use ($q) {
$sub->where('english_name', 'like', "%{$q}%")
->orWhere('sanskrit_name', 'like', "%{$q}%")
->orWhere('sanskrit_name_adapted', 'like', "%{$q}%");
});
})
->when($category > 0, function ($qb) use ($category) {
$qb->where('category_id', $category);
})
->when($benefit !== '', function ($qb) use ($benefit) {
$qb->where('benefits', 'like', "%{$benefit}%");
})
->orderBy('english_name');
$poses = $query->paginate($perPage)->withQueryString();
$categories = YogaCategory::orderBy('name')->get(['id','name']);
return view('yoga.index', [
'poses' => $poses,
'filters' => [
'q' => $q,
'category' => $category,
'benefit' => $benefit,
'per_page' => $perPage,
],
'categories' => $categories,
]);
}
public function show(YogaPose $pose): View
{
$pose->load('category');
// Find similar poses by same category or overlapping benefits keywords
$benefits = (string) $pose->benefits;
$tokens = collect(preg_split('/[^\p{L}]+/u', mb_strtolower($benefits)))
->filter(fn($t) => $t !== null && $t !== '' && mb_strlen($t) >= 4)
->unique()
->take(8)
->values()
->all();
$similarQuery = YogaPose::query()
->with('category')
->where('id', '!=', $pose->id)
->where(function ($q) use ($pose, $tokens) {
// Same category
if ($pose->category_id) {
$q->orWhere('category_id', $pose->category_id);
}
// Overlapping benefits terms
foreach ($tokens as $tk) {
$q->orWhere('benefits', 'like', "%{$tk}%");
}
})
->orderBy('english_name')
->limit(9);
$similarPoses = $similarQuery->get();
return view('yoga.show', compact('pose', 'similarPoses'));
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Symfony\Component\HttpFoundation\Response;
class SetLocale
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$locale = $request->query('lang');
if ($locale && in_array($locale, ['en', 'ro'], true)) {
session(['locale' => $locale]);
}
$current = session('locale', config('app.locale'));
App::setLocale($current);
return $next($request);
}
}