-
-
Notifications
You must be signed in to change notification settings - Fork 9
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: added full exercise history and graphs
- Loading branch information
1 parent
6ae426c
commit ff15691
Showing
5 changed files
with
250 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} exercises={exerciseInstances} /> | ||
{#if exerciseInstances} | ||
{#each exerciseInstances as instance} | ||
<WorkoutExerciseCard exercise={instance} /> | ||
{/each} | ||
{/if} | ||
{/if} | ||
</div> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |