From 75ca60688c2eb89fff3f6c1371827ec316adc760 Mon Sep 17 00:00:00 2001 From: Vlad Giusca Date: Mon, 25 May 2026 10:11:46 +0200 Subject: [PATCH 1/5] mobile db --- apps/mobile/app/(tabs)/explore.tsx | 128 ++++++------ apps/mobile/app/db/database.ts | 30 +++ apps/mobile/app/db/exercises.ts | 189 ++++++++++++++++++ apps/mobile/app/hooks/useExercises.ts | 112 +++++++++++ apps/mobile/package-lock.json | 21 ++ apps/mobile/package.json | 1 + schemas/ow_exercise.sql | 3 +- .../internal/domain/exercise.go | 30 +-- .../infrastructure/repository/mock.go | 19 +- .../infrastructure/repository/redis_cached.go | 28 +-- .../repository/redis_cached_test.go | 7 +- .../internal/infrastructure/repository/sql.go | 97 ++++----- .../repository/sql_postgres_test.go | 8 +- .../internal/service/service.go | 7 +- .../internal/service/service_test.go | 17 +- .../http/handlers/exercise_handler.go | 13 +- 16 files changed, 545 insertions(+), 165 deletions(-) create mode 100644 apps/mobile/app/db/database.ts create mode 100644 apps/mobile/app/db/exercises.ts create mode 100644 apps/mobile/app/hooks/useExercises.ts diff --git a/apps/mobile/app/(tabs)/explore.tsx b/apps/mobile/app/(tabs)/explore.tsx index 88d0b2f..8f3d777 100644 --- a/apps/mobile/app/(tabs)/explore.tsx +++ b/apps/mobile/app/(tabs)/explore.tsx @@ -1,39 +1,40 @@ -import { View, Text, ScrollView, TouchableOpacity, TextInput, StatusBar } from "react-native"; +import { View, Text, ScrollView, TouchableOpacity, TextInput, StatusBar, ActivityIndicator } from "react-native"; import { SafeAreaView } from 'react-native-safe-area-context'; -import { Image } from 'expo-image'; import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons'; -import { useState } from 'react'; +import { useState, useMemo } from 'react'; +import { useExercises } from '../hooks/useExercises'; +import type { LocalExercise } from '../db/exercises'; const categories = ['All', 'Chest', 'Back', 'Legs', 'Arms', 'Shoulders']; -const recentSearches = [ - { - name: 'Barbell Bench Press', - muscle: 'Chest', - equipment: 'Barbell', - image: 'https://images.unsplash.com/photo-1581009146145-b5ef050c2e1e?auto=format&fit=crop&w=150&h=150&q=80', - }, - { - name: 'Incline Dumbbell Press', - muscle: 'Chest', - equipment: 'Dumbbell', - image: 'https://images.unsplash.com/photo-1541534741688-6078c6bfb5c5?auto=format&fit=crop&w=150&h=150&q=80', - }, -]; +const categoryMuscleKeywords: Record = { + Chest: ['chest'], + Back: ['back', 'lats', 'traps', 'rhomboids'], + Legs: ['legs', 'quads', 'glutes', 'hamstrings', 'calves'], + Arms: ['bicep', 'tricep', 'forearm'], + Shoulders: ['delt', 'shoulder'], +}; -const popular = [ - { name: 'Squat (Barbell)', muscle: 'Legs', equipment: 'Barbell' }, - { name: 'Pull Up', muscle: 'Back', equipment: 'Bodyweight' }, - { - name: 'Deadlift (Barbell)', - muscle: 'Back / Legs', - equipment: 'Barbell', - image: 'https://images.unsplash.com/photo-1599058917212-d750089bc07e?auto=format&fit=crop&w=150&h=150&q=80', - }, -]; +function matchesCategory(ex: LocalExercise, category: string): boolean { + if (category === 'All') return true; + const keywords = categoryMuscleKeywords[category] ?? []; + const muscles = [...ex.primary_muscles, ...ex.secondary_muscles].map((m) => m.toLowerCase()); + return keywords.some((kw) => muscles.some((m) => m.includes(kw))); +} export default function ExploreScreen() { const [activeCategory, setActiveCategory] = useState(0); + const [search, setSearch] = useState(''); + const { exercises, isLoading } = useExercises(); + + const filtered = useMemo(() => { + const cat = categories[activeCategory]; + return exercises.filter( + (ex) => + matchesCategory(ex, cat) && + ex.name.toLowerCase().includes(search.toLowerCase()), + ); + }, [exercises, activeCategory, search]); return ( @@ -55,6 +56,8 @@ export default function ExploreScreen() { placeholder="Find an exercise..." placeholderTextColor="#52525b" style={{ flex: 1, color: '#fff', fontSize: 14 }} + value={search} + onChangeText={setSearch} /> @@ -91,51 +94,62 @@ export default function ExploreScreen() { {/* List */} - - - Recently Searched - - {recentSearches.map((ex) => )} - - - Popular - - {popular.map((ex) => )} - + {isLoading && exercises.length === 0 ? ( + + + + ) : ( + + {filtered.length === 0 ? ( + + No exercises found + + ) : ( + <> + + {filtered.length} exercise{filtered.length !== 1 ? 's' : ''} + + {filtered.map((ex) => ( + + ))} + + )} + + )} ); } -function ExerciseRow({ name, muscle, equipment, image }: { name: string; muscle: string; equipment: string; image?: string }) { +function ExerciseRow({ exercise }: { exercise: LocalExercise }) { + const muscle = exercise.primary_muscles[0] ?? exercise.exercise_type ?? '—'; + const isPending = exercise.sync_status === 'pending_create'; + return ( - {image ? ( - - ) : ( - - - - )} + + + - {name} - {muscle} • {equipment} + {exercise.name} + + {muscle} • {exercise.exercise_type || 'exercise'} + + {isPending && ( + syncing... + )} diff --git a/apps/mobile/app/db/database.ts b/apps/mobile/app/db/database.ts new file mode 100644 index 0000000..6da933d --- /dev/null +++ b/apps/mobile/app/db/database.ts @@ -0,0 +1,30 @@ +import * as SQLite from 'expo-sqlite'; + +let db: SQLite.SQLiteDatabase | null = null; + +export async function getDb(): Promise { + if (db) return db; + db = await SQLite.openDatabaseAsync('openworkout.db'); + await db.execAsync(` + CREATE TABLE IF NOT EXISTS exercises ( + local_id TEXT PRIMARY KEY, + exercise_id INTEGER UNIQUE, + name TEXT NOT NULL, + exercise_type TEXT, + primary_muscles TEXT, + secondary_muscles TEXT, + alt_names TEXT, + description TEXT, + user_id TEXT, + is_private INTEGER DEFAULT 0, + weight_direction INTEGER DEFAULT 1, + sync_status TEXT DEFAULT 'synced', + created_at INTEGER + ); + CREATE TABLE IF NOT EXISTS sync_meta ( + key TEXT PRIMARY KEY, + value TEXT + ); + `); + return db; +} diff --git a/apps/mobile/app/db/exercises.ts b/apps/mobile/app/db/exercises.ts new file mode 100644 index 0000000..d947853 --- /dev/null +++ b/apps/mobile/app/db/exercises.ts @@ -0,0 +1,189 @@ +import { getDb } from './database'; + +export type ExerciseModel = { + exercise_id: number; + name: string; + exercise_type: string; + primary_muscles: string[]; + secondary_muscles: string[]; + alt_names: string[]; + description: string; + user_id: string; + is_private: boolean; + weight_direction: number; + updated_at: string; +}; + +export type LocalExercise = { + local_id: string; + exercise_id: number | null; + name: string; + exercise_type: string; + primary_muscles: string[]; + secondary_muscles: string[]; + alt_names: string[]; + description: string; + user_id: string; + is_private: boolean; + weight_direction: number; + sync_status: 'synced' | 'pending_create'; + created_at: number; +}; + +export type NewExerciseInput = { + name: string; + exercise_type: string; + primary_muscles: string[]; + secondary_muscles: string[]; + alt_names: string[]; + description: string; + is_private: boolean; + weight_direction: number; +}; + +type RawRow = { + local_id: string; + exercise_id: number | null; + name: string; + exercise_type: string; + primary_muscles: string; + secondary_muscles: string; + alt_names: string; + description: string; + user_id: string; + is_private: number; + weight_direction: number; + sync_status: 'synced' | 'pending_create'; + created_at: number; +}; + +function parseRow(row: RawRow): LocalExercise { + return { + ...row, + primary_muscles: JSON.parse(row.primary_muscles || '[]'), + secondary_muscles: JSON.parse(row.secondary_muscles || '[]'), + alt_names: JSON.parse(row.alt_names || '[]'), + is_private: row.is_private === 1, + }; +} + +function generateLocalId(): string { + return `local_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`; +} + +export async function upsertServerExercises(exercises: ExerciseModel[]): Promise { + const db = await getDb(); + for (const ex of exercises) { + await db.runAsync( + `INSERT INTO exercises + (local_id, exercise_id, name, exercise_type, primary_muscles, secondary_muscles, + alt_names, description, user_id, is_private, weight_direction, sync_status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'synced', ?) + ON CONFLICT(exercise_id) DO UPDATE SET + name = excluded.name, + exercise_type = excluded.exercise_type, + primary_muscles = excluded.primary_muscles, + secondary_muscles = excluded.secondary_muscles, + alt_names = excluded.alt_names, + description = excluded.description, + user_id = excluded.user_id, + is_private = excluded.is_private, + weight_direction = excluded.weight_direction, + sync_status = 'synced' + WHERE exercises.sync_status != 'pending_create'`, + `server_${ex.exercise_id}`, + ex.exercise_id, + ex.name, + ex.exercise_type, + JSON.stringify(ex.primary_muscles ?? []), + JSON.stringify(ex.secondary_muscles ?? []), + JSON.stringify(ex.alt_names ?? []), + ex.description, + ex.user_id, + ex.is_private ? 1 : 0, + ex.weight_direction, + Date.now(), + ); + } +} + +export async function removeStaleExercises(serverIds: number[]): Promise { + if (serverIds.length === 0) return; + const db = await getDb(); + const placeholders = serverIds.map(() => '?').join(','); + await db.runAsync( + `DELETE FROM exercises + WHERE exercise_id IS NOT NULL + AND sync_status = 'synced' + AND exercise_id NOT IN (${placeholders})`, + ...serverIds, + ); +} + +export async function insertPendingExercise( + input: NewExerciseInput, + userId: string, +): Promise { + const db = await getDb(); + const localId = generateLocalId(); + await db.runAsync( + `INSERT INTO exercises + (local_id, exercise_id, name, exercise_type, primary_muscles, secondary_muscles, + alt_names, description, user_id, is_private, weight_direction, sync_status, created_at) + VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending_create', ?)`, + localId, + input.name, + input.exercise_type, + JSON.stringify(input.primary_muscles), + JSON.stringify(input.secondary_muscles), + JSON.stringify(input.alt_names), + input.description, + userId, + input.is_private ? 1 : 0, + input.weight_direction, + Date.now(), + ); + return localId; +} + +export async function confirmSyncedExercise(localId: string, serverId: number): Promise { + const db = await getDb(); + await db.runAsync( + `UPDATE exercises SET exercise_id = ?, sync_status = 'synced' WHERE local_id = ?`, + serverId, + localId, + ); +} + +export async function getAllExercises(userId: string): Promise { + const db = await getDb(); + const rows = await db.getAllAsync( + `SELECT * FROM exercises WHERE is_private = 0 OR user_id = ? ORDER BY created_at DESC`, + userId, + ); + return rows.map(parseRow); +} + +export async function getPendingExercises(): Promise { + const db = await getDb(); + const rows = await db.getAllAsync( + `SELECT * FROM exercises WHERE sync_status = 'pending_create'`, + ); + return rows.map(parseRow); +} + +export async function getLastSyncedAt(): Promise { + const db = await getDb(); + const row = await db.getFirstAsync<{ value: string }>( + `SELECT value FROM sync_meta WHERE key = 'last_synced_at'`, + ); + return row ? parseInt(row.value, 10) : null; +} + +export async function setLastSyncedAt(ts: number): Promise { + const db = await getDb(); + await db.runAsync( + `INSERT OR REPLACE INTO sync_meta (key, value) VALUES ('last_synced_at', ?)`, + ts.toString(), + ); +} diff --git a/apps/mobile/app/hooks/useExercises.ts b/apps/mobile/app/hooks/useExercises.ts new file mode 100644 index 0000000..7352efc --- /dev/null +++ b/apps/mobile/app/hooks/useExercises.ts @@ -0,0 +1,112 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useAuth } from '../_context/auth'; +import { + getAllExercises, + upsertServerExercises, + removeStaleExercises, + insertPendingExercise, + confirmSyncedExercise, + getPendingExercises, + getLastSyncedAt, + setLastSyncedAt, + type LocalExercise, + type ExerciseModel, + type NewExerciseInput, +} from '../db/exercises'; + +const ONE_DAY_MS = 86_400_000; + +export function useExercises() { + const { userID, apiFetch } = useAuth(); + const [exercises, setExercises] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + const loadLocal = useCallback(async () => { + if (!userID) return; + setExercises(await getAllExercises(userID)); + }, [userID]); + + const syncFromServer = useCallback(async () => { + if (!userID) return; + const lastSync = await getLastSyncedAt(); + const isFullSync = !lastSync || Date.now() - lastSync > ONE_DAY_MS; + const url = isFullSync + ? `/exercises?user_id=${encodeURIComponent(userID)}` + : `/exercises?user_id=${encodeURIComponent(userID)}&updated_after=${new Date(lastSync).toISOString()}`; + + try { + const res = await apiFetch(url); + if (!res.ok) return; + const data: ExerciseModel[] = await res.json(); + await upsertServerExercises(data); + if (isFullSync) { + await removeStaleExercises(data.map((e) => e.exercise_id)); + } + await setLastSyncedAt(Date.now()); + await loadLocal(); + } catch { + // network unavailable — local data remains usable + } + }, [userID, apiFetch, loadLocal]); + + const retryPending = useCallback(async () => { + const pending = await getPendingExercises(); + for (const ex of pending) { + try { + const res = await apiFetch('/exercises', { + method: 'POST', + body: JSON.stringify({ + name: ex.name, + exercise_type: ex.exercise_type, + primary_muscles: ex.primary_muscles, + secondary_muscles: ex.secondary_muscles, + alt_names: ex.alt_names, + description: ex.description, + user_id: ex.user_id, + is_private: ex.is_private, + weight_direction: ex.weight_direction, + }), + }); + if (res.ok) { + const created: ExerciseModel = await res.json(); + await confirmSyncedExercise(ex.local_id, created.exercise_id); + } + } catch { + // leave as pending_create, will retry on next mount + } + } + }, [apiFetch]); + + useEffect(() => { + if (!userID) return; + setIsLoading(true); + loadLocal() + .then(syncFromServer) + .then(retryPending) + .finally(() => setIsLoading(false)); + }, [userID]); + + const createExercise = useCallback( + async (input: NewExerciseInput) => { + if (!userID) return; + const localId = await insertPendingExercise(input, userID); + await loadLocal(); + try { + const res = await apiFetch('/exercises', { + method: 'POST', + body: JSON.stringify({ ...input, user_id: userID }), + }); + if (res.ok) { + const created: ExerciseModel = await res.json(); + await confirmSyncedExercise(localId, created.exercise_id); + await loadLocal(); + } + } catch { + // stays as pending_create, retried on next mount + } + }, + [userID, apiFetch, loadLocal], + ); + + return { exercises, createExercise, isLoading }; +} diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json index 793cf36..829337e 100644 --- a/apps/mobile/package-lock.json +++ b/apps/mobile/package-lock.json @@ -23,6 +23,7 @@ "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", "expo-splash-screen": "~31.0.13", + "expo-sqlite": "~16.0.0", "expo-status-bar": "~3.0.9", "expo-symbols": "~1.0.8", "expo-system-ui": "~6.0.9", @@ -4977,6 +4978,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/await-lock": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", + "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==", + "license": "MIT" + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -7456,6 +7463,20 @@ "expo": "*" } }, + "node_modules/expo-sqlite": { + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-16.0.10.tgz", + "integrity": "sha512-tUOKxE9TpfneRG3eOfbNfhN9236SJ7IiUnP8gCqU7umd9DtgDGB/5PhYVVfl+U7KskgolgNoB9v9OZ9iwXN8Eg==", + "license": "MIT", + "dependencies": { + "await-lock": "^2.2.2" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-status-bar": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-3.0.9.tgz", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 8ba1a06..9b94f5d 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -26,6 +26,7 @@ "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", "expo-splash-screen": "~31.0.13", + "expo-sqlite": "~16.0.0", "expo-status-bar": "~3.0.9", "expo-symbols": "~1.0.8", "expo-system-ui": "~6.0.9", diff --git a/schemas/ow_exercise.sql b/schemas/ow_exercise.sql index bc3a7e4..e6e085c 100644 --- a/schemas/ow_exercise.sql +++ b/schemas/ow_exercise.sql @@ -14,7 +14,8 @@ create table exercises user_id text, is_private boolean, weight_direction bigint, - alt_names text[] + alt_names text[], + updated_at timestamptz default now() ); alter table exercises diff --git a/services/exercise-service/internal/domain/exercise.go b/services/exercise-service/internal/domain/exercise.go index ed10124..da8dccd 100644 --- a/services/exercise-service/internal/domain/exercise.go +++ b/services/exercise-service/internal/domain/exercise.go @@ -4,6 +4,7 @@ import ( "context" "errors" "io" + "time" ) var ErrNotFound = errors.New("not found") @@ -79,16 +80,17 @@ func filterOut(muscles []string, exclude map[string]bool) []string { } type ExerciseModel struct { - ExerciseID int64 `json:"exercise_id"` - Name string `json:"name"` - ExerciseType string `json:"exercise_type"` - PrimaryMuscles []string `json:"primary_muscles"` - SecondaryMuscles []string `json:"secondary_muscles"` - AltNames []string `json:"alt_names"` - Description string `json:"description"` - UserID string `json:"user_id"` - IsPrivate bool `json:"is_private"` - WeightDirection int64 `json:"weight_direction"` + ExerciseID int64 `json:"exercise_id"` + Name string `json:"name"` + ExerciseType string `json:"exercise_type"` + PrimaryMuscles []string `json:"primary_muscles"` + SecondaryMuscles []string `json:"secondary_muscles"` + AltNames []string `json:"alt_names"` + Description string `json:"description"` + UserID string `json:"user_id"` + IsPrivate bool `json:"is_private"` + WeightDirection int64 `json:"weight_direction"` + UpdatedAt time.Time `json:"updated_at"` } type ExerciseMedia struct { @@ -118,7 +120,7 @@ type ExerciseService interface { GetTopExercises(ctx context.Context, state MuscleState, limit int) ([]ExerciseModel, error) - ListExercises(ctx context.Context, userID string) ([]ExerciseModel, error) + ListExercises(ctx context.Context, userID string, updatedAfter *time.Time) ([]ExerciseModel, error) GetExerciseById(ctx context.Context, id int64, callerUserID string) (*ExerciseModel, error) @@ -132,9 +134,9 @@ type ExerciseRepository interface { AddExerciseMedia(ctx context.Context, exerciseID int64, media *ExerciseMedia) error GetExerciseMedia(ctx context.Context, exerciseID int64, callerUserID string) ([]ExerciseMedia, error) - ListExercises(ctx context.Context, userID string) ([]ExerciseModel, error) - ListPublicExercises(ctx context.Context) ([]ExerciseModel, error) - ListUserExercises(ctx context.Context, userID string) ([]ExerciseModel, error) + ListExercises(ctx context.Context, userID string, updatedAfter *time.Time) ([]ExerciseModel, error) + ListPublicExercises(ctx context.Context, updatedAfter *time.Time) ([]ExerciseModel, error) + ListUserExercises(ctx context.Context, userID string, updatedAfter *time.Time) ([]ExerciseModel, error) GetExerciseById(ctx context.Context, id int64, callerUserID string) (*ExerciseModel, error) diff --git a/services/exercise-service/internal/infrastructure/repository/mock.go b/services/exercise-service/internal/infrastructure/repository/mock.go index 55bb8a1..f3c51f7 100644 --- a/services/exercise-service/internal/infrastructure/repository/mock.go +++ b/services/exercise-service/internal/infrastructure/repository/mock.go @@ -2,6 +2,7 @@ package repository import ( "context" + "time" "github.com/open-workout/ow/services/exercise-service/internal/domain" ) @@ -13,9 +14,9 @@ type MockRepository struct { AddExerciseMediaFunc func(ctx context.Context, exerciseID int64, media *domain.ExerciseMedia) error GetExerciseMediaFunc func(ctx context.Context, exerciseID int64, callerUserID string) ([]domain.ExerciseMedia, error) - ListExercisesFunc func(ctx context.Context, userID string) ([]domain.ExerciseModel, error) - ListPublicExercisesFunc func(ctx context.Context) ([]domain.ExerciseModel, error) - ListUserExercisesFunc func(ctx context.Context, userID string) ([]domain.ExerciseModel, error) + ListExercisesFunc func(ctx context.Context, userID string, updatedAfter *time.Time) ([]domain.ExerciseModel, error) + ListPublicExercisesFunc func(ctx context.Context, updatedAfter *time.Time) ([]domain.ExerciseModel, error) + ListUserExercisesFunc func(ctx context.Context, userID string, updatedAfter *time.Time) ([]domain.ExerciseModel, error) GetExerciseByIdFunc func(ctx context.Context, id int64, callerUserID string) (*domain.ExerciseModel, error) @@ -44,19 +45,19 @@ func (m *MockRepository) GetExerciseMedia(ctx context.Context, exerciseID int64, return m.GetExerciseMediaFunc(ctx, exerciseID, callerUserID) } -func (m *MockRepository) ListPublicExercises(ctx context.Context) ([]domain.ExerciseModel, error) { +func (m *MockRepository) ListPublicExercises(ctx context.Context, updatedAfter *time.Time) ([]domain.ExerciseModel, error) { m.Called = true - return m.ListPublicExercisesFunc(ctx) + return m.ListPublicExercisesFunc(ctx, updatedAfter) } -func (m *MockRepository) ListUserExercises(ctx context.Context, userID string) ([]domain.ExerciseModel, error) { +func (m *MockRepository) ListUserExercises(ctx context.Context, userID string, updatedAfter *time.Time) ([]domain.ExerciseModel, error) { m.Called = true - return m.ListUserExercisesFunc(ctx, userID) + return m.ListUserExercisesFunc(ctx, userID, updatedAfter) } -func (m *MockRepository) ListExercises(ctx context.Context, userID string) ([]domain.ExerciseModel, error) { +func (m *MockRepository) ListExercises(ctx context.Context, userID string, updatedAfter *time.Time) ([]domain.ExerciseModel, error) { m.Called = true - return m.ListExercisesFunc(ctx, userID) + return m.ListExercisesFunc(ctx, userID, updatedAfter) } func (m *MockRepository) GetExerciseById(ctx context.Context, id int64, callerUserID string) (*domain.ExerciseModel, error) { diff --git a/services/exercise-service/internal/infrastructure/repository/redis_cached.go b/services/exercise-service/internal/infrastructure/repository/redis_cached.go index 51e2bca..61c5c21 100644 --- a/services/exercise-service/internal/infrastructure/repository/redis_cached.go +++ b/services/exercise-service/internal/infrastructure/repository/redis_cached.go @@ -11,6 +11,9 @@ import ( "github.com/redis/go-redis/v9" ) +const publicExercisesCacheKey = "public_exercises" +const publicExercisesTTL = 10 * time.Minute + type RedisCachedRepository struct { redis *redis.Client repo domain.ExerciseRepository @@ -35,37 +38,38 @@ func (r *RedisCachedRepository) GetExerciseMedia(ctx context.Context, exerciseID return r.repo.GetExerciseMedia(ctx, exerciseID, callerUserID) } -func (r *RedisCachedRepository) ListPublicExercises(ctx context.Context) ([]domain.ExerciseModel, error) { - const cacheKey = "public_exercises" - - cached, err := r.redis.Get(ctx, cacheKey).Result() +func (r *RedisCachedRepository) ListPublicExercises(ctx context.Context, updatedAfter *time.Time) ([]domain.ExerciseModel, error) { + // Delta requests bypass the cache since the cached list covers all time. + if updatedAfter != nil { + return r.repo.ListPublicExercises(ctx, updatedAfter) + } + cached, err := r.redis.Get(ctx, publicExercisesCacheKey).Result() if err == nil { var exercises []domain.ExerciseModel if json.Unmarshal([]byte(cached), &exercises) == nil { return exercises, nil } } - if err != nil && !errors.Is(err, redis.Nil) { return nil, err } - exercises, err := r.repo.ListPublicExercises(ctx) + exercises, err := r.repo.ListPublicExercises(ctx, nil) if err != nil { return nil, err } data, err := json.Marshal(exercises) if err == nil { - _ = r.redis.Set(ctx, cacheKey, data, 10*time.Minute).Err() + _ = r.redis.Set(ctx, publicExercisesCacheKey, data, publicExercisesTTL).Err() } return exercises, nil } -func (r *RedisCachedRepository) ListUserExercises(ctx context.Context, userID string) ([]domain.ExerciseModel, error) { - return r.repo.ListUserExercises(ctx, userID) +func (r *RedisCachedRepository) ListUserExercises(ctx context.Context, userID string, updatedAfter *time.Time) ([]domain.ExerciseModel, error) { + return r.repo.ListUserExercises(ctx, userID, updatedAfter) } // GetExerciseById always hits the DB since visibility depends on the caller's identity. @@ -98,13 +102,13 @@ func (r *RedisCachedRepository) DeleteExercise(ctx context.Context, callerUserID return nil } -func (r *RedisCachedRepository) ListExercises(ctx context.Context, userID string) ([]domain.ExerciseModel, error) { - public, err := r.ListPublicExercises(ctx) +func (r *RedisCachedRepository) ListExercises(ctx context.Context, userID string, updatedAfter *time.Time) ([]domain.ExerciseModel, error) { + public, err := r.ListPublicExercises(ctx, updatedAfter) if err != nil { return nil, err } - private, err := r.ListUserExercises(ctx, userID) + private, err := r.ListUserExercises(ctx, userID, updatedAfter) if err != nil { return nil, err } diff --git a/services/exercise-service/internal/infrastructure/repository/redis_cached_test.go b/services/exercise-service/internal/infrastructure/repository/redis_cached_test.go index 726b78c..05255bb 100644 --- a/services/exercise-service/internal/infrastructure/repository/redis_cached_test.go +++ b/services/exercise-service/internal/infrastructure/repository/redis_cached_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "testing" + "time" "github.com/alicebob/miniredis/v2" "github.com/open-workout/ow/services/exercise-service/internal/domain" @@ -58,7 +59,7 @@ func TestListPublicExercises_CacheHit(t *testing.T) { mockRepo := repository.NewMockRepository() cache := repository.NewRedisCachedRepository(rdb, mockRepo) - result, err := cache.ListPublicExercises(context.Background()) + result, err := cache.ListPublicExercises(context.Background(), nil) if err != nil { t.Fatal(err) } @@ -204,7 +205,7 @@ func TestListPublicExercises_CacheMiss(t *testing.T) { repo := &repository.MockRepository{ Called: false, - ListPublicExercisesFunc: func(_ context.Context) ([]domain.ExerciseModel, error) { + ListPublicExercisesFunc: func(_ context.Context, _ *time.Time) ([]domain.ExerciseModel, error) { ex1 := domain.ExerciseModel{ ExerciseID: 1, Name: "Exercise1", @@ -231,7 +232,7 @@ func TestListPublicExercises_CacheMiss(t *testing.T) { cache := repository.NewRedisCachedRepository(rdb, repo) - _, err := cache.ListPublicExercises(context.Background()) + _, err := cache.ListPublicExercises(context.Background(), nil) if err != nil { t.Fatal(err) } diff --git a/services/exercise-service/internal/infrastructure/repository/sql.go b/services/exercise-service/internal/infrastructure/repository/sql.go index dbacb87..ce7a4e1 100644 --- a/services/exercise-service/internal/infrastructure/repository/sql.go +++ b/services/exercise-service/internal/infrastructure/repository/sql.go @@ -3,6 +3,7 @@ package repository import ( "context" "database/sql" + "time" "github.com/lib/pq" "github.com/open-workout/ow/services/exercise-service/internal/domain" @@ -46,10 +47,28 @@ func (r *SqlRepository) AddExerciseMedia(ctx context.Context, exerciseID int64, return err } -func (r *SqlRepository) ListPublicExercises(ctx context.Context) ([]domain.ExerciseModel, error) { - query := `SELECT * FROM exercises WHERE is_private = false` +const exerciseColumns = `exercise_id, name, exercise_type, primary_muscles, secondary_muscles, description, user_id, is_private, weight_direction, alt_names, updated_at` - rows, err := r.db.QueryContext(ctx, query) +func scanExercise(row interface { + Scan(...any) error +}, ex *domain.ExerciseModel) error { + return row.Scan( + &ex.ExerciseID, &ex.Name, &ex.ExerciseType, + pq.Array(&ex.PrimaryMuscles), pq.Array(&ex.SecondaryMuscles), + &ex.Description, &ex.UserID, &ex.IsPrivate, &ex.WeightDirection, + pq.Array(&ex.AltNames), &ex.UpdatedAt, + ) +} + +func (r *SqlRepository) ListPublicExercises(ctx context.Context, updatedAfter *time.Time) ([]domain.ExerciseModel, error) { + query := `SELECT ` + exerciseColumns + ` FROM exercises WHERE is_private = false` + args := []any{} + if updatedAfter != nil { + query += ` AND updated_at > $1` + args = append(args, *updatedAfter) + } + + rows, err := r.db.QueryContext(ctx, query, args...) if err != nil { return nil, err } @@ -58,27 +77,23 @@ func (r *SqlRepository) ListPublicExercises(ctx context.Context) ([]domain.Exerc var exercises []domain.ExerciseModel for rows.Next() { var ex domain.ExerciseModel - if err := rows.Scan( - &ex.ExerciseID, &ex.Name, &ex.ExerciseType, pq.Array(&ex.PrimaryMuscles), - pq.Array(&ex.SecondaryMuscles), &ex.Description, - &ex.UserID, &ex.IsPrivate, &ex.WeightDirection, pq.Array(&ex.AltNames), - ); err != nil { + if err := scanExercise(rows, &ex); err != nil { return nil, err } exercises = append(exercises, ex) } - - if err := rows.Err(); err != nil { - return nil, err - } - - return exercises, nil + return exercises, rows.Err() } -func (r *SqlRepository) ListUserExercises(ctx context.Context, userID string) ([]domain.ExerciseModel, error) { - query := `SELECT * FROM exercises WHERE is_private = true AND user_id = $1` +func (r *SqlRepository) ListUserExercises(ctx context.Context, userID string, updatedAfter *time.Time) ([]domain.ExerciseModel, error) { + query := `SELECT ` + exerciseColumns + ` FROM exercises WHERE is_private = true AND user_id = $1` + args := []any{userID} + if updatedAfter != nil { + query += ` AND updated_at > $2` + args = append(args, *updatedAfter) + } - rows, err := r.db.QueryContext(ctx, query, userID) + rows, err := r.db.QueryContext(ctx, query, args...) if err != nil { return nil, err } @@ -87,30 +102,21 @@ func (r *SqlRepository) ListUserExercises(ctx context.Context, userID string) ([ var exercises []domain.ExerciseModel for rows.Next() { var ex domain.ExerciseModel - if err := rows.Scan( - &ex.ExerciseID, &ex.Name, &ex.ExerciseType, pq.Array(&ex.PrimaryMuscles), - pq.Array(&ex.SecondaryMuscles), &ex.Description, - &ex.UserID, &ex.IsPrivate, &ex.WeightDirection, pq.Array(&ex.AltNames), - ); err != nil { + if err := scanExercise(rows, &ex); err != nil { return nil, err } exercises = append(exercises, ex) } - - if err := rows.Err(); err != nil { - return nil, err - } - - return exercises, nil + return exercises, rows.Err() } -func (r *SqlRepository) ListExercises(ctx context.Context, userID string) ([]domain.ExerciseModel, error) { - public, err := r.ListPublicExercises(ctx) +func (r *SqlRepository) ListExercises(ctx context.Context, userID string, updatedAfter *time.Time) ([]domain.ExerciseModel, error) { + public, err := r.ListPublicExercises(ctx, updatedAfter) if err != nil { return nil, err } - private, err := r.ListUserExercises(ctx, userID) + private, err := r.ListUserExercises(ctx, userID, updatedAfter) if err != nil { return nil, err } @@ -122,23 +128,19 @@ func (r *SqlRepository) UpdateExercise(ctx context.Context, callerUserID string, query := ` UPDATE exercises SET name = $1, exercise_type = $2, primary_muscles = $3, secondary_muscles = $4, - alt_names = $5, description = $6, is_private = $7, weight_direction = $8 + alt_names = $5, description = $6, is_private = $7, weight_direction = $8, + updated_at = NOW() WHERE exercise_id = $9 AND user_id = $10 - RETURNING exercise_id, name, exercise_type, primary_muscles, secondary_muscles, alt_names, description, user_id, is_private, weight_direction - ` + RETURNING ` + exerciseColumns var ex domain.ExerciseModel - err := r.db.QueryRowContext(ctx, query, + err := scanExercise(r.db.QueryRowContext(ctx, query, exercise.Name, exercise.ExerciseType, pq.Array(exercise.PrimaryMuscles), pq.Array(exercise.SecondaryMuscles), pq.Array(exercise.AltNames), exercise.Description, exercise.IsPrivate, exercise.WeightDirection, exercise.ExerciseID, callerUserID, - ).Scan( - &ex.ExerciseID, &ex.Name, &ex.ExerciseType, pq.Array(&ex.PrimaryMuscles), - pq.Array(&ex.SecondaryMuscles), pq.Array(&ex.AltNames), &ex.Description, - &ex.UserID, &ex.IsPrivate, &ex.WeightDirection, - ) + ), &ex) if err != nil { return nil, err } @@ -192,22 +194,11 @@ func (r *SqlRepository) GetExerciseMedia(ctx context.Context, exerciseID int64, } func (r *SqlRepository) GetExerciseById(ctx context.Context, id int64, callerUserID string) (*domain.ExerciseModel, error) { - query := ` - SELECT exercise_id, name, exercise_type, primary_muscles, secondary_muscles, alt_names, description, user_id, is_private, weight_direction - FROM exercises - WHERE exercise_id = $1 - AND (is_private = false OR user_id = $2) - ` + query := `SELECT ` + exerciseColumns + ` FROM exercises WHERE exercise_id = $1 AND (is_private = false OR user_id = $2)` var ex domain.ExerciseModel - err := r.db.QueryRowContext(ctx, query, id, callerUserID).Scan( - &ex.ExerciseID, &ex.Name, &ex.ExerciseType, pq.Array(&ex.PrimaryMuscles), - pq.Array(&ex.SecondaryMuscles), pq.Array(&ex.AltNames), &ex.Description, - &ex.UserID, &ex.IsPrivate, &ex.WeightDirection, - ) - if err != nil { + if err := scanExercise(r.db.QueryRowContext(ctx, query, id, callerUserID), &ex); err != nil { return nil, err } - return &ex, nil } diff --git a/services/exercise-service/internal/infrastructure/repository/sql_postgres_test.go b/services/exercise-service/internal/infrastructure/repository/sql_postgres_test.go index 25c2ce2..bdbeb1e 100644 --- a/services/exercise-service/internal/infrastructure/repository/sql_postgres_test.go +++ b/services/exercise-service/internal/infrastructure/repository/sql_postgres_test.go @@ -248,7 +248,7 @@ func TestSqlRepository_GetUserExercises(t *testing.T) { t.Fatalf("failed to create exercise ex2: %v", err) } - exercisesOfUser1, err := repo.ListUserExercises(context.Background(), ex1.UserID) + exercisesOfUser1, err := repo.ListUserExercises(context.Background(), ex1.UserID, nil) if err != nil { t.Fatalf("failed to list exercises: %v", err) } @@ -580,7 +580,7 @@ func TestSqlRepository_GetPublicExercises(t *testing.T) { t.Fatalf("failed to create exercise ex2: %v", err) } - publicExercises, err := repo.ListPublicExercises(context.Background()) + publicExercises, err := repo.ListPublicExercises(context.Background(), nil) if err != nil { t.Fatalf("failed to list public exercises: %v", err) } @@ -720,7 +720,7 @@ func TestSqlRepository_ListPublicExercises_ReturnsAltNames(t *testing.T) { t.Fatalf("failed to create exercise: %v", err) } - exercises, err := repo.ListPublicExercises(context.Background()) + exercises, err := repo.ListPublicExercises(context.Background(), nil) if err != nil { t.Fatalf("failed to list exercises: %v", err) } @@ -749,7 +749,7 @@ func TestSqlRepository_ListUserExercises_ReturnsAltNames(t *testing.T) { t.Fatalf("failed to create exercise: %v", err) } - exercises, err := repo.ListUserExercises(context.Background(), pgTestUser1) + exercises, err := repo.ListUserExercises(context.Background(), pgTestUser1, nil) if err != nil { t.Fatalf("failed to list user exercises: %v", err) } diff --git a/services/exercise-service/internal/service/service.go b/services/exercise-service/internal/service/service.go index b0d601d..bfda4b5 100644 --- a/services/exercise-service/internal/service/service.go +++ b/services/exercise-service/internal/service/service.go @@ -3,6 +3,7 @@ package service import ( "context" "sort" + "time" "github.com/open-workout/ow/services/exercise-service/internal/domain" ) @@ -45,8 +46,8 @@ func (s *Service) GetExerciseMedia(ctx context.Context, exerciseID int64, caller return s.repo.GetExerciseMedia(ctx, exerciseID, callerUserID) } -func (s *Service) ListExercises(ctx context.Context, userID string) ([]domain.ExerciseModel, error) { - exercises, err := s.repo.ListExercises(ctx, userID) +func (s *Service) ListExercises(ctx context.Context, userID string, updatedAfter *time.Time) ([]domain.ExerciseModel, error) { + exercises, err := s.repo.ListExercises(ctx, userID, updatedAfter) if err != nil { return nil, err } @@ -89,7 +90,7 @@ func (s *Service) GetTopExercises( limit = 10 } - exercises, err := s.ListExercises(ctx, state.UserID) + exercises, err := s.ListExercises(ctx, state.UserID, nil) if err != nil { return nil, err } diff --git a/services/exercise-service/internal/service/service_test.go b/services/exercise-service/internal/service/service_test.go index cabbfb7..1a89f30 100644 --- a/services/exercise-service/internal/service/service_test.go +++ b/services/exercise-service/internal/service/service_test.go @@ -6,6 +6,7 @@ import ( "reflect" "strings" "testing" + "time" "github.com/open-workout/ow/services/exercise-service/internal/domain" "github.com/open-workout/ow/services/exercise-service/internal/infrastructure/repository" @@ -199,7 +200,7 @@ func TestListExercises_Success(t *testing.T) { } mockRepo := &repository.MockRepository{ - ListExercisesFunc: func(ctx context.Context, userID string) ([]domain.ExerciseModel, error) { + ListExercisesFunc: func(ctx context.Context, userID string, updatedAfter *time.Time) ([]domain.ExerciseModel, error) { if userID != testUserID { t.Errorf("got user_id %q, want %q", userID, testUserID) } @@ -214,7 +215,7 @@ func TestListExercises_Success(t *testing.T) { } svc := service.NewService(mockRepo, mockMedia) - got, err := svc.ListExercises(context.Background(), testUserID) + got, err := svc.ListExercises(context.Background(), testUserID, nil) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -247,7 +248,7 @@ func TestGetTopExercises_DefaultLimitWhenMinusOne(t *testing.T) { exercises := make([]domain.ExerciseModel, 15) mockRepo := &repository.MockRepository{ - ListExercisesFunc: func(ctx context.Context, userID string) ([]domain.ExerciseModel, error) { + ListExercisesFunc: func(ctx context.Context, userID string, _ *time.Time) ([]domain.ExerciseModel, error) { return exercises, nil }, } @@ -271,7 +272,7 @@ func TestGetTopExercises_DefaultLimitWhenMinusOne(t *testing.T) { func TestGetTopExercises_LimitCappedToAvailable(t *testing.T) { mockRepo := &repository.MockRepository{ - ListExercisesFunc: func(_ context.Context, _ string) ([]domain.ExerciseModel, error) { + ListExercisesFunc: func(_ context.Context, _ string, _ *time.Time) ([]domain.ExerciseModel, error) { return []domain.ExerciseModel{ buildExercise(1, "chest"), buildExercise(2, "chest"), @@ -291,7 +292,7 @@ func TestGetTopExercises_LimitCappedToAvailable(t *testing.T) { func TestGetTopExercises_ZeroScoreExercisesExcluded(t *testing.T) { mockRepo := &repository.MockRepository{ - ListExercisesFunc: func(_ context.Context, _ string) ([]domain.ExerciseModel, error) { + ListExercisesFunc: func(_ context.Context, _ string, _ *time.Time) ([]domain.ExerciseModel, error) { return []domain.ExerciseModel{ buildExercise(1, "chest"), buildExercise(2, "back"), @@ -314,7 +315,7 @@ func TestGetTopExercises_ZeroScoreExercisesExcluded(t *testing.T) { func TestGetTopExercises_SortedByScoreDescending(t *testing.T) { mockRepo := &repository.MockRepository{ - ListExercisesFunc: func(_ context.Context, _ string) ([]domain.ExerciseModel, error) { + ListExercisesFunc: func(_ context.Context, _ string, _ *time.Time) ([]domain.ExerciseModel, error) { return []domain.ExerciseModel{ buildExercise(1, "chest"), buildExercise(2, "chest", "triceps"), @@ -334,7 +335,7 @@ func TestGetTopExercises_SortedByScoreDescending(t *testing.T) { func TestGetTopExercises_RepoError(t *testing.T) { mockRepo := &repository.MockRepository{ - ListExercisesFunc: func(_ context.Context, _ string) ([]domain.ExerciseModel, error) { + ListExercisesFunc: func(_ context.Context, _ string, _ *time.Time) ([]domain.ExerciseModel, error) { return nil, errRepo }, } @@ -348,7 +349,7 @@ func TestGetTopExercises_RepoError(t *testing.T) { func TestGetTopExercises_EmptyList(t *testing.T) { mockRepo := &repository.MockRepository{ - ListExercisesFunc: func(_ context.Context, _ string) ([]domain.ExerciseModel, error) { + ListExercisesFunc: func(_ context.Context, _ string, _ *time.Time) ([]domain.ExerciseModel, error) { return []domain.ExerciseModel{}, nil }, } diff --git a/services/exercise-service/internal/transport/http/handlers/exercise_handler.go b/services/exercise-service/internal/transport/http/handlers/exercise_handler.go index d8f92ab..82572d4 100644 --- a/services/exercise-service/internal/transport/http/handlers/exercise_handler.go +++ b/services/exercise-service/internal/transport/http/handlers/exercise_handler.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "strconv" + "time" "github.com/open-workout/ow/services/exercise-service/internal/domain" ) @@ -43,7 +44,17 @@ func (h *ExerciseHandler) ListExercises(w http.ResponseWriter, r *http.Request) return } - exercises, err := h.svc.ListExercises(r.Context(), userID) + var updatedAfter *time.Time + if s := r.URL.Query().Get("updated_after"); s != "" { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + http.Error(w, "invalid updated_after: use RFC3339 format", http.StatusBadRequest) + return + } + updatedAfter = &t + } + + exercises, err := h.svc.ListExercises(r.Context(), userID, updatedAfter) if err != nil { http.Error(w, "failed to list exercises", http.StatusInternalServerError) return From 64b7a72be15867d257ec515f473f52ee36dd17ec Mon Sep 17 00:00:00 2001 From: Vlad Giusca Date: Mon, 25 May 2026 10:37:08 +0200 Subject: [PATCH 2/5] add exercise functionality --- apps/mobile/app/(tabs)/explore.tsx | 24 +- .../app/components/AddExerciseModal.tsx | 561 ++++++++++++++++++ apps/mobile/package.json | 2 +- k8s/postgres/init-configmap.yaml | 8 +- 4 files changed, 587 insertions(+), 8 deletions(-) create mode 100644 apps/mobile/app/components/AddExerciseModal.tsx diff --git a/apps/mobile/app/(tabs)/explore.tsx b/apps/mobile/app/(tabs)/explore.tsx index 8f3d777..3c0ba63 100644 --- a/apps/mobile/app/(tabs)/explore.tsx +++ b/apps/mobile/app/(tabs)/explore.tsx @@ -3,6 +3,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons'; import { useState, useMemo } from 'react'; import { useExercises } from '../hooks/useExercises'; +import AddExerciseModal from '../components/AddExerciseModal'; import type { LocalExercise } from '../db/exercises'; const categories = ['All', 'Chest', 'Back', 'Legs', 'Arms', 'Shoulders']; @@ -25,7 +26,8 @@ function matchesCategory(ex: LocalExercise, category: string): boolean { export default function ExploreScreen() { const [activeCategory, setActiveCategory] = useState(0); const [search, setSearch] = useState(''); - const { exercises, isLoading } = useExercises(); + const [showAdd, setShowAdd] = useState(false); + const { exercises, isLoading, createExercise } = useExercises(); const filtered = useMemo(() => { const cat = categories[activeCategory]; @@ -44,9 +46,17 @@ export default function ExploreScreen() { Library - - - + + setShowAdd(true)} + style={{ width: 40, height: 40, borderRadius: 20, backgroundColor: '#18181b', alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: '#27272a' }} + > + + + + + + {/* Search */} @@ -120,6 +130,12 @@ export default function ExploreScreen() { )} )} + + setShowAdd(false)} + onSubmit={createExercise} + /> ); } diff --git a/apps/mobile/app/components/AddExerciseModal.tsx b/apps/mobile/app/components/AddExerciseModal.tsx new file mode 100644 index 0000000..49a9091 --- /dev/null +++ b/apps/mobile/app/components/AddExerciseModal.tsx @@ -0,0 +1,561 @@ +import React, { useState } from 'react'; +import { + Modal, + View, + Text, + TextInput, + ScrollView, + TouchableOpacity, + Switch, + KeyboardAvoidingView, + Platform, + StatusBar, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; +import type { NewExerciseInput } from '../db/exercises'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type ExerciseType = 'compound' | 'accessory' | 'isolation'; + +type FormState = { + name: string; + exercise_type: ExerciseType; + primary_muscles: string[]; + secondary_muscles: string[]; + altNameDraft: string; + alt_names: string[]; + description: string; + is_private: boolean; + weight_direction: 1 | -1; +}; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const INITIAL_FORM: FormState = { + name: '', + exercise_type: 'compound', + primary_muscles: [], + secondary_muscles: [], + altNameDraft: '', + alt_names: [], + description: '', + is_private: false, + weight_direction: 1, +}; + +const MUSCLE_SECTIONS: { label: string; muscles: string[]; isSuper: boolean }[] = [ + { label: 'Super Muscles', muscles: ['legs', 'back', 'shoulders'], isSuper: true }, + { label: 'Chest & Arms', muscles: ['chest', 'biceps', 'triceps', 'forearms'], isSuper: false }, + { label: 'Core', muscles: ['core', 'abs'], isSuper: false }, + { label: 'Legs', muscles: ['quads', 'glutes', 'hamstrings', 'calves'], isSuper: false }, + { label: 'Back', muscles: ['traps', 'lats', 'rhomboids', 'lower back'], isSuper: false }, + { label: 'Shoulders', muscles: ['front delts', 'side delts', 'rear delts'], isSuper: false }, +]; + +const EXERCISE_TYPES: { value: ExerciseType; label: string; description: string }[] = [ + { value: 'compound', label: 'Compound', description: 'Multi-joint' }, + { value: 'accessory', label: 'Accessory', description: 'Supporting lift' }, + { value: 'isolation', label: 'Isolation', description: 'Single joint' }, +]; + +// ─── Palette (matches app theme) ───────────────────────────────────────────── + +const C = { + bg: '#0a0a0a', + card: '#18181b', + cardAlt: '#1c1c1f', + border: '#27272a', + borderAlt: '#3f3f46', + text: '#f4f4f5', + textMuted: '#71717a', + textDim: '#52525b', + active: '#f4f4f5', + activeDark: '#09090b', +}; + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function SectionHeader({ children }: { children: string }) { + return ( + + {children} + + ); +} + +function MuscleChip({ + muscle, isSelected, isSuper, onPress, +}: { + muscle: string; + isSelected: boolean; + isSuper: boolean; + onPress: () => void; +}) { + return ( + + {isSuper && ( + + )} + + {muscle} + + + ); +} + +function MuscleSelector({ + sectionTitle, + selected, + onToggle, +}: { + sectionTitle: string; + selected: string[]; + onToggle: (muscle: string) => void; +}) { + return ( + + {sectionTitle} + {MUSCLE_SECTIONS.map((section) => ( + + + {section.label} + + + {section.muscles.map((muscle) => ( + onToggle(muscle)} + /> + ))} + + + ))} + + ); +} + +function AltNameTag({ name, onRemove }: { name: string; onRemove: () => void }) { + return ( + + {name} + + + + + ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +type Props = { + visible: boolean; + onClose: () => void; + onSubmit: (input: NewExerciseInput) => Promise; +}; + +export default function AddExerciseModal({ visible, onClose, onSubmit }: Props) { + const [form, setForm] = useState(INITIAL_FORM); + const [submitting, setSubmitting] = useState(false); + const [nameError, setNameError] = useState(''); + + function set(key: K, value: FormState[K]) { + setForm((prev) => ({ ...prev, [key]: value })); + } + + function toggleMuscle(list: 'primary_muscles' | 'secondary_muscles', muscle: string) { + setForm((prev) => { + const current = prev[list]; + const updated = current.includes(muscle) + ? current.filter((m) => m !== muscle) + : [...current, muscle]; + return { ...prev, [list]: updated }; + }); + } + + function addAltName() { + const trimmed = form.altNameDraft.trim(); + if (!trimmed || form.alt_names.includes(trimmed)) return; + setForm((prev) => ({ + ...prev, + alt_names: [...prev.alt_names, trimmed], + altNameDraft: '', + })); + } + + function removeAltName(name: string) { + setForm((prev) => ({ ...prev, alt_names: prev.alt_names.filter((n) => n !== name) })); + } + + async function handleSubmit() { + if (!form.name.trim()) { + setNameError('Name is required'); + return; + } + setNameError(''); + setSubmitting(true); + try { + await onSubmit({ + name: form.name.trim(), + exercise_type: form.exercise_type, + primary_muscles: form.primary_muscles, + secondary_muscles: form.secondary_muscles, + alt_names: form.alt_names, + description: form.description.trim(), + is_private: form.is_private, + weight_direction: form.weight_direction, + }); + setForm(INITIAL_FORM); + onClose(); + } finally { + setSubmitting(false); + } + } + + function handleClose() { + setForm(INITIAL_FORM); + setNameError(''); + onClose(); + } + + return ( + + + + + {/* Header */} + + + Cancel + + New Exercise + + + {submitting ? 'Saving…' : 'Save'} + + + + + + {/* ── Name ── */} + Name + { set('name', v); setNameError(''); }} + placeholder="e.g. Barbell Bench Press" + placeholderTextColor={C.textDim} + style={{ + backgroundColor: C.card, + borderWidth: 1, + borderColor: nameError ? '#ef4444' : C.border, + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 14, + color: C.text, + fontSize: 15, + }} + autoCapitalize="words" + /> + {!!nameError && ( + {nameError} + )} + + {/* ── Exercise Type ── */} + Exercise Type + + {EXERCISE_TYPES.map(({ value, label, description }) => { + const active = form.exercise_type === value; + return ( + set('exercise_type', value)} + style={{ + flex: 1, + backgroundColor: active ? C.active : C.card, + borderWidth: 1, + borderColor: active ? C.active : C.border, + borderRadius: 12, + paddingVertical: 12, + alignItems: 'center', + }} + > + + {label} + + + {description} + + + ); + })} + + + {/* ── Primary Muscles ── */} + toggleMuscle('primary_muscles', m)} + /> + + {/* ── Secondary Muscles ── */} + toggleMuscle('secondary_muscles', m)} + /> + + {/* ── Alt Names ── */} + Alternative Names + {form.alt_names.length > 0 && ( + + {form.alt_names.map((name) => ( + removeAltName(name)} /> + ))} + + )} + + set('altNameDraft', v)} + onSubmitEditing={addAltName} + placeholder="e.g. Flat Press" + placeholderTextColor={C.textDim} + returnKeyType="done" + blurOnSubmit={false} + style={{ + flex: 1, + backgroundColor: C.card, + borderWidth: 1, + borderColor: C.border, + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 12, + color: C.text, + fontSize: 14, + }} + /> + + + + + + {/* ── Description ── */} + Description (optional) + set('description', v)} + placeholder="Add notes about form, cues, or variations…" + placeholderTextColor={C.textDim} + multiline + numberOfLines={4} + style={{ + backgroundColor: C.card, + borderWidth: 1, + borderColor: C.border, + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 14, + color: C.text, + fontSize: 14, + minHeight: 96, + textAlignVertical: 'top', + }} + /> + + {/* ── Settings ── */} + Settings + + {/* Private toggle */} + + + Private + + Only visible to you + + + set('is_private', v)} + trackColor={{ false: C.border, true: '#3f3f46' }} + thumbColor={form.is_private ? C.active : C.textMuted} + /> + + + {/* Weight direction */} + + + Weight Direction + + + How difficulty changes as weight increases + + + {([ + { value: 1 as const, label: 'Normal', sub: 'Harder with more weight' }, + { value: -1 as const, label: 'Assisted', sub: 'Easier with more weight' }, + ] as const).map(({ value, label, sub }) => { + const active = form.weight_direction === value; + return ( + set('weight_direction', value)} + style={{ + flex: 1, + backgroundColor: active ? C.borderAlt : 'transparent', + borderWidth: 1, + borderColor: active ? C.borderAlt : C.border, + borderRadius: 10, + padding: 10, + alignItems: 'center', + }} + > + + {label} + + + {sub} + + + ); + })} + + + + + {/* bottom breathing room */} + + + + + + ); +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 9b94f5d..8de3e09 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -3,7 +3,7 @@ "main": "expo-router/entry", "version": "1.0.0", "scripts": { - "start": "expo start", + "start": "expo start --port 8088", "reset-project": "node ./scripts/reset-project.js", "android": "expo run:android", "ios": "expo run:ios", diff --git a/k8s/postgres/init-configmap.yaml b/k8s/postgres/init-configmap.yaml index fd18089..a2e87ed 100644 --- a/k8s/postgres/init-configmap.yaml +++ b/k8s/postgres/init-configmap.yaml @@ -29,18 +29,20 @@ data: exercise_id serial primary key, name text, exercise_type text, - primary_muscle text, + primary_muscles text[], secondary_muscles text[], description text, user_id text, is_private boolean, - weight_direction bigint + weight_direction bigint, + alt_names text[], + updated_at timestamptz default now() ); CREATE TABLE exercise_media ( exercise_id bigint, url text, - user_id bigint, + user_id text, media_id integer generated by default as identity ); From 55f0dc380f49fc828a6e589af71c8152424e4089 Mon Sep 17 00:00:00 2001 From: Vlad Giusca Date: Mon, 25 May 2026 10:45:38 +0200 Subject: [PATCH 3/5] fix tests --- .../internal/transport/http/handlers/exercise_handler_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/exercise-service/internal/transport/http/handlers/exercise_handler_test.go b/services/exercise-service/internal/transport/http/handlers/exercise_handler_test.go index 18c82b2..7896f16 100644 --- a/services/exercise-service/internal/transport/http/handlers/exercise_handler_test.go +++ b/services/exercise-service/internal/transport/http/handlers/exercise_handler_test.go @@ -76,7 +76,7 @@ func setupServer(t *testing.T) (*server, func()) { name TEXT, exercise_type TEXT, primary_muscles TEXT[], secondary_muscles TEXT[], description TEXT, user_id TEXT, is_private BOOLEAN, weight_direction BIGINT, - alt_names TEXT[] + alt_names TEXT[], updated_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE exercise_media ( exercise_id BIGINT, url TEXT, user_id TEXT From 4a4519027c98f60cb68e003432ddeb594f2fc60b Mon Sep 17 00:00:00 2001 From: Vlad Giusca Date: Mon, 25 May 2026 10:53:56 +0200 Subject: [PATCH 4/5] fix tests 2 --- .../internal/infrastructure/repository/sql_postgres_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/exercise-service/internal/infrastructure/repository/sql_postgres_test.go b/services/exercise-service/internal/infrastructure/repository/sql_postgres_test.go index bdbeb1e..a68aa75 100644 --- a/services/exercise-service/internal/infrastructure/repository/sql_postgres_test.go +++ b/services/exercise-service/internal/infrastructure/repository/sql_postgres_test.go @@ -77,7 +77,8 @@ func setupSchema(t *testing.T, db *sql.DB) { user_id TEXT, is_private BOOLEAN, weight_direction BIGINT, - alt_names TEXT[] + alt_names TEXT[], + updated_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE exercise_media ( From 0b36d660560871975dfe67e50c0d0e141f9fafd2 Mon Sep 17 00:00:00 2001 From: Vlad Giusca Date: Mon, 25 May 2026 11:04:55 +0200 Subject: [PATCH 5/5] fix tests --- tests/integration/setup_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/integration/setup_test.go b/tests/integration/setup_test.go index df3e55b..f2c721c 100644 --- a/tests/integration/setup_test.go +++ b/tests/integration/setup_test.go @@ -87,7 +87,8 @@ func setupStack(t *testing.T) *stack { user_id TEXT, is_private BOOLEAN, weight_direction BIGINT, - alt_names TEXT[] + alt_names TEXT[], + updated_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE exercise_media ( exercise_id BIGINT,