diff --git a/app/exercise/[id].tsx b/app/exercise/[id].tsx index fd77ebb..6612fcb 100644 --- a/app/exercise/[id].tsx +++ b/app/exercise/[id].tsx @@ -21,6 +21,7 @@ import { Trash2, Check, X, + StickyNote, } from 'lucide-react-native'; import { colors } from '@/constants/theme'; import { getDatabase } from '@/db/connection'; @@ -454,6 +455,78 @@ export default function ExerciseDetailScreen() { + {/* Notes section */} + {exercise.notes ? ( + router.push(`/exercise/edit/${exerciseId}`)} + accessibilityRole="button" + accessibilityLabel={t('exercise.notes')} + > + {({ pressed }) => ( + + + + + {t('exercise.notes')} + + + + {exercise.notes} + + + )} + + ) : !exercise.isPredefined ? ( + router.push(`/exercise/edit/${exerciseId}`)} + accessibilityRole="button" + accessibilityLabel={t('exercise.addNotes')} + > + {({ pressed }) => ( + + + + {t('exercise.addNotes')} + + + )} + + ) : null} + {/* Stats section */} {statsLoading ? ( diff --git a/app/exercise/create.tsx b/app/exercise/create.tsx index ae59363..027ee94 100644 --- a/app/exercise/create.tsx +++ b/app/exercise/create.tsx @@ -66,6 +66,7 @@ export default function CreateExerciseScreen() { const [name, setName] = useState(''); const [type, setType] = useState(''); + const [notes, setNotes] = useState(''); const [selectedMuscleGroups, setSelectedMuscleGroups] = useState([]); const [muscleGroupModalVisible, setMuscleGroupModalVisible] = useState(false); const [errors, setErrors] = useState({}); @@ -102,6 +103,7 @@ export default function CreateExerciseScreen() { muscleGroup: groups[0], muscleGroups: groups, illustration: null, + notes: notes.trim() || null, }); router.back(); } catch (error) { @@ -109,7 +111,7 @@ export default function CreateExerciseScreen() { Alert.alert('Error', 'Could not save exercise. Please try again.'); setSaving(false); } - }, [name, type, selectedMuscleGroups, router]); + }, [name, type, notes, selectedMuscleGroups, router]); return ( @@ -371,6 +373,19 @@ export default function CreateExerciseScreen() { + + {/* Notes */} + {/* Save button */} diff --git a/app/exercise/edit/[id].tsx b/app/exercise/edit/[id].tsx index 4e42100..022375e 100644 --- a/app/exercise/edit/[id].tsx +++ b/app/exercise/edit/[id].tsx @@ -71,6 +71,7 @@ export default function EditExerciseScreen() { const [notFound, setNotFound] = useState(false); const [name, setName] = useState(''); const [type, setType] = useState(''); + const [notes, setNotes] = useState(''); const [selectedMuscleGroups, setSelectedMuscleGroups] = useState([]); const [muscleGroupModalVisible, setMuscleGroupModalVisible] = useState(false); const [errors, setErrors] = useState({}); @@ -88,6 +89,7 @@ export default function EditExerciseScreen() { } setName(exercise.name); setType(exercise.type); + setNotes(exercise.notes ?? ''); setSelectedMuscleGroups(exercise.muscleGroups); } catch (error) { console.error('Failed to load exercise:', error); @@ -128,6 +130,7 @@ export default function EditExerciseScreen() { type: type as ExerciseType, muscleGroup: groups[0], muscleGroups: groups, + notes: notes.trim() || null, }); router.back(); } catch (error) { @@ -135,7 +138,7 @@ export default function EditExerciseScreen() { Alert.alert(t('common.error'), t('exercise.edit.saveError')); setSaving(false); } - }, [name, type, selectedMuscleGroups, exerciseId, router, t]); + }, [name, type, notes, selectedMuscleGroups, exerciseId, router, t]); if (loading) { return ( @@ -419,6 +422,19 @@ export default function EditExerciseScreen() { + + {/* Notes */} + {/* Save button */} diff --git a/app/routine/[id].tsx b/app/routine/[id].tsx index 4aa9505..45abaf5 100644 --- a/app/routine/[id].tsx +++ b/app/routine/[id].tsx @@ -28,6 +28,7 @@ function routineToExerciseItems(routine: RoutineWithExercises): RoutineExerciseI name: re.exercise.name, illustration: re.exercise.illustration, muscleGroup: re.exercise.muscleGroup, + notes: re.exercise.notes, })); } diff --git a/src/components/RoutineExerciseList.tsx b/src/components/RoutineExerciseList.tsx index 9303c9f..cbbb711 100644 --- a/src/components/RoutineExerciseList.tsx +++ b/src/components/RoutineExerciseList.tsx @@ -1,5 +1,6 @@ +import { useState } from 'react'; import { View, Text, Pressable } from 'react-native'; -import { ChevronUp, ChevronDown, Trash2 } from 'lucide-react-native'; +import { ChevronUp, ChevronDown, Trash2, StickyNote } from 'lucide-react-native'; import { colors } from '@/constants/theme'; import { ExerciseIllustration } from './ExerciseIllustration'; import type { RoutineExerciseItem } from '@/hooks/useRoutineForm'; @@ -32,87 +33,134 @@ function RoutineExerciseRow({ onRemove: (index: number) => void; onMove: (index: number, direction: 'up' | 'down') => void; }) { + const [notesExpanded, setNotesExpanded] = useState(false); + const hasNotes = Boolean(exercise.notes); + return ( - {/* Order number */} - - {index + 1} - - - {/* Illustration */} - - - {/* Name + muscle group */} - - - {exercise.name} - + {/* Order number */} - {formatMuscleGroup(exercise.muscleGroup)} + {index + 1} - - {/* Reorder buttons */} - - onMove(index, 'up')} - disabled={isFirst} - accessibilityRole="button" - accessibilityLabel={`Move ${exercise.name} up`} - > - {({ pressed }) => ( - + + {/* Name + muscle group */} + + + - - - )} - + {exercise.name} + + {hasNotes && ( + setNotesExpanded((prev) => !prev)} + accessibilityRole="button" + accessibilityLabel={`${notesExpanded ? 'Hide' : 'Show'} notes for ${exercise.name}`} + > + {({ pressed }) => ( + + + + )} + + )} + + + {formatMuscleGroup(exercise.muscleGroup)} + + + + {/* Reorder buttons */} + + onMove(index, 'up')} + disabled={isFirst} + accessibilityRole="button" + accessibilityLabel={`Move ${exercise.name} up`} + > + {({ pressed }) => ( + + + + )} + + onMove(index, 'down')} + disabled={isLast} + accessibilityRole="button" + accessibilityLabel={`Move ${exercise.name} down`} + > + {({ pressed }) => ( + + + + )} + + + + {/* Remove button */} onMove(index, 'down')} - disabled={isLast} + onPress={() => onRemove(index)} accessibilityRole="button" - accessibilityLabel={`Move ${exercise.name} down`} + accessibilityLabel={`Remove ${exercise.name}`} > {({ pressed }) => ( - + )} - {/* Remove button */} - onRemove(index)} - accessibilityRole="button" - accessibilityLabel={`Remove ${exercise.name}`} - > - {({ pressed }) => ( + {/* Notes expandable */} + {hasNotes && notesExpanded && ( + - + + {exercise.notes} + - )} - + + )} ); } diff --git a/src/db/schema.test.ts b/src/db/schema.test.ts index 8a31ab5..e101dd3 100644 --- a/src/db/schema.test.ts +++ b/src/db/schema.test.ts @@ -239,6 +239,41 @@ describe('Migration #4', () => { const row = await db.getFirstAsync<{ version: number }>( 'SELECT MAX(version) as version FROM schema_version', ); - expect(row!.version).toBe(4); + expect(row!.version).toBeGreaterThanOrEqual(4); + }); +}); + +describe('Migration #5', () => { + let db: SQLiteDatabase; + + beforeEach(async () => { + db = await createTestDatabase(); + }); + + it('should add notes column to exercises', async () => { + await db.runAsync( + "INSERT INTO exercises (name, type, muscle_group, notes) VALUES ('Test', 'weights', 'chest', 'Keep elbows in')", + ); + const row = await db.getFirstAsync<{ notes: string }>( + "SELECT notes FROM exercises WHERE name = 'Test'", + ); + expect(row!.notes).toBe('Keep elbows in'); + }); + + it('should default notes to null', async () => { + await db.runAsync( + "INSERT INTO exercises (name, type, muscle_group) VALUES ('NoNotes', 'weights', 'legs')", + ); + const row = await db.getFirstAsync<{ notes: string | null }>( + "SELECT notes FROM exercises WHERE name = 'NoNotes'", + ); + expect(row!.notes).toBeNull(); + }); + + it('should record schema version 5', async () => { + const row = await db.getFirstAsync<{ version: number }>( + 'SELECT MAX(version) as version FROM schema_version', + ); + expect(row!.version).toBe(5); }); }); diff --git a/src/db/schema.ts b/src/db/schema.ts index f794135..163041a 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -138,10 +138,15 @@ const MIGRATIONS: Migration[] = [ { version: 4, up: ` - -- Optional notes per workout set ALTER TABLE workout_sets ADD COLUMN notes TEXT; `, }, + { + version: 5, + up: ` + ALTER TABLE exercises ADD COLUMN notes TEXT; + `, + }, ]; export async function runMigrations(db: SQLiteDatabase): Promise { diff --git a/src/hooks/useRoutineForm.test.ts b/src/hooks/useRoutineForm.test.ts index 7e9b0a0..68ac76b 100644 --- a/src/hooks/useRoutineForm.test.ts +++ b/src/hooks/useRoutineForm.test.ts @@ -12,6 +12,7 @@ function makeExercise(overrides: Partial = {}): Exercise { isPredefined: true, illustration: 'bench-press', restSeconds: 90, + notes: null, createdAt: '2026-01-01T00:00:00', ...overrides, }; @@ -23,6 +24,7 @@ function makeExerciseItem(overrides: Partial = {}): Routine name: 'Bench Press', illustration: 'bench-press', muscleGroup: 'chest', + notes: null, ...overrides, }; } diff --git a/src/hooks/useRoutineForm.ts b/src/hooks/useRoutineForm.ts index bdb39e1..3ea6631 100644 --- a/src/hooks/useRoutineForm.ts +++ b/src/hooks/useRoutineForm.ts @@ -6,6 +6,7 @@ export interface RoutineExerciseItem { name: string; illustration: string | null; muscleGroup: string; + notes: string | null; } interface FormErrors { @@ -29,6 +30,7 @@ export function useRoutineForm( name: exercise.name, illustration: exercise.illustration, muscleGroup: exercise.muscleGroup, + notes: exercise.notes, }, ]); setErrors((prev) => ({ ...prev, exercises: undefined })); diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 6e7481a..f2a9a77 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -225,6 +225,11 @@ export const en = { 'exercise.restTimeEdit': 'Edit rest time', 'exercise.restTimeSave': 'Save', + // Exercise — notes + 'exercise.notes': 'Notes', + 'exercise.notesPlaceholder': 'Form tips, cues, personal notes...', + 'exercise.addNotes': 'Add notes', + // Body — charts 'body.chartWeight': 'Weight', 'body.chartBodyFat': 'Body Fat', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 0b8dace..2fe86e4 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -233,6 +233,11 @@ export const es: Record = { 'exercise.restTimeEdit': 'Editar tiempo de descanso', 'exercise.restTimeSave': 'Guardar', + // Exercise — notes + 'exercise.notes': 'Notas', + 'exercise.notesPlaceholder': 'Tips de forma, cues, notas personales...', + 'exercise.addNotes': 'A\u00F1adir notas', + // Body — charts 'body.chartWeight': 'Peso', 'body.chartBodyFat': 'Grasa Corporal', diff --git a/src/repositories/backup.repo.test.ts b/src/repositories/backup.repo.test.ts index ebb7744..ea0e4f7 100644 --- a/src/repositories/backup.repo.test.ts +++ b/src/repositories/backup.repo.test.ts @@ -388,6 +388,7 @@ describe('BackupRepository', () => { 'back', null, 120, + null, '2026-01-01T00:00:00.000Z', ); expect(db.getFirstAsync).toHaveBeenCalledWith( diff --git a/src/repositories/backup.repo.ts b/src/repositories/backup.repo.ts index 34885c8..263b73c 100644 --- a/src/repositories/backup.repo.ts +++ b/src/repositories/backup.repo.ts @@ -14,6 +14,7 @@ interface ExerciseRow { muscle_group: string; illustration: string | null; rest_seconds: number; + notes: string | null; created_at: string; } @@ -71,7 +72,7 @@ export class BackupRepository { async exportData(): Promise { // Exercises const exerciseRows = await this.db.getAllAsync( - 'SELECT id, name, type, muscle_group, illustration, rest_seconds, created_at FROM exercises ORDER BY id ASC', + 'SELECT id, name, type, muscle_group, illustration, rest_seconds, notes, created_at FROM exercises ORDER BY id ASC', ); // Muscle groups from pivot table const muscleGroupRows = await this.db.getAllAsync( @@ -92,6 +93,7 @@ export class BackupRepository { muscleGroups: groupsByExercise.get(row.id) ?? [row.muscle_group], illustration: row.illustration, restSeconds: row.rest_seconds, + notes: row.notes, createdAt: row.created_at, })); @@ -184,13 +186,14 @@ export class BackupRepository { // After each upsert, resolve the real local ID. for (const ex of backup.exercises) { await this.db.runAsync( - `INSERT OR IGNORE INTO exercises (name, type, muscle_group, illustration, rest_seconds, created_at) - VALUES (?, ?, ?, ?, ?, ?)`, + `INSERT OR IGNORE INTO exercises (name, type, muscle_group, illustration, rest_seconds, notes, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, ex.name, ex.type, ex.muscleGroup, ex.illustration ?? null, ex.restSeconds, + ex.notes ?? null, ex.createdAt, ); diff --git a/src/repositories/exercise.repo.test.ts b/src/repositories/exercise.repo.test.ts index 2baf3e4..22e3846 100644 --- a/src/repositories/exercise.repo.test.ts +++ b/src/repositories/exercise.repo.test.ts @@ -382,6 +382,105 @@ describe('ExerciseRepository', () => { expect(sets).toHaveLength(0); }); + // --- Notes tests --- + + it('should create an exercise with notes', async () => { + const exercise = await repo.create({ + name: 'Bench Press', + type: 'weights', + muscleGroup: 'chest', + notes: 'Keep elbows at 45 degrees', + }); + + expect(exercise.notes).toBe('Keep elbows at 45 degrees'); + }); + + it('should default notes to null when not provided', async () => { + const exercise = await repo.create({ + name: 'Squat', + type: 'weights', + muscleGroup: 'legs', + }); + + expect(exercise.notes).toBeNull(); + }); + + it('should return notes in getById', async () => { + const created = await repo.create({ + name: 'Deadlift', + type: 'weights', + muscleGroup: 'back', + notes: 'Hinge at hips, neutral spine', + }); + + const exercise = await repo.getById(created.id); + + expect(exercise!.notes).toBe('Hinge at hips, neutral spine'); + }); + + it('should return notes in getAll', async () => { + await repo.create({ + name: 'Bench Press', + type: 'weights', + muscleGroup: 'chest', + notes: 'Retract scapula', + }); + await repo.create({ + name: 'Squat', + type: 'weights', + muscleGroup: 'legs', + }); + + const exercises = await repo.getAll(); + const bench = exercises.find((e) => e.name === 'Bench Press')!; + const squat = exercises.find((e) => e.name === 'Squat')!; + + expect(bench.notes).toBe('Retract scapula'); + expect(squat.notes).toBeNull(); + }); + + it('should update notes', async () => { + const created = await repo.create({ + name: 'OHP', + type: 'weights', + muscleGroup: 'shoulders', + }); + + await repo.update(created.id, { notes: 'Brace core, press overhead' }); + + const updated = await repo.getById(created.id); + expect(updated!.notes).toBe('Brace core, press overhead'); + }); + + it('should clear notes by setting to null', async () => { + const created = await repo.create({ + name: 'OHP', + type: 'weights', + muscleGroup: 'shoulders', + notes: 'Some notes', + }); + + await repo.update(created.id, { notes: null }); + + const updated = await repo.getById(created.id); + expect(updated!.notes).toBeNull(); + }); + + it('should not affect notes when updating other fields', async () => { + const created = await repo.create({ + name: 'Press', + type: 'weights', + muscleGroup: 'chest', + notes: 'Keep tight', + }); + + await repo.update(created.id, { name: 'Bench Press' }); + + const updated = await repo.getById(created.id); + expect(updated!.name).toBe('Bench Press'); + expect(updated!.notes).toBe('Keep tight'); + }); + it('should return empty muscleGroups array as single primary group fallback', async () => { // Insert directly into DB without pivot data to test fallback await db.runAsync( diff --git a/src/repositories/exercise.repo.ts b/src/repositories/exercise.repo.ts index 9a1d4ba..08f99c4 100644 --- a/src/repositories/exercise.repo.ts +++ b/src/repositories/exercise.repo.ts @@ -9,6 +9,7 @@ export interface CreateExerciseData { illustration?: string | null; restSeconds?: number; isPredefined?: boolean; + notes?: string | null; } export interface UpdateExerciseData { @@ -18,6 +19,7 @@ export interface UpdateExerciseData { muscleGroups?: MuscleGroup[]; illustration?: string | null; restSeconds?: number; + notes?: string | null; } interface ExerciseRow { @@ -28,6 +30,7 @@ interface ExerciseRow { is_predefined: number; illustration: string | null; rest_seconds: number; + notes: string | null; created_at: string; } @@ -47,6 +50,7 @@ function rowToExercise(row: ExerciseRow, muscleGroups?: MuscleGroup[]): Exercise isPredefined: row.is_predefined === 1, illustration: row.illustration, restSeconds: row.rest_seconds, + notes: row.notes, createdAt: row.created_at, }; } @@ -59,14 +63,15 @@ export class ExerciseRepository { await this.db.withTransactionAsync(async () => { const result = await this.db.runAsync( - `INSERT INTO exercises (name, type, muscle_group, illustration, rest_seconds, is_predefined) - VALUES (?, ?, ?, ?, ?, ?)`, + `INSERT INTO exercises (name, type, muscle_group, illustration, rest_seconds, is_predefined, notes) + VALUES (?, ?, ?, ?, ?, ?, ?)`, data.name, data.type, data.muscleGroup, data.illustration ?? null, data.restSeconds ?? 90, data.isPredefined ? 1 : 0, + data.notes ?? null, ); const exerciseId = result.lastInsertRowId; @@ -186,6 +191,10 @@ export class ExerciseRepository { fields.push('rest_seconds = ?'); values.push(data.restSeconds); } + if (data.notes !== undefined) { + fields.push('notes = ?'); + values.push(data.notes); + } if (fields.length > 0) { values.push(id); diff --git a/src/repositories/routine.repo.ts b/src/repositories/routine.repo.ts index 9b6e443..ccc39a1 100644 --- a/src/repositories/routine.repo.ts +++ b/src/repositories/routine.repo.ts @@ -27,6 +27,7 @@ interface RoutineExerciseJoinRow { e_is_predefined: number; e_illustration: string | null; e_rest_seconds: number; + e_notes: string | null; e_created_at: string; } @@ -87,6 +88,7 @@ export class RoutineRepository { e.is_predefined as e_is_predefined, e.illustration as e_illustration, e.rest_seconds as e_rest_seconds, + e.notes as e_notes, e.created_at as e_created_at FROM routine_exercises re JOIN exercises e ON e.id = re.exercise_id @@ -117,6 +119,7 @@ export class RoutineRepository { isPredefined: row.e_is_predefined === 1, illustration: row.e_illustration, restSeconds: row.e_rest_seconds, + notes: row.e_notes, createdAt: row.e_created_at, }, })), @@ -216,6 +219,7 @@ export class RoutineRepository { e.is_predefined as e_is_predefined, e.illustration as e_illustration, e.rest_seconds as e_rest_seconds, + e.notes as e_notes, e.created_at as e_created_at FROM routine_exercises re JOIN exercises e ON e.id = re.exercise_id @@ -242,6 +246,7 @@ export class RoutineRepository { isPredefined: row.e_is_predefined === 1, illustration: row.e_illustration, restSeconds: row.e_rest_seconds, + notes: row.e_notes, createdAt: row.e_created_at, }, }); diff --git a/src/repositories/workout.repo.ts b/src/repositories/workout.repo.ts index 19899cf..e88993f 100644 --- a/src/repositories/workout.repo.ts +++ b/src/repositories/workout.repo.ts @@ -35,6 +35,7 @@ interface WorkoutSetJoinRow extends WorkoutSetRow { e_is_predefined: number; e_illustration: string | null; e_rest_seconds: number; + e_notes: string | null; e_created_at: string; } @@ -257,6 +258,7 @@ export class WorkoutRepository { e.is_predefined as e_is_predefined, e.illustration as e_illustration, e.rest_seconds as e_rest_seconds, + e.notes as e_notes, e.created_at as e_created_at FROM workout_sets ws JOIN exercises e ON e.id = ws.exercise_id @@ -279,6 +281,7 @@ export class WorkoutRepository { isPredefined: row.e_is_predefined === 1, illustration: row.e_illustration, restSeconds: row.e_rest_seconds, + notes: row.e_notes, createdAt: row.e_created_at, }, sets: [], diff --git a/src/types/index.ts b/src/types/index.ts index 81eeedd..a5cad9b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -13,6 +13,7 @@ export interface Exercise { isPredefined: boolean; illustration: string | null; restSeconds: number; + notes: string | null; createdAt: string; } @@ -150,6 +151,7 @@ export interface ExerciseExport { muscleGroups?: string[]; illustration: string | null; restSeconds: number; + notes?: string | null; createdAt: string; }