added yoga stuff, i18n, workout tracking and a dashboard to track progress
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\YogaCategory;
|
||||
use App\Models\YogaPose;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class FetchYogaDataCommand extends Command
|
||||
{
|
||||
protected $signature = 'yoga:fetch {--force : Recreate slugs}';
|
||||
|
||||
protected $description = 'Fetch yoga categories and poses from public API and upsert into local DB';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$url = 'https://yoga-api-nzy4.onrender.com/v1/categories';
|
||||
$this->info('Fetching: ' . $url);
|
||||
|
||||
$response = Http::timeout(30)->acceptJson()->get($url);
|
||||
if (!$response->ok()) {
|
||||
$this->error('Failed to fetch data: ' . $response->status());
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
if (!is_array($data)) {
|
||||
$this->error('Unexpected response format');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$countCategories = 0; $countPoses = 0;
|
||||
|
||||
foreach ($data as $cat) {
|
||||
$extId = (int) ($cat['id'] ?? 0);
|
||||
if ($extId <= 0) { continue; }
|
||||
|
||||
$category = YogaCategory::updateOrCreate(
|
||||
['external_id' => $extId],
|
||||
[
|
||||
'name' => (string) ($cat['category_name'] ?? 'Category ' . $extId),
|
||||
'description' => (string) ($cat['category_description'] ?? ''),
|
||||
]
|
||||
);
|
||||
$countCategories++;
|
||||
|
||||
$poses = $cat['poses'] ?? [];
|
||||
if (!is_array($poses)) { $poses = []; }
|
||||
|
||||
foreach ($poses as $pose) {
|
||||
$pExt = (int) ($pose['id'] ?? 0);
|
||||
if ($pExt <= 0) { continue; }
|
||||
|
||||
$payload = [
|
||||
'category_id' => $category->id,
|
||||
'english_name' => (string) ($pose['english_name'] ?? ''),
|
||||
'sanskrit_name_adapted' => (string) ($pose['sanskrit_name_adapted'] ?? ''),
|
||||
'sanskrit_name' => (string) ($pose['sanskrit_name'] ?? ''),
|
||||
'translation_name' => (string) ($pose['translation_name'] ?? ''),
|
||||
'description' => (string) ($pose['pose_description'] ?? ''),
|
||||
'benefits' => (string) ($pose['pose_benefits'] ?? ''),
|
||||
'url_svg' => (string) ($pose['url_svg'] ?? ''),
|
||||
'url_png' => (string) ($pose['url_png'] ?? ''),
|
||||
'url_svg_alt' => (string) ($pose['url_svg_alt'] ?? ''),
|
||||
];
|
||||
|
||||
$model = YogaPose::updateOrCreate(
|
||||
['external_id' => $pExt],
|
||||
$payload + [
|
||||
// slug may be regenerated with --force
|
||||
'slug' => $this->makeSlug($payload['english_name'], $pExt, $category->name),
|
||||
]
|
||||
);
|
||||
|
||||
if ($this->option('force')) {
|
||||
$model->slug = $this->makeSlug($payload['english_name'], $pExt, $category->name, true);
|
||||
$model->save();
|
||||
}
|
||||
|
||||
$countPoses++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Imported {$countCategories} categories, {$countPoses} poses.");
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function makeSlug(string $englishName, int $externalId, string $categoryName = '', bool $force = false): string
|
||||
{
|
||||
$base = Str::slug($englishName !== '' ? $englishName : ('pose-' . $externalId));
|
||||
if ($base === '') { $base = 'pose-' . $externalId; }
|
||||
$slug = $base;
|
||||
$i = 1;
|
||||
while (YogaPose::where('slug', $slug)->exists()) {
|
||||
// If force-regenerating we allow different category-based suffix to reduce collisions
|
||||
$suffix = $force && $categoryName ? '-' . Str::slug($categoryName) : '';
|
||||
$slug = $base . $suffix . '-' . (++$i);
|
||||
}
|
||||
return $slug;
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class WorkoutSession extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'workout_id',
|
||||
'started_at',
|
||||
'completed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'started_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function workout(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workout::class);
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorkoutSessionItem::class, 'session_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class WorkoutSessionItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'session_id',
|
||||
'workout_exercise_id',
|
||||
'done',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'done' => 'boolean',
|
||||
];
|
||||
|
||||
public function session(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(WorkoutSession::class, 'session_id');
|
||||
}
|
||||
|
||||
public function workoutExercise(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(WorkoutExercise::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class YogaCategory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'external_id',
|
||||
'name',
|
||||
'description',
|
||||
];
|
||||
|
||||
public function poses(): HasMany
|
||||
{
|
||||
return $this->hasMany(YogaPose::class, 'category_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class YogaPose extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'external_id',
|
||||
'category_id',
|
||||
'slug',
|
||||
'english_name',
|
||||
'sanskrit_name_adapted',
|
||||
'sanskrit_name',
|
||||
'translation_name',
|
||||
'description',
|
||||
'benefits',
|
||||
'url_svg',
|
||||
'url_png',
|
||||
'url_svg_alt',
|
||||
];
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(YogaCategory::class, 'category_id');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user