01Why build this?
Every Nepali knows the ritual: you need today's Bikram Sambat date, so you open a new tab, search “nepali patro”, land on a site plastered with ads, squint at a tiny calendar grid, and close it thirty seconds later. Then you do the same thing tomorrow.
I wanted the date to just be there — one click from any tab, no page load, no ads, and ideally carrying my notes with it. That's Calenote.
02Extension Architecture
A browser extension has a few distinct execution contexts, and understanding which code lives where is most of the battle:
🗔 Popup
The calendar UI you see when clicking the toolbar icon. Ephemeral — destroyed every time it closes.
⚙ Service Worker
Background script handling reminders and alarms. Can be killed by the browser at any moment.
🗄 Storage
chrome.storage.local for notes and settings. Survives across sessions and browser restarts.
⏰ Alarms API
Schedules reminders without keeping a timer alive. Wakes the service worker when due.
# Shared codebase, two builds calenote-extension/ ├── src/ │ ├── popup/ # Calendar UI │ ├── background/ # Service worker + alarms │ ├── lib/ │ │ ├── bs-converter.js # BS ↔ AD conversion │ │ ├── tithi.js # Lunar day calculation │ │ └── holidays.js # Public holiday data │ └── assets/ ├── manifest.chrome.json # MV3 └── manifest.firefox.json # MV3 with event page fallback
03The Manifest V3 Problem
Chrome and Firefox both support Manifest V3, but they implement the background context differently. Chrome uses a service worker. Firefox supports both service workers and traditional event pages, and event pages are considerably more forgiving.
{
"manifest_version": 3,
"name": "Calenote — Nepali Calendar",
"background": {
"service_worker": "background.js"
},
"permissions": ["storage", "alarms", "notifications"],
"action": { "default_popup": "popup.html" }
}
{
"manifest_version": 3,
"name": "Calenote — Nepali Calendar",
"background": {
"scripts": ["background.js"],
// Firefox event page — more forgiving lifecycle
},
"browser_specific_settings": {
"gecko": { "id": "[email protected]" }
},
"permissions": ["storage", "alarms", "notifications"]
}
chrome.storage, and any scheduled work must go through chrome.alarms, never setTimeout.Writing browser-agnostic code
Firefox uses the promise-based browser.* namespace; Chrome uses callback-based chrome.*. A tiny shim removes the difference:
// One namespace for both browsers const api = typeof browser !== "undefined" ? browser : chrome; export async function getStored(key) { const result = await api.storage.local.get(key); return result[key]; } export async function setStored(key, value) { await api.storage.local.set({ [key]: value }); }
04Bikram Sambat Date Logic
Here's the thing that surprises most developers: Bikram Sambat months don't have a fixed length. Baishakh might be 31 days in one year and 30 in another. There's no clean formula — the month lengths come from astronomical calculations published each year.
The practical approach is a lookup table: for each BS year, store an array of month lengths, plus a known anchor date that maps a BS date to a Gregorian one.
// Month lengths per BS year: [Baishakh ... Chaitra] const BS_CALENDAR = { 2082: [31,32,31,32,31,30,30,30,29,30,29,31], 2083: [31,31,32,31,31,31,30,29,30,29,30,30], // ... and so on }; // Anchor: 1 Baishakh 2000 BS = 14 April 1943 AD const ANCHOR_BS = { year: 2000, month: 1, day: 1 }; const ANCHOR_AD = new Date(1943, 3, 14); export function bsToAd(bsYear, bsMonth, bsDay) { let totalDays = 0; // Count days from anchor year to target year for (let y = ANCHOR_BS.year; y < bsYear; y++) { totalDays += BS_CALENDAR[y].reduce((a, b) => a + b, 0); } // Add days for completed months in the target year for (let m = 0; m < bsMonth - 1; m++) { totalDays += BS_CALENDAR[bsYear][m]; } totalDays += bsDay - 1; const result = new Date(ANCHOR_AD); result.setDate(result.getDate() + totalDays); return result; }
05Working Offline
Three design decisions make the extension fully functional without a connection:
- Bundled calendar data — month lengths, tithi tables, and the holiday list ship inside the extension package
- Local-first storage — notes, to-dos, and reminders write to
chrome.storage.localimmediately, syncing later if the user is signed in - No runtime CDN dependencies — every font, icon, and script is packaged; nothing is fetched at load time
06Publishing to Both Stores
Chrome Web Store
- One-time $5 developer registration fee
- Review typically takes 1–3 days
- Requires a privacy policy URL if you request any sensitive permission
- Screenshots must be exactly 1280×800 or 640×400
Firefox Add-ons (AMO)
- Free to publish
- Automated review is often near-instant; manual review takes longer
- Source code submission required if your build is minified or bundled
- Needs an explicit extension ID under
browser_specific_settings
# Build both variants from one source tree npm run build:chrome # copies manifest.chrome.json → manifest.json npm run build:firefox # copies manifest.firefox.json → manifest.json # Package for upload cd dist/chrome && zip -r ../calenote-chrome.zip . cd dist/firefox && zip -r ../calenote-firefox.zip .
README with exact build steps plus the unminified source archive cleared it on the second attempt.07Lessons Learned
1. Design for the service worker dying.
Chrome will kill your background script mid-thought. Treat it as stateless. Anything worth remembering goes to storage before you do anything else.
2. One codebase, two manifests.
Maintaining separate repos for Chrome and Firefox is a trap. Swap the manifest at build time and share everything else.
3. Offline isn't a feature, it's the whole point.
In Nepal, connectivity is inconsistent outside major cities. An extension that needs the network to show a date is an extension people uninstall.
4. Store review is a design constraint.
Every permission you request is something a reviewer will question and a user will see in the install dialog. Ask for the minimum.