Back to Portfolio
Browser ExtensionJavaScriptManifest V3

Building a Nepali Calendar Browser Extension

How I shipped Calenote to both the Chrome Web Store and Firefox Add-ons — one codebase, two manifest versions, and the Bikram Sambat date logic that ties it together.

RJ
Romanch Jung Rayamajhi
April 2026
9 min read
2
Store listings
6+
Browsers supported
100%
Offline capable
0
Ads or trackers
Table of Contents
  1. Why build this?
  2. Extension Architecture
  3. The Manifest V3 Problem
  4. Bikram Sambat Date Logic
  5. Working Offline
  6. Publishing to Both Stores
  7. Lessons Learned

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.

💡
The extension isn't a wrapper around the website. It runs its own local date engine so it works even with no connection — on a plane, in a village with patchy data, anywhere.

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.

project structure
# 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.chrome.json
{
  "manifest_version": 3,
  "name": "Calenote — Nepali Calendar",
  "background": {
    "service_worker": "background.js"
  },
  "permissions": ["storage", "alarms", "notifications"],
  "action": { "default_popup": "popup.html" }
}
manifest.firefox.json
{
  "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"]
}
⚠️
The gotcha: Chrome's service worker gets killed aggressively — often within 30 seconds of inactivity. Any state held in a top-level variable disappears. Everything that matters must go through 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:

lib/browser-api.js
// 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.

lib/bs-converter.js
// 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;
}
Because the conversion is pure arithmetic over a bundled lookup table, it runs instantly and needs zero network calls. That's what makes true offline support possible.

05Working Offline

Three design decisions make the extension fully functional without a connection:

06Publishing to Both Stores

Chrome Web Store

Firefox Add-ons (AMO)

build script
# 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 .
⚠️
Mozilla rejected my first submission because the bundled JavaScript was minified without source. Adding a 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.

🚀
Calenote is live on both stores — and works on Chrome, Brave, Edge, Vivaldi, Opera, Arc, and Firefox. Free, ad-free, built in Kathmandu.