New features

This commit is contained in:
Håkon Størdal 2025-09-04 21:38:07 +02:00
parent 55a112415d
commit 8981dc9615
17 changed files with 880 additions and 99 deletions

View 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}

View 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>

View 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>

View 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>

View 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>

View 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');
}
}
);

View 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;
}