SpeedTyping: A Spring Boot Typing Test
SpeedTyping: A Spring Boot Typing Test
Source: github.com/jackaholy/SpeedTyping
Why?
A team project for a software engineering course. The goal was to ship something end-to-end (backend, database, frontend) using Spring Boot and JPA.
The product side was a typing test aimed at younger learners: pick a difficulty, type the passage, get a WPM score, land on a leaderboard.
What?
A Spring Boot web app backed by MySQL via JPA. Three controllers handle the home/leaderboard view, the typing page, and score submission. Levels are seeded from a SQL file with thirty short passages tagged EASY, MEDIUM, or HARD. Vanilla JS on the frontend runs the timer, colors characters as you type, and auto-submits when the passage is finished.
The core domain is two entities: Level (the passage) and Score (a username, timestamp, WPM, and FK to a level). A LeaderboardService returns the top N scores per difficulty. Bootstrap handles styling.
Code
Domain model
The data model is small. A Level owns many Scores, with difficulty as an enum persisted as a string:
@Entity
@Table(name = "level")
public class Level {
@Id @GeneratedValue
private Integer id;
@OneToMany(mappedBy = "level")
private Set<Score> scores;
@Column(name = "levelname", nullable = false)
private String levelname;
@Column(name = "wordcount", nullable = false)
private Integer wordcount;
public enum LevelDifficulty { EASY, MEDIUM, HARD }
@Enumerated(EnumType.STRING)
@Column(name = "leveldifficulty")
private LevelDifficulty leveldifficulty;
@Column(name = "content", nullable = false, length = 65535)
private String content;
}Scoring a submission
When a typing test is submitted, the controller compares typed words to the level word-for-word, computes WPM from the elapsed time, and persists a Score. Only fully correct words count:
@PostMapping("/addData")
public String addDataPost(@Valid @ModelAttribute TypeTest typeTest, BindingResult result, RedirectAttributes attrs) {
if (result.hasErrors()) return "leaderboard";
Level testedLevel = null;
try {
Score score = new Score();
score.setUsername(typeTest.getUsername());
testedLevel = contentService.findByLevelid(typeTest.getLevelId());
score.setLevel(testedLevel);
score.setDate(Calendar.getInstance().getTime());
double time = typeTest.getTime();
int correctWordsTyped = calculateCorrectWordsTyped(typeTest.getTypedContent(), testedLevel.getContent());
double wordsPerMinute = (correctWordsTyped / time) * 60;
score.setWpm(wordsPerMinute);
contentService.saveScore(score);
} catch (Exception ex) {
result.addError(new ObjectError("globalError", "Failed to save data into database."));
}
return testedLevel != null
? "redirect:/leaderboard?difficulty=" + testedLevel.getLeveldifficulty()
: "leaderboard";
}Top-N leaderboard
The leaderboard service pulls every score for a difficulty, sorts by WPM descending in memory, and trims to N. Fine at this scale — there are 30 levels and a small number of submissions — but it’s the obvious thing to push into a JPQL query if the table ever grew:
@Override
public List<Score> getNScoresForDifficultySortByWpm(Level.LevelDifficulty leveldifficulty, int n) {
if (leveldifficulty == null) return new LinkedList<>();
if (n < 0) throw new IllegalArgumentException("n must be greater than or equal to 0");
List<Score> diffLevels = getScoresForLeveldifficulty(leveldifficulty);
diffLevels.sort(Comparator.comparing(Score::getWpm).reversed());
List<Score> subList = diffLevels.subList(0, Math.min(n, diffLevels.size()));
final int DECIMALS = 1;
for (Score score : subList) {
score.setWpm(Math.round(score.getWpm() * Math.pow(10, DECIMALS)) / Math.pow(10, DECIMALS));
}
return subList;
}Frontend feedback and auto-submit
Each character in the target passage gets its own <span> so it can be recolored on every keystroke. A second script runs the timer and, once the user’s word count and final-word length match the target, auto-submits the form with the elapsed seconds:
userContent.addEventListener("input", () => {
if (interval === null) {
interval = setInterval(displayTimer, 10);
}
let userSpaces = countSpaces(userContent.value);
let targetSpaces = countSpaces(targetString);
if (userSpaces >= targetSpaces) {
let userWords = userContent.value.split(' ');
let targetWords = targetString.split(' ');
let userLastWord = userWords[userWords.length - 1];
let targetLastWord = targetWords[targetWords.length - 1];
if ((userWords.length === targetWords.length && userLastWord.length === targetLastWord.length)
|| userWords.length > targetWords.length + 1) {
let form = document.getElementById('testForm');
let totalTime = minutes * 60 + seconds + milliseconds / 1000;
const timeInput = document.createElement('input');
timeInput.type = 'hidden';
timeInput.name = 'time';
timeInput.value = totalTime;
form.appendChild(timeInput);
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
clearInterval(interval);
interval = null;
}
}
});Results
End to end the app does what it should. You pick a difficulty, type the passage, get a score, and show up on the leaderboard. Unit tests cover the model classes and both service implementations.
What stuck with me is how much of a Spring Boot project is plumbing. Controllers hand data to templates, JPA annotations map entities to tables, and the actual interesting logic is a small fraction of the code. If this project kept growing, the first two things I would revisit are the leaderboard’s in-memory sort and the per-character span approach on the frontend.