Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ public ResponseEntity<Map<String, Object>> getToday(@AuthenticationPrincipal Use
return ResponseEntity.ok(workoutService.getTodayWorkout(user));
}

@GetMapping("/history/{exerciseId}")
public ResponseEntity<Map<String, Object>> exerciseHistory(
@AuthenticationPrincipal User user,
@PathVariable Long exerciseId) {
return ResponseEntity.ok(workoutService.getExerciseHistory(user, exerciseId));
}

@DeleteMapping("/log/{id}")
public ResponseEntity<Void> deleteEntry(
@AuthenticationPrincipal User user,
Expand Down
5 changes: 5 additions & 0 deletions aroha-backend/src/main/java/com/aroha/model/WorkoutLog.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ public interface WorkoutLogRepository extends JpaRepository<WorkoutLog, Long> {
List<WorkoutLog> findByUserIdAndLogDateOrderByLoggedAtAsc(Long userId, LocalDate date);

List<WorkoutLog> findByUserIdOrderByLogDateDescLoggedAtDesc(Long userId);

List<WorkoutLog> findByUserIdAndExerciseIdOrderByLogDateAscLoggedAtAsc(Long userId, Long exerciseId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<WorkoutLog> 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())
Expand All @@ -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<String, Object> getExerciseHistory(User user, Long exerciseId) {
List<WorkoutLog> 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<String, Object> response = new HashMap<>();
response.put("entries", entries);
response.put("bestWeightKg", bestWeightKg);
response.put("bestReps", bestReps);
return response;
}

public Map<String, Object> getTodayWorkout(User user) {
Expand Down
143 changes: 141 additions & 2 deletions aroha-mobile/src/screens/WorkoutScreen.js
Original file line number Diff line number Diff line change
@@ -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' },
Expand Down Expand Up @@ -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 (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<View style={styles.modalOverlay}>
<View style={[styles.modalCard, { maxHeight: '85%' }]}>
<Text style={styles.modalTitle}>{exercise?.name}</Text>
<Text style={styles.modalSub}>
{isWeighted ? `Best: ${history?.bestWeightKg ?? 0}kg` : `Best: ${history?.bestReps ?? 0} reps`}
</Text>

{loading ? (
<ActivityIndicator color={Colors.accentGold} style={{ marginVertical: 30 }} />
) : entries.length === 0 ? (
<Text style={styles.emptyText}>No history yet for this exercise.</Text>
) : (
<FlatList
data={[...entries].reverse()}
keyExtractor={e => String(e.id)}
ListHeaderComponent={
hasChart ? (
<LineChart
data={chartData}
width={SCREEN_W - 80}
height={160}
chartConfig={{
backgroundColor: Colors.card,
backgroundGradientFrom: Colors.card,
backgroundGradientTo: Colors.card,
color: () => 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 (
<View style={styles.logRow}>
<View style={styles.logInfo}>
<Text style={styles.logName}>
{item.sets} × {item.reps}{item.weightKg > 0 ? ` × ${item.weightKg}kg` : ''}
</Text>
<Text style={styles.logMeta}>{fmtDate(item.logDate)}</Text>
</View>
{delta && (
<Text style={[styles.deltaText, { color: delta.startsWith('+') ? Colors.success : Colors.textMuted }]}>
{delta}
</Text>
)}
</View>
);
}}
/>
)}

<TouchableOpacity style={styles.cancelBtn} onPress={onClose}>
<Text style={styles.cancelBtnText}>Close</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
}

export default function WorkoutScreen() {
const [exercises, setExercises] = useState([]);
const [filtered, setFiltered] = useState([]);
Expand All @@ -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 {
Expand Down Expand Up @@ -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.');
}
Expand Down Expand Up @@ -225,6 +354,9 @@ export default function WorkoutScreen() {
</View>
<View style={styles.exerciseRight}>
<Text style={styles.exerciseDefault}>{item.defaultSets}×{item.defaultReps}</Text>
<TouchableOpacity onPress={() => openHistory(item)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Ionicons name="stats-chart-outline" size={20} color={Colors.textSub} />
</TouchableOpacity>
<Ionicons name="add-circle-outline" size={22} color={Colors.accentGold} />
</View>
</TouchableOpacity>
Expand Down Expand Up @@ -265,6 +397,12 @@ export default function WorkoutScreen() {
onSave={saveLog}
/>

<HistoryModal
exercise={historyExercise}
visible={historyVisible}
onClose={() => setHistoryVisible(false)}
/>

<Modal visible={showGenerator} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowGenerator(false)}>
<WorkoutGeneratorScreen visible={showGenerator} onClose={() => setShowGenerator(false)} />
</Modal>
Expand Down Expand Up @@ -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 },

Expand Down
Loading