diff --git a/Dockerfile b/Dockerfile index 824bfe1..0fd045f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,64 +1,37 @@ -# Use the official Node.js runtime as the base image -FROM node:20-alpine AS base +# Build stage - includes dev dependencies for building the app +FROM node:22-alpine AS build -# Set the working directory inside the container +# Set working directory for build operations WORKDIR /app -# Copy package.json and package-lock.json (if available) +# Copy package files to install dependencies COPY package*.json ./ +# Install all dependencies including dev dependencies needed for build +RUN npm ci -# Install all dependencies (including devDependencies for building) -RUN npm ci && npm cache clean --force - -# Copy the rest of the application code +# Copy source code and build the application COPY . . - -# Set build-time environment variables for SvelteKit -ARG DATABASE_URL="postgresql://builduser:buildpass@localhost:5432/builddb" -ENV DATABASE_URL=${DATABASE_URL} - -# Run SvelteKit sync to generate .svelte-kit directory and prepare the build -RUN npm run prepare - -# Build the application with environment variables available RUN npm run build -# Production stage -FROM node:20-alpine AS production +# Production stage - clean runtime environment +FROM node:22-alpine AS production -# Install dumb-init for proper signal handling -RUN apk add --no-cache dumb-init - -# Create a non-root user -RUN addgroup -g 1001 -S nodejs -RUN adduser -S svelte -u 1001 - -# Set the working directory +# Set working directory for runtime WORKDIR /app -# Copy package.json and package-lock.json +# Copy package files for production install COPY package*.json ./ +# Install only production dependencies (excludes dev dependencies) +RUN npm ci --only=production -# Install only production dependencies -RUN npm ci --only=production && npm cache clean --force +# Copy built application from build stage +COPY --from=build /app/build ./build -# Copy the built application from the previous stage -COPY --from=base --chown=svelte:nodejs /app/build ./build -COPY --from=base --chown=svelte:nodejs /app/package.json ./package.json - -# Switch to the non-root user -USER svelte - -# Expose the port the app runs on +# Expose the port the application will run on EXPOSE 3000 -# Set environment variables - DATABASE_URL can be overridden at runtime +# Set environment to production ENV NODE_ENV=production -ENV PORT=3000 -ENV DATABASE_URL="" -# Use dumb-init to handle signals properly -ENTRYPOINT ["dumb-init", "--"] - -# Start the application with environment file support +# Start the application CMD ["node", "build"] diff --git a/src/lib/WorkoutDisplay.svelte b/src/lib/WorkoutDisplay.svelte deleted file mode 100644 index fcc7e7b..0000000 --- a/src/lib/WorkoutDisplay.svelte +++ /dev/null @@ -1,148 +0,0 @@ - - -
-

Today's Workout

- -
- 📅 {todayDate} -
- - {#if loading} -
-
⏳ Loading workout data...
-
- {:else if error} -
-
Error loading data
-
{error}
- -
- {:else if !workoutData} -
-
📝 No workout recorded for today
-
Start logging your exercises below!
-
- {:else} - -
- -
-
💪
-
{workoutData.pushups}
-
Push-ups
-
- - -
-
🏋️
-
{workoutData.situps}
-
Sit-ups
-
- - -
-
🧘
-
- {formatTime(workoutData.plank_time_seconds)} -
-
Plank
-
- - -
-
🏃
-
{workoutData.run_distance_km} km
-
Running
-
-
- - -
-

📊 Summary

-
-
- Total exercises: - {workoutData.pushups + workoutData.situps} -
-
- Plank time: - {formatTime(workoutData.plank_time_seconds)} -
-
- Distance: - {workoutData.run_distance_km} km -
-
-
- - - {#if workoutData.created_at || workoutData.updated_at} -
- {#if workoutData.created_at} -
Created: {new Date(workoutData.created_at).toLocaleString()}
- {/if} - {#if workoutData.updated_at && workoutData.updated_at !== workoutData.created_at} -
Updated: {new Date(workoutData.updated_at).toLocaleString()}
- {/if} -
- {/if} - {/if} - - -
- -
-
diff --git a/src/lib/WorkoutLogger.svelte b/src/lib/WorkoutLogger.svelte deleted file mode 100644 index b526ca6..0000000 --- a/src/lib/WorkoutLogger.svelte +++ /dev/null @@ -1,271 +0,0 @@ - - -
-

Log Today's Workout

- -
- 📅 {todayDate} -
- - -
- -
- - - -
-
- - -
- -
- - - -
-
- - -
- -
- - - -
-
- - -
- -
- - - -
-
- - -
- - -
- - - {#if message} -
- {message} -
- {/if} - - -
-

Your Example:

-
-
💪 Push-ups: 100
-
🏋️ Sit-ups: 50
-
🧘 Plank: 0 seconds
-
🏃 Running: 4.0 km
-
- -
-
diff --git a/src/routes/DbConnection.svelte b/src/lib/components/DbConnection.svelte similarity index 72% rename from src/routes/DbConnection.svelte rename to src/lib/components/DbConnection.svelte index dc84b0c..e23db3c 100644 --- a/src/routes/DbConnection.svelte +++ b/src/lib/components/DbConnection.svelte @@ -3,8 +3,7 @@ -

Here be connection: {await testConnection().current}

- +

Here be connection: {testConnection().current}

{#snippet pending()}

loading...

diff --git a/src/lib/components/ExampleNewExercise.svelte b/src/lib/components/ExampleNewExercise.svelte new file mode 100644 index 0000000..dcabe6b --- /dev/null +++ b/src/lib/components/ExampleNewExercise.svelte @@ -0,0 +1,107 @@ + + +
+

Example: Adding New Exercises

+ + +
+ + + +
+ + +
+ + + +
+ + +
+

How to add new exercises:

+
    +
  1. Define exercise config with name, icon, color, and quick-add options
  2. +
  3. + Use ExerciseField component for input forms +
  4. +
  5. + Use WorkoutStatCard component for display +
  6. +
  7. Add to workout-utils.ts for consistency
  8. +
+
+
diff --git a/src/lib/components/QuickAddButton.svelte b/src/lib/components/QuickAddButton.svelte new file mode 100644 index 0000000..2fdd40f --- /dev/null +++ b/src/lib/components/QuickAddButton.svelte @@ -0,0 +1,29 @@ + + + diff --git a/src/routes/db.remote.js b/src/lib/components/db.remote.js similarity index 100% rename from src/routes/db.remote.js rename to src/lib/components/db.remote.js diff --git a/src/lib/db.ts b/src/lib/db.ts index 0453cf0..4128794 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -10,7 +10,8 @@ const getPool = () => { if (!pool) { pool = new Pool({ connectionString: getDatabaseUrl, - ssl: false + ssl: false, + options: '-c timezone=Europe/Oslo' }); } return pool; @@ -63,6 +64,11 @@ export const db = { ) `); + await this.query(` + ALTER TABLE daily_exercises + ADD COLUMN IF NOT EXISTS hangups INTEGER DEFAULT 0 + `); + // Create index for faster date lookups await this.query(` CREATE INDEX IF NOT EXISTS idx_daily_exercises_date diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 0dd9c62..187defb 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,15 +1,6 @@
@@ -18,15 +9,9 @@

Track your daily fitness progress

- -
- -
+ - -
- -
+
diff --git a/src/routes/ExerciseField.svelte b/src/routes/ExerciseField.svelte new file mode 100644 index 0000000..92b0489 --- /dev/null +++ b/src/routes/ExerciseField.svelte @@ -0,0 +1,55 @@ + + +
+ +
+ + {#each quickAddOptions as option} + quickAdd(option.value)} {color} /> + {/each} +
+
diff --git a/src/routes/WorkoutDisplay.svelte b/src/routes/WorkoutDisplay.svelte new file mode 100644 index 0000000..d840031 --- /dev/null +++ b/src/routes/WorkoutDisplay.svelte @@ -0,0 +1,120 @@ + + +
+

Today's Workout

+ +
+ 📅 {todayDate} +
+ + + {@const workoutData = (await getTodaysWorkout()).data} + + {#if !workoutData} +
+
📝 No workout recorded for today
+
Start logging your exercises below!
+
+ {:else} + +
+ + + + + + + + + + + + + + +
+ + {@const allWorkouData = (await getWorkoutHistory()).data} + +
+

📊 Summary for last 7 days

+
+
+ Total pushups: + {allWorkouData.pushups} +
+
+ Total situps: + {workoutData.situps} +
+
+ Plank time: + {formatTime(workoutData.plankSeconds)} +
+
+ Hang ups: + {workoutData.hangups} +
+
+ Distance: + {formatDistance(workoutData.runKm)} +
+
+
+ {/if} + + {#snippet pending()} +
+
⏳ Loading workout data...
+
+ {/snippet} + + {#snippet failed(error, reset)} +
+
Error loading data
+
{error}
+ +
+ {/snippet} +
+
diff --git a/src/routes/WorkoutLogger.svelte b/src/routes/WorkoutLogger.svelte new file mode 100644 index 0000000..6d137ee --- /dev/null +++ b/src/routes/WorkoutLogger.svelte @@ -0,0 +1,152 @@ + + +
+ +
+

Log Today's Workout

+
📅 {todayDate}
+
+ + +
+ + handleFieldChange('pushups', value)} + quickAddOptions={exerciseConfigs.pushups.quickAddOptions} + color={exerciseConfigs.pushups.color} + max={exerciseConfigs.pushups.max} + step={exerciseConfigs.pushups.step} + /> + + + handleFieldChange('situps', value)} + defaultValue={form.situps} + quickAddOptions={exerciseConfigs.situps.quickAddOptions} + color={exerciseConfigs.situps.color} + max={exerciseConfigs.situps.max} + step={exerciseConfigs.situps.step} + /> + + + handleFieldChange('plankSeconds', value)} + defaultValue={form.plankSeconds} + quickAddOptions={exerciseConfigs.plankSeconds.quickAddOptions} + color={exerciseConfigs.plankSeconds.color} + max={exerciseConfigs.plankSeconds.max} + step={exerciseConfigs.plankSeconds.step} + /> + + + handleFieldChange('hangups', value)} + defaultValue={form.hangups} + quickAddOptions={exerciseConfigs.hangups.quickAddOptions} + color={exerciseConfigs.hangups.color} + max={exerciseConfigs.hangups.max} + step={exerciseConfigs.hangups.step} + /> + + handleFieldChange('runKm', value)} + defaultValue={form.runKm} + quickAddOptions={exerciseConfigs.runKm.quickAddOptions} + color={exerciseConfigs.runKm.color} + max={exerciseConfigs.runKm.max} + step={exerciseConfigs.runKm.step} + /> + + +
+ +
+ + + + {#if saveWorkout.result?.success} + + {/if} + + {#snippet pending()}{/snippet} + + +
+

Quick Example:

+
+
💪 Push-ups: {exampleWorkout.pushups}
+
🏋️ Sit-ups: {exampleWorkout.situps}
+
🧘 Plank: {exampleWorkout.plankSeconds} seconds
+
🏃 Running: {exampleWorkout.runKm} km
+
☕ Hangups: {exampleWorkout.hangups}
+
+ +
+
diff --git a/src/routes/WorkoutStatCard.svelte b/src/routes/WorkoutStatCard.svelte new file mode 100644 index 0000000..1695a19 --- /dev/null +++ b/src/routes/WorkoutStatCard.svelte @@ -0,0 +1,37 @@ + + +
+
{icon}
+
{displayValue}
+
{label}
+
diff --git a/src/routes/workout.remote.ts b/src/routes/workout.remote.ts index 5750823..2c7c960 100644 --- a/src/routes/workout.remote.ts +++ b/src/routes/workout.remote.ts @@ -1,69 +1,71 @@ -import { command, query } from '$app/server'; +import { command, form, query } from '$app/server'; import { db } from '$lib/db'; -import { z } from 'zod'; +import { error } from '@sveltejs/kit'; -interface WorkOutData { +export interface WorkOutData { pushups: number; situps: number; plankSeconds: number; + hangups: number; runKm: number; } -export const saveWorkout = command( - 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; +export const saveWorkout = form(async (data) => { + let pushups = Number(data.get('pushups')); + let situps = Number(data.get('situps')); + let plankSeconds = Number(data.get('plankSeconds')); + let hangups = Number(data.get('hangups')); + let runKm = Number(data.get('runKm')); - // 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'); - } + // Validate input data + if (typeof pushups !== 'number' || isNaN(pushups) || pushups < 0) { + error(400, 'Invalid pushups value'); + } + if (typeof situps !== 'number' || isNaN(situps) || situps < 0) { + error(400, 'Invalid situps value'); + } + if (typeof plankSeconds !== 'number' || isNaN(plankSeconds) || plankSeconds < 0) { + error(400, 'Invalid plank time value'); + } + if (typeof hangups !== 'number' || isNaN(hangups) || hangups < 0) { + error(400, 'Invalid hangups value'); + } + if (typeof runKm !== 'number' || isNaN(runKm) || runKm < 0) { + error(400, '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) + try { + // Insert or update today's workout + const result = await db.query( + ` + INSERT INTO daily_exercises (date, pushups, situps, plank_time_seconds, hangups, run_distance_km) + VALUES (CURRENT_DATE, $1, $2, $3, $4, $5) ON CONFLICT (date) DO UPDATE SET pushups = $1, situps = $2, plank_time_seconds = $3, - run_distance_km = $4, + hangups = $4, + run_distance_km = $5, updated_at = CURRENT_TIMESTAMP RETURNING * `, - [pushups, situps, plankSeconds, runKm] - ); + [pushups, situps, plankSeconds, hangups, runKm] + ); - await getTodaysWorkout().refresh(); + await getTodaysWorkout().refresh(); + await getWorkoutHistory().refresh(); - 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'); - } + 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 { @@ -77,9 +79,19 @@ export const getTodaysWorkout = query(async () => { }; } + // Take all rows and add them up into each category + const row = result.rows[0]; + const workoutData: WorkOutData = { + pushups: Number(row.pushups), + situps: Number(row.situps), + plankSeconds: Number(row.plank_time_seconds), + hangups: Number(row.hangups), + runKm: Number(row.run_distance_km) + }; + return { success: true, - data: result.rows[0] + data: workoutData }; } catch (error) { console.error('Error fetching workout:', error); @@ -93,15 +105,25 @@ export const getWorkoutHistory = query(async (days: number = 7) => { const result = await db.query( ` SELECT * FROM daily_exercises - WHERE date >= CURRENT_DATE - INTERVAL '$1 days' + WHERE date >= CURRENT_DATE - INTERVAL '1 day' * $1 ORDER BY date DESC `, [days] ); + // Take all rows and add them up into each category + const rows = result.rows; + const workoutData: WorkOutData = { + pushups: rows.reduce((sum, row) => sum + Number(row.pushups), 0), + situps: rows.reduce((sum, row) => sum + Number(row.situps), 0), + plankSeconds: rows.reduce((sum, row) => sum + Number(row.plank_time_seconds), 0), + hangups: rows.reduce((sum, row) => sum + Number(row.hangups), 0), + runKm: rows.reduce((sum, row) => sum + Number(row.run_distance_km), 0) + }; + return { success: true, - data: result.rows, + data: workoutData, message: `Retrieved ${result.rows.length} workout records` }; } catch (error) { diff --git a/src/routes/workoutData.ts b/src/routes/workoutData.ts new file mode 100644 index 0000000..796dee3 --- /dev/null +++ b/src/routes/workoutData.ts @@ -0,0 +1,72 @@ +export type ColorExercise = 'blue' | 'green' | 'orange' | 'red' | 'purple'; + +export interface ExerciseConfig { + name: string; + icon: string; + color: ColorExercise; + quickAddOptions: Array<{ label: string; value: number }>; + min?: number; + max?: number; + step?: number; + unit?: string; +} + +export const exerciseConfigs: Record = { + pushups: { + name: 'Push-ups', + icon: '💪', + color: 'blue', + quickAddOptions: [ + { label: '+10', value: 10 }, + { label: '+25', value: 25 } + ], + max: 9999, + step: 1 + }, + situps: { + name: 'Sit-ups', + icon: '🏋️', + color: 'green', + quickAddOptions: [ + { label: '+10', value: 10 }, + { label: '+20', value: 20 } + ], + max: 9999, + step: 1 + }, + plankSeconds: { + name: 'Plank', + icon: '🧘', + color: 'orange', + quickAddOptions: [ + { label: '+30s', value: 30 }, + { label: '+60s', value: 60 } + ], + max: 9999, + step: 1, + unit: 'seconds' + }, + runKm: { + name: 'Running', + icon: '🏃', + color: 'red', + quickAddOptions: [ + { label: '+1km', value: 1 }, + { label: '+2.5km', value: 2.5 } + ], + max: 999.99, + step: 0.01, + unit: 'km' + }, + hangups: { + name: 'Hang-ups', + icon: '🪂', + color: 'purple', + quickAddOptions: [ + { label: '+1', value: 1 }, + { label: '+5', value: 5 } + ], + max: 9999, + step: 1 + } +}; diff --git a/src/routes/workoutUtils.ts b/src/routes/workoutUtils.ts new file mode 100644 index 0000000..a2c1826 --- /dev/null +++ b/src/routes/workoutUtils.ts @@ -0,0 +1,28 @@ +import type { WorkOutData } from './workout.remote'; + +export 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`; +} + +export function formatDistance(km: number): string { + return `${km} km`; +} + +export function getTodayDateString(locale: string = 'nb-NO'): string { + return new Date().toLocaleDateString(locale); +} + +export const exampleWorkout: WorkOutData = { + pushups: 100, + situps: 50, + plankSeconds: 0, + hangups: 0, + runKm: 4.0 +};