Guided Project · Part 1 of 2
Build a workout tracker from scratch using the free wger API for exercise data. Module 1 parses a local workout log. Module 2 calls the wger REST API to enrich exercises with muscle/equipment data. Module 3 extracts an interface and computes Progressive Overload Rate — the direct analog of annualized returns.
| Financial App Analog | GymTrack |
|---|---|
| Portfolio of stocks | Workout log of exercises |
| Stock symbol + buy date + quantity | Exercise name + date + sets/reps/weight |
| Tiingo API (stock prices) | wger API (exercise details) |
TiingoCandle POJO | WgerExerciseInfo POJO |
| Sort by closing price | Sort by muscle count / name |
StockQuotesService interface | WorkoutDataProvider interface |
| Annualized return formula | Progressive Overload Rate formula |
AnnualizedReturn DTO | OverloadResult DTO |
Financial app equivalent: read the trades JSON. A stock app reads a trades file with stock symbols, quantities and dates; you read a workout log with exercises, sets, reps and weights.
WorkoutEntry objects with Jackson — the exact skill this module drills, just with a workout instead of a portfolio.
Initialize a Gradle Java application. (In Part 2 this single project gets split into a library + app — for now, one project is fine.)
# create the project
mkdir gymtrack && cd gymtrack
gradle init --type java-application \
--dsl groovy \
--test-framework junit-jupiter \
--package org.gymtrack \
--project-name gymtrack
dependencies { // Jackson for JSON parsing implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.0' implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.0' testImplementation libs.junit.jupiter testRuntimeOnly 'org.junit.platform:junit-platform-launcher' }
Create this at app/src/main/resources/workout_log.json. Notice each exercise appears multiple times at different dates — that is what makes Module 3's overload calculation possible (a stock app needs a buy date and a sell date; you need an earliest and latest session).
[
{ "exercise": "Bench Press", "muscleGroup": "Chest",
"sets": 4, "reps": 10, "weightKg": 40.0,
"date": "2026-01-15", "completed": true },
{ "exercise": "Bench Press", "muscleGroup": "Chest",
"sets": 4, "reps": 10, "weightKg": 50.0,
"date": "2026-03-01", "completed": true },
{ "exercise": "Bench Press", "muscleGroup": "Chest",
"sets": 4, "reps": 12, "weightKg": 55.0,
"date": "2026-05-01", "completed": true },
{ "exercise": "Squat", "muscleGroup": "Legs",
"sets": 5, "reps": 5, "weightKg": 60.0,
"date": "2026-01-15", "completed": true },
{ "exercise": "Squat", "muscleGroup": "Legs",
"sets": 5, "reps": 5, "weightKg": 80.0,
"date": "2026-03-10", "completed": true },
{ "exercise": "Squat", "muscleGroup": "Legs",
"sets": 5, "reps": 8, "weightKg": 90.0,
"date": "2026-05-10", "completed": true },
{ "exercise": "Deadlift", "muscleGroup": "Back",
"sets": 3, "reps": 5, "weightKg": 80.0,
"date": "2026-02-01", "completed": true },
{ "exercise": "Deadlift", "muscleGroup": "Back",
"sets": 3, "reps": 5, "weightKg": 100.0,
"date": "2026-04-15", "completed": true },
{ "exercise": "Overhead Press", "muscleGroup": "Shoulders",
"sets": 4, "reps": 8, "weightKg": 25.0,
"date": "2026-01-20", "completed": true },
{ "exercise": "Overhead Press", "muscleGroup": "Shoulders",
"sets": 4, "reps": 10, "weightKg": 32.5,
"date": "2026-04-20", "completed": false },
{ "exercise": "Barbell Row", "muscleGroup": "Back",
"sets": 4, "reps": 8, "weightKg": 50.0,
"date": "2026-02-10", "completed": true },
{ "exercise": "Barbell Row", "muscleGroup": "Back",
"sets": 4, "reps": 10, "weightKg": 60.0,
"date": "2026-05-05", "completed": true }
]
One Java field per JSON key. @JsonIgnoreProperties(ignoreUnknown = true) lets the JSON carry extra keys without breaking parsing, and the no-arg constructor is what Jackson uses to build the object.
package org.gymtrack.model; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; @JsonIgnoreProperties(ignoreUnknown = true) public class WorkoutEntry { private String exercise; private String muscleGroup; private int sets; private int reps; private double weightKg; private String date; private boolean completed; public WorkoutEntry() {} // Jackson needs this // --- getters & setters --- public String getExercise() { return exercise; } public void setExercise(String v) { exercise = v; } public String getMuscleGroup() { return muscleGroup; } public void setMuscleGroup(String v) { muscleGroup = v; } public int getSets() { return sets; } public void setSets(int v) { sets = v; } public int getReps() { return reps; } public void setReps(int v) { reps = v; } public double getWeightKg() { return weightKg; } public void setWeightKg(double v) { weightKg = v; } public String getDate() { return date; } public void setDate(String v) { date = v; } public boolean isCompleted() { return completed; } public void setCompleted(boolean v) { completed = v; } /** Training volume = sets × reps × weight. * Like a financial app's (quantity × price) for a trade. */ public double getVolume() { return sets * reps * weightKg; } @Override public String toString() { return exercise + " | " + sets + "x" + reps + " @ " + weightKg + "kg (" + date + ")"; } }
readLog loads the file from the classpath and lets Jackson map it to a List<WorkoutEntry> via a TypeReference (needed so generics survive type erasure). printSummary walks the list once and aggregates.
package org.gymtrack; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import org.gymtrack.model.WorkoutEntry; import java.io.IOException; import java.io.InputStream; import java.util.*; public class App { public static List<WorkoutEntry> readLog(String filename) throws IOException { ObjectMapper mapper = new ObjectMapper(); InputStream is = App.class.getClassLoader() .getResourceAsStream(filename); if (is == null) { throw new IOException("File not found: " + filename); } return mapper.readValue(is, new TypeReference<List<WorkoutEntry>>() {}); } public static void printSummary(List<WorkoutEntry> log) { Set<String> exercises = new LinkedHashSet<>(); Map<String, Integer> byMuscle = new TreeMap<>(); double totalVolume = 0; int completed = 0; for (WorkoutEntry e : log) { exercises.add(e.getExercise()); byMuscle.merge(e.getMuscleGroup(), 1, Integer::sum); totalVolume += e.getVolume(); if (e.isCompleted()) completed++; } System.out.println("Total entries: " + log.size()); System.out.println("Unique exercises: " + exercises.size() + " " + exercises); System.out.println("Completed: " + completed + "/" + log.size()); System.out.printf("Total volume: %.0f kg%n", totalVolume); System.out.println("By muscle group: " + byMuscle); } public static void main(String[] args) { try { List<WorkoutEntry> log = readLog("workout_log.json"); System.out.println("=== GymTrack Workout Log ==="); printSummary(log); System.out.println("\n--- All Entries ---"); for (WorkoutEntry e : log) System.out.println(" " + e); } catch (IOException e) { System.err.println("Error: " + e.getMessage()); } } }
./gradlew run
=== GymTrack Workout Log ===
Total entries: 12
Unique exercises: 5 [Bench Press, Squat, Deadlift, Overhead Press, Barbell Row]
Completed: 11/12
Total volume: 22140 kg
By muscle group: {Back=4, Chest=3, Legs=3, Shoulders=2}
--- All Entries ---
Bench Press | 4x10 @ 40.0kg (2026-01-15)
Bench Press | 4x10 @ 50.0kg (2026-03-01)
...
NullPointerException on getResourceAsStream — the file isn't under src/main/resources/. Gradle only copies classpath resources from there.UnrecognizedPropertyException — the JSON has a field your POJO doesn't. Add @JsonIgnoreProperties(ignoreUnknown = true).InvalidDefinitionException — missing no-arg constructor. Jackson needs public WorkoutEntry() {}.getVolumeByExercise() method returning a Map<String, Double> of exercise name → total volume across all entries, and print it in main. This mirrors a stock app summing quantities per stock symbol. (Check: Squat = 7100, Bench Press = 6240.)
workout_log.json — 12 entries across 5 exercisesWorkoutEntry POJO with a getVolume() helperApp.java reads JSON and prints summary statsFinancial app equivalent: call Tiingo for stock prices. You call wger for exercise details — same RestTemplate + response-POJO pattern, then sort with Comparators.
Comparators.
| Method | Does | GymTrack example |
|---|---|---|
| GET | Fetch data | Get exercise details from wger |
| POST | Create | Log a new workout (later) |
| PUT | Update | Update a set's weight (later) |
| DELETE | Remove | Delete a log entry (later) |
2xx = success, 4xx = your fault, 5xx = their fault. You only use GET here.
wger (Workout Manager) is a free, open-source fitness API — no signup or key for reads. Open this in your browser to see one exercise:
https://wger.de/api/v2/exerciseinfo/73/?format=json
/exerciseinfo has no top-level name. The name lives inside a translations array, one entry per language (language: 2 is English). Many tutorials get this wrong and end up with a null name. Your POJO has to read the name out of translations — we handle that below.
Exercise info (the shape you'll actually parse), trimmed:
{
"id": 73,
"category": { "id": 11, "name": "Chest" },
"muscles": [
{ "id": 4, "name": "Pectoralis major",
"name_en": "Chest", "is_front": true }
],
"muscles_secondary": [ { "id": 2, "name_en": "Front delts" } ],
"equipment": [ { "id": 1, "name": "Barbell" } ],
"translations": [
{ "language": 2, "name": "Bench Press" },
{ "language": 1, "name": "Bankdrücken" }
]
}
The nesting means several POJOs — the same pattern as a TiingoCandle response in a stock app.
Five small classes. The interesting one is WgerExerciseInfo.getName(): it returns a name set directly (used later by local/backup providers) or, for real API responses, digs the English name out of translations.
package org.gymtrack.model; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; @JsonIgnoreProperties(ignoreUnknown = true) public class WgerExerciseInfo { private String name; // set directly by local/backup providers private List<WgerTranslation> translations; // populated by the wger API private WgerCategory category; private List<WgerMuscle> muscles; @JsonProperty("muscles_secondary") private List<WgerMuscle> musclesSecondary; private List<WgerEquipment> equipment; public WgerExerciseInfo() {} /** English name: explicit if a provider set one, else from translations. */ public String getName() { if (name != null) return name; if (translations != null) { for (WgerTranslation t : translations) { if (t.getLanguage() == 2) return t.getName(); // 2 = English } if (!translations.isEmpty()) return translations.get(0).getName(); } return null; } public void setName(String v) { name = v; } public List<WgerTranslation> getTranslations() { return translations; } public void setTranslations(List<WgerTranslation> v) { translations = v; } public WgerCategory getCategory() { return category; } public void setCategory(WgerCategory v) { category = v; } public List<WgerMuscle> getMuscles() { return muscles; } public void setMuscles(List<WgerMuscle> v) { muscles = v; } public List<WgerMuscle> getMusclesSecondary() { return musclesSecondary; } public void setMusclesSecondary(List<WgerMuscle> v) { musclesSecondary = v; } public List<WgerEquipment> getEquipment() { return equipment; } public void setEquipment(List<WgerEquipment> v) { equipment = v; } /** Total muscles hit (primary + secondary). */ public int getTotalMuscleCount() { int c = 0; if (muscles != null) c += muscles.size(); if (musclesSecondary != null) c += musclesSecondary.size(); return c; } @Override public String toString() { return getName() + " (" + (category != null ? category.getName() : "?") + ") — " + getTotalMuscleCount() + " muscles"; } }
package org.gymtrack.model; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; @JsonIgnoreProperties(ignoreUnknown = true) public class WgerTranslation { private String name; private int language; // wger language id: 2 = English public WgerTranslation() {} public String getName() { return name; } public void setName(String v) { name = v; } public int getLanguage() { return language; } public void setLanguage(int v) { language = v; } }
package org.gymtrack.model; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; @JsonIgnoreProperties(ignoreUnknown = true) public class WgerMuscle { private int id; @JsonProperty("name_en") private String nameEn; @JsonProperty("is_front") private boolean isFront; public WgerMuscle() {} public int getId() { return id; } public void setId(int v) { id = v; } public String getNameEn() { return nameEn; } public void setNameEn(String v) { nameEn = v; } public boolean isFront() { return isFront; } public void setFront(boolean v) { isFront = v; } @Override public String toString() { return nameEn; } }
WgerCategory and WgerEquipment are tiny — each just an int id and a String name with getters/setters and a no-arg constructor (same pattern as above).
Add Spring Web for RestTemplate (this is just spring-web, not Spring Boot):
implementation 'org.springframework:spring-web:6.1.6'
wger doesn't offer a stable name search, so we map our log's friendly names to verified wger IDs. (Replacing this with a live lookup is a Module 6 exercise.)
package org.gymtrack.service; import org.gymtrack.model.WgerExerciseInfo; import org.springframework.web.client.RestTemplate; import java.util.Map; public class ExerciseApiClient { private final RestTemplate rest = new RestTemplate(); private static final String BASE_URL = "https://wger.de/api/v2"; // Log name -> verified wger exercise id (checked against the live API). private static final Map<String, Integer> EXERCISE_IDS = Map.of( "Bench Press", 73, "Squat", 1801, "Deadlift", 184, "Overhead Press", 687, "Barbell Row", 1698); /** Fetch exercise details from wger using RestTemplate.getForObject(). */ public WgerExerciseInfo fetchExerciseInfo(String exerciseName) { Integer id = EXERCISE_IDS.get(exerciseName); if (id == null) { System.out.println(" Unknown exercise: " + exerciseName); return null; } String url = BASE_URL + "/exerciseinfo/" + id + "/?format=json"; return rest.getForObject(url, WgerExerciseInfo.class); } }
Reusable sort orders. Each implements Comparator<WgerExerciseInfo>.
public class MuscleCountComparator implements Comparator<WgerExerciseInfo> { @Override public int compare(WgerExerciseInfo a, WgerExerciseInfo b) { // descending: most muscles (compound lifts) first return Integer.compare(b.getTotalMuscleCount(), a.getTotalMuscleCount()); } }
public class NameComparator implements Comparator<WgerExerciseInfo> { @Override public int compare(WgerExerciseInfo a, WgerExerciseInfo b) { return a.getName().compareToIgnoreCase(b.getName()); } }
public class CategoryComparator implements Comparator<WgerExerciseInfo> { @Override public int compare(WgerExerciseInfo a, WgerExerciseInfo b) { String ca = a.getCategory() != null ? a.getCategory().getName() : ""; String cb = b.getCategory() != null ? b.getCategory().getName() : ""; return ca.compareToIgnoreCase(cb); } }
Add a method that pulls the unique exercise names from the log, fetches each from wger, then prints them three ways. Keep all your Module 1 code; just call this after printSummary.
public static void fetchAndSortExercises(List<WorkoutEntry> log) { ExerciseApiClient client = new ExerciseApiClient(); Set<String> uniqueNames = new LinkedHashSet<>(); for (WorkoutEntry e : log) uniqueNames.add(e.getExercise()); List<WgerExerciseInfo> exercises = new ArrayList<>(); for (String name : uniqueNames) { System.out.println("Fetching: " + name); WgerExerciseInfo info = client.fetchExerciseInfo(name); if (info != null) exercises.add(info); } System.out.println("\n=== Exercise Details (" + exercises.size() + " found) ===\n"); exercises.sort(new MuscleCountComparator()); System.out.println("--- By Muscle Count (Compound First) ---"); for (WgerExerciseInfo ex : exercises) System.out.printf(" %d muscles %s [%s]%n", ex.getTotalMuscleCount(), ex.getName(), ex.getCategory() != null ? ex.getCategory().getName() : "?"); exercises.sort(new CategoryComparator()); System.out.println("\n--- By Category ---"); for (WgerExerciseInfo ex : exercises) System.out.printf(" %-12s %s%n", ex.getCategory() != null ? ex.getCategory().getName() : "?", ex.getName()); exercises.sort(new NameComparator()); System.out.println("\n--- A-Z ---"); for (WgerExerciseInfo ex : exercises) System.out.println(" " + ex.getName()); }
Fetching: Bench Press
Fetching: Squat
Fetching: Deadlift
Fetching: Overhead Press
Fetching: Barbell Row
=== Exercise Details (5 found) ===
--- By Muscle Count (Compound First) ---
5 muscles Barbell Full Squat [Legs]
3 muscles Bench Press [Chest]
2 muscles Deadlifts [Back]
1 muscles Overhead Press [Chest]
1 muscles Barbell Row (Overhand) [Back]
--- By Category ---
Back Deadlifts
Back Barbell Row (Overhand)
Chest Bench Press
Chest Overhead Press
Legs Barbell Full Squat
--- A-Z ---
Barbell Full Squat
Barbell Row (Overhand)
Bench Press
Deadlifts
Overhead Press
Squat comes back as "Barbell Full Squat" and Deadlift as "Deadlifts". Its categories are coarse too — Overhead Press is filed under Chest, not Shoulders. Your local muscleGroup and the API's category are two different opinions; production code reconciles them. Don't "fix" the API to match your expectations — learn its shape.
HttpClientErrorException: 404 — a wrong/stale exercise id. Paste the URL in a browser first. wger ids drift over time; the five above were verified against the live API.getName() returns null — you tried to read a top-level name that doesn't exist. Parse translations instead (the gotcha above).ResourceAccessException — no internet. Try curl "https://wger.de/api/v2/exerciseinfo/73/?format=json" to confirm.RestTemplateWgerTranslation + a smart getName()ExerciseApiClient with verified wger idsComparators; fetchAndSortExercises() enriches + sortsFinancial app equivalent: annualized returns behind a service interface. Extract a provider interface, then compute Progressive Overload Rate — the same formula as annualized returns.
WorkoutDataProvider so the calculation never knows (or cares) where exercise data comes from. (2) The payoff metric: with multiple dated sessions per exercise, compute how fast you're getting stronger — structurally identical to a stock's annualized return.
The annualized return formula and GymTrack's overload rate are the same equation, different domain:
// Financial app (annualized return) totalReturn = (sellPrice / buyPrice) - 1 annualizedReturn = (sellPrice / buyPrice) ^ (365.0 / totalDays) - 1 // GymTrack (volume = sets × reps × weightKg) absoluteGain = (endVolume / startVolume) - 1 overloadRate = (endVolume / startVolume) ^ (365.0 / daysBetween) - 1
| Component | Annualized Return | Overload Rate |
|---|---|---|
| Start value | buyPrice | startVolume |
| End value | sellPrice | endVolume |
| Time period | totalDays | daysBetween |
| Annualizer | 365 / totalDays | 365 / daysBetween |
| Result | Annual % stock growth | Annual % volume growth |
This mirrors the StockQuotesService pattern from a stock portfolio app. The calculator will depend on this, not on any concrete class.
package org.gymtrack.provider; import org.gymtrack.model.WgerExerciseInfo; /** Abstraction for any exercise data source. */ public interface WorkoutDataProvider { WgerExerciseInfo getExerciseDetails(String exerciseName); String getProviderName(); }
Implementation 1 — wger API. Same fetch logic as ExerciseApiClient, now behind the interface.
package org.gymtrack.provider; import org.gymtrack.model.WgerExerciseInfo; import org.springframework.web.client.RestTemplate; import java.util.Map; public class WgerApiProvider implements WorkoutDataProvider { private final RestTemplate rest = new RestTemplate(); private static final String BASE_URL = "https://wger.de/api/v2"; private static final Map<String, Integer> EXERCISE_IDS = Map.of( "Bench Press", 73, "Squat", 1801, "Deadlift", 184, "Overhead Press", 687, "Barbell Row", 1698); @Override public WgerExerciseInfo getExerciseDetails(String name) { Integer id = EXERCISE_IDS.get(name); if (id == null) return null; String url = BASE_URL + "/exerciseinfo/" + id + "/?format=json"; return rest.getForObject(url, WgerExerciseInfo.class); } @Override public String getProviderName() { return "wger API"; } }
Implementation 2 — local JSON. Builds basic WgerExerciseInfo objects from your log, no network. Proves the abstraction works: same interface, totally different source. (Note how it calls setName/setCategory directly — that's why getName() honors an explicit name.)
package org.gymtrack.provider; import org.gymtrack.model.*; import java.util.*; public class LocalExerciseProvider implements WorkoutDataProvider { private final Map<String, WgerExerciseInfo> exercises = new HashMap<>(); public LocalExerciseProvider(List<WorkoutEntry> log) { for (WorkoutEntry entry : log) { if (exercises.containsKey(entry.getExercise())) continue; WgerExerciseInfo info = new WgerExerciseInfo(); info.setName(entry.getExercise()); WgerCategory cat = new WgerCategory(); cat.setName(entry.getMuscleGroup()); info.setCategory(cat); info.setMuscles(List.of()); // local data has no muscle detail info.setMusclesSecondary(List.of()); info.setEquipment(List.of()); exercises.put(entry.getExercise(), info); } } @Override public WgerExerciseInfo getExerciseDetails(String name) { return exercises.get(name); } @Override public String getProviderName() { return "Local JSON (" + exercises.size() + " exercises)"; } }
Mirrors the AnnualizedReturn DTO pattern. Immutable, and Comparable so the default sort is highest rate first.
package org.gymtrack.model; public class OverloadResult implements Comparable<OverloadResult> { private final String exercise; private final double absoluteGain, overloadRate, startVolume, endVolume; private final int daysBetween; public OverloadResult(String exercise, double absoluteGain, double overloadRate, double startVolume, double endVolume, int daysBetween) { this.exercise = exercise; this.absoluteGain = absoluteGain; this.overloadRate = overloadRate; this.startVolume = startVolume; this.endVolume = endVolume; this.daysBetween = daysBetween; } public String getExercise() { return exercise; } public double getAbsoluteGain() { return absoluteGain; } public double getOverloadRate() { return overloadRate; } public double getStartVolume() { return startVolume; } public double getEndVolume() { return endVolume; } public int getDaysBetween() { return daysBetween; } @Override public int compareTo(OverloadResult o) { return Double.compare(o.overloadRate, this.overloadRate); // desc } }
Group entries by exercise, sort each group by date, take earliest vs. latest, apply the formula. Skip exercises with fewer than two sessions (you can't measure progress from one point).
package org.gymtrack.service; import org.gymtrack.model.OverloadResult; import org.gymtrack.model.WorkoutEntry; import java.time.LocalDate; import java.time.temporal.ChronoUnit; import java.util.*; public class OverloadCalculator { public static List<OverloadResult> calculate(List<WorkoutEntry> log) { Map<String, List<WorkoutEntry>> byExercise = new LinkedHashMap<>(); for (WorkoutEntry e : log) byExercise.computeIfAbsent(e.getExercise(), k -> new ArrayList<>()).add(e); List<OverloadResult> results = new ArrayList<>(); for (Map.Entry<String, List<WorkoutEntry>> entry : byExercise.entrySet()) { List<WorkoutEntry> es = entry.getValue(); if (es.size() < 2) continue; // need 2 points es.sort(Comparator.comparing(WorkoutEntry::getDate)); WorkoutEntry first = es.get(0), last = es.get(es.size() - 1); double startVol = first.getVolume(), endVol = last.getVolume(); long days = ChronoUnit.DAYS.between( LocalDate.parse(first.getDate()), LocalDate.parse(last.getDate())); if (days <= 0 || startVol <= 0) continue; double absoluteGain = (endVol / startVol) - 1; double overloadRate = Math.pow(endVol / startVol, 365.0 / days) - 1; results.add(new OverloadResult(entry.getKey(), absoluteGain, overloadRate, startVol, endVol, (int) days)); } Collections.sort(results); // highest rate first return results; } }
The key line: the method takes a WorkoutDataProvider — the interface. Pass it wger or local; the calculator code is identical.
public static void calculateOverloadRates( WorkoutDataProvider provider, List<WorkoutEntry> log) { System.out.println("Provider: " + provider.getProviderName()); Set<String> seen = new HashSet<>(); for (WorkoutEntry e : log) { if (seen.add(e.getExercise())) { WgerExerciseInfo info = provider.getExerciseDetails(e.getExercise()); if (info != null) System.out.println(" → " + info); } } List<OverloadResult> results = OverloadCalculator.calculate(log); System.out.println("\nRanked by Overload Rate:\n"); int rank = 1; for (OverloadResult r : results) { System.out.printf(" %d. %s%n" + " Volume: %.0f → %.0f kg (%+.0f%%)%n" + " Overload Rate: %+.1f%% annualized (%d days)%n%n", rank++, r.getExercise(), r.getStartVolume(), r.getEndVolume(), r.getAbsoluteGain() * 100, r.getOverloadRate() * 100, r.getDaysBetween()); } } // in main(), after fetchAndSortExercises(log): WorkoutDataProvider wger = new WgerApiProvider(); WorkoutDataProvider local = new LocalExerciseProvider(log); calculateOverloadRates(wger, log); // uses the INTERFACE, not the class calculateOverloadRates(local, log); // same calculator, different source
Ranked by Overload Rate:
1. Squat
Volume: 1500 → 3600 kg (+140%)
Overload Rate: +1509.7% annualized (115 days)
2. Overhead Press
Volume: 800 → 1300 kg (+62%)
Overload Rate: +616.4% annualized (90 days)
3. Barbell Row
Volume: 1600 → 2400 kg (+50%)
Overload Rate: +482.3% annualized (84 days)
4. Bench Press
Volume: 1600 → 2640 kg (+65%)
Overload Rate: +460.9% annualized (106 days)
5. Deadlift
Volume: 1200 → 1500 kg (+25%)
Overload Rate: +205.2% annualized (73 days)
1RM = weight × (1 + reps / 30.0) and track 1RM progression instead of volume. Does ranking by 1RM overload rate differ from volume overload rate? (Hint: Squat's rep jump from 5 to 8 boosts volume more than 1RM.)
WorkoutDataProvider interface + two implementations (wger, local)OverloadResult DTO (immutable, Comparable)OverloadCalculator with the Progressive Overload Rate formulaGymTrack reads your local log (M1), enriches exercises from wger (M2), and ranks them by Progressive Overload Rate (M3). The calculation uses the annualized-return formula, and the interface means swapping wger for another source is a one-line change — which is exactly where Part 2 begins.
app/src/main/java/org/gymtrack/
├── App.java
├── model/
│ ├── WorkoutEntry.java (M1)
│ ├── WgerExerciseInfo.java (M2)
│ ├── WgerTranslation.java (M2)
│ ├── WgerMuscle.java (M2)
│ ├── WgerCategory.java (M2)
│ ├── WgerEquipment.java (M2)
│ └── OverloadResult.java (M3)
├── comparator/
│ ├── MuscleCountComparator.java (M2)
│ ├── NameComparator.java (M2)
│ └── CategoryComparator.java (M2)
├── provider/
│ ├── WorkoutDataProvider.java (M3)
│ ├── WgerApiProvider.java (M3)
│ └── LocalExerciseProvider.java (M3)
└── service/
├── ExerciseApiClient.java (M2)
└── OverloadCalculator.java (M3)
app/src/main/resources/workout_log.json (M1)