New features
This commit is contained in:
parent
55a112415d
commit
8981dc9615
17 changed files with 880 additions and 99 deletions
94
src/routes/summary/+page.svelte
Normal file
94
src/routes/summary/+page.svelte
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<script lang="ts">
|
||||
import { getWorkoutHistory } from '../workout.remote';
|
||||
import type { WorkoutDataSummary } from '$lib/workout/types';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import TimePeriodSelector from './TimePeriodSelector.svelte';
|
||||
import OverviewCards from './OverviewCards.svelte';
|
||||
import { timePeriods, type TimePeriod } from './summaryUtils';
|
||||
import ExerciseStatistics from './ExerciseStatistics.svelte';
|
||||
|
||||
const startPeriod = timePeriods[0];
|
||||
let selectedPeriod = $state<TimePeriod>(startPeriod);
|
||||
|
||||
let workoutSummary = $state((await getWorkoutHistory(startPeriod.days)).data);
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl p-6">
|
||||
{@render header()}
|
||||
|
||||
<TimePeriodSelector bind:workoutSummary {timePeriods} bind:selectedPeriod />
|
||||
|
||||
<svelte:boundary>
|
||||
<OverviewCards {workoutSummary} {selectedPeriod} />
|
||||
{#key selectedPeriod}
|
||||
<ExerciseStatistics {selectedPeriod} {workoutSummary} />
|
||||
{/key}
|
||||
{@render footer(workoutSummary)}
|
||||
|
||||
{#snippet pending()}
|
||||
{@render loadingState()}
|
||||
{/snippet}
|
||||
|
||||
{#snippet failed(error, retry)}
|
||||
{@render errorState(error, retry)}
|
||||
{/snippet}
|
||||
</svelte:boundary>
|
||||
</div>
|
||||
|
||||
{#snippet header()}
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h2 class="text-2xl font-bold text-gray-900">📊 Workout Summary</h2>
|
||||
<button
|
||||
onclick={() => goto('/')}
|
||||
class="flex items-center rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:border-gray-400 hover:bg-gray-50 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
|
||||
>
|
||||
← Back to Home
|
||||
</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet footer(workoutSummary: WorkoutDataSummary)}
|
||||
{@const timeDiff = workoutSummary.endDate.getTime() - workoutSummary.startDate.getTime()}
|
||||
{@const days = Math.ceil(timeDiff / (1000 * 60 * 60 * 24)) + 1}
|
||||
<div class="mt-6 text-center text-sm text-gray-500">
|
||||
<p>
|
||||
Showing data for {days} day{days !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet noDataState()}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-8 text-center">
|
||||
<div class="mb-4 text-4xl">📊</div>
|
||||
<h3 class="mb-2 text-lg font-medium text-gray-900">No Data Available</h3>
|
||||
<p class="text-gray-600">No workout data found for the selected period.</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet loadingState()}
|
||||
<div class="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-8">
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="mr-3 h-6 w-6 animate-spin rounded-full border-b-2 border-blue-500"></div>
|
||||
<span class="text-blue-700">Loading workout data...</span>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet errorState(error: unknown, retry: () => void)}
|
||||
<div class="mb-6 rounded-lg border border-red-200 bg-red-50 p-4">
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2 text-xl text-red-500">⚠️</span>
|
||||
<p class="font-medium text-red-700">Failed to load workout data</p>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-red-600">
|
||||
{error instanceof Error ? error.message : String(error)}
|
||||
</p>
|
||||
<button
|
||||
class="mt-3 rounded bg-red-100 px-3 py-1 text-sm text-red-700 hover:bg-red-200"
|
||||
onclick={retry}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
209
src/routes/summary/ExerciseGraph.svelte
Normal file
209
src/routes/summary/ExerciseGraph.svelte
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
<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>
|
||||
119
src/routes/summary/ExerciseStatistics.svelte
Normal file
119
src/routes/summary/ExerciseStatistics.svelte
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<script lang="ts">
|
||||
import type { WorkoutDataSummary } from '$lib/workout/types';
|
||||
|
||||
import { exercises } from '$lib/workout';
|
||||
|
||||
import {
|
||||
type TimePeriod,
|
||||
getAveragePerDay,
|
||||
getGoalProgress,
|
||||
getProgressGradient
|
||||
} from './summaryUtils';
|
||||
import ExerciseGraph from './ExerciseGraph.svelte';
|
||||
|
||||
interface Props {
|
||||
selectedPeriod: TimePeriod;
|
||||
workoutSummary: WorkoutDataSummary;
|
||||
}
|
||||
|
||||
let { selectedPeriod, workoutSummary }: Props = $props();
|
||||
|
||||
// Track when component is mounted for initial animation
|
||||
let animated = $state(false);
|
||||
|
||||
// Track selected exercise for graph
|
||||
let selectedExercise = $state<string | null>(null);
|
||||
|
||||
// Set mounted flag after component loads
|
||||
setTimeout(() => {
|
||||
animated = true;
|
||||
}, 300);
|
||||
</script>
|
||||
|
||||
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||
<div class="border-b border-gray-200 bg-gray-50 px-6 py-4">
|
||||
<h3 class="text-lg font-medium text-gray-900">
|
||||
Detailed Statistics for {selectedPeriod.label.toLowerCase()}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-gray-200">
|
||||
{#each exercises as { key, config }}
|
||||
{@const totalValue = workoutSummary[key]}
|
||||
{@const dailyAverage = getAveragePerDay(totalValue, workoutSummary)}
|
||||
{@const goalProgress = getGoalProgress(totalValue, config.dailyGoal, workoutSummary)}
|
||||
|
||||
<div
|
||||
class="cursor-pointer px-6 py-4 transition-colors duration-200 {selectedExercise === key
|
||||
? 'bg-blue-50'
|
||||
: 'hover:bg-gray-50'}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => (selectedExercise = selectedExercise === key ? null : key)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
selectedExercise = selectedExercise === key ? null : key;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<span class="mr-3 text-2xl">{config.icon}</span>
|
||||
<h4 class="font-medium text-gray-900">{config.name}</h4>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-lg font-semibold {config.textColorClass}">
|
||||
{config.formatter(totalValue)}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">Total</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm text-gray-600">
|
||||
<div>
|
||||
<span class="font-medium">Daily Average:</span>
|
||||
{config.formatter(Number(dailyAverage.toFixed(2)))}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="mr-2 h-4 w-48 overflow-hidden rounded-full bg-gray-200">
|
||||
<div
|
||||
class="h-4 rounded-full bg-gradient-to-r {getProgressGradient(
|
||||
config.color
|
||||
)} transition-all duration-1000 ease-out"
|
||||
style="width: {animated ? goalProgress : 0}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-sm font-medium">{goalProgress.toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Goal Information -->
|
||||
<div class="mt-2 flex items-center justify-between text-xs text-gray-500">
|
||||
<div>
|
||||
Goal: {config.formatter(config.dailyGoal)}/day
|
||||
</div>
|
||||
{#if goalProgress >= 100}
|
||||
<div class="font-medium text-green-600">✅ Goal achieved!</div>
|
||||
{:else if goalProgress >= 80}
|
||||
<div class="font-medium text-yellow-600">🎯 Close to goal</div>
|
||||
{:else if totalValue > 0}
|
||||
<div class="text-gray-400">Keep going!</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selectedExercise === key}
|
||||
<div class="mt-4 rounded-lg border bg-gray-50 p-4">
|
||||
<ExerciseGraph
|
||||
exerciseKey={key}
|
||||
{selectedPeriod}
|
||||
dailyGoal={config.dailyGoal}
|
||||
color={config.color}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
57
src/routes/summary/OverviewCards.svelte
Normal file
57
src/routes/summary/OverviewCards.svelte
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<script lang="ts">
|
||||
import type { WorkoutDataSummary } from '$lib/workout';
|
||||
import {
|
||||
type TimePeriod,
|
||||
getTotalWorkouts,
|
||||
getOverallProgress,
|
||||
formatPeriodInfo
|
||||
} from './summaryUtils';
|
||||
|
||||
interface Props {
|
||||
selectedPeriod: TimePeriod;
|
||||
workoutSummary: WorkoutDataSummary;
|
||||
}
|
||||
|
||||
let { selectedPeriod, workoutSummary }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="mb-8 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<!-- Total Workouts Card -->
|
||||
<div class="rounded-lg border border-blue-200 bg-gradient-to-r from-blue-50 to-blue-100 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-600">Days with training</p>
|
||||
<p class="text-2xl font-bold text-blue-900">
|
||||
{getTotalWorkouts(workoutSummary)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-3xl">🗓️</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overall Progress Card -->
|
||||
<div class="rounded-lg border border-green-200 bg-gradient-to-r from-green-50 to-green-100 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-green-600">Overall Progress</p>
|
||||
<p class="text-lg font-bold text-green-900">
|
||||
{getOverallProgress(workoutSummary).toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-3xl">🎯</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Period Card -->
|
||||
<div
|
||||
class="rounded-lg border border-purple-200 bg-gradient-to-r from-purple-50 to-purple-100 p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-purple-600">{selectedPeriod.label}</p>
|
||||
<p class="text-lg font-bold text-purple-900">{formatPeriodInfo(workoutSummary)}</p>
|
||||
</div>
|
||||
<div class="text-3xl">📅</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
36
src/routes/summary/TimePeriodSelector.svelte
Normal file
36
src/routes/summary/TimePeriodSelector.svelte
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<script lang="ts">
|
||||
import type { WorkoutDataSummary } from '$lib/workout';
|
||||
import { getWorkoutHistory } from '../workout.remote';
|
||||
import type { TimePeriod } from './summaryUtils';
|
||||
|
||||
interface Props {
|
||||
timePeriods: TimePeriod[];
|
||||
selectedPeriod: TimePeriod;
|
||||
workoutSummary: WorkoutDataSummary;
|
||||
}
|
||||
|
||||
let { workoutSummary = $bindable(), timePeriods, selectedPeriod = $bindable() }: Props = $props();
|
||||
|
||||
async function onPeriodChange(period: TimePeriod) {
|
||||
if (period.label === selectedPeriod.label) return;
|
||||
selectedPeriod = period;
|
||||
workoutSummary = (await getWorkoutHistory(period.days)).data;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mb-6">
|
||||
<h3 class="mb-3 text-sm font-medium text-gray-700">Time Period</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each timePeriods as period}
|
||||
<button
|
||||
class="rounded-lg px-4 py-2 text-sm font-medium transition-all duration-200 {selectedPeriod.label ===
|
||||
period.label
|
||||
? 'scale-105 transform bg-blue-500 text-white shadow-md'
|
||||
: 'border border-gray-300 bg-white text-gray-700 hover:border-gray-400 hover:bg-gray-50'}"
|
||||
onclick={() => onPeriodChange(period)}
|
||||
>
|
||||
{period.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
40
src/routes/summary/summary.remote.ts
Normal file
40
src/routes/summary/summary.remote.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { query } from '$app/server';
|
||||
import { db } from '$lib/db';
|
||||
import { type WorkoutDataWithDate } from '$lib/workout';
|
||||
import zod from 'zod';
|
||||
|
||||
export const getWorkoutForEachDayCountingBackwards = query(
|
||||
zod.number(),
|
||||
async (days: number = 7) => {
|
||||
try {
|
||||
// Get workout data for each day counting backwards from today
|
||||
const result = await db.query(
|
||||
`
|
||||
SELECT * FROM daily_exercises
|
||||
WHERE date >= CURRENT_DATE - INTERVAL '1 day' * $1
|
||||
ORDER BY date DESC
|
||||
`,
|
||||
[days]
|
||||
);
|
||||
|
||||
// Convert each row to WorkoutDataWithDate
|
||||
const dailyWorkouts: WorkoutDataWithDate[] = result.rows.map((row: any) => ({
|
||||
date: new Date(row.date),
|
||||
pushups: Number(row.pushups) || 0,
|
||||
situps: Number(row.situps) || 0,
|
||||
plankSeconds: Number(row.plank_time_seconds) || 0,
|
||||
hangups: Number(row.hangups) || 0,
|
||||
runKm: Number(row.run_distance_km) || 0
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: dailyWorkouts,
|
||||
message: `Retrieved ${result.rows.length} daily workout records`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching daily workouts:', error);
|
||||
throw new Error('Failed to fetch daily workouts from database');
|
||||
}
|
||||
}
|
||||
);
|
||||
125
src/routes/summary/summaryUtils.ts
Normal file
125
src/routes/summary/summaryUtils.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import type { ColorExercise, WorkoutData, WorkoutDataSummary } from '$lib/workout/types';
|
||||
import { exercises } from '$lib/workout';
|
||||
|
||||
/**
|
||||
* Calculate actual days for calendar periods
|
||||
*/
|
||||
function calculateActualDays(label: string): number {
|
||||
const now = new Date();
|
||||
switch (label) {
|
||||
case 'This week': {
|
||||
const dayOfWeek = now.getDay();
|
||||
const monday = new Date(now);
|
||||
monday.setDate(now.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1));
|
||||
return Math.ceil((now.getTime() - monday.getTime()) / (1000 * 60 * 60 * 24)) + 1;
|
||||
}
|
||||
case 'This month': {
|
||||
return now.getDate();
|
||||
}
|
||||
case 'This year': {
|
||||
const startOfYear = new Date(now.getFullYear(), 0, 1);
|
||||
return Math.ceil((now.getTime() - startOfYear.getTime()) / (1000 * 60 * 60 * 24)) + 1;
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export type TimePeriod = {
|
||||
label: string;
|
||||
get days(): number;
|
||||
};
|
||||
|
||||
function createTimePeriod(label: string): TimePeriod {
|
||||
return {
|
||||
label,
|
||||
get days() {
|
||||
return calculateActualDays(this.label);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const timePeriods: TimePeriod[] = [
|
||||
createTimePeriod('This week'),
|
||||
createTimePeriod('This month'),
|
||||
createTimePeriod('This year')
|
||||
];
|
||||
|
||||
/**
|
||||
* Get actual days for a time period (for backwards compatibility)
|
||||
*/
|
||||
export function getActualDays(period: TimePeriod): number {
|
||||
return period.days;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average per day for a given value using WorkoutDataSummary
|
||||
*/
|
||||
export function getAveragePerDay(value: number, workoutSummary: WorkoutDataSummary): number {
|
||||
const timeDiff = workoutSummary.endDate.getTime() - workoutSummary.startDate.getTime();
|
||||
const actualDays = Math.ceil(timeDiff / (1000 * 60 * 60 * 24)) + 1;
|
||||
return actualDays > 0 ? value / actualDays : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total workout days
|
||||
*/
|
||||
export function getTotalWorkouts(workoutSummary: WorkoutDataSummary): number {
|
||||
const timeDiff = workoutSummary.endDate.getTime() - workoutSummary.startDate.getTime();
|
||||
return Math.ceil(timeDiff / (1000 * 60 * 60 * 24)) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate goal-based progress percentage for an exercise
|
||||
*/
|
||||
export function getGoalProgress(
|
||||
totalValue: number,
|
||||
dailyGoal: number,
|
||||
workoutSummary: WorkoutDataSummary
|
||||
): number {
|
||||
const timeDiff = workoutSummary.endDate.getTime() - workoutSummary.startDate.getTime();
|
||||
const actualDays = Math.ceil(timeDiff / (1000 * 60 * 60 * 24)) + 1;
|
||||
const totalGoal = dailyGoal * actualDays;
|
||||
return totalGoal > 0 ? (totalValue / totalGoal) * 100 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate overall progress across all exercises
|
||||
*/
|
||||
export function getOverallProgress(workoutSummary: WorkoutDataSummary): number {
|
||||
let totalProgress = 0;
|
||||
|
||||
exercises.forEach(({ key, config }) => {
|
||||
const exerciseProgress = getGoalProgress(workoutSummary[key], config.dailyGoal, workoutSummary);
|
||||
totalProgress += Math.min(exerciseProgress, 100);
|
||||
});
|
||||
|
||||
return totalProgress / exercises.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format period information
|
||||
*/
|
||||
export function formatPeriodInfo(workoutSummary: WorkoutDataSummary): string {
|
||||
const formatDate = (date: Date) =>
|
||||
date.toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: '2-digit'
|
||||
});
|
||||
|
||||
return `${formatDate(workoutSummary.startDate)} - ${formatDate(workoutSummary.endDate)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get gradient class for progress bars
|
||||
*/
|
||||
export function getProgressGradient(color: ColorExercise): string {
|
||||
const gradients = {
|
||||
blue: 'from-blue-400 to-blue-600',
|
||||
green: 'from-green-400 to-green-600',
|
||||
orange: 'from-orange-400 to-orange-600',
|
||||
purple: 'from-purple-400 to-purple-600',
|
||||
red: 'from-red-400 to-red-600'
|
||||
};
|
||||
return gradients[color] || gradients.red;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue