Skip to content

Commit

Permalink
feat: added full exercise history and graphs
Browse files Browse the repository at this point in the history
  • Loading branch information
WhyAsh5114 committed Dec 23, 2024
1 parent 6ae426c commit 6e14556
Show file tree
Hide file tree
Showing 5 changed files with 250 additions and 0 deletions.
1 change: 1 addition & 0 deletions src/lib/trpc/routes/workouts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,7 @@ export const workouts = t.router({
workout: {
select: {
startedAt: true,
userBodyweight: true,
workoutOfMesocycle: {
select: {
splitDayIndex: true,
Expand Down
1 change: 1 addition & 0 deletions src/routes/(components)/layout/NavLinks.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
const linkItems: ({ text: string; href: string } | null)[] = [
{ text: 'Dashboard', href: '/dashboard' },
{ text: 'Exercise stats', href: '/exercise-stats' },
null,
{ text: 'Exercise splits', href: '/exercise-splits' },
{ text: 'Mesocycles', href: '/mesocycles' },
Expand Down
15 changes: 15 additions & 0 deletions src/routes/exercise-stats/+page.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { prisma } from '$lib/prisma.js';
import { redirect } from '@sveltejs/kit';

export const load = async ({ parent }) => {
const { session } = await parent();
if (!session) redirect(302, '/');

const exerciseList = prisma.workoutExercise.findMany({
where: { workout: { userId: session.user!.id } },
select: { name: true, targetMuscleGroup: true, customMuscleGroup: true },
distinct: ['name']
});

return { exerciseList };
};
89 changes: 89 additions & 0 deletions src/routes/exercise-stats/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import * as Command from '$lib/components/ui/command/index.js';
import * as Popover from '$lib/components/ui/popover/index.js';
import H2 from '$lib/components/ui/typography/H2.svelte';
import { trpc } from '$lib/trpc/client.js';
import type { RouterOutputs } from '$lib/trpc/router';
import { onMount } from 'svelte';
import ChevronsUpDown from 'virtual:icons/lucide/chevrons-up-down';
import LoaderCircle from 'virtual:icons/lucide/loader-circle';
import WorkoutExerciseCard from '../workouts/[workoutId]/(components)/WorkoutExerciseCard.svelte';
import ExerciseStatsChart from './ExerciseStatsChart.svelte';
type WorkoutExercise = RouterOutputs['workouts']['getExerciseHistory'][number];
type BasicExerciseData = Pick<WorkoutExercise, 'name' | 'targetMuscleGroup' | 'customMuscleGroup'>;
let { data } = $props();
let exercisesByMuscleGroup = $state<{ group: string; exercises: BasicExerciseData[] }[]>();
let searchText = $state('');
let searchOpen = $state(true);
let selectedExercise = $state<string>();
let exerciseInstances = $state<WorkoutExercise[]>();
onMount(async () => {
const exerciseList = await data.exerciseList;
exercisesByMuscleGroup = Object.entries(
Object.groupBy(exerciseList, (ex) => ex.customMuscleGroup ?? ex.targetMuscleGroup)
).map(([group, exercises]) => ({
group,
exercises: exercises!.filter((ex) => ex !== undefined)
}));
});
async function selectExercise(name: string) {
searchText = name;
searchOpen = false;
selectedExercise = name;
exerciseInstances = await trpc().workouts.getExerciseHistory.query({ exerciseName: name });
}
</script>

<H2>Exercise stats</H2>

<Popover.Root bind:open={searchOpen}>
<Popover.Trigger asChild let:builder>
<Button builders={[builder]} variant="outline" role="combobox" class="mb-2 w-full justify-between">
{selectedExercise ?? 'Search for an exercise'}
<ChevronsUpDown class="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</Popover.Trigger>
<Popover.Content sameWidth>
<Command.Root class="mb-6 h-fit">
<Command.Input placeholder="Type here" bind:value={searchText} />
<Command.List>
{#if exercisesByMuscleGroup === undefined}
<Command.Loading>
<div
class="flex h-full w-full flex-row items-center justify-center gap-2 p-4 text-sm text-muted-foreground"
>
<LoaderCircle class="animate-spin" />
<span>Fetching exercises...</span>
</div>
</Command.Loading>
{:else}
<Command.Empty>No results found.</Command.Empty>
{#each exercisesByMuscleGroup as { exercises, group }}
<Command.Group heading={group}>
{#each exercises as ex}
<Command.Item onclick={() => selectExercise(ex.name)}>{ex.name}</Command.Item>
{/each}
</Command.Group>
{/each}
{/if}
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>

<div class="flex flex-col gap-2">
{#if selectedExercise}
<ExerciseStatsChart selectedExercise={selectedExercise} exercises={exerciseInstances} />
{#if exerciseInstances}
{#each exerciseInstances as instance}
<WorkoutExerciseCard exercise={instance} />
{/each}
{/if}
{/if}
</div>
144 changes: 144 additions & 0 deletions src/routes/exercise-stats/ExerciseStatsChart.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<script lang="ts">
import type { RouterOutputs } from '$lib/trpc/router';
import * as Card from '$lib/components/ui/card';
import * as Popover from '$lib/components/ui/popover';
import { Label } from '$lib/components/ui/label';
import * as RadioGroup from '$lib/components/ui/radio-group';
import * as ToggleGroup from '$lib/components/ui/toggle-group';
import { generateShadesAndTints } from '$lib/utils';
import { solveBergerFormula } from '$lib/utils/workoutUtils';
import LoaderCircle from 'virtual:icons/lucide/loader-circle';
import MenuIcon from 'virtual:icons/lucide/menu';
import {
CategoryScale,
Chart,
Filler,
LinearScale,
LineController,
LineElement,
PointElement,
Title,
Tooltip
} from 'chart.js';
import Separator from '$lib/components/ui/separator/separator.svelte';
Chart.register(Tooltip, CategoryScale, LineController, LineElement, PointElement, Filler, LinearScale, Title);
type WorkoutExercise = RouterOutputs['workouts']['getExerciseHistory'][number];
type PropsType = { exercises: WorkoutExercise[] | undefined; selectedExercise: string };
let { exercises: reverseExercises, selectedExercise }: PropsType = $props();
let exercises = $derived(reverseExercises?.toReversed() ?? []);
let chart: Chart;
let chartCanvas: HTMLCanvasElement | undefined = $state();
let maxSets = $derived(Math.max(...exercises.map((ex) => ex.sets.length)));
let chartType: 'relative-overload' | 'absolute-load' = $state('relative-overload');
let selectedSets: string[] = $state([]);
$effect(() => {
selectedSets = Array.from({ length: Math.min(maxSets, 2) }, (_, idx) => idx.toString());
});
$effect(() => {
if (chartCanvas === undefined) return;
if (chart) chart.destroy();
const colors = generateShadesAndTints(maxSets);
let dataValues: (number | null)[][];
if (chartType === 'relative-overload') {
dataValues = Array.from({ length: maxSets }, (_, setIdx) =>
exercises.map((ex, idx) => {
if (idx === 0) return 0;
const oldestSet = exercises.find((ex) => ex.sets[setIdx] && !ex.sets[setIdx].skipped)!.sets[setIdx];
if (!selectedSets.includes(setIdx.toString())) return null;
if (!ex.sets[setIdx]) return null;
return solveBergerFormula({
variableToSolve: 'OverloadPercentage',
knownValues: {
bodyweightFraction: ex.bodyweightFraction,
newSet: ex.sets[setIdx],
oldSet: oldestSet,
oldUserBodyweight: idx === 0 ? ex.workout.userBodyweight : exercises[idx - 1].workout.userBodyweight,
newUserBodyweight: ex.workout.userBodyweight
}
});
})
);
} else {
dataValues = Array.from({ length: maxSets }, (_, setIdx) =>
exercises.map((ex) => {
if (!selectedSets.includes(setIdx.toString())) return null;
if (!ex.sets[setIdx]) return null;
return ex.sets[setIdx].load;
})
);
}
chart = new Chart(chartCanvas, {
type: 'line',
data: {
labels: exercises.map((ex) =>
new Date(ex.workout.startedAt).toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })
),
datasets: dataValues.map((data, idx) => ({
label: `Set ${idx + 1}`,
data,
borderColor: colors[idx],
tension: 0.2,
borderWidth: 2
}))
},
options: {
plugins: {
legend: {
display: false
}
}
}
});
});
</script>

<Card.Root>
<Card.Header>
<div class="flex justify-between gap-6">
<Card.Title class="truncate">{selectedExercise}</Card.Title>
<Popover.Root>
<Popover.Trigger aria-label="Menu"><MenuIcon /></Popover.Trigger>
<Popover.Content align="end">
<span class="font-semibold">Chart type</span>
<RadioGroup.Root class="py-2" bind:value={chartType}>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="relative-overload" id="relative-overload" />
<Label for="relative-overload">Relative overload</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="absolute-load" id="absolute-load" />
<Label for="absolute-load">Absolute load</Label>
</div>
</RadioGroup.Root>

<Separator class="my-2" />

<span class="font-semibold">Sets to graph</span>
<ToggleGroup.Root class="justify-start py-1" type="multiple" bind:value={selectedSets}>
{#each Array.from({ length: maxSets }) as _, idx}
<ToggleGroup.Item size="sm" value={idx.toString()}>{idx + 1}</ToggleGroup.Item>
{/each}
</ToggleGroup.Root>
</Popover.Content>
</Popover.Root>
</div>
</Card.Header>
<Card.Content>
{#if exercises.length === 0}
<div class="flex items-center gap-2 px-2 text-sm text-muted-foreground">
<LoaderCircle class="animate-spin" /> Fetching performances
</div>
{:else}
<canvas bind:this={chartCanvas} height="240"></canvas>
{/if}
</Card.Content>
</Card.Root>

0 comments on commit 6e14556

Please sign in to comment.