210 lines
5.3 KiB
Svelte
210 lines
5.3 KiB
Svelte
|
|
<script lang="ts">
|
||
|
|
import type { TimePeriod } from './summaryUtils';
|
||
|
|
import { getWorkoutForEachDayCountingBackwards } from './summary.remote';
|
||
|
|
import type { WorkoutDataSummary, WorkoutDataWithDate } from '$lib/workout';
|
||
|
|
|
||
|
|
interface Props {
|
||
|
|
exerciseKey: string;
|
||
|
|
selectedPeriod: TimePeriod;
|
||
|
|
dailyGoal: number;
|
||
|
|
color: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
let { exerciseKey, selectedPeriod, dailyGoal, color }: Props = $props();
|
||
|
|
|
||
|
|
function processWorkoutData(dailyWorkouts: WorkoutDataWithDate[], key: string) {
|
||
|
|
if (!dailyWorkouts || dailyWorkouts.length === 0) return [];
|
||
|
|
|
||
|
|
return dailyWorkouts
|
||
|
|
.map((workout) => ({
|
||
|
|
date: workout.date,
|
||
|
|
value: getValueFromWorkout(workout, key)
|
||
|
|
}))
|
||
|
|
.reverse(); // Reverse to show oldest to newest
|
||
|
|
}
|
||
|
|
|
||
|
|
function getValueFromWorkout(workout: WorkoutDataWithDate, key: string): number {
|
||
|
|
switch (key) {
|
||
|
|
case 'pushups':
|
||
|
|
return workout.pushups || 0;
|
||
|
|
case 'situps':
|
||
|
|
return workout.situps || 0;
|
||
|
|
case 'plankSeconds':
|
||
|
|
return workout.plankSeconds || 0;
|
||
|
|
case 'hangups':
|
||
|
|
return workout.hangups || 0;
|
||
|
|
case 'runKm':
|
||
|
|
return workout.runKm || 0;
|
||
|
|
default:
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Graph dimensions
|
||
|
|
const width = 400;
|
||
|
|
const height = 200;
|
||
|
|
const margin = { top: 20, right: 20, bottom: 40, left: 50 };
|
||
|
|
const graphWidth = width - margin.left - margin.right;
|
||
|
|
const graphHeight = height - margin.top - margin.bottom;
|
||
|
|
|
||
|
|
// Animation state
|
||
|
|
let mounted = $state(false);
|
||
|
|
|
||
|
|
// Set mounted flag after component loads for animation
|
||
|
|
setTimeout(() => {
|
||
|
|
mounted = true;
|
||
|
|
}, 100);
|
||
|
|
|
||
|
|
function formatDate(date: Date): string {
|
||
|
|
return date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
|
||
|
|
}
|
||
|
|
|
||
|
|
function getColorClass(color: string): string {
|
||
|
|
switch (color) {
|
||
|
|
case 'blue':
|
||
|
|
return 'fill-blue-500';
|
||
|
|
case 'green':
|
||
|
|
return 'fill-green-500';
|
||
|
|
case 'orange':
|
||
|
|
return 'fill-orange-500';
|
||
|
|
case 'purple':
|
||
|
|
return 'fill-purple-500';
|
||
|
|
case 'red':
|
||
|
|
return 'fill-red-500';
|
||
|
|
default:
|
||
|
|
return 'fill-gray-500';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
let dailyWorkouts = $state<WorkoutDataWithDate[]>([]);
|
||
|
|
let loading = $state(true);
|
||
|
|
|
||
|
|
// Fetch workout data when selectedPeriod changes
|
||
|
|
$effect(() => {
|
||
|
|
loading = true;
|
||
|
|
|
||
|
|
const fetchData = async () => {
|
||
|
|
try {
|
||
|
|
const result = await getWorkoutForEachDayCountingBackwards(selectedPeriod.days);
|
||
|
|
dailyWorkouts = result.data;
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Error fetching workout data:', error);
|
||
|
|
dailyWorkouts = [];
|
||
|
|
} finally {
|
||
|
|
loading = false;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
fetchData();
|
||
|
|
});
|
||
|
|
|
||
|
|
const dailyData = $derived(processWorkoutData(dailyWorkouts, exerciseKey));
|
||
|
|
const maxValue = $derived(Math.max(dailyGoal, ...dailyData.map((d: any) => d.value), 1));
|
||
|
|
const barSpacing = 4;
|
||
|
|
const barWidth = $derived(Math.max(graphWidth / Math.max(dailyData.length, 1) - barSpacing, 2));
|
||
|
|
const yScale = $derived(graphHeight / maxValue);
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<div class="w-full">
|
||
|
|
{#if loading}
|
||
|
|
<div class="flex h-48 items-center justify-center text-gray-500">Loading...</div>
|
||
|
|
{:else if dailyData.length === 0}
|
||
|
|
<div class="flex h-48 items-center justify-center text-gray-500">
|
||
|
|
No data available for this period
|
||
|
|
</div>
|
||
|
|
{:else}
|
||
|
|
<svg {width} {height} class="mx-auto">
|
||
|
|
<!-- Goal line -->
|
||
|
|
<line
|
||
|
|
x1={margin.left}
|
||
|
|
y1={margin.top + graphHeight - dailyGoal * yScale}
|
||
|
|
x2={margin.left + graphWidth}
|
||
|
|
y2={margin.top + graphHeight - dailyGoal * yScale}
|
||
|
|
stroke="#ef4444"
|
||
|
|
stroke-width="2"
|
||
|
|
stroke-dasharray="5,5"
|
||
|
|
/>
|
||
|
|
|
||
|
|
<!-- Goal label -->
|
||
|
|
<text
|
||
|
|
x={margin.left + graphWidth - 5}
|
||
|
|
y={margin.top + graphHeight - dailyGoal * yScale - 5}
|
||
|
|
text-anchor="end"
|
||
|
|
class="fill-red-500 text-xs"
|
||
|
|
>
|
||
|
|
Goal: {dailyGoal}
|
||
|
|
</text>
|
||
|
|
|
||
|
|
<!-- Bars -->
|
||
|
|
{#each dailyData as { date, value }, i}
|
||
|
|
<rect
|
||
|
|
x={margin.left + i * (barWidth + barSpacing) + barSpacing / 2}
|
||
|
|
y={margin.top + graphHeight - (mounted ? value * yScale : 0)}
|
||
|
|
width={barWidth}
|
||
|
|
height={mounted ? value * yScale : 0}
|
||
|
|
class={getColorClass(color)}
|
||
|
|
opacity="0.8"
|
||
|
|
style="transition: height 1s ease-out {i * 10}ms, y 1s ease-out {i * 10}ms;"
|
||
|
|
>
|
||
|
|
<title>{formatDate(date)}: {value}</title>
|
||
|
|
</rect>
|
||
|
|
{/each}
|
||
|
|
|
||
|
|
<!-- Y-axis -->
|
||
|
|
<line
|
||
|
|
x1={margin.left}
|
||
|
|
y1={margin.top}
|
||
|
|
x2={margin.left}
|
||
|
|
y2={margin.top + graphHeight}
|
||
|
|
stroke="#6b7280"
|
||
|
|
stroke-width="1"
|
||
|
|
/>
|
||
|
|
|
||
|
|
<!-- X-axis -->
|
||
|
|
<line
|
||
|
|
x1={margin.left}
|
||
|
|
y1={margin.top + graphHeight}
|
||
|
|
x2={margin.left + graphWidth}
|
||
|
|
y2={margin.top + graphHeight}
|
||
|
|
stroke="#6b7280"
|
||
|
|
stroke-width="1"
|
||
|
|
/>
|
||
|
|
|
||
|
|
<!-- Y-axis labels -->
|
||
|
|
<text x={margin.left - 10} y={margin.top + 5} text-anchor="end" class="fill-gray-600 text-xs">
|
||
|
|
{maxValue}
|
||
|
|
</text>
|
||
|
|
<text
|
||
|
|
x={margin.left - 10}
|
||
|
|
y={margin.top + graphHeight + 5}
|
||
|
|
text-anchor="end"
|
||
|
|
class="fill-gray-600 text-xs"
|
||
|
|
>
|
||
|
|
0
|
||
|
|
</text>
|
||
|
|
|
||
|
|
<!-- X-axis labels (show first and last date) -->
|
||
|
|
{#if dailyData.length > 0}
|
||
|
|
<text
|
||
|
|
x={margin.left}
|
||
|
|
y={margin.top + graphHeight + 20}
|
||
|
|
text-anchor="start"
|
||
|
|
class="fill-gray-600 text-xs"
|
||
|
|
>
|
||
|
|
{formatDate(dailyData[0].date)}
|
||
|
|
</text>
|
||
|
|
{#if dailyData.length > 1}
|
||
|
|
<text
|
||
|
|
x={margin.left + graphWidth}
|
||
|
|
y={margin.top + graphHeight + 20}
|
||
|
|
text-anchor="end"
|
||
|
|
class="fill-gray-600 text-xs"
|
||
|
|
>
|
||
|
|
{formatDate(dailyData[dailyData.length - 1].date)}
|
||
|
|
</text>
|
||
|
|
{/if}
|
||
|
|
{/if}
|
||
|
|
</svg>
|
||
|
|
{/if}
|
||
|
|
</div>
|