Working example

This commit is contained in:
Håkon Størdal 2025-09-02 22:51:17 +02:00
parent 5c04a0f1ee
commit b4444e7bd2
14 changed files with 935 additions and 22 deletions

View file

@ -1,16 +0,0 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
],
"tailwindStylesheet": "./src/app.css"
}

186
package-lock.json generated
View file

@ -7,6 +7,11 @@
"": { "": {
"name": "egentrening", "name": "egentrening",
"version": "0.0.1", "version": "0.0.1",
"dependencies": {
"@types/pg": "^8.15.5",
"pg": "^8.16.3",
"zod": "^4.1.5"
},
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^6.0.0", "@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/kit": "^2.22.0", "@sveltejs/kit": "^2.22.0",
@ -1225,6 +1230,26 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": {
"version": "24.3.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
"integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.10.0"
}
},
"node_modules/@types/pg": {
"version": "8.15.5",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.5.tgz",
"integrity": "sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==",
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.15.0", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@ -1833,6 +1858,95 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/pg": {
"version": "8.16.3",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
"pg-protocol": "^1.10.3",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.2.7"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
"integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
"integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -1882,6 +1996,45 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.6.2", "version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
@ -2096,6 +2249,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/svelte": { "node_modules/svelte": {
"version": "5.38.6", "version": "5.38.6",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.38.6.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.38.6.tgz",
@ -2226,6 +2388,12 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/undici-types": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
"license": "MIT"
},
"node_modules/vite": { "node_modules/vite": {
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.4.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.4.tgz",
@ -2321,6 +2489,15 @@
} }
} }
}, },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
@ -2337,6 +2514,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

@ -26,5 +26,10 @@
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"vite": "^7.0.4" "vite": "^7.0.4"
},
"dependencies": {
"@types/pg": "^8.15.5",
"pg": "^8.16.3",
"zod": "^4.1.5"
} }
} }

20
prettier.config.ts Normal file
View file

@ -0,0 +1,20 @@
import type { Config } from 'prettier';
const config: Config = {
useTabs: true,
singleQuote: true,
trailingComma: 'none',
printWidth: 100,
plugins: ['prettier-plugin-svelte', 'prettier-plugin-tailwindcss'],
overrides: [
{
files: '*.svelte',
options: {
parser: 'svelte'
}
}
],
tailwindStylesheet: './src/app.css'
};
export default config;

21
src/hooks.client.ts Normal file
View file

@ -0,0 +1,21 @@
import type { HandleClientError } from '@sveltejs/kit';
import { dev } from '$app/environment';
// Handle client-side errors
export const handleError: HandleClientError = ({ error, event }) => {
console.error('Client error:', error);
// Log additional context in development
if (dev) {
console.error('Event details:', {
url: event.url,
route: event.route?.id
});
}
// Return user-friendly error message
return {
message: dev ? String(error) : 'Something went wrong',
code: 'CLIENT_ERROR'
};
};

35
src/hooks.server.ts Normal file
View file

@ -0,0 +1,35 @@
import type { Handle, ServerInit } from '@sveltejs/kit';
import { dev } from '$app/environment';
import { db } from '$lib/db';
export const init: ServerInit = async () => {
db.createTables();
console.log('Tables created');
};
// The handle function runs on every request
export const handle: Handle = async ({ event, resolve }) => {
// Initialize on first request (lazy initialization)
// Add custom headers or modify request/response if needed
const response = await resolve(event);
// Optional: Add security headers
if (!dev) {
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-XSS-Protection', '1; mode=block');
}
return response;
};
// Optional: Handle server errors
export const handleError = ({ error, event }) => {
console.error('Server error:', error);
// Don't expose sensitive error details in production
return {
message: dev ? String(error) : 'Internal server error'
};
};

View file

@ -0,0 +1,148 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getTodaysWorkout } from '../routes/workout.remote';
// Workout data
let workoutData: any = null;
let loading = true;
let error = '';
// Today's date for display
let todayDate = '';
onMount(() => {
const today = new Date();
todayDate = today.toLocaleDateString('en-CA'); // YYYY-MM-DD format
loadTodaysWorkout();
});
export async function loadTodaysWorkout() {
loading = true;
error = '';
try {
const result = await getTodaysWorkout();
workoutData = result.data;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load workout data';
} finally {
loading = false;
}
}
function formatTime(seconds: number): string {
if (seconds === 0) return '0 seconds';
if (seconds < 60) return `${seconds} seconds`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (remainingSeconds === 0) return `${minutes} minutes`;
return `${minutes}m ${remainingSeconds}s`;
}
</script>
<div class="mx-auto max-w-md rounded-lg bg-white p-6 shadow-lg">
<h2 class="mb-6 text-center text-2xl font-bold text-gray-800">Today's Workout</h2>
<div class="mb-4 text-center text-gray-600">
📅 {todayDate}
</div>
{#if loading}
<div class="flex items-center justify-center py-8">
<div class="text-gray-500">⏳ Loading workout data...</div>
</div>
{:else if error}
<div class="rounded-md border border-red-200 bg-red-100 p-4 text-red-800">
<div class="font-medium">Error loading data</div>
<div class="text-sm">{error}</div>
<button
on:click={loadTodaysWorkout}
class="mt-2 rounded bg-red-200 px-3 py-1 text-xs text-red-700 hover:bg-red-300"
>
🔄 Retry
</button>
</div>
{:else if !workoutData}
<div class="rounded-md bg-gray-50 p-6 text-center">
<div class="text-gray-600">📝 No workout recorded for today</div>
<div class="mt-2 text-sm text-gray-500">Start logging your exercises below!</div>
</div>
{:else}
<!-- Workout Stats Grid -->
<div class="grid grid-cols-2 gap-4">
<!-- Push-ups -->
<div class="rounded-lg bg-blue-50 p-4 text-center">
<div class="text-2xl">💪</div>
<div class="text-lg font-bold text-blue-700">{workoutData.pushups}</div>
<div class="text-sm text-blue-600">Push-ups</div>
</div>
<!-- Sit-ups -->
<div class="rounded-lg bg-green-50 p-4 text-center">
<div class="text-2xl">🏋️</div>
<div class="text-lg font-bold text-green-700">{workoutData.situps}</div>
<div class="text-sm text-green-600">Sit-ups</div>
</div>
<!-- Plank -->
<div class="rounded-lg bg-orange-50 p-4 text-center">
<div class="text-2xl">🧘</div>
<div class="text-lg font-bold text-orange-700">
{formatTime(workoutData.plank_time_seconds)}
</div>
<div class="text-sm text-orange-600">Plank</div>
</div>
<!-- Running -->
<div class="rounded-lg bg-red-50 p-4 text-center">
<div class="text-2xl">🏃</div>
<div class="text-lg font-bold text-red-700">{workoutData.run_distance_km} km</div>
<div class="text-sm text-red-600">Running</div>
</div>
</div>
<!-- Summary Stats -->
<div class="mt-6 rounded-md bg-gray-50 p-4">
<h3 class="mb-2 font-medium text-gray-700">📊 Summary</h3>
<div class="space-y-1 text-sm text-gray-600">
<div class="flex justify-between">
<span>Total exercises:</span>
<span class="font-medium">{workoutData.pushups + workoutData.situps}</span>
</div>
<div class="flex justify-between">
<span>Plank time:</span>
<span class="font-medium">{formatTime(workoutData.plank_time_seconds)}</span>
</div>
<div class="flex justify-between">
<span>Distance:</span>
<span class="font-medium">{workoutData.run_distance_km} km</span>
</div>
</div>
</div>
<!-- Timestamps -->
{#if workoutData.created_at || workoutData.updated_at}
<div class="mt-4 text-xs text-gray-500">
{#if workoutData.created_at}
<div>Created: {new Date(workoutData.created_at).toLocaleString()}</div>
{/if}
{#if workoutData.updated_at && workoutData.updated_at !== workoutData.created_at}
<div>Updated: {new Date(workoutData.updated_at).toLocaleString()}</div>
{/if}
</div>
{/if}
{/if}
<!-- Refresh button -->
<div class="mt-6 text-center">
<button
on:click={loadTodaysWorkout}
disabled={loading}
class="rounded bg-gray-200 px-4 py-2 text-sm text-gray-700 hover:bg-gray-300 disabled:opacity-50"
>
{loading ? '⏳ Loading...' : '🔄 Refresh'}
</button>
</div>
</div>

View file

@ -0,0 +1,271 @@
<script lang="ts">
import { onMount, createEventDispatcher } from 'svelte';
import { saveWorkout as saveWorkoutRemote, getTodaysWorkout } from '../routes/workout.remote';
const dispatch = createEventDispatcher();
// Form data
let pushups = 0;
let situps = 0;
let plankSeconds = 0;
let runKm = 0.0;
// UI state
let loading = false;
let message = '';
let messageType: 'success' | 'error' | '' = '';
// Today's date for display
let todayDate = '';
onMount(() => {
const today = new Date();
todayDate = today.toLocaleDateString('en-CA'); // YYYY-MM-DD format
loadTodaysWorkout();
});
async function loadTodaysWorkout() {
try {
const result = await getTodaysWorkout();
if (result.data) {
pushups = result.data.pushups || 0;
situps = result.data.situps || 0;
plankSeconds = result.data.plank_time_seconds || 0;
runKm = result.data.run_distance_km || 0.0;
}
} catch (error) {
// If no data for today, that's fine - keep defaults
console.log('No workout data for today yet');
}
}
async function saveWorkout() {
loading = true;
message = '';
try {
const result = await saveWorkoutRemote({
pushups,
situps,
plankSeconds,
runKm
});
message = result.message || 'Workout saved successfully! 🎉';
messageType = 'success';
// Dispatch event to refresh display
dispatch('workoutSaved');
} catch (error) {
message = error instanceof Error ? error.message : 'Error saving workout. Please try again.';
messageType = 'error';
} finally {
loading = false;
}
}
function resetForm() {
pushups = 0;
situps = 0;
plankSeconds = 0;
runKm = 0.0;
message = '';
messageType = '';
}
// Quick add buttons
function quickAdd(exercise: string, amount: number) {
switch (exercise) {
case 'pushups':
pushups += amount;
break;
case 'situps':
situps += amount;
break;
case 'plank':
plankSeconds += amount;
break;
case 'run':
runKm += amount;
break;
}
}
</script>
<div class="mx-auto max-w-md rounded-lg bg-white p-6 shadow-lg">
<h2 class="mb-6 text-center text-2xl font-bold text-gray-800">Log Today's Workout</h2>
<div class="mb-4 text-center text-gray-600">
📅 {todayDate}
</div>
<!-- Push-ups -->
<div class="mb-6">
<label for="pushups" class="mb-2 block text-sm font-medium text-gray-700"> 💪 Push-ups </label>
<div class="flex items-center space-x-2">
<input
id="pushups"
type="number"
bind:value={pushups}
min="0"
class="flex-1 rounded-md border border-gray-300 px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="0"
/>
<button
type="button"
on:click={() => quickAdd('pushups', 10)}
class="rounded bg-blue-100 px-3 py-1 text-xs text-blue-700 hover:bg-blue-200"
>
+10
</button>
<button
type="button"
on:click={() => quickAdd('pushups', 25)}
class="rounded bg-blue-100 px-3 py-1 text-xs text-blue-700 hover:bg-blue-200"
>
+25
</button>
</div>
</div>
<!-- Sit-ups -->
<div class="mb-6">
<label for="situps" class="mb-2 block text-sm font-medium text-gray-700"> 🏋️ Sit-ups </label>
<div class="flex items-center space-x-2">
<input
id="situps"
type="number"
bind:value={situps}
min="0"
class="flex-1 rounded-md border border-gray-300 px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="0"
/>
<button
type="button"
on:click={() => quickAdd('situps', 10)}
class="rounded bg-green-100 px-3 py-1 text-xs text-green-700 hover:bg-green-200"
>
+10
</button>
<button
type="button"
on:click={() => quickAdd('situps', 20)}
class="rounded bg-green-100 px-3 py-1 text-xs text-green-700 hover:bg-green-200"
>
+20
</button>
</div>
</div>
<!-- Plank -->
<div class="mb-6">
<label for="plank" class="mb-2 block text-sm font-medium text-gray-700">
🧘 Plank (seconds)
</label>
<div class="flex items-center space-x-2">
<input
id="plank"
type="number"
bind:value={plankSeconds}
min="0"
class="flex-1 rounded-md border border-gray-300 px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="0"
/>
<button
type="button"
on:click={() => quickAdd('plank', 30)}
class="rounded bg-orange-100 px-3 py-1 text-xs text-orange-700 hover:bg-orange-200"
>
+30s
</button>
<button
type="button"
on:click={() => quickAdd('plank', 60)}
class="rounded bg-orange-100 px-3 py-1 text-xs text-orange-700 hover:bg-orange-200"
>
+60s
</button>
</div>
</div>
<!-- Running -->
<div class="mb-6">
<label for="run" class="mb-2 block text-sm font-medium text-gray-700"> 🏃 Running (km) </label>
<div class="flex items-center space-x-2">
<input
id="run"
type="number"
bind:value={runKm}
min="0"
step="0.1"
class="flex-1 rounded-md border border-gray-300 px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="0.0"
/>
<button
type="button"
on:click={() => quickAdd('run', 1)}
class="rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
>
+1km
</button>
<button
type="button"
on:click={() => quickAdd('run', 2.5)}
class="rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
>
+2.5km
</button>
</div>
</div>
<!-- Action buttons -->
<div class="mb-4 flex space-x-3">
<button
on:click={saveWorkout}
disabled={loading}
class="flex-1 rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? '⏳ Saving...' : '💾 Save Workout'}
</button>
<button
on:click={resetForm}
disabled={loading}
class="rounded-md bg-gray-300 px-4 py-2 text-gray-700 hover:bg-gray-400 disabled:opacity-50"
>
🔄 Reset
</button>
</div>
<!-- Message display -->
{#if message}
<div
class="rounded-md p-3 text-sm font-medium {messageType === 'success'
? 'border border-green-200 bg-green-100 text-green-800'
: 'border border-red-200 bg-red-100 text-red-800'}"
>
{message}
</div>
{/if}
<!-- Quick example -->
<div class="mt-6 rounded-md bg-gray-50 p-4">
<h3 class="mb-2 text-sm font-medium text-gray-700">Your Example:</h3>
<div class="space-y-1 text-sm text-gray-600">
<div>💪 Push-ups: 100</div>
<div>🏋️ Sit-ups: 50</div>
<div>🧘 Plank: 0 seconds</div>
<div>🏃 Running: 4.0 km</div>
</div>
<button
on:click={() => {
pushups = 100;
situps = 50;
plankSeconds = 0;
runKm = 4.0;
}}
class="mt-2 rounded bg-gray-200 px-2 py-1 text-xs text-gray-700 hover:bg-gray-300"
>
Load Example
</button>
</div>
</div>

81
src/lib/db.ts Normal file
View file

@ -0,0 +1,81 @@
import { DATABASE_URL } from '$env/static/private';
import { Pool } from 'pg';
// Create a connection pool
const pool = new Pool({
connectionString: DATABASE_URL,
ssl: false
});
// Simple database client
export const db = {
// Execute a query
async query(text: string, params?: any[]) {
const client = await pool.connect();
try {
const result = await client.query(text, params);
return result;
} finally {
client.release();
}
},
// Test connection
async testConnection() {
try {
const result = await this.query('SELECT NOW() as current_time');
console.log('Database connected successfully:', result.rows[0]);
return true;
} catch (error) {
console.error('Database connection failed:', error);
return false;
}
},
// Create tables for tracking daily exercises
async createTables() {
// Create exercises table with all activities in one table
await this.query(`
CREATE TABLE IF NOT EXISTS daily_exercises (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
date DATE NOT NULL UNIQUE,
pushups INTEGER DEFAULT 0,
situps INTEGER DEFAULT 0,
plank_time_seconds INTEGER DEFAULT 0,
run_distance_km DECIMAL(5,2) DEFAULT 0.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// Create index for faster date lookups
await this.query(`
CREATE INDEX IF NOT EXISTS idx_daily_exercises_date
ON daily_exercises(date)
`);
// Create trigger to update updated_at timestamp
await this.query(`
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql'
`);
await this.query(`
DROP TRIGGER IF EXISTS update_daily_exercises_updated_at ON daily_exercises;
CREATE TRIGGER update_daily_exercises_updated_at
BEFORE UPDATE ON daily_exercises
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column()
`);
},
// Close all connections
async close() {
await pool.end();
}
};

View file

@ -1,3 +1,32 @@
<h1>Welcome to SvelteKit</h1> <script lang="ts">
<p>Here by text</p> import WorkoutLogger from '$lib/WorkoutLogger.svelte';
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p> import WorkoutDisplay from '$lib/WorkoutDisplay.svelte';
let workoutDisplayComponent: WorkoutDisplay;
function handleWorkoutSaved() {
// Refresh the display when workout is saved
if (workoutDisplayComponent) {
workoutDisplayComponent.loadTodaysWorkout();
}
}
</script>
<div class="min-h-screen bg-gray-100 py-8">
<div class="container mx-auto px-4">
<h1 class="mb-8 text-center text-4xl font-bold text-gray-800">🏋️ Egentrening</h1>
<p class="mb-8 text-center text-gray-600">Track your daily fitness progress</p>
<div class="grid grid-cols-1 gap-8 lg:grid-cols-2">
<!-- Display today's workout -->
<div>
<WorkoutDisplay bind:this={workoutDisplayComponent} />
</div>
<!-- Log new workout -->
<div>
<WorkoutLogger on:workoutSaved={handleWorkoutSaved} />
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,12 @@
<script lang="ts">
import { testConnection } from './db.remote';
</script>
<svelte:boundary>
<p>Here be connection: {await testConnection().current}</p>
{#snippet pending()}
<p>loading...</p>
{/snippet}
</svelte:boundary>

6
src/routes/db.remote.js Normal file
View file

@ -0,0 +1,6 @@
import { query } from '$app/server';
import { db } from '$lib/db';
export const testConnection = query(async () => {
return db.testConnection();
});

View file

@ -0,0 +1,109 @@
import { query } from '$app/server';
import { db } from '$lib/db';
import { z } from 'zod';
interface WorkOutData {
pushups: number;
situps: number;
plankSeconds: number;
runKm: number;
}
export const saveWorkout = query(
z.object({
pushups: z.number().min(0),
situps: z.number().min(0),
plankSeconds: z.number().min(0),
runKm: z.number().min(0)
}),
async (workoutData: WorkOutData) => {
const { pushups, situps, plankSeconds, runKm } = workoutData;
// Validate input data
if (typeof pushups !== 'number' || pushups < 0) {
throw new Error('Invalid pushups value');
}
if (typeof situps !== 'number' || situps < 0) {
throw new Error('Invalid situps value');
}
if (typeof plankSeconds !== 'number' || plankSeconds < 0) {
throw new Error('Invalid plank time value');
}
if (typeof runKm !== 'number' || runKm < 0) {
throw new Error('Invalid run distance value');
}
try {
// Insert or update today's workout
const result = await db.query(
`
INSERT INTO daily_exercises (date, pushups, situps, plank_time_seconds, run_distance_km)
VALUES (CURRENT_DATE, $1, $2, $3, $4)
ON CONFLICT (date)
DO UPDATE SET
pushups = $1,
situps = $2,
plank_time_seconds = $3,
run_distance_km = $4,
updated_at = CURRENT_TIMESTAMP
RETURNING *
`,
[pushups, situps, plankSeconds, runKm]
);
return {
success: true,
data: result.rows[0],
message: 'Workout saved successfully!'
};
} catch (error) {
console.error('Error saving workout:', error);
throw new Error('Failed to save workout to database');
}
}
);
export const getTodaysWorkout = query(async () => {
try {
// Get today's workout data
const result = await db.query('SELECT * FROM daily_exercises WHERE date = CURRENT_DATE');
if (result.rows.length === 0) {
return {
data: null,
message: 'No workout recorded for today'
};
}
return {
success: true,
data: result.rows[0]
};
} catch (error) {
console.error('Error fetching workout:', error);
throw new Error('Failed to fetch workout from database');
}
});
export const getWorkoutHistory = query(async (days: number = 7) => {
try {
// Get workout history for the last N days
const result = await db.query(
`
SELECT * FROM daily_exercises
WHERE date >= CURRENT_DATE - INTERVAL '$1 days'
ORDER BY date DESC
`,
[days]
);
return {
success: true,
data: result.rows,
message: `Retrieved ${result.rows.length} workout records`
};
} catch (error) {
console.error('Error fetching workout history:', error);
throw new Error('Failed to fetch workout history from database');
}
});

View file

@ -1,12 +1,18 @@
import adapter from '@sveltejs/adapter-auto'; import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import type { Config } from '@sveltejs/kit';
/** @type {import('@sveltejs/kit').Config} */ const config: Config = {
const config = {
// Consult https://svelte.dev/docs/kit/integrations // Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors // for more information about preprocessors
preprocess: vitePreprocess(), preprocess: vitePreprocess(),
kit: { adapter: adapter() } compilerOptions: {
experimental: { async: true }
},
kit: {
adapter: adapter(),
experimental: { remoteFunctions: true }
}
}; };
export default config; export default config;