<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vineet J Karni</title>
    <description>The latest articles on DEV Community by Vineet J Karni (@vineetjk).</description>
    <link>https://dev.to/vineetjk</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1196105%2Fcf0267f4-2871-4bc4-a153-50f6837a1d4a.jpeg</url>
      <title>DEV Community: Vineet J Karni</title>
      <link>https://dev.to/vineetjk</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vineetjk"/>
    <language>en</language>
    <item>
      <title>CloudSea: a sunrise forecast for Nandi Hills</title>
      <dc:creator>Vineet J Karni</dc:creator>
      <pubDate>Sat, 10 Oct 2026 10:12:35 +0000</pubDate>
      <link>https://dev.to/vineetjk/cloudsea-a-sunrise-forecast-for-nandi-hills-that-the-people-at-the-top-grade-every-morning-3b89</link>
      <guid>https://dev.to/vineetjk/cloudsea-a-sunrise-forecast-for-nandi-hills-that-the-people-at-the-top-grade-every-morning-3b89</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Every weekend, thousands of people in Bangalore set an alarm for 3:30 AM and ride 60 km north to Nandi Hills. The hope is to stand at 1,478 m as the sun comes up over a sea of clouds, with the plains buried under white and only the neighbouring hilltops sticking out. Some mornings you get that. Many mornings you get grey haze, a clear view of fields, or fog so thick you can't see the railing. Whether it's worth going is a coin you toss at bedtime.&lt;/p&gt;

&lt;p&gt;I live in Electronics City, 80 km and an hour and a half from the hill, so I can't go every weekend to find out. Instead I built &lt;strong&gt;CloudSea&lt;/strong&gt;, which makes the call the night before and lets the people who &lt;em&gt;are&lt;/em&gt; at the top tell it whether it was right.&lt;/p&gt;

&lt;p&gt;At 8:30 PM every night CloudSea publishes &lt;strong&gt;GO, MAYBE or SKIP&lt;/strong&gt; for tomorrow's sunrise, with the chance of a cloud sea, the sunrise time, when to leave your area, and what to carry. At sunrise, people at the summit can upload one photo; an open model rates it and they confirm the rating with one tap. At 8:15 AM the app grades its own call against the airport's dawn reports and those photos, in public.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqg64r3s571in783ckt25.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqg64r3s571in783ckt25.png" alt="CloudSea on a phone: tonight's call, the photo upload, and the scoreboard" width="800" height="561"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The live app on 10 Oct. The call shown is a daytime test run (issued 2:44 PM); the nightly job publishes the real one at 8:30 PM.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live app:&lt;/strong&gt; &lt;a href="https://cloudsea-web.onrender.com" rel="noopener noreferrer"&gt;https://cloudsea-web.onrender.com&lt;/a&gt;. Install it as a PWA on your phone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How it works and the backtest:&lt;/strong&gt; &lt;a href="https://cloudsea-web.onrender.com/#/how" rel="noopener noreferrer"&gt;https://cloudsea-web.onrender.com/#/how&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The call is short enough to read half asleep, and it's written by Gemma in English, Kannada and Hindi:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyqtfn7sccyxv65zq26cc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyqtfn7sccyxv65zq26cc.png" alt="The call card in Kannada, written by Gemma 4" width="716" height="568"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Sharing is one tap. The server draws the card for WhatsApp and Instagram stories:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxhtm7svdtvlab1brudpz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxhtm7svdtvlab1brudpz.png" alt="Share card: SKIP, 8% chance of a cloud sea, Sunday 11 Oct" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;



&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
  IEM["Airport reports&amp;lt;br/&amp;gt;(Iowa Env. Mesonet)"] --&amp;gt; N["nightly job&amp;lt;br/&amp;gt;20:30 IST"]
  OM["ECMWF run&amp;lt;br/&amp;gt;(Open-Meteo)"] --&amp;gt; N
  N &amp;lt;--&amp;gt;|features → probability| T["TabPFN"]
  N &amp;lt;--&amp;gt;|4 tools → call text| G["Gemma 4 E2B&amp;lt;br/&amp;gt;on CPU (Ollama)"]
  N --&amp;gt; DB[("Postgres")]
  P(("people at&amp;lt;br/&amp;gt;the summit")) --&amp;gt;|photo| A["API"] --&amp;gt;|rate| G
  A &amp;lt;--&amp;gt; DB
  GR["grader&amp;lt;br/&amp;gt;08:15 IST"] --&amp;gt; DB&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Everything runs on &lt;strong&gt;Render&lt;/strong&gt; in Singapore, from one &lt;code&gt;render.yaml&lt;/code&gt; Blueprint:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a static PWA and a FastAPI service;&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;private service running Ollama with Gemma 4 on CPU&lt;/strong&gt;, with its weights on a persistent disk;&lt;/li&gt;
&lt;li&gt;two &lt;strong&gt;cron jobs&lt;/strong&gt;, one at 8:30 PM for the call and one at 8:15 AM for grading;&lt;/li&gt;
&lt;li&gt;Postgres.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cron jobs reach Gemma over Render's private network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TabPFN makes the forecast.&lt;/strong&gt; The training set is small: about 2,000 labelled days, and only two fog seasons have the forecast-model features that existed on the night. TabPFN is a tabular foundation model made for that size, with no tuning: rows in, probability out. It only sees what existed at 8:30 PM: airport reports up to 7:30 PM and the 00 UTC ECMWF run of that day. A test scrambles everything after the cut-off and fails if any feature changes. It caught a real bug when I moved the cut-off and forgot the rain window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gemma 4 does four jobs with one small model.&lt;/strong&gt; It's the E2B variant at 4-bit, a 4.6 GB download:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The call.&lt;/strong&gt; An agent with four tools (&lt;code&gt;get_forecast&lt;/code&gt;, &lt;code&gt;get_sunrise&lt;/code&gt;, &lt;code&gt;get_leave_by&lt;/code&gt;, &lt;code&gt;get_conditions&lt;/code&gt;) writes four short sentences.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A guardrail on the call.&lt;/strong&gt; It checks every number against the tool results, &lt;em&gt;and that each number sits in the right place&lt;/em&gt;. The sunrise time has to be in the sunrise sentence, the leave-by time in the leave sentence, and the probability on "chance of a cloud sea". A draft that fails is retried; after three failures a template goes out. The placement check came from reading &lt;a href="https://dev.to/sansk_ya/golden-hour-a-go-outside-planner-where-a-small-local-gemma-cannot-invent-numbers-3ol5"&gt;golden-hour: a go-outside planner where a small local Gemma cannot invent numbers&lt;/a&gt; by &lt;a class="mentioned-user" href="https://dev.to/sansk_ya"&gt;@sansk_ya&lt;/a&gt;. It shows how "is this number somewhere in the facts" passes swapped values, and mine did too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Photo rating.&lt;/strong&gt; Gemma rates each summit photo into strict JSON: cloud sea, partial, clear, or socked in. It answers evidence questions first (are nearby trees hazy? is the ground visible below?), which took agreement with my reference labels from 3/11 to 7–8/11.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kannada and Hindi.&lt;/strong&gt; Every number must match the English before a translation is shown.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Open data feeds it:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Airport reports for Kempegowda airport (VOBL) back to 2008, from the Iowa Environmental Mesonet.&lt;/li&gt;
&lt;li&gt;One archived ECMWF run per day since March 2024, from Open-Meteo.&lt;/li&gt;
&lt;li&gt;Drive times from OSRM on OpenStreetMap.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why open matters here
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The model sits next to the data, with no per-token bill.&lt;/strong&gt; The whole stack is about $150 a month on Render, and most of that is the Gemma box. A nightly call takes 12 seconds on 2 CPUs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When Gemma got something wrong, I could see exactly what it was sent and what it said back.&lt;/strong&gt; For a SKIP at 8% it once wrote "GO, 0%" three times in a row. The guardrail threw all three away. Reading the raw exchange is how I found the guardrail's own bug: the degree sign in "17°C" was escaped into a code ending in &lt;code&gt;00b0&lt;/code&gt;, which quietly made "0" and "00" allowed numbers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One small open model covers text, tool calls, vision and two Indian languages&lt;/strong&gt;, so one private service is enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Photos stay on my server.&lt;/strong&gt; They're private by default, and all EXIF, including GPS, is stripped before storage. No photo goes to a third-party model.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The honest part: an airport is not a mountain top
&lt;/h2&gt;

&lt;p&gt;There is no weather station on Nandi Hills. The nearest one that reports every half hour is the airport, 19 km away and 563 m lower. So "was there a cloud sea at Nandi at sunrise?" has to be estimated. I defined it as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;at least two of the airport's dawn reports show fog, visibility under 1,500 m, or cloud at or below 1,000 ft;&lt;/li&gt;
&lt;li&gt;no rain;&lt;/li&gt;
&lt;li&gt;the air at summit height isn't saturated. This last part separates fog below you from standing inside the cloud.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then I checked it against real photos. Wikimedia Commons has 94 photos of Nandi Hills whose camera clock says they were taken between 5 and 9 AM. On 31 of those mornings you can see the view below.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When the airport rule said "sea", the photos agreed 5 times out of 6.&lt;/li&gt;
&lt;li&gt;It caught 5 of the 9 cloud seas in the photos.&lt;/li&gt;
&lt;li&gt;It never fired on the 6 mornings when the summit was inside the fog.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's a small sample, labelled by me, so it's a sanity check, not proof.&lt;/p&gt;

&lt;p&gt;The proxy can't see how thick the layer is, valley fog north of the hill, or fog that forms after 7:30. That's why the app collects summit photos and grades itself every morning against both the airport and the photos. Once two or more people agree on what they saw, their answer replaces the airport's in the training data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Backtest on the 2025-26 fog season.&lt;/strong&gt; The model never saw these 151 nights; there was a cloud sea on 35% of them.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Brier (lower is better)&lt;/th&gt;
&lt;th&gt;ROC-AUC&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TabPFN, airport + ECMWF (live model)&lt;/td&gt;
&lt;td&gt;0.147&lt;/td&gt;
&lt;td&gt;0.87&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gradient-boosted tree, airport + ECMWF&lt;/td&gt;
&lt;td&gt;0.144&lt;/td&gt;
&lt;td&gt;0.87&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TabPFN, airport only&lt;/td&gt;
&lt;td&gt;0.177&lt;/td&gt;
&lt;td&gt;0.80&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gradient-boosted tree, airport only&lt;/td&gt;
&lt;td&gt;0.201&lt;/td&gt;
&lt;td&gt;0.72&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monthly climatology&lt;/td&gt;
&lt;td&gt;0.204&lt;/td&gt;
&lt;td&gt;0.70&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistence (tomorrow = this morning)&lt;/td&gt;
&lt;td&gt;0.223&lt;/td&gt;
&lt;td&gt;0.64&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hand rule (moist, calm, clear evening)&lt;/td&gt;
&lt;td&gt;0.250&lt;/td&gt;
&lt;td&gt;0.53&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ECMWF's raw dawn low-cloud forecast&lt;/td&gt;
&lt;td&gt;0.398&lt;/td&gt;
&lt;td&gt;0.55&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Going on all 43 Saturday and Sunday mornings would have meant 43 trips and 17 cloud seas. Going only when CloudSea said GO: 12 trips and 10 cloud seas.&lt;/strong&gt; Staying home on SKIP would have missed 1.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6issbf11fn16ydegt09c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6issbf11fn16ydegt09c.png" alt="Forecast error by model" width="800" height="511"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What I didn't expect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TabPFN and the gradient-boosted tree tie once the forecast-model features are in.&lt;/strong&gt; TabPFN's advantage shows where data is thin. Season by season, each season predicted only from earlier data, it had the better Brier score every time (0.208 vs 0.260 in 2022-23, trained on 547 rows).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The latest season was the easy one.&lt;/strong&gt; Earlier seasons scored AUC 0.72, 0.55 and 0.74, so I expect live skill below the headline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The probabilities are a bit timid.&lt;/strong&gt; Nights it gave 10–30% had a cloud sea only 3–7% of the time, and all 14 nights it put above 80% had one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F30b5hnes9fdzesii3t2q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F30b5hnes9fdzesii3t2q.png" alt="Calibration on the 2025-26 season" width="800" height="675"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gemma 4 E2B, same model and prompts on both machines:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;MacBook M2, 8 GB (Metal)&lt;/th&gt;
&lt;th&gt;Render 2c-8g (CPU)&lt;/th&gt;
&lt;th&gt;Render 4c-8g (CPU)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Generation&lt;/td&gt;
&lt;td&gt;22–28 tokens/s&lt;/td&gt;
&lt;td&gt;6.1 tokens/s&lt;/td&gt;
&lt;td&gt;6.4 tokens/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nightly call (agent, 4 tools)&lt;/td&gt;
&lt;td&gt;2–3 s&lt;/td&gt;
&lt;td&gt;11.8 s&lt;/td&gt;
&lt;td&gt;10.9 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rate one photo&lt;/td&gt;
&lt;td&gt;~4 s&lt;/td&gt;
&lt;td&gt;29.8 s&lt;/td&gt;
&lt;td&gt;30.3 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;the laptop&lt;/td&gt;
&lt;td&gt;$135/month&lt;/td&gt;
&lt;td&gt;$175/month&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Doubling the cores bought 5%, so the cheaper box is the right one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Taking it outside, remotely
&lt;/h2&gt;

&lt;p&gt;I couldn't get to the hill this weekend, and the app went live the day before the deadline, so nobody has used it at the summit yet. Instead I tested it against people who had already been up there and shared their photos.&lt;/p&gt;

&lt;p&gt;Wikimedia Commons has Nandi Hills photos whose camera clock places them at dawn. For 30 mornings between 2012 and 2020 you can see what the view below looked like. I ran CloudSea's model on each of those mornings, using only the airport reports from the evening before. It was the airport-only version, because the archived ECMWF runs start in 2024, and it was trained only on 2021–2025, so it had never seen any of these days.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It ranked the mornings well enough to be useful: &lt;strong&gt;ROC-AUC 0.71&lt;/strong&gt;. Cloud-sea mornings got 25% on average, the rest 12–15%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It said GO once, and that morning was a cloud sea.&lt;/strong&gt; It never said GO on a clear morning or one inside the fog.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It missed 4 of the 10 cloud seas with a SKIP.&lt;/strong&gt; Most fell in months it treats as off-season (March, May, August). On one there was nothing at the airport at all, probably valley fog it can't see. That is the gap the photo uploads are meant to close.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fouidtq80xp75fnptrk1a.jpg" alt="6 Jan 2017: fog fills the valleys, hills poke through" width="800" height="450"&gt;&lt;/th&gt;
&lt;th&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgkanczszygsuq4sps0yr.jpg" alt="13 Jan 2019: sunrise over the cloud sea, a crowd at the railing" width="800" height="600"&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;6 Jan 2017&lt;/strong&gt;, cloud sea. CloudSea: &lt;strong&gt;GO, 67%&lt;/strong&gt;. &lt;em&gt;&lt;a href="https://commons.wikimedia.org/wiki/File:Nandi_Hills,_Bangalore.jpg" rel="noopener noreferrer"&gt;Photo&lt;/a&gt; by RITWIK J, &lt;a href="https://creativecommons.org/licenses/by-sa/4.0" rel="noopener noreferrer"&gt;CC BY-SA 4.0&lt;/a&gt;&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;13 Jan 2019&lt;/strong&gt;, cloud sea. CloudSea: &lt;strong&gt;MAYBE, 48%&lt;/strong&gt;. &lt;em&gt;&lt;a href="https://commons.wikimedia.org/wiki/File:Nandi_Hills,_Bengaluru.jpg" rel="noopener noreferrer"&gt;Photo&lt;/a&gt; by Sidhant Soni, &lt;a href="https://creativecommons.org/licenses/by-sa/4.0" rel="noopener noreferrer"&gt;CC BY-SA 4.0&lt;/a&gt;&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq6dqbuumtusioj93nw0a.jpg" alt="6 Mar 2016: a classic sea of clouds at sunrise" width="800" height="365"&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe3684zsl8v4gl93xxsy5.jpg" alt="7 Jun 2016: the viewpoint inside the fog" width="800" height="593"&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;6 Mar 2016&lt;/strong&gt;, cloud sea. CloudSea: &lt;strong&gt;SKIP, 11%&lt;/strong&gt;, a miss. &lt;em&gt;&lt;a href="https://commons.wikimedia.org/wiki/File:Sun_rising_over_a_blanket_of_clouds,_Nandi_Hills,_Karnataka.jpg" rel="noopener noreferrer"&gt;Photo&lt;/a&gt; by Sri chakra pranav, &lt;a href="https://creativecommons.org/licenses/by-sa/4.0" rel="noopener noreferrer"&gt;CC BY-SA 4.0&lt;/a&gt;&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;7 Jun 2016&lt;/strong&gt;, inside the fog. CloudSea: &lt;strong&gt;SKIP, 2%&lt;/strong&gt;. &lt;em&gt;&lt;a href="https://commons.wikimedia.org/wiki/File:Nandi_hills,_Karnataka.jpg" rel="noopener noreferrer"&gt;Photo&lt;/a&gt; by Shravan Kamath94, &lt;a href="https://creativecommons.org/licenses/by-sa/4.0" rel="noopener noreferrer"&gt;CC BY-SA 4.0&lt;/a&gt;&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;This morning, 10 Oct.&lt;/strong&gt; Using only the evening of 9 Oct, the live model (airport + ECMWF) says &lt;strong&gt;SKIP, 7%&lt;/strong&gt;. The grader, run this afternoon on this morning's airport reports, says &lt;strong&gt;no cloud sea&lt;/strong&gt;: visibility 4 km in mist, no low ceiling, summit-level humidity 64%. One real call, and it was right. The first scheduled call goes out at 8:30 PM tonight, and the scoreboard at &lt;a href="https://cloudsea-web.onrender.com/#/scoreboard" rel="noopener noreferrer"&gt;https://cloudsea-web.onrender.com/#/scoreboard&lt;/a&gt; fills from 8:15 AM tomorrow, right or wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that went wrong
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A sentence can be numerically right and still wrong.&lt;/strong&gt; One draft said "Leave Electronics City by 03:40 from the parking area". Gemma had pulled "parking" from the what-to-carry list. The prompt now fixes the order of four sentences.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Translation needed a glossary.&lt;/strong&gt; Without one, Gemma's Kannada turned "cloud sea" into "cloudy weather" and read "Sun" (Sunday) as "evening".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Visibility in miles.&lt;/strong&gt; The archive stores visibility in statute miles, so a reported "1500 m" comes back as 1497 m and slipped under a "&amp;lt; 1500 m" rule. I found it by reading example days, not from a metric.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The forecast-model archive I first planned to use would have flattered the model.&lt;/strong&gt; Open-Meteo's historical-forecast API stitches together the first hours of each run, which is close to what actually happened. I switched to the archive of single runs, which only goes back to March 2024.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/vineetjk/CloudSea" rel="noopener noreferrer"&gt;https://github.com/vineetjk/CloudSea&lt;/a&gt;&lt;/strong&gt; (Apache 2.0)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;make help&lt;/code&gt; lists every step, from data download to backtest to deploy.&lt;/li&gt;
&lt;li&gt;71 Python tests (label, leakage, guardrail, translation, EXIF stripping, API) and 24 frontend tests.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/vineetjk/CloudSea/blob/main/BUILD_LOG.md" rel="noopener noreferrer"&gt;BUILD_LOG.md&lt;/a&gt; has every decision, dead end and number with a timestamp.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Collect summit photos through the fog season and retrain on photo-confirmed labels.&lt;/li&gt;
&lt;li&gt;Web push for "GO tonight".&lt;/li&gt;
&lt;li&gt;More hills with the same problem: Skandagiri, Makalidurga.&lt;/li&gt;
&lt;li&gt;Check the Electronics City drive time and the gate opening time on a real trip. Both are marked as estimates in the app.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI assistance
&lt;/h2&gt;

&lt;p&gt;I built this with Claude Code, an AI coding agent, as my pair. I wrote the brief and its rules: open models and open data doing the real work, no closed model APIs, no invented data, and an honest account of what the proxy can't see. The agent wrote much of the code, did much of the research, and proposed the cloud-sea label and the GO/SKIP thresholds from the data when I asked it to run every phase; the reasoning is in BUILD_LOG.md. Every number above comes from code in the repo.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Partner tech: Gemma 4 (Google), TabPFN (Prior Labs), Render, Entire. Weather data by &lt;a href="https://open-meteo.com/" rel="noopener noreferrer"&gt;Open-Meteo.com&lt;/a&gt;; airport reports via the Iowa Environmental Mesonet. A forecast, not a guarantee.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>I built a city on my GitHub profile. It grows every time I commit.</title>
      <dc:creator>Vineet J Karni</dc:creator>
      <pubDate>Wed, 07 Oct 2026 19:12:23 +0000</pubDate>
      <link>https://dev.to/vineetjk/i-built-a-city-on-my-github-profile-it-grows-every-time-i-commit-1944</link>
      <guid>https://dev.to/vineetjk/i-built-a-city-on-my-github-profile-it-grows-every-time-i-commit-1944</guid>
      <description>&lt;p&gt;My GitHub profile is a city now. It's called Commit City, it looks a lot like Bengaluru, and it gets a little bigger every time I commit.&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://github.com/vineetjk" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Favatars.githubusercontent.com%2Fu%2F42497152%3Fv%3D4%3Fs%3D400" height="460" class="m-0" width="460"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://github.com/vineetjk" rel="noopener noreferrer" class="c-link"&gt;
            vineetjk (Vineet J Karni) · GitHub
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            
    Best things need time!
. vineetjk has 80 repositories available. Follow their code on GitHub.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgithub.githubassets.com%2Ffavicons%2Ffavicon.svg" width="32" height="32"&gt;
          github.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F11pyu4e0dtn1jdtcytru.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F11pyu4e0dtn1jdtcytru.png" alt="Commit City in the daytime: a metro train on an elevated line, a half-built town hall with cranes, glass towers and apartment blocks named after my repos, and a street with a BMTC bus, autos and a coffee shop" width="800" height="458"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Earlier this week I turned my contribution graph into a &lt;a href="https://dev.to/vineetjk/i-turned-my-github-contribution-graph-into-a-metro-train-4m50"&gt;Namma Metro train&lt;/a&gt;. It ran on a viaduct past a flat skyline that didn't mean anything. A metro line needs a city around it, so I built one, and I made every piece of it out of my GitHub data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in the city
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Each named building is one of my public repos, with the repo's name on a sign on its roof.&lt;/li&gt;
&lt;li&gt;Repos I pushed to in the last four months are glass towers. The ones from the last two years are apartment blocks with black water tanks on the roof, like most apartment blocks in Bengaluru. Older repos are small houses with red Mangalore-tile roofs.&lt;/li&gt;
&lt;li&gt;The more I committed to a repo this year, the taller its building is and the more of its windows are lit at night.&lt;/li&gt;
&lt;li&gt;The metro train is still my contribution graph: one coach per week and one window per day.&lt;/li&gt;
&lt;li&gt;Every pull request I merged this year is a BMTC bus, with the repo and the PR number on its LED board. Each bus drives near the week it was merged, and the newest one waits at the bus stop.&lt;/li&gt;
&lt;li&gt;My DEV posts fly over the city as banners towed by small planes.&lt;/li&gt;
&lt;li&gt;Every week owns the stretch of road under its coach, and that stretch is as busy as the week was. A week with lots of commits has a crowded footpath and a cyclist. A week with none is nearly empty.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next to the metro station there's a darshini, one of the stand-up coffee and tiffin places Bengaluru runs on, and the signs are in Kannada and English.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkpx4r566fu44f3giv7q5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkpx4r566fu44f3giv7q5.png" alt="Street level: the BMTC bus for PR #10 of Alpha-Fin at the bus stop, an auto-rickshaw, a cyclist, Shri Commit Darshini with its menu board, and the stairs up to the metro" width="799" height="212"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  It grows
&lt;/h2&gt;

&lt;p&gt;The city starts small and fills in as my all-time contributions add up. The first building cost 25 contributions, and each one after that costs 5 more than the one before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buildingCost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;buildingBase&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;buildingStep&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;buildings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;spent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buildings&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxBuildings&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;spent&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;buildingCost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buildings&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;contributions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;spent&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;buildingCost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buildings&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;buildings&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the city filled in quickly at first, and now it grows more slowly. When I took these screenshots I had 925 contributions since 2018, which buys 15 buildings. The 16th is a construction site with a crane and a board that said it needed 75 more. By the time I finished writing this post it needed 67. The busiest repos get the plots next to the metro station, and the rest line up further back along the line, so the ride goes from the outskirts into downtown.&lt;/p&gt;

&lt;p&gt;Some things only show up at milestones: the darshini at 100 contributions, autos at 300, the BMTC buses at 500 and the planes at 600. There's a new tree for every 20.&lt;/p&gt;

&lt;p&gt;The biggest one is the town hall, Commit Soudha, a mock of Bengaluru's Vidhana Soudha. Work on it starts at 800 contributions and it opens at 1,500. In these screenshots it's 18% built, so you see a blueprint of the whole building with the walls going up inside it, two cranes and a site board. The real Vidhana Soudha has "Government work is God's work" written over its entrance. Mine says ಕಮಿಟ್ ಕೆಲಸ ದೇವರ ಕೆಲಸ, "commit work is God's work".&lt;/p&gt;

&lt;p&gt;Here's the same city at 300, 1,000 and 2,000 contributions. The last two are still ahead of me:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgv0ebt317wxmt62ekr9e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgv0ebt317wxmt62ekr9e.png" alt="The city at 300 contributions with only a blueprint of the town hall, at 1,000 with its walls going up between cranes, and at 2,000 finished and floodlit at night" width="800" height="844"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The city never shrinks. Every run saves the highest all-time total it has seen, so a repo going private or a count coming back a bit lower doesn't knock buildings down.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sky follows Bengaluru
&lt;/h2&gt;

&lt;p&gt;The light changes with the time in Bengaluru: dawn from 05:30, day from 07:00, dusk from 17:45 and night from 19:00. At night the windows light up, the streetlights come on, the cars get headlights and the cranes get red warning lights. Your GitHub theme doesn't change the sky. It only changes the frame around it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F28w9vxr8cootw4wfbor2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F28w9vxr8cootw4wfbor2.png" alt="Commit City at dawn, during the day, at dusk and at night" width="800" height="461"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The ride
&lt;/h2&gt;

&lt;p&gt;Like the metro panel before it, the city loops every 90 seconds. It rests on Commit Street, fades back a year, and then the camera rides along the line to today, past every week of the year.&lt;/p&gt;

&lt;p&gt;The city is drawn in three layers that move at different speeds. The far skyline moves at 30% of the camera's speed, the buildings at 75%, and the line and the street at full speed. That's most of what makes it feel like a real place instead of a flat picture. This is the last few seconds of the ride, arriving downtown:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6yivt3hntn19lnyi707i.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6yivt3hntn19lnyi707i.gif" alt="The camera riding into downtown past the town hall's cranes and into Commit Street station" width="720" height="412"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Still one SVG
&lt;/h3&gt;

&lt;p&gt;GitHub doesn't run JavaScript in a README, so like everything on my profile, the city is an SVG loaded through an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tag and animated with CSS. A Node script draws it from my data on GitHub Actions and commits it. It comes to about 270 KB and around 3,000 shapes. Anything that repeats along the street, like pillars, kerbs, hedges and streetlights, is an SVG pattern, so a longer street doesn't cost more.&lt;/p&gt;

&lt;p&gt;The far layer moves by a fraction of the camera's offset. The buildings need one more step: they have to line up with the street exactly when the camera rests on Commit Street, so their offset is measured from there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;near&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;panAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;panAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;last&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;PARALLAX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mid&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;A&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;world&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="na"&gt;far&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;panAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;PARALLAX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;far&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;u&lt;/code&gt; is the week in focus and &lt;code&gt;A&lt;/code&gt; is where Commit Street is. Each of these becomes a CSS keyframe animation with the same timing as the train, so everything stays in step.&lt;/p&gt;

&lt;h3&gt;
  
  
  Timing the buses
&lt;/h3&gt;

&lt;p&gt;A bus can't drive forever. It has to loop, and if it jumps back to the start while the camera is looking at it, you see it teleport. So every vehicle drives a stretch of road around its spot, once per 90-second loop, and the loop is timed so the vehicle is in the middle of its stretch at the moment the camera passes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** A vehicle loop that is mid-stretch at time `at` (seconds), its spot being (x, y). */&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;vehicle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;extra&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;attrs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(((&lt;/span&gt;&lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;DURATION&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;DURATION&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;DURATION&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;DURATION&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;DURATION&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;g class="mv &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;R&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;L&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" style="animation-delay:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;n&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;s"&amp;gt;&amp;lt;use href="#&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cycle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cyc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;R&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;L&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" x="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;n&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;" y="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;n&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/g&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;at&lt;/code&gt; comes from the camera's keyframes. The jump back happens 45 seconds later, when the camera is on the other side of the year. Walkers don't need any of this. They stroll a few steps one way, turn around and come back, so their loop has no jump at all.&lt;/p&gt;

&lt;p&gt;It works. When the camera reaches July, the buses for the Alpha-Fin PRs I merged that week are right there under the gantry signs:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3oz0e7rqsox6ujxz4vui.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3oz0e7rqsox6ujxz4vui.png" alt="Mid-ride in July: a convoy of BMTC buses for Alpha-Fin pull requests on both sides of the road, under the Alpha-Fin and Vyuha-Network signs, past tile-roof houses for my older repos" width="800" height="458"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;India drives on the left, so traffic on the far side of the road heads right and traffic on the near side heads left.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kannada in an SVG
&lt;/h3&gt;

&lt;p&gt;An SVG in a README can't load web fonts, so every font has to be embedded. Noto Sans Kannada is far too big for that, so I cut it down to the glyphs of the nine Kannada signs in the city. That's 18 KB. Kannada isn't monospaced like the rest of my text, so I measured each sign's width in Chrome once and keep the numbers in a table to lay the signs out.&lt;/p&gt;

&lt;h3&gt;
  
  
  The same picture every time
&lt;/h3&gt;

&lt;p&gt;Everything that looks random, like which windows are lit, where the trees go and who's walking where, comes from a seeded random generator. The same data always draws the same SVG, byte for byte, so when nothing has changed, the workflow has nothing to commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  GitHub's scheduler runs late
&lt;/h2&gt;

&lt;p&gt;The sky depends on the workflow running when the light changes, and that turned out to be the weak spot. In its first two days the workflow had about 30 scheduled runs. GitHub started eight of them, every one late. On the day the city went live, the evening redraws, due at 17:50 and 19:05, started at 22:53 and 23:01. If I hadn't started one by hand, the city would have stayed in daylight until almost eleven at night. Scheduled workflows are best effort, and GitHub drops some of them when Actions is busy.&lt;/p&gt;

&lt;p&gt;Runs started by a push, an issue or the API start within seconds. Only the schedule is unreliable. If you build something like this, don't count on the cron to run on time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stock market is still there
&lt;/h2&gt;

&lt;p&gt;The $VJK stock market from &lt;a href="https://dev.to/vineetjk/i-ipod-my-github-profile-you-can-buy-shares-in-me-2mio"&gt;my first post&lt;/a&gt; still runs right under the city, so you can still buy shares in me. Its price board is in the city too, hanging at Commit Street station.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Commit City is at the top of &lt;a href="https://github.com/vineetjk" rel="noopener noreferrer"&gt;my profile&lt;/a&gt;. The code lives in the same repo, mostly in &lt;a href="https://github.com/vineetjk/vineetjk/blob/master/scripts/lib/render/city.js" rel="noopener noreferrer"&gt;&lt;code&gt;scripts/lib/render/city.js&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://github.com/vineetjk/vineetjk/tree/master/scripts/lib/render/city" rel="noopener noreferrer"&gt;&lt;code&gt;scripts/lib/render/city/&lt;/code&gt;&lt;/a&gt;. Running &lt;code&gt;node scripts/preview.js --phases&lt;/code&gt; draws the city in all four lights, so you can see it before you push anything.&lt;/p&gt;

&lt;p&gt;My next goal is 1,500 contributions, so the town hall can open.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>github</category>
      <category>svg</category>
      <category>githubactions</category>
    </item>
    <item>
      <title>I turned my GitHub contribution graph into a Metro train</title>
      <dc:creator>Vineet J Karni</dc:creator>
      <pubDate>Tue, 06 Oct 2026 18:45:30 +0000</pubDate>
      <link>https://dev.to/vineetjk/i-turned-my-github-contribution-graph-into-a-metro-train-4m50</link>
      <guid>https://dev.to/vineetjk/i-turned-my-github-contribution-graph-into-a-metro-train-4m50</guid>
      <description>&lt;p&gt;My GitHub contribution graph is a metro train now.&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://github.com/vineetjk" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Favatars.githubusercontent.com%2Fu%2F42497152%3Fv%3D4%3Fs%3D400" height="460" class="m-0" width="460"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://github.com/vineetjk" rel="noopener noreferrer" class="c-link"&gt;
            vineetjk (Vineet J Karni) · GitHub
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            
    Best things need time!
. vineetjk has 80 repositories available. Follow their code on GitHub.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgithub.githubassets.com%2Ffavicons%2Ffavicon.svg" width="32" height="32"&gt;
          github.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Figwcufssortlx81jtsxh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Figwcufssortlx81jtsxh.png" alt="Commit Metro: one long Namma Metro train on an elevated viaduct at night, its driver's cab at the Commit Street platform" width="800" height="295"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A few days ago I &lt;a href="https://dev.to/vineetjk/i-ipod-my-github-profile-you-can-buy-shares-in-me-2mio"&gt;listed my GitHub profile on the stock market&lt;/a&gt;. The stock, $VJK, gets its value from my commits, but the commits themselves were still only a number in a panel. I wanted the contribution graph on the profile too, drawn as something other than green squares.&lt;/p&gt;

&lt;p&gt;Bengaluru's Namma Metro runs on long elevated viaducts across the city, so the graph became a metro line.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the graph becomes a train
&lt;/h2&gt;

&lt;p&gt;GitHub's graph is 53 columns by 7 rows: a column for every week, a square for every day. Turn it on its side and it's a train.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every coach is a week. There are 53 of them.&lt;/li&gt;
&lt;li&gt;Every coach has seven windows, Sunday to Saturday.&lt;/li&gt;
&lt;li&gt;A window lights up with that day's commits. No commits, it stays dark. A busy day, it glows bright gold.&lt;/li&gt;
&lt;li&gt;The oldest week rides at the tail. This week's coach is right behind the driver's cab, and today's window pulses.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My busiest repos hang on sign gantries over the line, each one above the coach of the week I committed to it the most. The line ends at Commit Street, the interchange where the green line carries $VJK, with a small LED board showing the live price.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ride
&lt;/h2&gt;

&lt;p&gt;The panel loops every 90 seconds:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It rests on today, with the driver's cab at the Commit Street platform.&lt;/li&gt;
&lt;li&gt;It fades, goes back a year, and picks up the tail of the train.&lt;/li&gt;
&lt;li&gt;The train runs along the viaduct while the camera moves along it a little faster, from the oldest coach toward the newest.&lt;/li&gt;
&lt;li&gt;Under each repo gantry the train slows down and the camera zooms in. Between gantries it speeds up and the camera zooms out.&lt;/li&gt;
&lt;li&gt;It brakes into Commit Street, and the loop starts again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Passing under a gantry:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frrnsiyh7od0m1ia0wxjz.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frrnsiyh7od0m1ia0wxjz.gif" alt="The train slowing down as March passes under the vfd-explorer gantry, then speeding up toward tripppyyy" width="720" height="265"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Arriving at Commit Street:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F36gcptzs3h0g60yzrjce.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F36gcptzs3h0g60yzrjce.gif" alt="The head of the train pulling into the Commit Street platform, next to the green line train and the $VJK price board" width="720" height="265"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Zoomed out at full speed, months of coaches go by:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0yoi2wvn1rlkdqtt6x0d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0yoi2wvn1rlkdqtt6x0d.png" alt="The camera zoomed out at full speed, with coaches from March to September on screen" width="800" height="295"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It follows your GitHub theme, so light mode gets a daytime version:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fquzhpbcwt20r5jevvlhx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fquzhpbcwt20r5jevvlhx.png" alt="Commit Metro in light mode" width="800" height="295"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  It took a few tries
&lt;/h2&gt;

&lt;p&gt;The first version gave every week its own 7-coach train, with a coach for each day. The camera panned along a row of them, and they didn't move. It looked like a camera driving past parked trains.&lt;/p&gt;

&lt;p&gt;So I fixed the camera and let the trains run through. That looked better, but now the stations had nowhere to go, because a station can't move along with a train.&lt;/p&gt;

&lt;p&gt;The next version moved both, and that idea stayed: the camera moves faster than the trains, so they run forward while it overtakes them. Then I put gaps between the trains, because 53 trains nose to tail looked like one endless train.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdyceaglw7j2qtvwq0zcf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdyceaglw7j2qtvwq0zcf.png" alt="An earlier version: separate 7-coach trains, one per week, running on the same track" width="800" height="295"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And then the obvious question: why are 53 trains sharing one track? One long train fixed that, and it maps more directly onto the graph too, one coach per week.&lt;/p&gt;

&lt;p&gt;The last change was the stations. A 53-coach train stopping with a coach from March at the platform looks wrong, because a real train stops with the driver at the front of the platform. So repos became sign gantries over the line, the train only slows down under them, and the one real stop is Commit Street.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;h3&gt;
  
  
  It's still just an SVG
&lt;/h3&gt;

&lt;p&gt;GitHub doesn't run JavaScript in a README. Like everything else on my profile, Commit Metro is an SVG loaded through an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tag and animated with CSS. A Node script redraws it from my contribution data every time the profile's GitHub Actions workflow runs, so new commits show up on their own.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keeping each gantry above the right coach
&lt;/h3&gt;

&lt;p&gt;The train and the camera move at different speeds, so how does a gantry end up exactly above the right coach? It's built there. If week &lt;code&gt;u&lt;/code&gt; is the coach in focus, the camera looks at &lt;code&gt;world(u)&lt;/code&gt;, and the train has moved &lt;code&gt;world(u) - centre(u)&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;centre&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;coachX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;COACH&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// coach u, in the train's own coordinates&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;world&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;STRETCH&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;centre&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;W&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// where the camera looks&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;world&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;centre&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// how far the train has moved along the line&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;STRETCH = 3&lt;/code&gt; the camera covers three times the distance the train does. The gantry for week &lt;code&gt;i&lt;/code&gt; stands at &lt;code&gt;world(i)&lt;/code&gt;, so whenever the camera reaches it, coach &lt;code&gt;i&lt;/code&gt; is right underneath.&lt;/p&gt;

&lt;h3&gt;
  
  
  Speeding up and slowing down without a jolt
&lt;/h3&gt;

&lt;p&gt;Each stretch between gantries is two CSS &lt;code&gt;cubic-bezier&lt;/code&gt; curves, one that speeds up and one that slows down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;EASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;speedUp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`cubic-bezier(.3,&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="mf"&gt;0.3&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;,.7,&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;PEAK&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;slowDown&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`cubic-bezier(.3,&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="mf"&gt;0.3&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;PEAK&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;,.7,&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The slope at each end of a cubic-bezier is the speed at that end. So the two halves meet at the same top speed in the middle, and a stretch arrives at a gantry at the same slow speed the next one leaves with. Every stretch takes time in proportion to its length, so they all share one average speed. I sampled the motion to check it: under a gantry the train runs at 0.44 of its average speed, and it peaks at 1.62.&lt;/p&gt;

&lt;p&gt;The zoom is one more keyframe animation with the same percentages and the same curves, zoomed in at the gantries and out where the train is fastest. Because every animated layer shares that timing, the camera, the train and the zoom stay in step.&lt;/p&gt;

&lt;h3&gt;
  
  
  A first frame that makes sense
&lt;/h3&gt;

&lt;p&gt;Some places that show a README never play the animation, like screenshots and some apps, and people with reduced motion turned on don't get it either. So the resting frame, with the train at Commit Street, is also the starting state. Anything that doesn't animate still shows today.&lt;/p&gt;

&lt;h3&gt;
  
  
  The driver's cab
&lt;/h3&gt;

&lt;p&gt;The nose is one cubic Bézier curve from the roof down to the tip, extended back into depth for the top of the nose. The rear cab uses the same curve, flipped. The front one also has a small horn on the roof and two headlights whose beams fade out down the track.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F600xjl6ehqsu4dd6zvgr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F600xjl6ehqsu4dd6zvgr.png" alt="Close-up of the driver's cab: a bullet-train nose with a horn on the roof and two headlight beams, behind this week's coach" width="800" height="248"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Where the gantries come from
&lt;/h3&gt;

&lt;p&gt;GitHub's GraphQL API returns commits per repo per day:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="err"&gt;contributionsCollection&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;commitContributionsByRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;maxRepositories&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;isPrivate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;contributions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;occurredAt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;commitCount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I group each repo's commits by week and keep my six busiest public repos. Private repos are left out, so their names never show up. If two repos have the same busiest week, they share one gantry and its board lists both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Commit Metro is at the top of &lt;a href="https://github.com/vineetjk" rel="noopener noreferrer"&gt;my profile&lt;/a&gt;. The code lives in the same repo, mostly in &lt;a href="https://github.com/vineetjk/vineetjk/blob/master/scripts/lib/render/metro.js" rel="noopener noreferrer"&gt;&lt;code&gt;scripts/lib/render/metro.js&lt;/code&gt;&lt;/a&gt;, if you want a train of your own.&lt;/p&gt;

&lt;p&gt;If your graph has a lot of empty windows, like mine does in August, that's what it looks like on a train too.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>github</category>
      <category>svg</category>
      <category>css</category>
    </item>
    <item>
      <title>I IPO'd my GitHub profile. You can buy shares in me.</title>
      <dc:creator>Vineet J Karni</dc:creator>
      <pubDate>Tue, 06 Oct 2026 03:12:20 +0000</pubDate>
      <link>https://dev.to/vineetjk/i-ipod-my-github-profile-you-can-buy-shares-in-me-2mio</link>
      <guid>https://dev.to/vineetjk/i-ipod-my-github-profile-you-can-buy-shares-in-me-2mio</guid>
      <description>&lt;p&gt;My GitHub profile is a stock now. It's called &lt;strong&gt;$VJK&lt;/strong&gt;, it trades in rupees during Indian market hours, and anyone with a GitHub account can buy or sell it. My commits decide what it's worth.&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://github.com/vineetjk" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Favatars.githubusercontent.com%2Fu%2F42497152%3Fv%3D4%3Fs%3D400" height="460" class="m-0" width="460"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://github.com/vineetjk" rel="noopener noreferrer" class="c-link"&gt;
            vineetjk (Vineet J Karni) · GitHub
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            
    Best things need time!
. vineetjk has 80 repositories available. Follow their code on GitHub.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgithub.githubassets.com%2Ffavicons%2Ffavicon.svg" width="32" height="32"&gt;
          github.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F81br6vfze1qhhxpev3fe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F81br6vfze1qhhxpev3fe.png" alt="My GitHub profile as a trading screen: ticker, price, candlestick chart, buy and sell buttons, shareholders and fundamentals" width="800" height="1275"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this came from
&lt;/h2&gt;

&lt;p&gt;Over the last few weeks I kept running into posts here on DEV where people had turned their GitHub profile into something completely different. Giorgi built a &lt;a href="https://dev.to/georgekobaidze/i-turned-my-github-profile-into-a-cyberpunk-console-with-a-city-built-from-my-contributions-h4c"&gt;cyberpunk console with a city made out of his contributions&lt;/a&gt;. Someone turned theirs into a &lt;a href="https://dev.to/g00ds0ul/i-was-overwhelmed-so-i-turned-my-github-profile-into-a-roguelike-dungeon-17a8"&gt;roguelike dungeon&lt;/a&gt;, someone else into a &lt;a href="https://dev.to/rudratosh/readme-driven-development-except-the-readme-is-a-spacecraft-491h"&gt;spaceship mission console&lt;/a&gt;, and there's a &lt;a href="https://dev.to/ecoplse/i-turned-github-contribution-graphs-into-an-animated-minecraft-tree-10ma"&gt;Minecraft tree that grows from your commits&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I loved all of them. My own profile README at that point was empty. I had cleaned it up and never put anything back.&lt;/p&gt;

&lt;p&gt;I wanted to make one too, but I didn't want to build another city or another game. What I liked about those posts is that each profile says something about the person who made it. So I asked myself what mine would say. The honest answer is that I spend a lot of my free time reading about markets and finance.&lt;/p&gt;

&lt;p&gt;So I listed myself on the stock market.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's on the profile
&lt;/h2&gt;

&lt;p&gt;From top to bottom:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A ticker that scrolls the price, the fair value and the latest market news.&lt;/li&gt;
&lt;li&gt;A quote panel with the price, the day's open, high, low and volume, and how far the price can still move today.&lt;/li&gt;
&lt;li&gt;A daily candlestick chart. The dashed orange line is the "fair value" my commits give the stock.&lt;/li&gt;
&lt;li&gt;Four buttons: buy 5, buy 25, sell 5 and sell 25.&lt;/li&gt;
&lt;li&gt;The top shareholders and the latest trades.&lt;/li&gt;
&lt;li&gt;My GitHub numbers, shown like a company's fundamentals. My languages show up as "sector allocation".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It follows your GitHub theme, so light mode users get a light version.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuimp6pj93yizqkcucrqb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuimp6pj93yizqkcucrqb.png" alt="The quote panel in dark and light themes" width="799" height="110"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How a trade works
&lt;/h2&gt;

&lt;p&gt;There's no website and no server. Trading happens through GitHub issues.&lt;/p&gt;

&lt;p&gt;When you click &lt;strong&gt;BUY 5&lt;/strong&gt;, GitHub opens a new issue in my profile repo with the title &lt;code&gt;BUY 5 $VJK&lt;/code&gt; already filled in. You can change the number to anything from 1 to 25. Click &lt;strong&gt;Create&lt;/strong&gt;, and that's your order.&lt;/p&gt;

&lt;p&gt;Opening the issue starts a GitHub Actions workflow. It reads the title, checks the order and fills it. If it's your first trade, you get ₹10,000 of play money to start with.&lt;/p&gt;

&lt;p&gt;Every share you buy pushes the price up 0.15%, and you pay the price after your own push. $VJK listed at ₹55.10, so buying 5 shares at that price goes like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the price moves from ₹55.10 to ₹55.50&lt;/li&gt;
&lt;li&gt;you pay ₹55.50 × 5 = ₹277.50&lt;/li&gt;
&lt;li&gt;you're left with ₹9,722.50 in cash and 5 shares&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The bot then redraws the images on my profile, commits them, leaves a receipt on your issue and closes it. This is the receipt for that exact trade:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;✅ Filled · BUY 5 $VJK @ ₹55.50&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fill price (after your own impact)&lt;/td&gt;
&lt;td&gt;₹55.50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Order value&lt;/td&gt;
&lt;td&gt;₹277.50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Your trade moved the price&lt;/td&gt;
&lt;td&gt;▲ +0.73%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Your position&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5 shares, avg ₹55.50&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cash&lt;/td&gt;
&lt;td&gt;₹9,722.50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Portfolio value&lt;/td&gt;
&lt;td&gt;₹10,000.00 (+0.00%)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Selling is the same thing in reverse. Each share you sell pushes the price down 0.15%, and you get the price after your push. If you sold those 5 shares ten minutes later and nobody else had traded, the price would drop back to ₹55.10 and you'd get ₹275.50. You'd be down ₹2.&lt;/p&gt;

&lt;p&gt;That's on purpose. If you could buy and sell straight away without losing anything, someone would write a script to pump the price and dump it.&lt;/p&gt;

&lt;p&gt;A few other rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One order every 10 minutes per person.&lt;/li&gt;
&lt;li&gt;If you try to sell more than you have, it sells what you have. If you have nothing, the order is rejected.&lt;/li&gt;
&lt;li&gt;If you don't have enough cash, you get as many shares as you can afford.&lt;/li&gt;
&lt;li&gt;Bots can't trade, and neither can I.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Market hours and limits
&lt;/h2&gt;

&lt;p&gt;The market keeps Indian market hours: 09:15 to 15:30 IST, Monday to Friday. If you place an order outside those hours, it waits. The bot comments that it's queued, leaves the issue open, and fills it at the next morning's open. Indian brokers call this an after-market order (AMO). Closing the issue cancels it.&lt;/p&gt;

&lt;p&gt;The price can't move more than 10% from the previous day's close. When it hits the upper limit, buying stops for the rest of the day, and when it hits the lower limit, selling stops. Indian exchanges call this a circuit. Here it also stops one person with ₹10,000 from doubling the price in an afternoon.&lt;/p&gt;

&lt;p&gt;Prices move in steps of ₹0.05, and big amounts use Indian digit grouping, so you'll see ₹1,00,000.00 and not ₹100,000.00.&lt;/p&gt;

&lt;h2&gt;
  
  
  How my commits move the price
&lt;/h2&gt;

&lt;p&gt;This is my favourite part. Every weekday at 15:33 IST, right after the close, the workflow looks at my contributions from the last 30 days and works out a fair value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fairValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;contrib30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listingPrice&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;contrib30&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;baseline30&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;snap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tickPaise&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A normal month for me is about 30 contributions, and that's worth ₹100 a share. A month with nothing drops it to ₹50. The square root is there so one crazy day doesn't make the number explode, and the &lt;code&gt;+ 10&lt;/code&gt; keeps a quiet month from taking it to zero.&lt;/p&gt;

&lt;p&gt;Then the closing price moves 20% of the way toward that fair value, still inside the 10% limit. So during the day you decide the price, and at the close my commits pull it back toward what they say it's worth.&lt;/p&gt;

&lt;p&gt;Commits I make at night or on weekends still count. They stay in the 30-day window, so the next weekday close picks them up, and they keep counting for 30 days.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr6wm0hyaifzrw9dfd74l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr6wm0hyaifzrw9dfd74l.png" alt="The fundamentals panel: contributions, streaks, repos, stars, followers, merged PRs and languages" width="800" height="253"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  A chart from day one
&lt;/h3&gt;

&lt;p&gt;A brand new stock would start with an empty chart. So when $VJK listed, it backtested the previous 70 trading days: start at ₹100 and run every close as if the market had already existed, using my real contribution history.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffxd54yatczrydycicnir.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffxd54yatczrydycicnir.png" alt="The live ticker, quote and chart on listing day" width="799" height="561"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The chart is pretty honest about my year. I was busy in July, and the price climbed to ₹174. I didn't push anything public in August or September, and it slid all the way down to ₹50. Then I made 30 contributions on October 4th, and the last session before listing closed right at its +10% limit.&lt;/p&gt;

&lt;p&gt;So $VJK listed at ₹55.10, with a fair value of ₹101.25. If I keep committing, it should keep climbing for a while.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dividends
&lt;/h3&gt;

&lt;p&gt;Every time one of my pull requests gets merged, everyone holding shares gets ₹2 per share. It's a small reason to hold instead of flipping.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like with traders
&lt;/h2&gt;

&lt;p&gt;This one is from a local rehearsal. I ran two and a half weeks of made-up trading through the same code, with fake usernames, to see how the leaderboard and the chart look once people are actually trading. The real market starts empty.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feeq0grbzw4sfy8ghaygb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feeq0grbzw4sfy8ghaygb.png" alt="A rehearsal with fake traders: price, chart with volume, top shareholders and trade tape" width="800" height="842"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How it's built
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Everything on the profile is an SVG
&lt;/h3&gt;

&lt;p&gt;GitHub doesn't run JavaScript in a README, and it strips &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; tags. But an SVG loaded through &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; can carry its own CSS, including animations. So every panel is an SVG. The ticker scrolls, the market status dot pulses, and the buy and sell buttons have a shine that sweeps across them. A link can only wrap a whole image, which is why each button is a separate file.&lt;/p&gt;

&lt;p&gt;SVGs shown this way can't load anything external either, not even Google Fonts. So I embedded JetBrains Mono, cut down to just the characters I use. That comes to about 5 KB per weight. JetBrains Mono has no ₹ sign, so the rupee comes from a 628-byte cut of Noto Sans Mono, which has the same character width. Avatars get downloaded and embedded too.&lt;/p&gt;

&lt;p&gt;For dark and light mode, the README uses &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;picture&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;source&lt;/span&gt; &lt;span class="na"&gt;media=&lt;/span&gt;&lt;span class="s"&gt;"(prefers-color-scheme: dark)"&lt;/span&gt; &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;"assets/dark/quote.svg?v=f3a9c1d2e0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"assets/light/quote.svg?v=8b2e4a7c91"&lt;/span&gt; &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"100%"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"..."&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/picture&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;?v=&lt;/code&gt; part is a hash of the file. GitHub caches images, so when a panel changes, the README points at a new URL.&lt;/p&gt;

&lt;h3&gt;
  
  
  One workflow for everything
&lt;/h3&gt;

&lt;p&gt;The workflow runs at the opening bell, at the closing bell, and every time someone opens an issue with VJK in the title:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;47&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;3&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1-5"&lt;/span&gt; &lt;span class="c1"&gt;# 09:17 IST&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1-5"&lt;/span&gt; &lt;span class="c1"&gt;# 15:33 IST&lt;/span&gt;
  &lt;span class="na"&gt;issues&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;opened&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every run does the same job: fetch my GitHub stats, ring whichever bell is due, settle every open order, redraw the SVGs, regenerate the README, then commit and push. The receipts only go out after the push works, so nobody gets a receipt for a trade that isn't in the ledger.&lt;/p&gt;

&lt;p&gt;The ledger is just &lt;code&gt;data/market.json&lt;/code&gt; in the repo, and its git history is the full record of every trade.&lt;/p&gt;

&lt;h3&gt;
  
  
  Things I had to work around
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;GitHub cancels queued runs.&lt;/strong&gt; With a concurrency group, GitHub keeps one run going and one waiting, and cancels the rest. If ten people order at once, most of those runs never happen. So no run belongs to a single issue. Each run settles every open order, and whichever run survives does the work of the cancelled ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Queued runs start from an old commit.&lt;/strong&gt; A waiting run checks out the commit from when it was triggered, which can be behind what the previous run just pushed. Checking out the branch name instead fixes that, and if a push still gets rejected, the job resets to the new tip and runs the tick again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bursts and rate limits.&lt;/strong&gt; Every order costs two API calls, a comment and a close, and GitHub limits how fast a bot can create content. So each run settles at most 20 orders. If more are waiting, it triggers itself again with &lt;code&gt;workflow_dispatch&lt;/code&gt;, one of the few events a workflow's own token is allowed to start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Issue titles are user input.&lt;/strong&gt; Anyone can open an issue with any title. Here the title only ever gets matched against a regex in Node. It never goes into a shell command and never gets written into the README. rudratosh wrote &lt;a href="https://dev.to/rudratosh/your-github-profile-shows-your-follower-count-it-also-shows-attackers-your-whole-attack-surface-j7b"&gt;a good post on what profile automation workflows can expose&lt;/a&gt;, and it's worth reading before you build one of these.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No empty commits.&lt;/strong&gt; Rendering is deterministic. If nothing changed, the files come out byte for byte the same and the workflow skips the commit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bot's commits aren't mine.&lt;/strong&gt; They're authored by &lt;code&gt;github-actions[bot]&lt;/code&gt;, so trading can't inflate my contribution graph, and through it, my own stock price.&lt;/p&gt;

&lt;h3&gt;
  
  
  No dependencies
&lt;/h3&gt;

&lt;p&gt;It's plain Node with no npm packages. The market itself is a pure function of the current state, my stats and the time, which made it easy to test. 29 tests cover the rules, the rendering and a full run of the workflow script against fixtures.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Go buy some $VJK on &lt;a href="https://github.com/vineetjk" rel="noopener noreferrer"&gt;my profile&lt;/a&gt;. The market's open 09:15 to 15:30 IST on weekdays, and orders placed outside those hours wait for the next morning.&lt;/p&gt;

&lt;p&gt;If you want your own stock, the code is in the same repo. &lt;a href="https://github.com/vineetjk/vineetjk/blob/master/scripts/README.md" rel="noopener noreferrer"&gt;&lt;code&gt;scripts/README.md&lt;/code&gt;&lt;/a&gt; has the steps: change the symbol and the username, and set how many contributions a normal month is for you.&lt;/p&gt;

&lt;p&gt;I'm curious who ends up as the biggest shareholder.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>github</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A Food Companion I Built for My Friend Living in a PG</title>
      <dc:creator>Vineet J Karni</dc:creator>
      <pubDate>Sun, 04 Oct 2026 09:24:03 +0000</pubDate>
      <link>https://dev.to/vineetjk/can-prithvi-eat-this-a-food-companion-i-built-for-my-friend-living-in-a-pg-2nf</link>
      <guid>https://dev.to/vineetjk/can-prithvi-eat-this-a-food-companion-i-built-for-my-friend-living-in-a-pg-2nf</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-weekend-2026-10-01"&gt;Hacktoberfest Weekend Challenge: Build for a Friend&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;My friend &lt;strong&gt;Prithvi&lt;/strong&gt; lives in a PG (paying-guest accommodation). If you've lived in one, you know how food works there: the mess decides the menu, the kitchen isn't yours, and when the mess food doesn't work for you, the fallback is ordering in or grabbing something nearby.&lt;/p&gt;

&lt;p&gt;For most people that's just boring. For Prithvi it's hard, because her body has a few rules of its own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lactose bothers her, but only sometimes.&lt;/strong&gt; A little ghee is fine; a bowl of paneer in cream might not be.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;She has a sensitive gut.&lt;/strong&gt; Deep-fried, very spicy, or fizzy things can ruin her day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Her TSH is high (thyroid).&lt;/strong&gt; Soy and some millets are best limited.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;She's working on losing weight.&lt;/strong&gt; Fried snacks, sweets, and refined flour quietly add up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these is manageable on its own. The trouble is keeping all four in mind at once, every meal, with food she didn't cook. A mess dal is probably fine. The paneer butter masala on Sunday? The chole bhature someone orders for the floor? The masala chai? She can't always eat good food, so she needs a quick way to know which options are okay, which are okay in moderation, and how to make them better.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;AllergySafe Table&lt;/strong&gt;, a small app that answers one question in plain words: &lt;strong&gt;"Can Prithvi eat this?"&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ask&lt;/strong&gt; in plain language by typing, speaking, or sending a photo: &lt;em&gt;"Can Prithvi eat paneer butter masala?"&lt;/em&gt; or &lt;em&gt;"What can she have for breakfast?"&lt;/em&gt; The answer comes back as a clear verdict, the reasons, easy swaps, and better options. It can also be read aloud.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scan&lt;/strong&gt; a mess menu, a recipe, or a packaged-food label to see exactly which ingredients to watch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remix&lt;/strong&gt; a dish she loves into a version that suits her, swapping only what's needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan&lt;/strong&gt; dinners that suit her, with a shopping list, for the days she gets to cook or eat out with friends.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notes&lt;/strong&gt; keep track of what she loved and what didn't sit well, so her own experience builds up over time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Under the hood, two open pieces work together: &lt;strong&gt;Google's open-weight Gemma 2, running locally through Ollama&lt;/strong&gt;, works out what's in a dish the app has never heard of, and a set of &lt;strong&gt;open, readable food rules&lt;/strong&gt; decides whether that suits her. The model fills in the recipe; it never gets to say "safe".&lt;/p&gt;

&lt;p&gt;It doesn't try to be a doctor. It's a friend who remembers all four of her rules, every single time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Ask anything.&lt;/strong&gt; Her conditions are always visible, and the answer explains itself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr4wr8xznmgse2dawezqn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr4wr8xznmgse2dawezqn.png" alt="Asking whether Prithvi can eat paneer butter masala" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Ask&lt;/th&gt;
&lt;th&gt;Answer&lt;/th&gt;
&lt;th&gt;Dark mode&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fasy3iqsswk32ha41i00s.png" alt="Ask screen" width="780" height="1688"&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F385fntfdrf8lgjmz47ku.png" alt="Answer with swaps" width="780" height="1688"&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkj9dtw0vl88udjzs95w5.png" alt="Dark mode answer" width="780" height="1688"&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;A dish the app has never seen.&lt;/strong&gt; Dabeli isn't in its recipe list, so Gemma 2 (on the same laptop) guesses the ingredients. The rules spot the butter and suggest a swap, and the app shows exactly what Gemma guessed so Prithvi can correct it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmwpalxedo9ft316zhwi2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmwpalxedo9ft316zhwi2.png" alt="Gemma 2 guessing what's in dabeli, and the rules flagging butter" width="800" height="1362"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check a menu or a photo, remix a dish, plan dinners, and keep notes.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Ingredient check&lt;/th&gt;
&lt;th&gt;Recipe remix&lt;/th&gt;
&lt;th&gt;Dinner plan&lt;/th&gt;
&lt;th&gt;Food notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2lunqxd9guley7kqwr4d.png" alt="Scan result" width="780" height="1688"&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1v187ty1coin8ok8yztg.png" alt="Remixed recipe" width="780" height="1688"&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5bn8suqzqbc4w5jfqqxr.png" alt="Dinner plan" width="780" height="1688"&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2jmu97zn0yq13yaxli8c.png" alt="Food notes" width="780" height="1688"&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Her profile&lt;/strong&gt; explains what each condition means in everyday terms. It's editable, so the app also works for anyone else, including people with real food allergies like gluten or nuts.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxuynbh50mvlzhy46ew7e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxuynbh50mvlzhy46ew7e.png" alt="Prithvi's food profile" width="780" height="1688"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/vineetjk" rel="noopener noreferrer"&gt;
        vineetjk
      &lt;/a&gt; / &lt;a href="https://github.com/vineetjk/AllergySafe" rel="noopener noreferrer"&gt;
        AllergySafe
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A friendly food companion built for a friend.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;AllergySafe Table&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Can Prithvi eat this?&lt;/strong&gt; A friendly food companion built for a friend.&lt;/p&gt;
&lt;p&gt;My friend Prithvi is sometimes lactose intolerant, has a sensitive gut, has high TSH (thyroid), and is trying to lose weight. Keeping all four in mind every time we cook or order food is hard. AllergySafe Table answers the everyday question &lt;em&gt;"can she eat this?"&lt;/em&gt; in plain language, suggests easy swaps, and plans meals everyone can share.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Built for the Hacktoberfest Weekend Challenge: Build for a Friend (HF26).&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://allergysafe-table-web.onrender.com" rel="nofollow noopener noreferrer"&gt;https://allergysafe-table-web.onrender.com&lt;/a&gt; (free Render plan, so the first visit can take up to a minute to wake up. The hosted version uses the rules only; Gemma runs when you run the app locally.)&lt;/p&gt;
&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/vineetjk/AllergySafe/docs/screenshots/desktop-ask.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fvineetjk%2FAllergySafe%2FHEAD%2Fdocs%2Fscreenshots%2Fdesktop-ask.png" alt="Asking whether Prithvi can eat paneer butter masala on desktop" width="900"&gt;&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Features&lt;/h2&gt;
&lt;/div&gt;

&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;Ask in plain words&lt;/h3&gt;
&lt;/div&gt;

&lt;p&gt;Type, speak, or send a photo, for example &lt;em&gt;"Can Prithvi eat paneer butter masala?"&lt;/em&gt; or &lt;em&gt;"What can she have for breakfast?"&lt;/em&gt;. Each answer gives a clear verdict, the…&lt;/p&gt;&lt;/div&gt;


&lt;/div&gt;
&lt;br&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/vineetjk/AllergySafe" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;br&gt;
&lt;/div&gt;
&lt;br&gt;
&lt;br&gt;&lt;br&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/vineetjk/AllergySafe.git
&lt;span class="nb"&gt;cd &lt;/span&gt;AllergySafe
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv backend/venv &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; backend/venv/bin/pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; backend/requirements.txt
&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;frontend &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;install&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;cp &lt;/span&gt;backend/.env.example backend/.env   &lt;span class="c"&gt;# optional: add an ElevenLabs key for voice&lt;/span&gt;

&lt;span class="c"&gt;# Optional, for dishes the app doesn't know: local Gemma 2 (about 1.6 GB)&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;ollama &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ollama serve &amp;amp;
ollama pull gemma2:2b

./start.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The stack:&lt;/strong&gt; a Next.js 16 frontend, a FastAPI backend, Gemma 2 (2B) through Ollama for unknown dishes, ElevenLabs for voice, and a Render Blueprint that deploys both services.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rules, not guesses
&lt;/h3&gt;

&lt;p&gt;The heart of the app is a small, readable food engine, not a chatbot. When you ask about a dish, it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Works out what you mean.&lt;/strong&gt; It tells apart a dish ("can she eat rajma chawal?"), a list of ingredients, a request for ideas ("what can she have for breakfast?"), and a follow-up to its own question.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Looks up typical ingredients&lt;/strong&gt; from a library of about 80 common Indian and international dishes, from dal makhani and masala dosa to pizza.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checks each ingredient&lt;/strong&gt; against her profile. Allergies (gluten, nuts, and so on) are marked &lt;strong&gt;not safe&lt;/strong&gt;. Her conditions (lactose, gut, thyroid, weight goal) are marked &lt;strong&gt;in moderation&lt;/strong&gt;, because that's what they are: things to limit, not things that will hurt her.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explains itself:&lt;/strong&gt; which ingredient caused each flag, a swap for each one, and a couple of healthier options.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Gemma fills in the dishes the rules don't know
&lt;/h3&gt;

&lt;p&gt;A library of 80 dishes will never cover everything a PG mess or a street stall serves. Before Gemma, asking about dabeli or misal pav got "I don't know, tell me the ingredients", which is the last thing you want to type at the counter.&lt;/p&gt;

&lt;p&gt;Now the backend asks &lt;strong&gt;Gemma 2 (2B), running locally in Ollama&lt;/strong&gt;, one narrow question: &lt;em&gt;what usually goes into this dish?&lt;/em&gt; It replies in strict JSON, and the backend validates it (2 to 12 short ingredient names, or &lt;code&gt;{"known": false}&lt;/code&gt; for anything that isn't food, so "can she eat my laptop" doesn't get a recipe). Those ingredients then go through the &lt;strong&gt;same rules&lt;/strong&gt; as everything else.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;ingredients&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;open_llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;suggest_ingredients&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dish&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# Gemma 2: "what's in it?"
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ingredients&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FoodAssistant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_analyse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dish&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ingredients&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;assumed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# rules: "is it okay?"
&lt;/span&gt;    &lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ingredients_source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;                    &lt;span class="c1"&gt;# shown to her in the app
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That split is deliberate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The model only guesses ingredients.&lt;/strong&gt; It never decides whether something is safe. The verdict always traces back to a rule anyone can read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The guess is shown, not hidden.&lt;/strong&gt; The app says &lt;em&gt;"Ingredients guessed by gemma2:2b on this device: ..."&lt;/em&gt;, so Prithvi can see what it assumed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;She can correct it.&lt;/strong&gt; If Gemma is wrong, she types the real ingredients and the rules check again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A 2B model is wrong sometimes. It left the fried farsan off misal pav, and it thought malai kofta had meat. That's exactly why it doesn't get the final say. When Ollama isn't running, the app quietly falls back to asking her for the ingredients.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it runs:&lt;/strong&gt; Gemma runs on the same machine as the backend: a laptop on the same Wi-Fi, opened from a phone (&lt;code&gt;start.sh&lt;/code&gt; prints the address to open). The free Render demo has only 512 MB of RAM, too little for Gemma, so &lt;strong&gt;the hosted demo uses the rules only&lt;/strong&gt;. To get the Gemma step, run the app locally with the commands above.&lt;/p&gt;

&lt;h3&gt;
  
  
  Indian food needed its own care
&lt;/h3&gt;

&lt;p&gt;Paneer, curd, malai, and lassi count as dairy. Ghee is nearly lactose-free, so it isn't flagged for an intolerance. "Peanut butter" and "coconut milk" are not dairy, and "stir-fried" is not "deep-fried". When neither the library nor Gemma knows a dish, it says so and asks what's in it, instead of pretending. For photos without an image model, it asks for the dish name rather than guessing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Voice that sounds like a person
&lt;/h3&gt;

&lt;p&gt;Prithvi can ask by voice and hear the answer, which matters when your hands are full or you're standing at the mess counter. Questions are transcribed with &lt;strong&gt;ElevenLabs Scribe&lt;/strong&gt;. Answers are spoken with &lt;strong&gt;Eleven v4 Turbo&lt;/strong&gt;, which is only available over a realtime websocket, so the backend streams the text, collects the audio, and returns a single clip. If that ever fails, it falls back to Turbo v2.5, so she still hears a real voice and not a robotic one.&lt;/p&gt;

&lt;p&gt;iPhones block audio that starts after a network request, so the app starts a tiny silent clip at the moment you tap and reuses it for the real answer. Small detail, big difference on a phone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Built for a phone
&lt;/h3&gt;

&lt;p&gt;Prithvi will use this on her phone, so the whole app is a phone-first shell: a bottom tab bar where every section is always visible, a chat where only the conversation scrolls and the input stays put, and light and dark themes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Production details
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;The browser only talks to the Next.js server, which forwards &lt;code&gt;/api/*&lt;/code&gt; to FastAPI, so there are no cross-origin or mixed-content issues on phones.&lt;/li&gt;
&lt;li&gt;Her profile and notes are stored &lt;strong&gt;only in her browser&lt;/strong&gt;, never on a shared server.&lt;/li&gt;
&lt;li&gt;Endpoints that spend ElevenLabs credits are rate-limited, and every input has a size limit.&lt;/li&gt;
&lt;li&gt;If the server is slow or down, the app shows a clear error. It never invents a "safe" verdict.&lt;/li&gt;
&lt;li&gt;GitHub Actions runs the backend tests on Python 3.11 to 3.13 and builds the frontend on every push.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Built with an agent
&lt;/h3&gt;

&lt;p&gt;I paired with Claude Code, connected to DEV through DevRelay. This curated session shows the moment we found that Gemma was configured but never actually called, and how we wired it in so the model guesses ingredients and the rules make the call:&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Health details are personal.&lt;/strong&gt; Lactose, gut trouble, thyroid levels, and weight are things Prithvi shares with friends, not with an ad network or a model's training data. Gemma runs on the user's own machine, and it only ever sees a dish name, never her profile. The food checks use open rules, and her profile never leaves her browser. The only outside service is ElevenLabs, and only when she chooses to use voice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every answer can be checked.&lt;/strong&gt; A closed chatbot might say "a little cream is fine" with total confidence. In this app, every verdict traces back to a rule in a plain Python file that anyone can read. If something is wrong for her, like "curd is actually fine for me", it can be fixed in one line, by anyone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's free to run and easy to extend.&lt;/strong&gt; Nobody living in a PG should need a monthly AI subscription to figure out dinner. Gemma 2 2B is a 1.6 GB download that runs on an ordinary laptop with no internet and no API key. Anyone can swap in a bigger open model with one setting (&lt;code&gt;OLLAMA_MODEL&lt;/code&gt;), add their own dishes or their mess's menu, or add another condition, and share it back.&lt;/p&gt;

&lt;p&gt;A closed API would have given me a clever chatbot that decides on its own what's safe. An open model plus open rules gave Prithvi something she can trust, understand, and change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gemma:&lt;/strong&gt; Gemma 2 (2B), running locally through Ollama, works out the ingredients of dishes the app doesn't know, as validated JSON. The open food rules then decide the verdict, so the model fills in knowledge but never makes the safety call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render:&lt;/strong&gt; the whole app ships as a Render Blueprint (&lt;code&gt;render.yaml&lt;/code&gt;). It deploys the FastAPI backend and the Next.js frontend as two services, with the frontend proxying API calls to the backend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ElevenLabs:&lt;/strong&gt; voice questions use ElevenLabs Scribe for speech-to-text, and answers and recipe steps are read aloud with Eleven v4 Turbo over the realtime websocket, with a Turbo v2.5 fallback.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Built with open source and a lot of care for a friend who deserves good food.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>hf26challenge</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
