A daily mood tracker that grew into a full social app — friends, real-time chat, voice notes, an achievement system — built as a single HTML file with no backend. Firebase holds the data; Firestore's security rules hold the trust boundary a server would normally own.
Most mood trackers want a paragraph, a mood wheel, and a streak to protect. I wanted something closer to a private ledger: pick 1–10, write a line if you feel like it, decide right there whether it's private. That part came first and stayed the core of the app.
The social side came from actually using it — rating your day alone gets old fast, and the two people this started for wanted to compare notes, not just log in isolation. So a friend system, a shared chat, and a head-to-head "Compare" tab stopped being nice-to-haves and became half the app. There's still a fossil of that origin buried in the code: if the whole users collection ever has exactly two accounts in it, a migration function auto-friends them — no request, no accept, just instantly connected. I've left it in.
Everything above is one screen, one Firebase project, and zero servers I have to keep running. That constraint shaped almost every decision below.
It's plain HTML, CSS, and JavaScript in a single file — no React, no bundler, no package.json. For a project this size, that was a deliberate trade: I wanted every part of the stack to be something I fully controlled and could reason about end to end, not abstracted behind tooling. The whole app ships to GitHub Pages as-is.
Auth, with no email address in sight.
I wanted sign-up to be just a username and a password — no email field, no verification step. Firebase's password provider still wants something email-shaped under the hood, so I manufacture one deterministically from the username:
function usernameToEmail(username) { return username.trim().toLowerCase().replace(/[^a-z0-9_]/g,'') + '@dayvibe.local'; }
A username becomes username@dayvibe.local — a domain that never has to resolve, because Firebase Auth never sends anything through it. I reused the exact same function later, in an unrelated spot: re-authenticating a user with their password before letting them delete their account, since Firebase requires a fresh credential for that and the "credential" is just this same fake email plus their real password.
The security rules are the backend.
With no server, firestore.rules is the only thing standing between any signed-in user and everyone else's data. So it isn't a formality — it's real schema validation, written as if I were validating request bodies in an API. The rule for creating a chat message, for instance, enforces a completely different required shape depending on whether the message is text, a shared day, or a voice note. Here's a smaller example: the rule that locks a user's isPremium field so it can never change through a normal client update —
allow update: if isSelf(uid)
&& request.resource.data.username == resource.data.username
&& request.resource.data.usernameLower == resource.data.usernameLower
&& request.resource.data.createdAt == resource.data.createdAt
// Not self-service — see note at the top of this file.
&& request.resource.data.isPremium == resource.data.isPremium
// memberNumber may be claimed once (self-heal path for old accounts)
// but never reassigned after that.
&& (request.resource.data.memberNumber == resource.data.get('memberNumber', null)
|| resource.data.get('memberNumber', null) == null);
Writing rules like this forces a different kind of discipline than writing UI code: every field on every collection has to be justified out loud, because the default in Firestore is deny, not allow.
Voice notes inside someone else's size limit.
Recording uses MediaRecorder against a mono getUserMedia stream, with a fallback chain across five MIME types because not every browser will encode opus. The harder constraint was underneath that: Firestore caps a document at 1 MiB, and a voice note has to be stored as base64 text inside one. That meant working the math backwards from the limit instead of forwards from "how long should a voice note be":
const VOICE_MAX_MS = 30000; // hard auto-stop at 30s
// 32kbps mono opus for ~30s lands around 120-150KB raw / ~160-200KB base64 - inside the
// 150-250KB target. The hard cap below is a safety net for browsers (e.g. Safari's AAC
// encoder) that don't honor the requested bitrate; it still leaves the doc well under
// Firestore's 1 MiB limit once the other small message fields are added.
const VOICE_MAX_BASE64_BYTES = 350 * 1024;
const VOICE_MIME_CANDIDATES = ['audio/webm;codecs=opus', 'audio/ogg;codecs=opus', 'audio/mp4', 'audio/webm', 'audio/mpeg'];
30 seconds, 32kbps, a byte ceiling with headroom for browsers that ignore the bitrate hint — and then that same ceiling enforced twice: once client-side for instant feedback, and again inside the Firestore rule itself, so the check can't be skipped by talking to Firestore directly instead of through the UI.
Something I caught, not something I hid.
While writing this case study I went back through the rules against the actual code, line by line, instead of trusting that they still agreed with each other. They didn't, in one place: the rule above locks isPremium from client writes, but the redeem-code button in Settings still sets it directly from the browser —
redeemCodeBtn.addEventListener('click', async () => {
const code = redeemCodeInput.value.trim().toUpperCase();
if (!code) return showProfileMsg('Enter a code first.', true, redeemMsg);
if (!PREMIUM_CODES.includes(code)) return showProfileMsg("That code doesn't look right.", true, redeemMsg);
redeemCodeBtn.disabled = true;
try {
await db.collection('users').doc(currentUser.uid).update({ isPremium: true, premiumSince: firebase.firestore.FieldValue.serverTimestamp() });
currentProfile.isPremium = true;
renderSettingsScreen();
showProfileMsg('Premium unlocked!', false, redeemMsg);
launchConfetti(24);
} catch (e) { console.error(e); showProfileMsg('Could not redeem — try again.', true, redeemMsg); }
redeemCodeBtn.disabled = false;
});
Deploy the rules exactly as written and that button starts failing with permission-denied the next time someone redeems a code. It's a one-line fix in one of two directions — either move the grant behind a trusted server check if Premium is ever meant to gate something real, or drop the lock and accept it as the fun, unenforced toggle it is today — and I'd rather show that I found it during review than pretend the first draft was airtight. The full reasoning, plus the exact line numbers on both sides, is in the technical write-up.
Working without a backend doesn't remove the hard problems a backend usually absorbs — it just moves them somewhere more visible. Every validation rule, every access check, every "can this field ever change after creation" question had to be answered explicitly in firestore.rules instead of hiding inside a framework's ORM or an API middleware. That turned out to be a good thing: I understand this data model completely, because I had to write down every rule that governs it by hand.
The other lesson is smaller but stuck with me: the bug most worth finding wasn't in the UI at all — it was in the disagreement between two files that never run in the same place. Catching that took deliberately reading the rules and the client code side by side, not just testing the happy path in the browser.
The full technical write-up covers the complete data model, every collection and field, the full security-rules walkthrough, and every code reference above in context — including line numbers, cross-checked directly against the source.