case study
Lekh Patro — Nepali Typing, OCR and Calendar
featured- dictionary
- 763
- React
- TypeScript
- Vite
- Tailwind CSS
- Tesseract.js
- Transformers.js
- Capacitor
Type Nepali the way you already text it: 'kasto chha' becomes कस्तो छ as you type, IME-style, from a phonetic engine and a 763-word dictionary. It OCRs documents in the browser with nothing uploaded, translates English↔Nepali online or fully on-device, and carries a Bikram Sambat calendar.
The problem
Nepali speakers type Nepali in Latin letters. Not as a fallback but as the normal thing, because a Devanagari keyboard is one more layout to install and learn on a phone that already autocorrects English. So the app takes what people already type: write 'kasto chha' and it commits कस्तो छ as you go, IME-style, the way Gboard commits a word rather than making you finish a sentence and press convert. A phonetic engine does the transliteration and a 763-word dictionary supplies suggestion chips over the top of it, which is the part a purely phonetic engine cannot do on its own. Romanized Nepali has no fixed spelling, so the same word arrives half a dozen ways and the chips have to cover the variants rather than insist on one. What the dictionary does not reach, a searchable Devanagari cheat sheet does: every glyph is one tap to insert.
Nothing you upload leaves the browser
The Translate tab accepts a photo, a PDF, a DOCX or a TXT file, and none of them are uploaded anywhere; Tesseract.js does the OCR inside the browser. Translation is online by default because the good model is large, but an optional NLLB-200 model downloads once and runs fully on-device after that, so a document you would not paste into a web form never has to be. The whole app works offline from the first visit rather than the second: the service worker takes control of the page that installed it, so the shell and the OCR payload are cached on the visit that fetched them. That behaviour is one line of workbox config and it disappears silently: switching the plugin to prompt-style updates drops it without an error, so it is verified by grepping the built service worker for clientsClaim rather than trusted.
Why the festivals are tabulated
The calendar is two problems wearing one coat, and only one of them is solvable in general. Bikram Sambat months run 29 to 32 days on a pattern set by solar transits rather than a formula, so every implementation ships a lookup table; the conversion works across BS 2000–2090 because that is how far the table goes. Festivals are different: Dashain, Tihar, Teej and most of the rest fall on a tithi, a lunar day, which needs real lunar ephemeris plus the panchang convention about which sunrise a tithi counts against. Computing that in the browser would be a large bundle and a decent chance of landing a day out, and a Dashain on the wrong day is worse than no Dashain at all. So festivals are tabulated (36 months, BS 2081 to 2083) and the interface states the range it covers instead of rendering an uncovered month as one that happens to have no festivals in it. Holidays then refresh from an upstream almanac that re-scrapes daily, which is the single network call this app makes, and the calendar names the source of the month you are looking at.
Two guards, because BS 2084 lies convincingly
Nothing fetched at runtime has been reviewed, so live data has to prove itself twice. A month whose length disagrees with the conversion table is rejected, and so is a month with no festival names at all, which is what an unpublished year looks like. BS 2084 currently returns 31 days and zero festivals for a month whose first day is नयाँ वर्ष, and across the 36 bundled months the festival count runs 11 to 25 and is never zero, so zero is a signal rather than a guess. The generator applies the same suspicion at build time and drops any year where the two sources disagree: BS 2084 disagrees on five of twelve months, and shipping it would have put every festival from Jestha onward on the wrong square while looking entirely normal. One more rule sits above all of it: never read these dates back with toISOString(). The converter returns a Date at local midnight and Nepal is UTC+05:45, so a UTC read moves every festival a day earlier. That mistake got made once while verifying the feature, and it looked exactly like a data error.
Nepal changed its weekend in April 2026
Nepal moved to a two-day weekend on 6 April 2026, Chaitra 23, 2082, when the cabinet added Sunday to the Saturday it already had. The app encodes that as a rule with a start date instead of a global setting, so paging back to BS 2081 still shows a one-day weekend instead of rewriting history, and reversing the policy means adding an end date rather than deleting the rule. It deliberately does not take the weekend from the festival data, whose own holiday flag marks 29 of 53 Saturdays in BS 2081 but 48 of 52 in BS 2082 and does not encode the Sunday policy at all. The change also only covers government offices and schools; several local levels rejected it and the private sector is not covered, which is the kind of detail a calendar either states carefully or gets wrong.
The aurora cost 22 frames a second
An earlier design put a drifting ambient gradient behind everything and frosted glass on top of it. Every frosted surface sampled that moving backdrop, which meant none of the blurs could ever be cached. Measured on the Type page while scrolling, the drifting version ran at 8.8 fps where a parked one ran at 30.9, and the same gap held at 4x and 8x CPU throttling, so it was compositor work rather than script. Parking the drift recovered most of it and deleting the aurora and the glass recovered the rest. Blur now survives only on the app bar and the mobile dock, where the page genuinely scrolls underneath. One structural trap came out of the same work: backdrop-filter makes an element a containing block for its position:fixed descendants, exactly as transform does, so the app bar's blur lives on its ::before rather than on the bar itself. With the filter on the parent, the bottom dock anchored to the bar and landed under the header.
Why there is a native Android app at all
A PWA cannot provide an Android home-screen widget. The widgets member in the web app manifest is Microsoft's Windows 11 Widgets Board feature, not the Android home screen, so a native AppWidgetProvider is the only route to one. A calendar you have to open to read is a calendar you stop reading. So there is a small Kotlin widget showing today's BS date, tithi and festival, in four sizes, sharing the web app's calendar data rather than keeping a second copy of it; its own date algorithm was validated against the converter over 3,970 conversions. It ships inside a Capacitor wrapper whose server.url points at the live site, which means the web app and the Android app are the same deploy and most fixes reach both without a new build. On Android 12 and up the app and the widgets take the palette Android derives from the user's wallpaper, held to the same contrast floors as the hand-picked one, measured per candidate rather than trusted, because Android's shade numbers only mean a given lightness on something close to AOSP and several manufacturers substitute their own extraction. It is in Play Console internal testing.