A flat TODO app forces you to paste in URLs every time you want to remember "three things to come back to in this PR" or "where I left off in this article." A Chrome extension that pins the list to whichever page you're on is the obvious shape — but the design choices around URL identity, storage layout, and badge sync are interesting enough to be worth writing up.
150 lines + 21 tests + zero
host_permissions.
🧩 Demo: https://sen.ltd/portfolio/page-todo/
📦 GitHub: https://github.com/sen-ltd/page-todo
Storage shape: pick one key
Three plausible layouts for chrome.storage.local:
| Option | Shape | Pros | Cons |
|---|---|---|---|
| A |
{ "https://...": [...todos] } at the top level |
Flat, write-per-URL | Top level pollution; storage.get(null) mixes data with anything else |
| B |
{ todos: { url: [...] } } under a single top key |
One read covers everything; onChanged listener watches one key |
Whole-store rewrites on every edit |
| C | One key per URL (url:https://...) |
Partial writes | Listener glue is messy; no clean "list all" |
B wins for this size of data. A typical user holds a few KB to tens of KB. The "rewrite the whole thing every edit" cost is a few milliseconds; the simplicity of chrome.storage.onChanged triggering on a single named key is worth it.
const KEY = "todos";
// Storage shape
{
todos: {
"https://example.com/post/42": [
{ id, text, done, created },
...
],
"https://github.com/sen-ltd/page-todo/pulls": [...]
}
}
URL normalisation — eat the ?utm_source and #section
What counts as "the same page"? If you store the raw URL as the key, these all become different pages:
https://example.com/post/42https://example.com/post/42?utm_source=newsletterhttps://example.com/post/42#comments
That's clearly wrong UX. Tracking parameters land you on the same article from a Twitter click; in-page anchors take you to a different section but the same article. The list should be one list.
The normalisation: origin + pathname, drop query and hash:
function urlKey(rawUrl) {
if (!rawUrl) return null;
try {
const u = new URL(rawUrl);
if (u.protocol === "chrome:" || u.protocol === "about:" || u.protocol === "file:") {
return rawUrl; // internal-page schemes pass through verbatim
}
let path = u.pathname;
if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
return u.origin + path;
} catch {
return null;
}
}
Trailing slash is also normalised (so /post/ and /post share a list), but the root path / keeps its slash so the key is non-empty.
chrome://, about:, and file:// URLs aren't worth disassembling and are rare enough that "verbatim" is fine.
When query is the page
For older ?id=42-driven web apps, dropping the query loses the page identity. v1 doesn't try to be clever about this — supporting it would mean a per-host allow-list or a manual "save as separate page" flag, both of which are complexity buying very little. SPA-only sites are a strong majority now; the few pre-SPA cases that suffer can paste raw URLs into a flat note app instead.
Toolbar badge — three sync paths into one helper
The badge shows the open-TODO count for the active tab's URL. That value can change for three reasons:
- The user switches tabs →
chrome.tabs.onActivated - A tab's URL changes (link click, SPA navigation) →
chrome.tabs.onUpdated - The storage changes (popup adds/removes/toggles a TODO) →
chrome.storage.onChanged
All three call the same helper:
async function refreshBadgeForTab(tabId, url) {
const count = await openCountFor(storage, url);
const text = count > 0 ? String(count) : "";
await chrome.action.setBadgeText({ tabId, text });
if (count > 0) {
await chrome.action.setBadgeBackgroundColor({ tabId, color: "#58a6ff" });
}
}
chrome.tabs.onActivated.addListener(async ({ tabId }) => {
const tab = await chrome.tabs.get(tabId);
if (tab?.url) await refreshBadgeForTab(tabId, tab.url);
});
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
if (changeInfo.url || changeInfo.status === "complete") {
if (tab?.url) await refreshBadgeForTab(tabId, tab.url);
}
});
chrome.storage.onChanged.addListener(async (changes, area) => {
if (area !== "local" || !changes.todos) return;
const tabs = await chrome.tabs.query({ active: true });
for (const t of tabs) {
if (t.url && t.id !== undefined) await refreshBadgeForTab(t.id, t.url);
}
});
The onUpdated filter — changeInfo.url || changeInfo.status === "complete" — is the small thing that makes this feel right:
- SPA in-place navigation fires only
changeInfo.url. - Full navigations end with
changeInfo.status === "complete".
Listening to either alone leaves the other case stale.
The changes.todos check on onChanged keeps unrelated keys from triggering recompute, in case you ever add other state.
Pruning empty URL keys keeps the store tight
Removing a TODO leaves an empty array. Letting those accumulate makes the store bloat with URLs the user effectively cleared:
async function removeTodo(storage, rawUrl, id) {
const all = await loadAll(storage);
const list = all[key];
list.splice(idx, 1);
if (list.length === 0) delete all[key]; // prune
else all[key] = list;
await saveAll(storage, all);
}
clearDone does the same. After a year of normal use, the URL key set stays bounded by currently relevant pages, not every page ever visited.
ID generation — crypto.getRandomValues over Date.now()
Two adds in the same millisecond — easy to trigger from the popup if the user spams Enter — would collide on a Date.now()-based ID. Use the WebCrypto random instead:
function makeId() {
const arr = new Uint8Array(5);
crypto.getRandomValues(arr);
return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("").slice(0, 9);
}
A test (one of 21) explicitly proves "100 rapid adds in the same millisecond all get unique IDs."
Tests — no chrome polyfill, no jsdom
todos.js takes its storage argument (anything matching chrome.storage.local's get / set Promise shape). The test mock is ten lines:
function makeStorage(initial = {}) {
const data = JSON.parse(JSON.stringify(initial));
return {
async get(keys) {
const out = {};
const arr = Array.isArray(keys) ? keys : [keys];
for (const k of arr) if (k in data) out[k] = data[k];
return out;
},
async set(items) { Object.assign(data, items); },
};
}
node --test runs 21 cases in 0.08 seconds. No @types/chrome, no sinon-chrome, no jsdom.
The reasoning: dependency-injecting storage covers ~90% of the LOC under unit tests; the remaining 10% (popup DOM operations, service-worker listener wiring) is smoke-tested manually in a real Chrome with "Load unpacked." Polyfills for the bits we don't actually use buy nothing.
Takeaways
-
Single top-level key in
chrome.storage.localkeepsonChangedlisteners and writes simple. The "rewrite the whole thing per edit" cost is invisible at this data size. -
URL key =
origin + pathname, drop query and hash.chrome:///about:/file://pass through verbatim. Trailing slash on non-root paths gets normalised. -
Badge stays in sync via three triggers (
tabs.onActivated,tabs.onUpdatedfiltered for URL change OR status complete,storage.onChanged) all calling one helper. - Empty URL keys are pruned from storage so long-term use doesn't bloat the store.
-
Random IDs, not
Date.now()— covered by an explicit unique-id test. -
No chrome polyfill in tests — dependency-inject the storage shim, mock it in 10 lines, run under
node --test.
Full source on GitHub. MIT licensed.
This is the second entry in the browser-extension series, after copy-as-md (entry #214, HTML→Markdown clipboard).

Top comments (0)