New features
This commit is contained in:
parent
55a112415d
commit
8981dc9615
17 changed files with 880 additions and 99 deletions
12
package-lock.json
generated
12
package-lock.json
generated
|
|
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -9,4 +9,9 @@
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
<svelte:boundary>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
{#snippet pending()}
|
||||||
|
Loading..
|
||||||
|
{/snippet}
|
||||||
|
</svelte:boundary>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue