Back to Blog
Angularalgorithmsjson-serverDockerside-project

Building a lunch roulette with weighted probability and Angular Signals

March 4, 20267 min read

How I built Almorz.ar — a wheel that decides where to go for lunch using a smart weighting algorithm that factors in ratings, cost, and how long it's been since your last visit.

The problem: decision fatigue at noon

Every day at lunch, the same conversation: "where do you want to go?" — "I don't know, where do you want to go?" It's a surprisingly hard decision because it's low-stakes enough that nobody commits, but frequent enough that it drains energy. I wanted to automate the decision. But not with pure randomness — random is boring and ignores everything you already know about the options. I wanted something smarter: a wheel that learns from your history and naturally promotes the best options without feeling rigged.

The weight algorithm

Each place gets a computed weight that determines how large its segment is on the wheel — and therefore its selection probability: weight = ratingFactor × costFactor × agingFactor ratingFactor = averageRating / 5 — higher-rated places get more wheel space. costFactor = 1 / (1 + averageCost / 1000) — cheaper options are gently favored. agingFactor = 1 + (weeksSinceLastVisit × 0.2) — this is the interesting one. Every week a place goes unvisited, its weight grows by 20%. A place you haven't visited in a month gets roughly 2.5× its base weight. New places with no history start at a neutral weight of 1.0 until data accumulates. The last selected place is always excluded from the next spin — no immediate repeats.

The "✨ New" segment

One design challenge: how do you surface new places when established favorites dominate the wheel? A new place starts at weight 1.0 which might be tiny compared to a well-loved spot with a high rating. The solution is a dedicated "✨ New" segment whose weight equals the average of all eligible places. When the arrow lands on it, the wheel pauses briefly and a second automatic spin runs — but this time only among places that have never been visited. It gives new places their own fair chance without distorting the main wheel's probabilities.

SVG wheel and graph coloring

The wheel is drawn entirely in SVG. Each segment is an arc path computed from polar coordinates, with its size proportional to its weight relative to the total. This means the geometry reflects the actual probabilities — you can visually see which places dominate. Color assignment was interesting. The naive approach (sequential palette) often assigns similar colors to adjacent segments, which looks bad. Instead I use a graph coloring algorithm on a cycle: since the wheel is a cycle graph where every node is adjacent to exactly two others, it's always 2-colorable (or 3-colorable for odd cycles). The algorithm assigns colors greedily from a small palette, ensuring no two neighboring segments share the same color regardless of how many places are in the rotation.

State management with Angular Signals

This was my first real project using Angular Signals end-to-end, and they fit the problem well. The places list, the active tag filter, the spin state machine, and the computed wheel segments are all modeled as signal() and computed() values. The spin animation itself is a state machine with four states: idle → spinning → result → idle. The "✨ New" flow adds a fifth: new-pending between the first and second spin. Angular's ChangeDetectionStrategy.OnPush means the DOM only updates when signals change — which keeps animation frames smooth even as the wheel dynamics recalculate.

Persistence: json-server and the EBUSY trap

For a side project with no auth and a single user, json-server is a perfect fit: zero setup, full REST API, and atomic writes to a JSON file. The data model is intentionally flat — one places collection with visits embedded as an array inside each place, plus a settings singleton for global state like lastSelectedId. Deploying it revealed a sharp edge: json-server uses steno under the hood, which writes atomically via a temp-file rename. On Linux, if you bind-mount a single file into a Docker container, the kernel locks the inode and rename() fails with EBUSY. The fix is to mount the parent directory instead of the file, and pass the full path to json-server explicitly: yaml volumes: - /srv/almorz:/srv/almorz command: npx json-server /srv/almorz/db.json --port 3001 --host 0.0.0.0 This is one of those bugs that only surfaces in production and takes two minutes to fix once you understand the cause.

Written by Luna Lancuba

← More articles