This week I went for a coffee with a friend. As we talked the topic of hobbies came up. I mentioned that I was having a lot of fun working on a toy project over the past week, and then I showed it to him.
Demo
Him: So you are dabbling in React? Me: No, this is just Rails. Him: Which libraries did you use? Me: None, this is as vanilla as it gets. Him: What!?
Backstory
For the past 12 months, each month I built an app within a strict time-box of 12 hours.
If the project went overtime, and wasn’t at most 4 hours away from completion, I’d abandon it. If a project failed, and a lot of them did in the beginning, I’d reflect what led to that and tried to fix it in the next one.
The time-box really helped me make better decisions. At first all my apps failed because I either over-estimated my speed, or under-estimated the complexity and unknowns. But around the third one I got better at both. Then came the hard part - learning to strike a balance between quality work, functionality, and not obsessing over details.
Simpliki
Simpliki is the 12th app I’ve built.
It’s an app for practicing diaphragmatic breathing. I got the idea for it from my girlfriend. She uses a similar app in her mindfulness practice.
The name is a combination of “simple” and the Japanese word “iki”, which means “breath”.
Google Translate result for translating "breath" to Japanese
You can practice breathing with Simpliki at simpliki.app. And you can peruse the source-code on GitHub.
Starting from the center
The best way to finish a project is to start from the center and work your way from there. I focus on what makes the app work - what makes it what it is - and tackle everything else later. That way I can cut less important bits if I have to.
For Simpliki the center is the breathing exercise - the place where you practice diaphragmatic breathing - without that there is no Simpliki.
Simpliki's center
But I also need a way to get to the breathing exercise - an index screen or something similar.
Personally, when I want to relax and practice mindfulness I don’t like to have too many options to choose from - it takes me out of the experience. So I decided to add a “home” screen with a single button that links to a random exercise.
Simpliki's two centers
My girlfriend likes to practice specific exercises so I added an “Explore” button that leads to an index page with all exercises.
An app can have many centers interconnected with different features. I think of the exercise and the home screen as two separate centers, and the index is just a feature between them.
I learned this from Christopher Alexander's design theory. The most apt analogy I found is in a paper that compares this design principle to patterns of a carpet.
Simpliki's two centers and the features between them
Now that I have an idea what I'm building I'll create a breadboard so that I can work out problems on paper instead of experimenting in code. I skipped this step for Simpliki as it was, well, simple. But the breadboard would look something like this.
The breadboard
This also helps me figure out the domain language - what models I have and what their attributes and methods are called.
The domain
Design
I had a clear vibe I wanted to go for - Endel + Headspace. I like Endel's minimalist and monochrome style - it has a mystical, simple, and concise vibe that I wanted to replicate. And I find the organic, whimsical, animated shapes and play button from Headspace calming - exactly the vibe I want for a mindfulness app.
Endel
Headspace
So I decided to combine the two for Simpliki. I'd use Endel's color-scheme and the animated organic shapes from Headspace.
Simpliki's UI
Starting a new project
I decided to use Tailwind because I like working with it. And I chose SQLite as the database because it's the simplest one to use out of the presets. I honestly don't even need a database for this app, but it's easier to use one than not.
rails new simpliki --css=tailwind
Then in that project I generated my models and a controller
bin/rails g model Exercise name:string
bin/rails g model Exercise::Step exercise:belongs_to action:string duration:integer position:integer
bin/rails g scaffold_controller Exercise
I removed everything that I didn't need, added a "home" action to the controller, a view for that action, and pointed the root route to that action.
Over the past 5 years I've completely changed my mind about fixtures. I used to think that they are finicky and convoluted compared to factories. But I've come to like them more after I've seen the downsides of factories over the years - they are extremely slow, it's hard to manage what will get created or you have to define every single record you want to create in every single test.
My favorite advantage of fixtures is:
bin/rails db:fixtures:load
This will take all my fixtures and put them in my current (development or production) database.
Now I don't need to create a way to add or edit Exercises, or add and edit them manually through the console, before I can start working on the show page. I can just load in a few exercises and use them. Best of all, I get to see and use the exact same data that's in my tests - no more trying to figure out what went wrong, I can just open the same Exercise and see.
Then I started working on the views. At first I just added links between the pages, added a header that said "Simpl息" on each page, added a plain HTML "Practice" button on the exercise show page, and created a very simple index page that shows a list of exercises.
The ruby tag
Simpliki's logo
Then I remembered reading about the HTML ruby element which is used to show the pronunciation of characters. I thought it would be a nice way to show where the app's name comes from.
It took about a minute to add and style the "iki" - plain HTML is amazing.
Animating SVGs
Next I wanted to animate the play button. I've done similar things before so I was quite confident that I could do it quickly.
Demo of the circle animation
The basic idea is to have an SVG with a single path that consists of 16 points with Bezier curves that form a circle. The number of points determines how organic and uneven the circle will look in the end - 8 is too blocky, 24 is too smooth, 16 is just right.
Basic SVG circle with 16 points and a Bezier curve
Then, every 10ms I change the position of each point slightly and adjust their Bezier curve points to animate the circle.
To make it undulate - move like there are ripples going across it - I can pull or push one point after another in or out, and then adjust the Bezier curve points to make it look hand-drawn.
That's an old computer science trick I learned in college. It sounds complicated, but it's quite straightforward.
There is just one thing you have to know - if you plot x = cos(a) and y = sin(a) where a is any value between 0 and 2PI you'll get a circle of radius 1. Then to get any size circle you just multiply everything by the radius you want.
constNUM_POINTS=16constRADIUS=100functiongeneratePoints(time){constcirclePoints=[]for (letindex=0;index<NUM_POINTS;i++){// Calculate at what angle the point is supposed to beconstangle=(point.index/NUM_POINTS)*Math.PI*2// Convert the angle to coordinatesconstx=RADIUS*Math.cos(angle)consty=RADIUS*Math.sin(angle)circlePoints.push([x,y])}returncirclePoints}
The most basic circle
To form waves, I can change the radius for each point using any function that also includes COS and SIN. That way each point moves in its own little circle which, when put all together, looks like a wave. And to make it consistent the SIN and COS have to include the current time.
constNUM_POINTS=16constRADIUS=100constRIPPLE_FREQ=0.5// determines how quickly the waves moveconstRIPPLE_AMPLITUDE=5// determines how big the waves areconstcirclePoints=[]functiongeneratePoints(time){constcirclePoints=[]for (letindex=0;index<NUM_POINTS;i++){// Calculate at what angle the point is supposed to beconstangle=(point.index/NUM_POINTS)*Math.PI*2// Generate a new radius// the values were completely arbitrarily chosen// you could also just use sin(time) + cos(time)// this just looks nice to meconstrippleY=Math.sin(time*RIPPLE_FREQ+angle)constrippleX=Math.cos(time*RIPPLE_FREQ*0.7+angle*1.3)constripple=(rippleY*0.6+rippleX*0.4)*RIPPLE_AMPLITUDE// Convert the angle to coordinatesconstx=RADIUS*Math.cos(angle)+(ripple*Math.cos(angle))consty=RADIUS*Math.sin(angle)+(ripple*Math.sin(angle))circlePoints.push([x,y])}returncirclePoints}
Just adding this one line will already add a fade effect to all page transitions. Then to move individual elements around, like the logo, I have to add it a view-transition-name CSS property with a unique name.
#logo{view-transition-name:logo;}
After that the browser animates the transitions on its own.
Demo of the logo moving with page navigation
For the explore page I wanted to add an animation where the list slides in from the bottom of the page. Luckily this can be done with a few lines of CSS.
This tells the browser to apply a slide-to-top-from-out-of-view animation for any new exercises list that appear.
Demo of the exercise list sliding in from the bottom of the screen
There is one caveat, View Transitions aren't supported on Firefox. This only works on Chromium and WebKit based browsers. As one of the 2% of global Firefox users I don't mind, the app still works without the transitions.
The breathing animation
Now that I have an animated circle I want to create a breathing animation to help people pace their breath.
The circle should grow when you have to breathe in, and shrink when you have to breathe out.
Demo of the breathing animation
To animate this I need to create three circles - a maximum indicator, a minimum indicator and a progress indicator.
The maximum indicator is static and just has an opacity of 10%. The minimum indicator is mostly static after an initial animation that moves it in place. And the progress indicator constantly shrinks and grows as needed.
But rendering one animated SVG at 60 FPS is already quite taxing, and synchronizing three undulation animations would be a headache. Luckily plain HTML saves the day again with SVG symbols.
In an SVG you can define any set of paths or shapes as a symbol and give it an ID. Then you can render the same symbol in any other SVG in the same HTML with the use tag. You can even change the fill and stroke properties on the use tag to get different versions of the same shape. And when you update the shape in the symbol it immediately reflects in all other SVGs using that symbol.
<!-- Defines a symbol "foo" as a circle --><svgxmlns="http://www.w3.org/2000/svg"><symbolid="foo"viewBox="0 0 100 100"><circlecx="50"cy="50"r="50"/></symbol></svg><!-- Renders a red circle with a black stroke --><svgxmlns="http://www.w3.org/2000/svg"viewBox="0 0 100 100"><usehref="#foo"fill="red"stroke="black"stroke-width="2"></use></svg><!-- Renders a blue circle --><svgxmlns="http://www.w3.org/2000/svg"viewBox="0 0 100 100"><usehref="#foo"fill="blue"></use></svg>
circle_id=dom_id(@exercise,:circle)# Renders an animated circle as a symbol with the id circle_idanimated_circle(symbol: circle_id,class: "hidden")+# Renders the minimum indicatoranimated_circle(use: circle_id,class: "absolute inset-0 z-30 w-full select-none",stroke: "black",stroke_width: 2,name: "exercise-circle-min",data: {exercise_target: "minCircle"})+# Renders the progress indicatoranimated_circle(use: circle_id,class: "absolute inset-0 z-20 w-full select-none",data: {exercise_target: "progressCircle"})+# Renders the maximum indicatoranimated_circle(use: circle_id,class: "absolute inset-0 z-10 w-full select-none opacity-10",name: "exercise-circle-max",data: {exercise_target: "maxCircle"})
With that out of the way I now have to animate the progress circle. I read about the Web Animations API a few weeks ago and it seemed like just the right tool for the job.
It was my first time using that API and I think I hit every edge case there is with it. Long story short, don't use fill forwards and instead manually set your desired state with the onfinish callback. For some reason, fill forwards applied a new CSS rule every time an animation ran. After a few minutes I had a hundred animation style blocks in my Inspection window, all overriding each other. Also, remove any transitions on the animated element as that will make the animation wonky in Firefox (but works fine everywhere else) - the browser will handle the transition for you.
After I figured that out, the rest was straightforward
With the animation done I wanted to also add an audio cue that you should breathe in or out.
At first I thought that the simplest way to add sound was to play some sound file - like a gong or a flute - at different playback speeds for different durations, and pitch it down to cue and exhale.
This turned out to be a dead end. I could change the playback speed but at 0.5x playback the sound of a gong starts to sound like Hell's bells.
I created a scaffold class with the interfaces I wanted to have and asked ChatGPT to implement the class so that it generates "a nice and relaxing sound that inspires breathing in a mindfulness app I'm working on, kind of like Endel or Headspace". After a bit of back and forth I had something that I thought was decent.
But it didn't work on my iPhone... Turns out that Apple disables Web Audio playback if your ringer is set to silent, even though the volume is up - odd choice, but ok.
You can listen to the sound at simpliki.app, just pick any exercise and start it. If you are on an iPhone be sure to set your phone OFF silent.
Scroll snapping
As I was working on the breathing animation I noticed that I forgot to record if you are supposed to breathe with your nose or mouth, so I had to go back, add a migration, and update the fixtures.
Then I thought that showing a short description about the exercise would be nice so I added ActionText, and a few fixtures for it.
bin/rails action_text:install
But now that the exercise page could have an overflow and users would have to scroll I wanted the scrollbar to snap if you were close to the top of the screen. That way the exercise circle would always be in focus and you can't accidentally scroll away.
Once again HTML, or rather CSS, saves the day - this time with scroll-snap.
I just added the snap-y and snap-mandatory Tailwind classes to the container, and snap-start to the container of the exercise circle - that's it. Now the browser snaps to the top when you scroll near the top of the page.
Demo of the page snapping to the top when you scroll near the top
Full text search
Since I had four hours left at this point I decided to add full-text search to the index page.
They have a "pagination" controller that goes and fetches the HTML of the next page, parses it to get the contents of itself from the next page, and then adds that contents to the current page.
No need for special routes or turbo-stream handlers, you just render the paginated page like always and let the controller do the rest. So I tried to recreate that.
First I tried to fetch the next page and parse it using DOMParser to get the next page's document. My first stab worked quite well.
async#fetchNextPageDocument(){consturl=this.nextPageLinkTarget.hrefconstresponse=awaitfetch(url,{headers:{"Accept":"text/html",}})if (response.ok){returnnewDOMParser().parseFromString(awaitresponse.text(),"text/html")}else{console.error("Failed to fetch next page:",response.status)returnnull}}
Then all I had to do was append the children from the new document to the current one
And it worked like a charm. I just render the next page like always and let the controller do the rest.
Demo of the pagination controller loading in more exercises
Auth, create, update & delete
Since I still had time left I decided to implement CRUD for exercises. Here I decided to use the new auth generator. It gave me Users and Sessions, all I now needed to do was add views.
I quickly wrote a nested form Stimulus controller, some CSS to style the inputs and called it a day.
Demo of the sign in process and CRUD actions
Conclusion
I wanted to share how I built Simpliki because everyone I show it to seems to think that it's some SPA made with the latest technology trend when in reality it's just a vanilla Rails app that uses a few browser APIs.
Modern browsers are capable of a lot that just a few years ago required cutting edge technology and all the complexity that comes with it.
But I also wanted to sing the praises of time-boxing your work and working on toy-projects. It helped me develop a healthier way to think and break an old habit.