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

12
package-lock.json generated
View file

@ -9,7 +9,8 @@
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@types/pg": "^8.15.5", "@types/pg": "^8.15.5",
"pg": "^8.16.3" "pg": "^8.16.3",
"zod": "^4.1.5"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-node": "^5.3.1", "@sveltejs/adapter-node": "^5.3.1",
@ -2732,6 +2733,15 @@
"integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==", "integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
},
"node_modules/zod": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.5.tgz",
"integrity": "sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
} }
} }
} }

View file

@ -29,6 +29,7 @@
}, },
"dependencies": { "dependencies": {
"@types/pg": "^8.15.5", "@types/pg": "^8.15.5",
"pg": "^8.16.3" "pg": "^8.16.3",
"zod": "^4.1.5"
} }
} }

View file

@ -95,6 +95,89 @@ export const db = {
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column() EXECUTE FUNCTION update_updated_at_column()
`); `);
// Generate test data for August 2024
await this.generateAugustTestData();
},
// Generate comprehensive test data for August 2024
async generateAugustTestData() {
const year = 2025;
const month = 8; // August
const daysInAugust = 31;
console.log('Generating test data for August 2024...');
for (let day = 1; day <= daysInAugust; day++) {
const date = new Date(year, month - 1, day); // month is 0-indexed
const dateStr = date.toISOString().split('T')[0];
// Generate realistic but varied exercise data
// Some days will be rest days (random 20% chance)
const isRestDay = Math.random() < 0.2;
let pushups = 0;
let situps = 0;
let plankTimeSeconds = 0;
let runDistanceKm = 0;
let hangups = 0;
if (!isRestDay) {
// Pushups: 10-50 range with some progression over the month
const progressionBonus = Math.floor(day / 3); // Slight improvement over time
pushups = Math.floor(Math.random() * 40) + 10 + progressionBonus;
// Situps: 15-60 range
situps = Math.floor(Math.random() * 45) + 15 + progressionBonus;
// Plank: 30-180 seconds
plankTimeSeconds = Math.floor(Math.random() * 150) + 30;
// Running: 0-8km, not every day (60% chance)
if (Math.random() < 0.6) {
runDistanceKm = Math.round((Math.random() * 7 + 1) * 100) / 100; // 1-8km with 2 decimal places
}
// Hangups: 3-20 range
hangups = Math.floor(Math.random() * 18) + 3;
// Weekend bonus (slightly higher values on weekends)
const dayOfWeek = date.getDay();
if (dayOfWeek === 0 || dayOfWeek === 6) {
// Sunday or Saturday
pushups = Math.floor(pushups * 1.2);
situps = Math.floor(situps * 1.2);
plankTimeSeconds = Math.floor(plankTimeSeconds * 1.1);
hangups = Math.floor(hangups * 1.1);
}
}
// Insert the data, using ON CONFLICT to avoid duplicates
try {
await this.query(
`
INSERT INTO daily_exercises (date, pushups, situps, plank_time_seconds, run_distance_km, hangups)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (date) DO UPDATE SET
pushups = EXCLUDED.pushups,
situps = EXCLUDED.situps,
plank_time_seconds = EXCLUDED.plank_time_seconds,
run_distance_km = EXCLUDED.run_distance_km,
hangups = EXCLUDED.hangups,
updated_at = CURRENT_TIMESTAMP
`,
[dateStr, pushups, situps, plankTimeSeconds, runDistanceKm, hangups]
);
console.log(
`Added data for ${dateStr}: pushups=${pushups}, situps=${situps}, plank=${plankTimeSeconds}s, run=${runDistanceKm}km, hangups=${hangups}`
);
} catch (error) {
console.error(`Failed to insert data for ${dateStr}:`, error);
}
}
console.log('August 2024 test data generation completed!');
}, },
// Close all connections // Close all connections

View file

@ -9,8 +9,13 @@ export const exerciseConfigs: Record<keyof WorkoutData, ExerciseConfig> = {
{ label: '+10', value: 10 }, { label: '+10', value: 10 },
{ label: '+25', value: 25 } { label: '+25', value: 25 }
], ],
quickAddColorClass: 'bg-blue-100 text-blue-700 hover:bg-blue-200 focus:ring-blue-500',
colorClass: 'bg-blue-50 text-blue-700',
textColorClass: 'text-blue-600',
max: 9999, max: 9999,
step: 1 step: 1,
dailyGoal: 30,
formatter: (number) => number.toString()
}, },
situps: { situps: {
name: 'Sit-ups', name: 'Sit-ups',
@ -20,8 +25,13 @@ export const exerciseConfigs: Record<keyof WorkoutData, ExerciseConfig> = {
{ label: '+10', value: 10 }, { label: '+10', value: 10 },
{ label: '+20', value: 20 } { label: '+20', value: 20 }
], ],
quickAddColorClass: 'bg-green-100 text-green-700 hover:bg-green-200 focus:ring-green-500',
colorClass: 'bg-green-50 text-green-700',
textColorClass: 'text-green-600',
max: 9999, max: 9999,
step: 1 step: 1,
dailyGoal: 35,
formatter: (number) => number.toString()
}, },
plankSeconds: { plankSeconds: {
name: 'Planke', name: 'Planke',
@ -31,9 +41,13 @@ export const exerciseConfigs: Record<keyof WorkoutData, ExerciseConfig> = {
{ label: '+30s', value: 30 }, { label: '+30s', value: 30 },
{ label: '+60s', value: 60 } { label: '+60s', value: 60 }
], ],
quickAddColorClass: 'bg-orange-100 text-orange-700 hover:bg-orange-200 focus:ring-orange-500',
colorClass: 'bg-orange-50 text-orange-700',
textColorClass: 'text-orange-600',
max: 9999, max: 9999,
step: 1, step: 1,
unit: 'sekunder', unit: 'sekunder',
dailyGoal: 45,
formatter: (number) => formatTime(number) formatter: (number) => formatTime(number)
}, },
hangups: { hangups: {
@ -44,8 +58,13 @@ export const exerciseConfigs: Record<keyof WorkoutData, ExerciseConfig> = {
{ label: '+1', value: 1 }, { label: '+1', value: 1 },
{ label: '+5', value: 5 } { label: '+5', value: 5 }
], ],
quickAddColorClass: 'bg-purple-100 text-purple-700 hover:bg-purple-200 focus:ring-purple-500',
colorClass: 'bg-purple-50 text-purple-700',
textColorClass: 'text-purple-600',
max: 9999, max: 9999,
step: 1 dailyGoal: 5,
step: 1,
formatter: (number) => number.toString()
}, },
runKm: { runKm: {
name: 'Running', name: 'Running',
@ -55,9 +74,13 @@ export const exerciseConfigs: Record<keyof WorkoutData, ExerciseConfig> = {
{ label: '+1km', value: 1 }, { label: '+1km', value: 1 },
{ label: '+2.5km', value: 2.5 } { label: '+2.5km', value: 2.5 }
], ],
quickAddColorClass: 'bg-red-100 text-red-700 hover:bg-red-200 focus:ring-red-500',
colorClass: 'bg-red-50 text-red-700',
textColorClass: 'text-red-600',
max: 999.99, max: 999.99,
step: 0.01, step: 0.1,
unit: 'km', unit: 'km',
dailyGoal: 1,
formatter: (value) => `${value.toFixed(2)} km` formatter: (value) => `${value.toFixed(2)} km`
} }
}; };
@ -74,7 +97,10 @@ function formatTime(seconds: number): string {
} }
// Get strongly typed entries directly from exerciseConfigs // Get strongly typed entries directly from exerciseConfigs
export const exercises = Object.entries(exerciseConfigs).map(([key, config]) => ({ export const exercises: Array<{
key: keyof WorkoutData;
config: ExerciseConfig;
}> = Object.entries(exerciseConfigs).map(([key, config]) => ({
key: key as keyof WorkoutData, key: key as keyof WorkoutData,
config config
})); }));

View file

@ -1,23 +1,36 @@
export type ColorExercise = 'blue' | 'green' | 'orange' | 'red' | 'purple'; export type ColorExercise = 'blue' | 'green' | 'orange' | 'red' | 'purple';
export type WorkoutData = { export interface WorkoutData {
pushups: number; pushups: number;
situps: number; situps: number;
plankSeconds: number; plankSeconds: number;
hangups: number; hangups: number;
runKm: number; runKm: number;
}; }
export interface WorkoutDataWithDate extends WorkoutData {
date: Date;
}
export interface WorkoutDataSummary extends WorkoutData {
startDate: Date;
endDate: Date;
}
export interface ExerciseConfig { export interface ExerciseConfig {
name: string; name: string;
icon: string; icon: string;
color: ColorExercise; color: ColorExercise;
quickAddOptions: Array<{ label: string; value: number }>; quickAddOptions: Array<{ label: string; value: number }>;
quickAddColorClass: string;
colorClass: string;
textColorClass: string;
min?: number; min?: number;
max?: number; max: number;
step?: number; step: number;
unit?: string; unit?: string;
formatter?: (value: number) => string; formatter: (value: number) => string;
dailyGoal: number;
} }
export interface QuickAddOption { export interface QuickAddOption {

View file

@ -9,4 +9,9 @@
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
</svelte:head> </svelte:head>
{@render children?.()} <svelte:boundary>
{@render children?.()}
{#snippet pending()}
Loading..
{/snippet}
</svelte:boundary>

View file

@ -1,36 +1,20 @@
<script lang="ts"> <script lang="ts">
import type { ExerciseConfig, ColorExercise } from '$lib/workout'; import type { ExerciseConfig } from '$lib/workout';
interface Props extends ExerciseConfig { interface Props extends ExerciseConfig {
value: number; value: number;
} }
let { let { value = $bindable(), ...restProps }: Props = $props();
value = $bindable(),
min = 0,
max = 9999,
step = 1,
color = 'blue',
...restProps
}: Props = $props();
// Destructure the remaining props for easier access // Destructure the remaining props for easier access
const { icon, name, quickAddOptions, unit } = restProps; const { max, step, icon, name, quickAddOptions, quickAddColorClass, unit } = restProps;
const label = unit ? `${icon} ${name} (${unit})` : `${icon} ${name}`; const label = unit ? `${icon} ${name} (${unit})` : `${icon} ${name}`;
function quickAdd(amount: number) { function quickAdd(amount: number) {
const newValue = Math.max(min, value + amount); value = value + amount;
value = step < 1 ? Number(newValue.toFixed(2)) : newValue;
} }
const colorClasses = {
blue: 'bg-blue-100 text-blue-700 hover:bg-blue-200 focus:ring-blue-500',
green: 'bg-green-100 text-green-700 hover:bg-green-200 focus:ring-green-500',
orange: 'bg-orange-100 text-orange-700 hover:bg-orange-200 focus:ring-orange-500',
red: 'bg-red-100 text-red-700 hover:bg-red-200 focus:ring-red-500',
purple: 'bg-purple-100 text-purple-700 hover:bg-purple-200 focus:ring-purple-500'
};
</script> </script>
<div class="mb-6"> <div class="mb-6">
@ -44,26 +28,24 @@
type="number" type="number"
bind:value bind:value
defaultValue={value} defaultValue={value}
{min} min="0"
{max} {max}
{step} {step}
required={true} required={true}
class="flex-1 rounded-md border border-gray-300 px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none" class="w-32 rounded-md border border-gray-300 px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/> />
{#each quickAddOptions as option} {#each quickAddOptions as option}
{@render quickAddButton(option.label, () => quickAdd(option.value), color)} {@render quickAddButton(option.label, () => quickAdd(option.value), quickAddColorClass)}
{/each} {/each}
</div> </div>
</div> </div>
{#snippet quickAddButton(label: string, onclick: () => void, color: ColorExercise)} {#snippet quickAddButton(label: string, onclick: () => void, color: string)}
<button <button
type="button" type="button"
{onclick} {onclick}
class="rounded px-3 py-1 text-xs focus:ring-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 {colorClasses[ class="rounded px-3 py-1 text-xs focus:ring-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 {quickAddColorClass}"
color
]}"
> >
{label} {label}
</button> </button>

View file

@ -1,35 +1,18 @@
<script lang="ts"> <script lang="ts">
import { import { getTodayDateString, exercises } from '$lib/workout';
getTodayDateString, import { getTodaysWorkout } from './workout.remote';
type WorkoutData, import { goto } from '$app/navigation';
type ColorExercise,
exercises
} from '$lib/workout';
import { getTodaysWorkout, getWorkoutHistory } from './workout.remote';
// Today's date for display // Today's date for display
let todayDate = getTodayDateString(); let todayDate = getTodayDateString();
// Color classes for the stat cards function navigateToSummary() {
const colorClasses = { goto('/summary');
blue: 'bg-blue-50 text-blue-700', }
green: 'bg-green-50 text-green-700',
orange: 'bg-orange-50 text-orange-700',
red: 'bg-red-50 text-red-700',
purple: 'bg-purple-50 text-purple-700'
};
const textColorClasses = {
blue: 'text-blue-600',
green: 'text-green-600',
orange: 'text-orange-600',
red: 'text-red-600',
purple: 'text-purple-600'
};
</script> </script>
<div class="mx-auto max-w-md rounded-lg bg-white p-6 shadow-lg"> <div class="mx-auto w-full max-w-md rounded-lg bg-white p-6 shadow-lg sm:p-6">
<h2 class="mb-6 text-center text-2xl font-bold text-gray-800">Today's Workout</h2> <h2 class="mb-6 text-center text-xl font-bold text-gray-800">Today's Workout</h2>
<div class="mb-4 text-center text-gray-600"> <div class="mb-4 text-center text-gray-600">
📅 {todayDate} 📅 {todayDate}
@ -51,25 +34,24 @@
config.icon, config.icon,
workoutData[key], workoutData[key],
config.name, config.name,
config.color, config.colorClass,
config.textColorClass,
config.formatter config.formatter
)} )}
{/each} {/each}
</div> </div>
{@const allWorkouData = (await getWorkoutHistory()).data}
<!-- Summary Stats -->
<div class="mt-6 rounded-md bg-gray-50 p-4">
<h3 class="mb-2 font-medium text-gray-700">📊 Summary for last 7 days</h3>
<div class="space-y-1 text-sm text-gray-600">
{#each exercises as { key, config }}
{@render summaryStatRow(config.name, allWorkouData[key], config.formatter)}
{/each}
</div>
</div>
{/if} {/if}
<!-- Navigation Button to Summary Page -->
<div class="mt-6 text-center">
<button
onclick={navigateToSummary}
class="rounded-lg bg-blue-500 px-6 py-3 font-medium text-white transition-colors hover:bg-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
>
📊 View Full Summary
</button>
</div>
{#snippet pending()} {#snippet pending()}
<div class="flex items-center justify-center py-8"> <div class="flex items-center justify-center py-8">
<div class="text-gray-500">⏳ Loading workout data...</div> <div class="text-gray-500">⏳ Loading workout data...</div>
@ -89,25 +71,21 @@
</div> </div>
{/snippet} {/snippet}
</svelte:boundary> </svelte:boundary>
<!-- Create a button that navigates to summary -->
</div> </div>
{#snippet workoutStatCard( {#snippet workoutStatCard(
icon: string, icon: string,
value: number, value: number,
label: string, label: string,
color: ColorExercise = 'blue', color: string,
formatter?: (value: number) => string textColorClass: string,
formatter: (value: number) => string
)} )}
<div class="rounded-lg p-4 text-center {colorClasses[color]}"> <div class="rounded-lg p-4 text-center {color}">
<div class="text-2xl">{icon}</div> <div class="text-2xl">{icon}</div>
<div class="text-lg font-bold">{formatter ? formatter(value) : value}</div> <div class="text-lg font-bold">{formatter(value)}</div>
<div class="text-sm {textColorClasses[color]}">{label}</div> <div class="text-sm {textColorClass}">{label}</div>
</div>
{/snippet}
{#snippet summaryStatRow(label: string, value: number, formatter?: (value: number) => string)}
<div class="flex justify-between">
<span>{label}</span>
<span class="font-medium">{formatter ? formatter(value) : value}</span>
</div> </div>
{/snippet} {/snippet}

View file

@ -5,7 +5,7 @@
import { getTodaysWorkout, saveWorkout } from './workout.remote'; import { getTodaysWorkout, saveWorkout } from './workout.remote';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
let todayDate = getTodayDateString(); const todayDate = getTodayDateString();
// Form state // Form state
let form: WorkoutData = $state({ let form: WorkoutData = $state({
@ -26,10 +26,10 @@
} }
</script> </script>
<div class="mx-auto max-w-md rounded-lg bg-white p-6 shadow-lg"> <div class="mx-auto w-full max-w-md rounded-lg bg-white p-4 shadow-lg sm:p-6">
<!-- Header --> <!-- Header -->
<header class="mb-6 text-center"> <header class="mb-6 text-center">
<h2 class="text-2xl font-bold text-gray-800">Log Today's Workout</h2> <h2 class="text-xl font-bold text-gray-800">Log Today's Workout</h2>
<div class="mt-2 text-gray-600">📅 {todayDate}</div> <div class="mt-2 text-gray-600">📅 {todayDate}</div>
</header> </header>
@ -67,7 +67,7 @@
{/if} {/if}
<!-- Example Section --> <!-- Example Section -->
<section class="rounded-md bg-gray-50 p-4"> <section class="rounded-md bg-gray-50 p-3 sm:p-4">
<h3 class="mb-2 text-sm font-medium text-gray-700">Quick Example:</h3> <h3 class="mb-2 text-sm font-medium text-gray-700">Quick Example:</h3>
<div class="space-y-1 text-sm text-gray-600"> <div class="space-y-1 text-sm text-gray-600">
<div>💪 Push-ups: {exampleWorkout.pushups}</div> <div>💪 Push-ups: {exampleWorkout.pushups}</div>

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

View file

@ -1,8 +1,9 @@
import { command, form, query } from '$app/server'; import { command, form, query } from '$app/server';
import { db } from '$lib/db'; import { db } from '$lib/db';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { WorkoutData } from '../lib/workout/types'; import type { WorkoutData, WorkoutDataSummary } from '../lib/workout/types';
import { exerciseConfigs, exercises } from '$lib/workout'; import { exerciseConfigs, exercises } from '$lib/workout';
import zod from 'zod';
export const saveWorkout = form(async (data) => { export const saveWorkout = form(async (data) => {
let pushups = Number(data.get(exerciseConfigs.pushups.name)); let pushups = Number(data.get(exerciseConfigs.pushups.name));
@ -48,7 +49,7 @@ export const saveWorkout = form(async (data) => {
); );
await getTodaysWorkout().refresh(); await getTodaysWorkout().refresh();
await getWorkoutHistory().refresh(); await getWorkoutHistory(7).refresh(); //TODO fixme
return { return {
success: true, success: true,
@ -93,7 +94,7 @@ export const getTodaysWorkout = query(async () => {
} }
}); });
export const getWorkoutHistory = query(async (days: number = 7) => { export const getWorkoutHistory = query(zod.number(), async (days: number = 7) => {
try { try {
// Get workout history for the last N days // Get workout history for the last N days
const result = await db.query( const result = await db.query(
@ -107,7 +108,9 @@ export const getWorkoutHistory = query(async (days: number = 7) => {
// Take all rows and add them up into each category // Take all rows and add them up into each category
const rows = result.rows; const rows = result.rows;
const workoutData: WorkoutData = { const workoutDataSummary: WorkoutDataSummary = {
endDate: rows[0].date,
startDate: rows[rows.length - 1].date,
pushups: rows.reduce((sum, row) => sum + Number(row.pushups), 0), pushups: rows.reduce((sum, row) => sum + Number(row.pushups), 0),
situps: rows.reduce((sum, row) => sum + Number(row.situps), 0), situps: rows.reduce((sum, row) => sum + Number(row.situps), 0),
plankSeconds: rows.reduce((sum, row) => sum + Number(row.plank_time_seconds), 0), plankSeconds: rows.reduce((sum, row) => sum + Number(row.plank_time_seconds), 0),
@ -117,7 +120,7 @@ export const getWorkoutHistory = query(async (days: number = 7) => {
return { return {
success: true, success: true,
data: workoutData, data: workoutDataSummary,
message: `Retrieved ${result.rows.length} workout records` message: `Retrieved ${result.rows.length} workout records`
}; };
} catch (error) { } catch (error) {