From ff642087897632f10f238efc7f31f8a7488fca95 Mon Sep 17 00:00:00 2001 From: soham chavan Date: Thu, 9 Jul 2026 16:31:00 +0530 Subject: [PATCH] feat: progressive overload tracking - PR detection + exercise history PR detection on log (compares logged set vs all-time best for that exercise), GET /workouts/history/{exerciseId}, WorkoutScreen 'New PR!' alert and per-exercise history modal with LineChart progression + delta list. --- .../aroha/controller/WorkoutController.java | 7 + .../main/java/com/aroha/model/WorkoutLog.java | 5 + .../repository/WorkoutLogRepository.java | 2 + .../com/aroha/service/WorkoutService.java | 36 ++++- aroha-mobile/src/screens/WorkoutScreen.js | 143 +++++++++++++++++- 5 files changed, 190 insertions(+), 3 deletions(-) diff --git a/aroha-backend/src/main/java/com/aroha/controller/WorkoutController.java b/aroha-backend/src/main/java/com/aroha/controller/WorkoutController.java index 1b63816..5e3a0af 100644 --- a/aroha-backend/src/main/java/com/aroha/controller/WorkoutController.java +++ b/aroha-backend/src/main/java/com/aroha/controller/WorkoutController.java @@ -46,6 +46,13 @@ public ResponseEntity> getToday(@AuthenticationPrincipal Use return ResponseEntity.ok(workoutService.getTodayWorkout(user)); } + @GetMapping("/history/{exerciseId}") + public ResponseEntity> exerciseHistory( + @AuthenticationPrincipal User user, + @PathVariable Long exerciseId) { + return ResponseEntity.ok(workoutService.getExerciseHistory(user, exerciseId)); + } + @DeleteMapping("/log/{id}") public ResponseEntity deleteEntry( @AuthenticationPrincipal User user, diff --git a/aroha-backend/src/main/java/com/aroha/model/WorkoutLog.java b/aroha-backend/src/main/java/com/aroha/model/WorkoutLog.java index b09a598..ca974c1 100644 --- a/aroha-backend/src/main/java/com/aroha/model/WorkoutLog.java +++ b/aroha-backend/src/main/java/com/aroha/model/WorkoutLog.java @@ -43,4 +43,9 @@ public class WorkoutLog { @CreationTimestamp private LocalDateTime loggedAt; + + // true if this entry beats the user's previous best for this exercise — not persisted + @Transient + @Builder.Default + private boolean newPR = false; } diff --git a/aroha-backend/src/main/java/com/aroha/repository/WorkoutLogRepository.java b/aroha-backend/src/main/java/com/aroha/repository/WorkoutLogRepository.java index 88b51d5..12990ff 100644 --- a/aroha-backend/src/main/java/com/aroha/repository/WorkoutLogRepository.java +++ b/aroha-backend/src/main/java/com/aroha/repository/WorkoutLogRepository.java @@ -11,4 +11,6 @@ public interface WorkoutLogRepository extends JpaRepository { List findByUserIdAndLogDateOrderByLoggedAtAsc(Long userId, LocalDate date); List findByUserIdOrderByLogDateDescLoggedAtDesc(Long userId); + + List findByUserIdAndExerciseIdOrderByLogDateAscLoggedAtAsc(Long userId, Long exerciseId); } diff --git a/aroha-backend/src/main/java/com/aroha/service/WorkoutService.java b/aroha-backend/src/main/java/com/aroha/service/WorkoutService.java index 2d6491e..f4c3af8 100644 --- a/aroha-backend/src/main/java/com/aroha/service/WorkoutService.java +++ b/aroha-backend/src/main/java/com/aroha/service/WorkoutService.java @@ -27,6 +27,22 @@ public WorkoutLog logExercise(User user, WorkoutLogRequest request) { Exercise exercise = exerciseRepository.findById(request.getExerciseId()) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Exercise not found")); + List history = workoutLogRepository + .findByUserIdAndExerciseIdOrderByLogDateAscLoggedAtAsc(user.getId(), exercise.getId()); + + boolean newPR = false; + if (!history.isEmpty()) { + if (request.getWeightKg() > 0) { + double prevBestWeight = history.stream().mapToDouble(WorkoutLog::getWeightKg).max().orElse(0); + newPR = request.getWeightKg() > prevBestWeight; + } else { + int prevBestReps = history.stream() + .filter(h -> h.getWeightKg() == 0) + .mapToInt(WorkoutLog::getReps).max().orElse(0); + newPR = request.getReps() > prevBestReps; + } + } + WorkoutLog log = WorkoutLog.builder() .userId(user.getId()) .exerciseId(exercise.getId()) @@ -38,7 +54,25 @@ public WorkoutLog logExercise(User user, WorkoutLogRequest request) { .logDate(LocalDate.now()) .build(); - return workoutLogRepository.save(log); + WorkoutLog saved = workoutLogRepository.save(log); + saved.setNewPR(newPR); + return saved; + } + + public Map getExerciseHistory(User user, Long exerciseId) { + List entries = workoutLogRepository + .findByUserIdAndExerciseIdOrderByLogDateAscLoggedAtAsc(user.getId(), exerciseId); + + double bestWeightKg = entries.stream().mapToDouble(WorkoutLog::getWeightKg).max().orElse(0); + int bestReps = entries.stream() + .filter(e -> e.getWeightKg() == 0) + .mapToInt(WorkoutLog::getReps).max().orElse(0); + + Map response = new HashMap<>(); + response.put("entries", entries); + response.put("bestWeightKg", bestWeightKg); + response.put("bestReps", bestReps); + return response; } public Map getTodayWorkout(User user) { diff --git a/aroha-mobile/src/screens/WorkoutScreen.js b/aroha-mobile/src/screens/WorkoutScreen.js index c618fc3..0a27d9a 100644 --- a/aroha-mobile/src/screens/WorkoutScreen.js +++ b/aroha-mobile/src/screens/WorkoutScreen.js @@ -1,14 +1,22 @@ import React, { useState, useEffect, useCallback } from 'react'; import { View, Text, TouchableOpacity, FlatList, - StyleSheet, StatusBar, ActivityIndicator, Alert, Modal, TextInput, + StyleSheet, StatusBar, ActivityIndicator, Alert, Modal, TextInput, Dimensions, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; +import { LineChart } from 'react-native-chart-kit'; import Colors from '../constants/colors'; import client from '../api/client'; import WorkoutGeneratorScreen from './WorkoutGeneratorScreen'; +const SCREEN_W = Dimensions.get('window').width; + +function fmtDate(dateStr) { + const [, m, d] = dateStr.split('-'); + return `${parseInt(m)}/${parseInt(d)}`; +} + const CATEGORIES = [ { key: 'all', label: 'All', icon: 'grid-outline' }, { key: 'strength', label: 'Strength', icon: 'barbell-outline' }, @@ -87,6 +95,117 @@ function LogModal({ exercise, visible, onClose, onSave }) { ); } +function HistoryModal({ exercise, visible, onClose }) { + const [loading, setLoading] = useState(true); + const [history, setHistory] = useState(null); + + useEffect(() => { + if (visible && exercise) { + setLoading(true); + setHistory(null); + client.get(`/workouts/history/${exercise.id}`) + .then(({ data }) => setHistory(data)) + .catch(() => setHistory({ entries: [], bestWeightKg: 0, bestReps: 0 })) + .finally(() => setLoading(false)); + } + }, [visible, exercise]); + + const entries = history?.entries || []; + const isWeighted = entries.some(e => e.weightKg > 0) || (history?.bestWeightKg || 0) > 0; + const hasChart = entries.length >= 2; + + let chartData = null; + if (hasChart) { + const step = Math.max(1, Math.floor(entries.length / 6)); + const labels = entries.map((e, i) => + i % step === 0 || i === entries.length - 1 ? fmtDate(e.logDate) : ''); + chartData = { + labels, + datasets: [{ data: entries.map(e => isWeighted ? e.weightKg : e.reps) }], + }; + } + + return ( + + + + {exercise?.name} + + {isWeighted ? `Best: ${history?.bestWeightKg ?? 0}kg` : `Best: ${history?.bestReps ?? 0} reps`} + + + {loading ? ( + + ) : entries.length === 0 ? ( + No history yet for this exercise. + ) : ( + String(e.id)} + ListHeaderComponent={ + hasChart ? ( + Colors.accentGold, + labelColor: () => Colors.textSub, + propsForDots: { r: '3', strokeWidth: '2', stroke: Colors.accentGold }, + propsForBackgroundLines: { stroke: Colors.cardBorder, strokeDasharray: '' }, + decimalPlaces: isWeighted ? 1 : 0, + }} + bezier + withInnerLines={false} + withOuterLines={false} + style={{ borderRadius: 10, marginBottom: 16 }} + /> + ) : null + } + renderItem={({ item, index }) => { + const reversed = [...entries].reverse(); + const prev = reversed[index + 1]; + let delta = null; + if (prev) { + if (isWeighted) { + const diff = item.weightKg - prev.weightKg; + if (diff !== 0) delta = `${diff > 0 ? '+' : ''}${diff}kg`; + } else { + const diff = item.reps - prev.reps; + if (diff !== 0) delta = `${diff > 0 ? '+' : ''}${diff} reps`; + } + } + return ( + + + + {item.sets} × {item.reps}{item.weightKg > 0 ? ` × ${item.weightKg}kg` : ''} + + {fmtDate(item.logDate)} + + {delta && ( + + {delta} + + )} + + ); + }} + /> + )} + + + Close + + + + + ); +} + export default function WorkoutScreen() { const [exercises, setExercises] = useState([]); const [filtered, setFiltered] = useState([]); @@ -97,6 +216,8 @@ export default function WorkoutScreen() { const [selected, setSelected] = useState(null); const [modalVisible, setModalVisible] = useState(false); const [showGenerator, setShowGenerator] = useState(false); + const [historyExercise, setHistoryExercise] = useState(null); + const [historyVisible, setHistoryVisible] = useState(false); const loadExercises = useCallback(async () => { try { @@ -137,11 +258,19 @@ export default function WorkoutScreen() { setModalVisible(true); } + function openHistory(exercise) { + setHistoryExercise(exercise); + setHistoryVisible(true); + } + async function saveLog({ sets, reps, weightKg }) { setModalVisible(false); try { - await client.post('/workouts/log', { exerciseId: selected.id, sets, reps, weightKg }); + const { data } = await client.post('/workouts/log', { exerciseId: selected.id, sets, reps, weightKg }); loadTodayLog(); + if (data.newPR) { + Alert.alert('🏆 New PR!', `New personal record for ${selected.name}!`); + } } catch { Alert.alert('Error', 'Could not save log.'); } @@ -225,6 +354,9 @@ export default function WorkoutScreen() { {item.defaultSets}×{item.defaultReps} + openHistory(item)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}> + + @@ -265,6 +397,12 @@ export default function WorkoutScreen() { onSave={saveLog} /> + setHistoryVisible(false)} + /> + setShowGenerator(false)}> setShowGenerator(false)} /> @@ -306,6 +444,7 @@ const styles = StyleSheet.create({ logInfo: { flex: 1 }, logName: { fontSize: 14, color: Colors.text, fontWeight: '500' }, logMeta: { fontSize: 12, color: Colors.textSub, marginTop: 2 }, + deltaText: { fontSize: 13, fontWeight: '700' }, emptyText: { textAlign: 'center', color: Colors.textMuted, fontSize: 13, marginTop: 20 },