The Learnings of Leitner
02-09-2021
Introduction
I’ve had lots of side projects in the time I’ve been programming, but one thing I’ve never done (outside of my day-to-day job) is a project post-mortem. Now that I’m releasing my side-project, Leitner, which I’ve been working on for the past few months (when life hasn’t got in the way), I thought this would be a perfect opportunity to do a quick post-mortem.
Now for those reading this who don’t know what a post-mortem is, I will happily give a quick overview. Software post-mortems come in various forms and occur at various points. Sometimes they happen after a software outage happened or they can often occur at the end of a project. Regardless of the timings, they occur for one very important reason:
To observe what went well and what didn’t go so well, so that we can repeat the good and not repeat / be aware of the bad.
Now that we know what a post-mortem is, I’d like to give you an overview of Leitner.
What is the Leitner system studying technique?
The Leitner system was invented by Sebastian Leitner in the 1970s as a method of “spaced repetition learning”. Spaced repetition is a technique of using flashcards where the student studies newer and more difficult cards more frequently than older and easier cards.
In the Leitner system the user has a number of boxes, with each box representing a level of difficulty. These boxes can be numbered, with the lowest number being the box containing the more difficult / recent cards and the highest numbered box containing the easiest cards. All cards begin in the lowest box. The student then studies the lowest box, and the cards that the user gets correct get promoted to the next level box; the cards that the student gets wrong will be demoted if at a higher level box or remain in the lowest level box. When studying a user chooses which boxes they will be studying, with the highest level boxes being studied less frequently.
It’s probably best to demonstrate this with an example.
Example
Let’s say that there’s a 3 box system with three cards, A, B and C. All of these cards start in Box 1, as they are new. The boxes are going to be studied in an unrealistic order for demonstration purposes. A student of the Leitner system should study Box 1 more frequently than Box 2 and Box 2 should be studied more frequently than Box 3.
The student decides to study Box 1. The student gets cards A and B correct and they get card C incorrect. Cards A and B get promoted to Box 2; card C remains in Box 1, as this is the lowest level box. The student then decides to study Box 2, which only contains cards A and B. The student gets card A correct but Card B incorrect. Card A is promoted to Box 3; Card B is demoted to Box 1.
Then, the student studies Box 3, which only contains card A. The student gets this correct, but card A is in the highest level box and so remains in Box 3; if the student got this card incorrect it would be demoted to Box 2.
So let’s recap the journey of the cards:
Card A: Box 1; Correct –> Box 2; Correct –> Box 3; Correct –> Box 3;
Card B: Box 1; Correct –> Box 2; Incorrect –> Box 1;
Card C: Box 1; Incorrect –> Box 1;
That is how the Leitner studying system works, and really the core fundamentals of how the software works too! Fairly simple: if card answers are correct they get promoted; if card answers are incorrect they get demoted. The only major difference in the software is that cards are organised into quizzes, which is just a way of grouping cards. It is important to note the positions of a card in a quiz doesn’t effect the position of the same card in another quiz. E.g. if card A in quiz Y is in box 3, it doesn’t mean that card A in quiz Z will be in box 3. It could be in Box 1!
The Tech Stack
This project has really been about getting a MERN stack project on my GitHub and portfollio, to prove that I am capable of creating software with that stack. It’s OK me saying I can do that, but I should probably have some proof. The technologies I’ve used for this project are:
- Language: TypeScript
- Front-End Framework: React.js
- Server Framework: Express.js
- Database: MongoDB
- Unit Testing Framework: Jest
- End-To-End Testing: TestCafe
The Lessons
Now that you understand the Leitner system and the tech stack of the project, let’s begin walking through the lessons I have learnt whilst working on this project. I must admit, a lot of these lessons of the post-mortem might be common sense in a professional setting, however it is easy to forget some common sense things when you get lost in a full-stack personal project that took a bit longer than expected.
Whilst some are common sense, for the spirit and quality of a post-mortem, I feel it is still worth documenting.
So, let’s begin discussing the lessons I have learnt from this project.
1. If you know what features are coming, plan for them.
When starting this project, I had a good idea on how to get started. I drew out some nice designs and diagrams for how all the different components would look and work. Then, after making all the initial components, designs and software for the CRUD operations for the cards and quizzes, I got to the point where I needed to program how the quizzes and boxes worked when studying.
Unfortunately, I hadn’t actually created designs or plans for this. Rather than taking a step back to create these documents, I thought I’d carry and essentially wing it. Now, the result isn’t a bad result, but I know it would have been a lot easier if I took a step back and created plans.
Software is much easier to create when you have plans on how different parts interact and what the front-end should look like rather than winging it.
2. Writing tests as you go along makes the project a lot more easier to develop than writing tests after the fact.
Having previously been a QA Engineer I understand the value of good testing. I also understand as a developer how easy it is to get carried away with developing feature and not writing tests. I’m not here to advocated that test-driven development – writing tests prior to writing a component of code - is the best way to develop software; what I am saying is writing tests as you go along makes life easier: you know if what you’ve just written is broken and because your head is already in that area of the software, you have a good idea of what the inputs, outputs and edge cases are.
There came a point in this project where I had developed a fair amount of features without writing tests, an issue I was aware of at the time but chose to ignore. This meant I had to spend a large chunk of time solidly going back and writing tests for the different parts of the software. This really damaged the momentum of the project because with each component I was writing tests for I had to go back and get context.
If I wrote tests as I wrote components, the momentum wouldn’t have been damaged and I would have been confident with each component I wrote that it was working as expected.
3. Don’t do redundant tasks!
Before starting this point let’s get some basic definitions down: - Unit Test: A unit test is a test that tests one “unit” of software, be that a function, a class or a module, so that it can be determined that that “unit” works. - Integration Test: An integration test on the other had checks that different units of software interact with each other correctly.
Both of the above are fine testing methods, and indeed both are quite valuable. In this personal project, I blended both integration and unit tests, which is fine! However with this being a personal project, each moment of time I spent programming is time I could have spent doing something else: be that making new features, tackling tech debt, creating designs, relaxing with other hobbies or spending time with my family and friends. How do these two points really link?
Well, as well as those two testing types I was also including end-to-end (E2E) tests (see below), these essentially served as integration tests, albeit much slower to run. In this project I blended unit, integration and E2E tests when really I could have saved my time without integration tests. It also didn’t help that unit tests and integration tests were used within close proximity to each other (e.g. one file would have unit AND integration tests in), which actually made debugging tests much more difficult.
All in all I would say: make sure the tasks you’re doing aren’t made redundant by another task and save yourself time.
4. End-to-end tests – whilst slow to run – will save a huge amount of time.
Now on to E2E tests! E2E tests are tests that use the system as if the user were using it and works through a user flow from one end to the other, hence the name! So an example would be E2E testing that the user can create a quiz: it would spin up a web browser, click the buttons on the screen and type in the inputs – like an actual user! Ideally you don’t want too many of these as they take much longer to run than unit tests. E2E tests take minutes to run; unit tests take fractions of a second.
E2E tests are really useful - despite their run time - in a personal project! Rather than manually checking that “yes I can still make a quiz”, I could just run the E2E tests which would take just as long; the beauty is whilst they are running I could be doing other tasks! Therefore, by running these E2E tests I would save myself a huge amount of time. Unfortunately, I developed these for the project quite late so I didn’t get to have the massive time savings. Once I did write the E2E tests and used them, I really did save myself a massive amount of time.
So, whilst E2E tests are slow and not feature development, creating them will be a massive boon to your time and project.
5. Keep up momentum.
I started Leitner in March 2021, the middle of the Covid-19 pandemic; I am releasing version 1.0.0 in August 2021, 6 months later. A lot of changes in life have happened between the start of the project and version 1.0.0., I couldn’t really help losing momentum, but I wish I tried harder to keep up momentum.
I didn’t solidly program every night or even every week on this project, which is partially why the project took so long to complete, but I wish I had programmed every week on it. With each break I took – no matter the reason – it took a long time to get my head back into the project, which then made the time programming much less valuable, as I was spending time working out what task I was doing, why I was doing it and what the next task would be.
This is a short point: but keep up momentum to keep your limited time working on a project valuable.
6. Make sure you’re having fun; chunk dull tasks with fun tasks.
Another thing to make sure of when working on a side-project is to make sure you’re having fun. There will be times when obviously tasks are dull and difficult to complete; fortunately when developing Leitner there were few of these tasks to do. The mistake I made is that I chunked dull tasks together: so for a few weeks I would being updating documentation and writing tests.
Ideally what I should have done was update documentation (a task I was finding dull) and follow that with a more fun task, like developing a feature. That way I wouldn’t have large chunks of time doing mundane things and it would be much easier to keep momentum going.
7. Make sure a minimum viable product is actually a minimum viable product.
This lesson seems fairly self explanatory: a minimum viable product (MVP) should be a MVP and nothing more. There was a point in this project where the quizzes and cards were creatable and able to be studied. At that point I should have released version 1.0.0 as the MVP, and save the box mechanic for a version 1.1.0.
What I decided to do, however, was tell myself that there needed to be the box system in place. Partially that is true, it wouldn’t be Leitner without the boxes. But was the software usable and valuable without the boxes? I’d say so. But regardless, I locked myself into development until the box system was ready, despite that being more than an MVP.
Therefore the lesson is: when a software is usable and valuable, even if you feel more should be done, release it as a MVP. A minimum viable product really is a product that has enough features to be usable and valuable.
8. Keep pull requests small for easier self-code-reviews!
Critiquing your own work is difficult and self-reviewing code is no different. It’s good practice to keep pull requests (PRs) small and contained to only one context so that it’s easy for other people to review your code. When it comes to small personal projects that you will be self-reviewing it is no different. Keep the PRs small, so you can review them easily and effectively.
9. Structure project with server and client as two separate projects, e.g. have unit tests for server and unit tests for client. DECOUPLE THE TWO!
I might be incorrect about whether this is a best practice or not: but an issue I had is that my client and server
for this project were actually quite coupled together. Not in terms of how they ran, but in terms of development.
They essentially shared one root directory, which is where the unit tests were ran from and all the different
configurations such as environment variables and tsconfig were set.
For the most part this was actually fine and it worked, apart from the odd issue here and there which were minor annoyances rather than mission critical issues. I did think to myself though, what if in a future project I wanted a React front-end but a say Java back-end. It wouldn’t make sense for the configurations to be passed down I’d just want the Java config in the back-end and the JavaScript config in the front-end. Therefore, in future projects, to add some separation of concerns, I think I will structure my project as if the server and client were two different projects in terms of configurations, with the server config in the server directory and the client config in the client directory, with incredibly limited shared config.
10. Add issues to GitHub for tasks, so others can work on them!
Whilst working on this project I was keeping a personal Trello board of tasks to do, some of them I knew were not being released for version 1.0.0. Rather than adding these to GitHub as issues for others to complete, I kept them on the Trello board until nearly the end of the project, thinking no one would really look at the issues or indeed contribute to fixing them.
To my surprise, when I created the issues someone gladly fixed some of them! I’m adding this partially as a thank you to Jacoberson who has tackled a few of the issues, and as a reminder to myself to add issues to GitHub early so that someone may fix them.
If you don’t ask, the answer is always no! In this instance, the “asking” is just making the issues public!
11. The project was fun!
The final lesson is a much more positive one than the others. I found that doing a full-stack personal project was really fun, interesting and rewarding!
I know it sounds cliché but creating a product that I myself would use and hopefully others would use is a really rewarding thing, and it really is why I started programming as a hobby (before becoming a full-time developer!).
Combining that with doing full-stack work was rewarding, interesting and fun because I was constantly learning new things and recapping on others. I don’t think there was a single week with programming Leitner that I didn’t hit an issue and learn how to solve it. I was constantly learning and getting familiar with some technologies that I hadn’t touched for a while – along with those I was already familiar with.
For me that really is what programming is all about: learning and having fun with technology. It really is a bonus that I’ve created a piece of software, that I will use and I hope you enjoy using too.