Guided Project · Part 1 of 2

GymTrack
Modules 1, 2 & 3

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.

Java Gradle Jackson RestTemplate Comparators Interfaces

Concept map Financial App → GymTrack

Financial App AnalogGymTrack
Portfolio of stocksWorkout log of exercises
Stock symbol + buy date + quantityExercise name + date + sets/reps/weight
Tiingo API (stock prices)wger API (exercise details)
TiingoCandle POJOWgerExerciseInfo POJO
Sort by closing priceSort by muscle count / name
StockQuotesService interfaceWorkoutDataProvider interface
Annualized return formulaProgressive Overload Rate formula
AnnualizedReturn DTOOverloadResult DTO
M1

JSON parsing & local data

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.

What & why
Every data app starts the same way: get raw data into typed objects you can reason about. Here that means turning a flat JSON array into a list of WorkoutEntry objects with Jackson — the exact skill this module drills, just with a workout instead of a portfolio.

Project setup Gradle

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
app/build.gradle (dependencies)
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'
}

The data file Source of truth

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).

app/src/main/resources/workout_log.json
[
  { "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 }
]

The POJO model/WorkoutEntry.java

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.

app/.../model/WorkoutEntry.java
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 + ")";
  }
}

Parse & summarize App.java

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.

app/.../App.java (Module 1)
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());
    }
  }
}

Run it Verified output

./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)
  ...

Troubleshooting Common errors

Try it
Add a 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.)
  • Gradle project initialized with the Jackson dependency
  • workout_log.json — 12 entries across 5 exercises
  • WorkoutEntry POJO with a getVolume() helper
  • App.java reads JSON and prints summary stats
M2

HTTP, REST & the wger API

Financial app equivalent: call Tiingo for stock prices. You call wger for exercise details — same RestTemplate + response-POJO pattern, then sort with Comparators.

What & why
Local data only tells you what you recorded. To enrich each exercise with which muscles it works and what equipment it needs, you call an external REST API and map its JSON onto your own objects — exactly like calling Tiingo in a stock app. Then you sort the enriched list with reusable Comparators.

HTTP recap GET only, this module

MethodDoesGymTrack example
GETFetch dataGet exercise details from wger
POSTCreateLog a new workout (later)
PUTUpdateUpdate a set's weight (later)
DELETERemoveDelete a log entry (later)

2xx = success, 4xx = your fault, 5xx = their fault. You only use GET here.

The wger API Free · no auth

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
Gotcha — the real response shape. wger's /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.

Response POJOs model/

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.

model/WgerExerciseInfo.java
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";
  }
}
model/WgerTranslation.java
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; }
}
model/WgerMuscle.java
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).

The API client service/ExerciseApiClient.java

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.)

service/ExerciseApiClient.java
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);
  }
}

Three comparators comparator/

Reusable sort orders. Each implements Comparator<WgerExerciseInfo>.

comparator/MuscleCountComparator.java
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());
  }
}
comparator/NameComparator.java
public class NameComparator implements Comparator<WgerExerciseInfo> {
  @Override public int compare(WgerExerciseInfo a, WgerExerciseInfo b) {
    return a.getName().compareToIgnoreCase(b.getName());
  }
}
comparator/CategoryComparator.java
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);
  }
}

Wire it together App.java

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.

app/.../App.java (new method)
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());
}

Expected output Verified against live wger

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
Real data is messy — and that's the lesson. wger returns its own canonical names, so 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.

Troubleshooting Common errors

  • Spring Web dependency + RestTemplate
  • 5 response POJOs incl. WgerTranslation + a smart getName()
  • ExerciseApiClient with verified wger ids
  • 3 reusable Comparators; fetchAndSortExercises() enriches + sorts
M3

Interfaces & Progressive Overload

Financial app equivalent: annualized returns behind a service interface. Extract a provider interface, then compute Progressive Overload Rate — the same formula as annualized returns.

What & why
Two ideas land together here. (1) Program to an interface: right now the app is welded to wger. Hide the data source behind 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 formula Core idea

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
ComponentAnnualized ReturnOverload Rate
Start valuebuyPricestartVolume
End valuesellPriceendVolume
Time periodtotalDaysdaysBetween
Annualizer365 / totalDays365 / daysBetween
ResultAnnual % stock growthAnnual % volume growth

Extract the interface provider/

This mirrors the StockQuotesService pattern from a stock portfolio app. The calculator will depend on this, not on any concrete class.

provider/WorkoutDataProvider.java
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.

provider/WgerApiProvider.java
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.)

provider/LocalExerciseProvider.java
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)";
  }
}

The DTO model/OverloadResult.java

Mirrors the AnnualizedReturn DTO pattern. Immutable, and Comparable so the default sort is highest rate first.

model/OverloadResult.java
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
  }
}

The calculator service/OverloadCalculator.java

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).

service/OverloadCalculator.java
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;
  }
}

Wire it all together App.java

The key line: the method takes a WorkoutDataProvider — the interface. Pass it wger or local; the calculator code is identical.

app/.../App.java (new method + main)
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

Expected output Verified numbers

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)
Why the rates look enormous. Annualizing a few months of gains projects them across a full year, so a +140% jump in 115 days extrapolates to +1509%. That's the formula working as intended (annualized returns do the same on short holds) — the ranking, not the raw %, is the signal: it tells you where you're progressing fastest.
Try it
Add an estimated 1-rep-max via the Epley formula 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 formula
  • App computes rates through the interface — source-agnostic
DONE

Where you are after Part 1

GymTrack 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.

Project tree

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)
Next
Part 2 (Modules 4–8) turns this into a real product: package it as a published library, add a backup provider with failover, harden error handling with typed exceptions, and parallelize the API calls for a ~10× speedup. → Continue to Modules 4–8