What it actually is
Shitty Human ("rate your day" — that's the literal <title>) is a daily mood-rating app: pick a number 1–10, add a note and up to three context tags, decide if it's private. On top of that single-player core it layers a genuinely full social product — username-based friend requests, real-time 1:1 chat with text/shared-day-cards/30-second voice notes, emoji reactions, a 13-item achievement system the app calls Citations, a head-to-head "Compare" view against one friend at a time, and a client-toggled Premium tier unlocked with redeem codes.
There is no backend. Every screen talks straight to Firebase from the browser — Firebase Authentication for accounts and Cloud Firestore for every piece of data, real-time listeners included. The firestore.rules file you provided isn't a nice-to-have here; it is the entire access-control layer for the app.
The rules lock users/{uid}.isPremium so request.resource.data.isPremium must equal the existing value on every update — see line 77 of your rules file. But the Settings screen's Redeem button calls update({ isPremium: true, … }) directly from the browser (line 1753 of index.html). Deploy the rules exactly as given, and that button will fail with permission-denied the next time anyone tries to redeem a code. This is exactly the "ONE THING TO DECIDE" your rules file's own header comment flags — it just hasn't been resolved in the code yet. Full detail and both fix options are in Security rules.
Tech stack
Nothing here needs a build step. There's no package.json, no bundler, no framework — index.html is deployed as-is.
| Layer | What's used |
|---|---|
| Markup / logic | One file, index.html — 2,894 lines, ~181 KB. HTML, CSS, and JS all inline, no external .js/.css files. |
| Auth | Firebase Authentication, compat SDK 12.15.0, email/password provider (see Authentication for why it's email/password with no real emails). |
| Database | Cloud Firestore, compat SDK 12.15.0 — real-time listeners (onSnapshot) for friendships and the active chat thread, plain get() reads everywhere else. |
| Charts | Chart.js 4.5.0 (UMD build) — line, bar, and doughnut charts on the Stats and Compare tabs. |
| Fonts | Google Fonts: Source Serif 4 (default display face), Inter (body/UI), IBM Plex Mono (data-ish bits), plus two swappable display faces — Manrope ("Modern") and Fraunces ("Cozy") — chosen at runtime in Settings. |
| Audio | Zero audio files for UI sound effects — every chime is a Web Audio oscillator generated on the fly (see Chat & voice notes). |
| Hosting | Static hosting on GitHub Pages. Avatar images are fetched from raw.githubusercontent.com/Jishnuec/shitty-human/master/Profile/…, which is also the deploy source repo. |
| CDN | Everything loads from cdnjs.cloudflare.com — no npm install, no local node_modules. |
All four script tags load right before the app's own script, in this order:
Architecture
The file is one linear document with four regions, in this order:
<head>: meta, font links, and the entire CSS. A custom-property design system (theme tokens, a light-mode override under [data-theme="light"], four font pairings under [data-font="…"]) drives every screen.<script> that runs before first paint. It reads sh_theme / sh_font from localStorage and sets data-theme/data-font on <html> immediately, so returning users never see a flash of the wrong theme.<body> markup: the auth screen, the main app shell (four tabs — Rate / Chat / Stats / Compare — plus a header with profile/notifications entry points), the Profile / Settings / Notifications / Find-People panels, and three overlays (avatar picker, day-share picker, delete-account confirmation).<script src> tags from the table above.(function(){ "use strict"; … })() IIFE holding the entire application: constants, a $ = id => document.getElementById(id) helper for DOM refs, every feature function, and a boot() at the very end that initializes Firebase and wires auth.onAuthStateChanged.There's no module system — no import/export, everything lives in one closure. Screens are shown/hidden with a hidden attribute toggled by switchTab() / openOverlay() / closeOverlay() rather than any router.
Data model
Four top-level collections, two of them with a subcollection. This is exactly what the code reads and writes — every path below was found with a direct grep -n "collection(" pass over index.html, cross-checked against the rules.
[a-zA-Z0-9_]. Immutable after signup.meta/counters, then frozen'public' | 'friends' | 'private' — default 'friends'{ unlockedAt }. Doc id is the citation id, e.g. century.'pending' → 'accepted' → (optionally) 'ended' → back to 'pending' on re-friendFieldValue.increment(1)true only by the 2-user auto-friend path — see Code referencetype: 'text' | 'day_share' | 'voice', plus senderUid, senderName, reactions (map of uid→emoji), createdAtuid + '_' + date, e.g. abc123_2026-08-06memberNumberYour brief for this document listed dayMarkers and a groups collection (for group chat). I searched index.html for every .collection(…) call — there are 46 of them — and neither exists. Chat is 1:1 only, through friendships/{pairId}/messages; there's no group thread anywhere in the code, and the rules you pasted don't have a match /groups/{'{'}...{'}'} block either. I've documented the app as written above. If group chat or a separate day-marker log is on your roadmap, both would need new collections and new rules blocks — right now, writing to either would be silently rejected (Firestore denies by default when no rule matches a path).
The ratings rule validates rating, note, private, username, and updatedAt, but it doesn't call .keys().hasOnly([…]) the way the messages rule does — so tags, ratedAtHour, and createdAt pass through without a type or size check. Nobody can touch another user's rating either way (that's still gated by isSelf(...)), it's just a looser check than the rest of the schema if you're tightening things up later.
Authentication
There's no real email collected anywhere. Firebase's email/password provider needs something email-shaped, so the app manufactures one from the username:
function usernameToEmail(username) { return username.trim().toLowerCase().replace(/[^a-z0-9_]/g,'') + '@dayvibe.local'; }
A username of jordan_k becomes the Firebase Auth identity jordan_k@dayvibe.local — a domain that doesn't need to resolve to anything, because Firebase Auth never actually sends mail through it. The regex strips anything outside a-z0-9_ after lowercasing, so it's tolerant of case but still guarantees a valid local-part.
Signup
Client-side validation first — username 3–15 chars matching ^[a-zA-Z0-9_]{3,15}$, password ≥ 6 characters, an avatar (philosopher) selected. Then it's a two-step write: create the Auth user, then create the Firestore profile. If the profile write throws, it deletes the just-created Auth user so you don't end up with an orphaned account that has no profile:
signupForm.addEventListener('submit', async (e) => {
e.preventDefault();
clearAuthError();
const username = signupUsername.value.trim();
const password = signupPassword.value;
if (!/^[a-zA-Z0-9_]{3,15}$/.test(username)) return showAuthError('Username: 3–15 letters, numbers, or underscores.');
if (password.length < 6) return showAuthError('Password needs at least 6 characters.');
if (!selectedSignupAvatarId) return showAuthError('Pick a philosopher first!');
setAuthLoading(true);
isSigningUp = true;
const email = usernameToEmail(username);
let cred;
try { cred = await auth.createUserWithEmailAndPassword(email, password); }
catch (err) { isSigningUp = false; setAuthLoading(false); return showAuthError(friendlyAuthError(err)); }
try {
const memberNumber = await claimMemberNumber();
await db.collection('users').doc(cred.user.uid).set({
username: username,
usernameLower: username.toLowerCase(),
displayName: username,
avatarId: selectedSignupAvatarId,
memberNumber: memberNumber,
profileVisibility: 'friends',
shareRatings: true,
shareNotes: true,
isPremium: false,
messagesSent: 0,
daysLoggedCount: 0,
deleted: false,
createdAt: firebase.firestore.FieldValue.serverTimestamp()
});
} catch (profileErr) {
await cred.user.delete().catch(() => {});
isSigningUp = false; setAuthLoading(false);
return showAuthError('Something went wrong creating your profile. Try again.');
}
isSigningUp = false; setAuthLoading(false);
await loadProfileAndShowApp(cred.user);
});
Notice the profile fields being set here — username, usernameLower, displayName, isPremium: false, deleted: false, createdAt — match the rules' create validation on users/{uid} exactly.
Login & logout
Login re-derives the same fake email and calls signInWithEmailAndPassword. Logout is a one-liner: auth.signOut(), after clearing in-memory friend/chat state via resetSocialState() so a second account signing in on the same tab doesn't inherit stale listeners.
Firebase's auth/* error codes are translated to plain copy by friendlyAuthError() — auth/email-already-in-use becomes "That username is taken," auth/wrong-password and auth/user-not-found both collapse to "Incorrect username or password" (so login never leaks which part was wrong).
Re-authentication for account deletion
The same fake-email trick is reused when a user deletes their account — firebase.auth.EmailAuthProvider.credential(usernameToEmail(currentProfile.username), password) rebuilds a credential to reauthenticate before Firebase will allow currentUser.delete().
Security rules
Because there's no backend, firestore.rules is the only thing stopping any signed-in user from reading or writing anyone else's data. It's organized as one helper block plus four match blocks that mirror the four collections above one-for-one.
| Helper | What it checks |
|---|---|
isSignedIn() | a Firebase Auth session exists |
isSelf(uid) | the caller's uid matches the doc's owner |
pairParticipants(pairId) | splits a friendship id on _ — safe because Firebase uids never contain one |
isPairParticipant(pairId) | caller's uid is one of the two in the pair |
friendshipIsAccepted / IsReadable(pairId) | gates writes to 'accepted' only; reads also allow 'ended' so unfriending never deletes chat history |
isFriendOf(otherUid) | rebuilds the pair id and checks for an accepted friendship — used by the ratings rule |
This is the same issue flagged in Overview, in more detail. The users/{uid} update rule requires the new value of isPremium to equal the existing one:
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);
Firestore evaluates request.resource.data on an update as the resulting document, not just the patch — so when the app runs this from the Settings screen…
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;
});
…request.resource.data.isPremium is true, resource.data.isPremium is the existing false, they don't match, and Firestore denies the write. As written, deploying both files together means nobody can actually redeem a code. Two ways to resolve it, per your rules file's own comment:
- It's meant to be a real gate (paid, or otherwise meaningful) → move the grant server-side: a Cloud Function (or any trusted backend) that verifies the code and sets
isPremiumwith the Admin SDK, which bypasses these rules entirely. - It's just a fun toggle with no real stakes → delete the
isPremium == resource.data.isPremiumline from the update rule and let the client keep setting it directly, same as it does today.
The other rule worth reading closely is the message create rule, since it's doing real schema validation per message type — this is what actually enforces the 3 message shapes, the 2000/280-char text limits, and the 360 KB voice-note cap:
allow create: if isPairParticipant(pairId) && friendshipIsAccepted(pairId)
&& request.resource.data.senderUid == request.auth.uid
&& request.resource.data.senderName is string
&& request.resource.data.reactions == {}
&& request.resource.data.createdAt == request.time
&& request.resource.data.keys().hasOnly(
['type','text','dayShare','audio','audioMimeType','durationSec',
'senderUid','senderName','reactions','createdAt'])
&& (
(request.resource.data.type == 'text'
&& request.resource.data.text is string
&& request.resource.data.text.size() > 0
&& request.resource.data.text.size() <= 2000)
||
(request.resource.data.type == 'day_share'
&& request.resource.data.dayShare.keys().hasOnly(['date','rating','note','noteHidden'])
&& request.resource.data.dayShare.date is string
&& request.resource.data.dayShare.rating is number
&& request.resource.data.dayShare.rating >= 1
&& request.resource.data.dayShare.rating <= 10
&& request.resource.data.dayShare.note is string
&& request.resource.data.dayShare.note.size() <= 280
&& request.resource.data.dayShare.noteHidden is bool)
||
// 32kbps opus x 30s lands ~160-200KB base64; 360KB leaves headroom
// for browsers that ignore the bitrate hint, still well under the
// 1MiB Firestore doc cap once the other small fields are added.
(request.resource.data.type == 'voice'
&& request.resource.data.audio is string
&& request.resource.data.audio.size() <= 360000
&& request.resource.data.audioMimeType is string
&& request.resource.data.durationSec is number
&& request.resource.data.durationSec > 0
&& request.resource.data.durationSec <= 30)
);
Everything else follows the same pattern as the app: ratings can never be deleted (rule: allow delete: if false — it's meant to be a permanent log), message delete is likewise always false (there's no unsend feature in the UI, so the rule doesn't need to allow one), and meta/counters can only ever move up by exactly 1 per write.
Rating your day
The Rate tab is a 1–10 button row. Selecting a number doesn't save anything by itself — it just stages the form; the actual write happens on submit, keyed to today's date so re-submitting the same day updates in place instead of creating a duplicate:
submitBtn.addEventListener('click', async () => {
if (!selectedRating) return;
submitBtn.disabled = true;
const today = todayStr();
const docId = currentUser.uid + '_' + today;
const isPrivate = privacyCheckbox.checked;
const payload = { uid: currentUser.uid, username: currentProfile.username, date: today, rating: selectedRating, note: noteInput.value.trim().slice(0,280), private: isPrivate, tags: Array.from(selectedTags), updatedAt: firebase.firestore.FieldValue.serverTimestamp() };
const isNewDay = !todayDocExists;
if (isNewDay) { payload.ratedAtHour = new Date().getHours(); payload.createdAt = firebase.firestore.FieldValue.serverTimestamp(); }
try {
await db.collection('ratings').doc(docId).set(payload, { merge: true });
todayDocExists = true;
if (isNewDay) {
currentProfile.daysLoggedCount = (currentProfile.daysLoggedCount || 0) + 1;
db.collection('users').doc(currentUser.uid).update({ daysLoggedCount: firebase.firestore.FieldValue.increment(1) }).catch(()=>{});
}
celebrateRating(selectedRating);
submitBtn.textContent = "Update today's shittiness"; alreadyRatedNote.hidden = false;
await fetchMyEntries(true);
renderTodayHero();
} catch (err) { alert('Could not save your rating — check your connection and try again.'); }
submitBtn.disabled = false;
});
function celebrateRating(n) {
const bucket = bucketFor(n);
const captions = REACTION_CAPTIONS[bucket];
reactionCard.textContent = captions[Math.floor(Math.random()*captions.length)];
reactionCard.className = 'reaction-card show ' + bucket;
clearTimeout(reactionTimeout);
reactionTimeout = setTimeout(() => reactionCard.classList.remove('show'), 3400);
fireGradeTag('Grade ' + LETTER_GRADE[n]);
if (bucket === 'rough') { playSadSound(); triggerShake(reactionCard); }
else if (bucket === 'meh') { playNeutralSound(); }
else if (bucket === 'good') { playSuccessSound(); }
else { playSuccessSound(); launchConfetti(30); }
}
Every rating carries a letter grade (LETTER_GRADE: 1→F … 10→A+), an emoji (EMOJI_BY_RATING), and a word label (LABEL_BY_RATING: "Terrible" … "Best Day Ever"). Submitting fires one of four reaction states via bucketFor() — rough (1–3, plays a descending sawtooth tone and shakes the card), meh (4–6), good (7–8), or amazing (9–10, triggers confetti in the app's own brand colors) — each with its own pool of caption strings drawn at random.
Notes & tags
An optional note (280 chars in the form, 600 stored) and up to 3 context tags from a fixed set of 12: Work, Sleep, Health, Social, Family, Love, Money, Weather, Travel, Hobby, News, Chores. ratedAtHour is only stamped on the first save of a given day, which is what lets the Early Bird / Night Owl citations key off "what time did you actually log this."
Privacy is two layers
A per-entry Private checkbox hides that specific day from friends regardless of anything else. Separately, the profile-level Share ratings / Share notes toggles in Settings control the default — a friend only ever sees a day if the entry isn't marked private and your profile-level sharing is on.
Friends
Friendship is a single document per pair (friendships/{pairId}), state-machined through status. sendFriendRequest() looks the target up by usernameLower, then runs a transaction that reads the pair doc before writing — so a request can't be sent twice, and re-requesting an already-accepted pair or an already-pending one both fail cleanly with a specific message rather than a generic error.
| Transition | Who can trigger it | Result |
|---|---|---|
| Send request | either user, via username search | status: 'pending' |
| Accept | the invited side only (rule checks request.auth.uid != requestedBy) | status: 'accepted' |
| Decline / cancel | either side, while still pending | doc hard-deleted (no messages exist yet) |
| Unfriend | either side, once accepted | status: 'ended' — soft-ended, not deleted, so chat history stays readable |
| Re-friend | either side, after ended | same doc flips back to 'pending' — old messages were never touched |
Find People is an exact-username lookup (no fuzzy search) that shows a profile card with an Add button when you're not already friends or pending. The Notifications panel isn't its own collection — it's computed client-side each render from three arrays already in memory: incoming requests, friends with unread > 0, and outgoing (sent) requests.
Chat & voice notes
Chat is real-time via onSnapshot on friendships/{pairId}/messages, ordered by createdAt. There are exactly three message shapes, and all three are sent the same way — a batched write that both adds the message doc and bumps the friendship's lastMessageAt / lastMessageText / the recipient's unreadFor counter in one atomic batch.commit():
async function sendChatMessage(text) {
const pairId = activeChatPairId, friendUid = activeChatFriendUid;
const msgRef = db.collection('friendships').doc(pairId).collection('messages').doc();
const payload = { type: 'text', text: text.slice(0,500), senderUid: currentUser.uid, senderName: currentProfile.displayName || currentProfile.username, reactions: {}, createdAt: firebase.firestore.FieldValue.serverTimestamp() };
const batch = db.batch();
batch.set(msgRef, payload);
const fUpdate = { lastMessageAt: firebase.firestore.FieldValue.serverTimestamp(), lastMessageText: payload.text.slice(0,80) };
fUpdate['unreadFor.' + friendUid] = firebase.firestore.FieldValue.increment(1);
batch.set(db.collection('friendships').doc(pairId), fUpdate, { merge:true });
await batch.commit();
playPopSound(); bumpMessagesSent();
}
Sharing a day into chat
shareDayToChat() builds a day_share message from a past rating. It respects the sender's shareNotes setting at the moment of sharing — if notes are off, note is sent empty and noteHidden: true is set instead, so the recipient sees "note hidden" rather than nothing at all.
Voice notes
Recording uses MediaRecorder against a mono getUserMedia stream (echo cancellation, noise suppression, and auto-gain all requested), targeting audio/webm;codecs=opus at 32 kbps with fallbacks through ogg/mp4/plain webm/mpeg for browsers that don't support opus. Recording hard-stops at 30 seconds. On stop, the blob is base64-encoded and sent as a Firestore string field:
async function sendVoiceMessage(voice) {
const pairId = activeChatPairId, friendUid = activeChatFriendUid;
const msgRef = db.collection('friendships').doc(pairId).collection('messages').doc();
const payload = { type: 'voice', audio: voice.base64, audioMimeType: voice.mimeType, durationSec: voice.durationSec, senderUid: currentUser.uid, senderName: currentProfile.displayName || currentProfile.username, reactions: {}, createdAt: firebase.firestore.FieldValue.serverTimestamp() };
const batch = db.batch();
batch.set(msgRef, payload);
const fUpdate = { lastMessageAt: firebase.firestore.FieldValue.serverTimestamp(), lastMessageText: (currentProfile.displayName||currentProfile.username) + ' sent a voice note (' + formatMmSs(voice.durationSec) + ')' };
fUpdate['unreadFor.' + friendUid] = firebase.firestore.FieldValue.increment(1);
batch.set(db.collection('friendships').doc(pairId), fUpdate, { merge:true });
await batch.commit();
playPopSound(); bumpMessagesSent();
}
That 360 KB ceiling is enforced twice — once client-side before sending (with a friendly "try a shorter voice note" alert) and again in the Firestore rule itself, so a modified client can't skip the check. Playback renders a waveform from decoded peaks (decodeWaveformPeaks) rather than a plain progress bar, and only one voice message plays at a time app-wide — starting a second one pauses whichever was already playing.
Reactions
Six quick-reaction emoji (❤️ 😂 😮 😢 👍 🔥). Reacting again with the same emoji removes it — toggled inside a transaction that only ever touches your own key in the reactions map, which is also exactly what the rule enforces (reactions.diff(...).affectedKeys().hasOnly([request.auth.uid])):
async function toggleReaction(pairId, messageId, emoji) {
if (!pairId) return;
const ref = db.collection('friendships').doc(pairId).collection('messages').doc(messageId);
try {
await db.runTransaction(async (tx) => {
const snap = await tx.get(ref);
if (!snap.exists) return;
const reactions = snap.data().reactions || {};
const mine = reactions[currentUser.uid];
const updates = {};
updates['reactions.' + currentUser.uid] = (mine === emoji) ? firebase.firestore.FieldValue.delete() : emoji;
tx.update(ref, updates);
});
} catch (e) { /* non-fatal */ }
}
Stats & compare
The Stats tab is entirely derived from your own ratings — no extra collection backs it. It computes a current streak and longest streak (consecutive logged days, gap-tolerant math via consecutiveDateRuns()), a calendar heatmap, a rating-bucket doughnut, a weekday breakdown (which day of the week tends to be your best/worst), and a trend line comparing recent averages to your all-time average.
The Compare tab runs the same math against one friend at a time (only over days that friend has chosen to share) and adds head-to-head framing: a "who's ahead this week" verdict, a win-streak banner, an agreement percentage — how often you're both within 1 point of each other on the same day — and four Chart.js visualizations (a shared line chart, a grouped bar chart of average/best day, and a doughnut per person).
Citations (badges)
The app's own word for its achievement system is Citations — "little commendations for your chaos." There are 13, defined as one array with a name, emoji, description, and unlock-check function each:
const BADGE_DEFS = [
{ id:'slump', emoji:'🌧️', name:'The Slump', desc:'Survived 3 rough days (below 4) in a row', check:hasSlump },
{ id:'consistent', emoji:'🧘', name:'Mr. Consistent', desc:'A whole week of steady 5s and 6s', check:hasConsistent },
{ id:'party', emoji:'🎉', name:'Party Animal', desc:'Hit a perfect 10 on a Friday', check:hasPartyAnimal },
{ id:'moody', emoji:'🎭', name:'Moody', desc:'Swung 5+ points between two logged days', check:hasMoody },
{ id:'early_bird', emoji:'🐦', name:'Early Bird', desc:'Rated your day before 9am', check:hasEarlyBird },
{ id:'night_owl', emoji:'🦉', name:'Night Owl', desc:'Rated your day after 11pm', check:hasNightOwl },
{ id:'century', emoji:'💯', name:'Century Club', desc:'Logged 100 days total', check:hasCentury },
{ id:'tag_team', emoji:'🤝', name:'Tag Team', desc:'Connected with a friend', check:hasTagTeam },
{ id:'popular', emoji:'🌟', name:'Popular', desc:'Set your profile to Public', check:hasPopular },
{ id:'networker', emoji:'🕸️', name:'Networker', desc:'Made 3 or more friends', check:hasNetworker },
{ id:'chatterbox', emoji:'💬', name:'Chatterbox', desc:'Sent 50+ chat messages', check:hasChatterbox },
{ id:'historian', emoji:'📜', name:'Historian', desc:'Logged 365 days total', check:hasHistorian },
{ id:'comeback', emoji:'🔄', name:'Comeback', desc:'Followed a rough day (≤3) with an amazing one (9+)', check:hasComeback }
];
| Citation | Unlocks when… | |
|---|---|---|
| 🌧️ | The Slump | 3 rough days (rating ≤ 3) in a row |
| 🧘 | Mr. Consistent | a full week of steady 5s and 6s |
| 🎉 | Party Animal | a perfect 10 lands on a Friday |
| 🎭 | Moody | two logged days swing 5+ points apart |
| 🐦 | Early Bird | a day is rated before 9am |
| 🦉 | Night Owl | a day is rated after 11pm |
| 💯 | Century Club | 100 total days logged |
| 🤝 | Tag Team | you connect with at least one friend |
| 🌟 | Popular | profile visibility is set to Public |
| 🕸️ | Networker | 3 or more friends |
| 💬 | Chatterbox | 50+ chat messages sent |
| 📜 | Historian | 365 total days logged |
| 🔄 | Comeback | a day ≤ 3 is directly followed by a day ≥ 9 |
refreshBadges() runs this check list once per load of the Rate tab against whatever's already unlocked, awards any new ones (each written as its own users/{uid}/badges/{id} doc), and fires a toast + confetti + chime for each. Unlocks are entirely client-reported — the rules let you create a badge doc for yourself with no server-side verification of the underlying claim, the same trust model as the messagesSent / daysLoggedCount counters on the profile. Fine for a personal app; not tamper-proof against a user editing their own account.
Profile & privacy
Avatars are philosophers
There's no photo upload. Every avatar is one of six portraits, hard-coded as a PHILOSOPHERS array and served from the app's own GitHub repo: Albert Camus, Emil Cioran, Friedrich Nietzsche, Fyodor Dostoevsky, Jean-Paul Sartre, and Søren Kierkegaard. You pick one at signup ("Pick a philosopher first!" is the validation message if you try to skip it) and can change it later from the profile screen.
Visibility levels
profileVisibility gates what a visitor to your profile can see — days-logged count, messages-sent count, friend count, and your citations summary. Your username, avatar, and member number stay visible regardless.
| Setting | Value | Who sees your stats/citations |
|---|---|---|
| Public | 'public' | anyone signed in |
| Friends (default) | 'friends' | accepted friends only |
| No one | 'private' | only you |
This is exactly what unlocks the Popular citation — it's a direct read of profileVisibility === 'public', nothing more.
Settings
Theme — Dusk / Paper
Dark ("Dusk") or light ("Paper"). Persisted to localStorage under sh_theme and applied pre-paint by the head-of-document script.
Font — 4 pairings
Editorial (Source Serif 4, default), Modern (Manrope), Cozy (Fraunces), Terminal (IBM Plex Mono for everything). Stored under sh_font.
Sound effects
One toggle, in-memory only (soundEnabled) — not written to localStorage or Firestore, so it resets to on each fresh page load.
Share ratings / Share notes
The profile-level half of the two-layer privacy model described in Rating your day.
Account deletion
Deletion requires typing your exact username plus your password (re-authenticated via the same fake-email credential trick from Authentication) before anything happens. Once confirmed, it's a soft delete of the data and a hard delete of the login:
- The
users/{uid}doc is updated:deleted: true,displayNameset to"Former Shitty Human",profileVisibilityforced to'public'(so the tombstone state is consistently visible rather than ambiguously hidden). - The Firebase Auth user itself is deleted via
currentUser.delete()— the login is genuinely gone. - Ratings, past messages, and friendships are not purged — they stay in Firestore, just attached to a profile now marked deleted and rebranded.
Code reference
The migration function deserves its own callout — it's the most idiosyncratic piece of code in the file. runOneTimeMigration() backfills any field a legacy account might be missing (visibility, share toggles, isPremium, counters, a member number), and includes this, verbatim:
If the entire users collection has exactly 2 documents in it, the migration auto-creates an accepted friendship between them — no request, no acceptance, just instantly friends, flagged autoMigrated: true. That's a strong tell this app started as a two-person project before the friend system generalized to more people.
Every other snippet referenced elsewhere in this document is collected here for quick lookup:
usernameToEmail()
function usernameToEmail(username) { return username.trim().toLowerCase().replace(/[^a-z0-9_]/g,'') + '@dayvibe.local'; }
Signup handler
signupForm.addEventListener('submit', async (e) => {
e.preventDefault();
clearAuthError();
const username = signupUsername.value.trim();
const password = signupPassword.value;
if (!/^[a-zA-Z0-9_]{3,15}$/.test(username)) return showAuthError('Username: 3–15 letters, numbers, or underscores.');
if (password.length < 6) return showAuthError('Password needs at least 6 characters.');
if (!selectedSignupAvatarId) return showAuthError('Pick a philosopher first!');
setAuthLoading(true);
isSigningUp = true;
const email = usernameToEmail(username);
let cred;
try { cred = await auth.createUserWithEmailAndPassword(email, password); }
catch (err) { isSigningUp = false; setAuthLoading(false); return showAuthError(friendlyAuthError(err)); }
try {
const memberNumber = await claimMemberNumber();
await db.collection('users').doc(cred.user.uid).set({
username: username,
usernameLower: username.toLowerCase(),
displayName: username,
avatarId: selectedSignupAvatarId,
memberNumber: memberNumber,
profileVisibility: 'friends',
shareRatings: true,
shareNotes: true,
isPremium: false,
messagesSent: 0,
daysLoggedCount: 0,
deleted: false,
createdAt: firebase.firestore.FieldValue.serverTimestamp()
});
} catch (profileErr) {
await cred.user.delete().catch(() => {});
isSigningUp = false; setAuthLoading(false);
return showAuthError('Something went wrong creating your profile. Try again.');
}
isSigningUp = false; setAuthLoading(false);
await loadProfileAndShowApp(cred.user);
});
Rating submit
submitBtn.addEventListener('click', async () => {
if (!selectedRating) return;
submitBtn.disabled = true;
const today = todayStr();
const docId = currentUser.uid + '_' + today;
const isPrivate = privacyCheckbox.checked;
const payload = { uid: currentUser.uid, username: currentProfile.username, date: today, rating: selectedRating, note: noteInput.value.trim().slice(0,280), private: isPrivate, tags: Array.from(selectedTags), updatedAt: firebase.firestore.FieldValue.serverTimestamp() };
const isNewDay = !todayDocExists;
if (isNewDay) { payload.ratedAtHour = new Date().getHours(); payload.createdAt = firebase.firestore.FieldValue.serverTimestamp(); }
try {
await db.collection('ratings').doc(docId).set(payload, { merge: true });
todayDocExists = true;
if (isNewDay) {
currentProfile.daysLoggedCount = (currentProfile.daysLoggedCount || 0) + 1;
db.collection('users').doc(currentUser.uid).update({ daysLoggedCount: firebase.firestore.FieldValue.increment(1) }).catch(()=>{});
}
celebrateRating(selectedRating);
submitBtn.textContent = "Update today's shittiness"; alreadyRatedNote.hidden = false;
await fetchMyEntries(true);
renderTodayHero();
} catch (err) { alert('Could not save your rating — check your connection and try again.'); }
submitBtn.disabled = false;
});
function celebrateRating(n) {
const bucket = bucketFor(n);
const captions = REACTION_CAPTIONS[bucket];
reactionCard.textContent = captions[Math.floor(Math.random()*captions.length)];
reactionCard.className = 'reaction-card show ' + bucket;
clearTimeout(reactionTimeout);
reactionTimeout = setTimeout(() => reactionCard.classList.remove('show'), 3400);
fireGradeTag('Grade ' + LETTER_GRADE[n]);
if (bucket === 'rough') { playSadSound(); triggerShake(reactionCard); }
else if (bucket === 'meh') { playNeutralSound(); }
else if (bucket === 'good') { playSuccessSound(); }
else { playSuccessSound(); launchConfetti(30); }
}
sendChatMessage()
async function sendChatMessage(text) {
const pairId = activeChatPairId, friendUid = activeChatFriendUid;
const msgRef = db.collection('friendships').doc(pairId).collection('messages').doc();
const payload = { type: 'text', text: text.slice(0,500), senderUid: currentUser.uid, senderName: currentProfile.displayName || currentProfile.username, reactions: {}, createdAt: firebase.firestore.FieldValue.serverTimestamp() };
const batch = db.batch();
batch.set(msgRef, payload);
const fUpdate = { lastMessageAt: firebase.firestore.FieldValue.serverTimestamp(), lastMessageText: payload.text.slice(0,80) };
fUpdate['unreadFor.' + friendUid] = firebase.firestore.FieldValue.increment(1);
batch.set(db.collection('friendships').doc(pairId), fUpdate, { merge:true });
await batch.commit();
playPopSound(); bumpMessagesSent();
}
sendVoiceMessage()
async function sendVoiceMessage(voice) {
const pairId = activeChatPairId, friendUid = activeChatFriendUid;
const msgRef = db.collection('friendships').doc(pairId).collection('messages').doc();
const payload = { type: 'voice', audio: voice.base64, audioMimeType: voice.mimeType, durationSec: voice.durationSec, senderUid: currentUser.uid, senderName: currentProfile.displayName || currentProfile.username, reactions: {}, createdAt: firebase.firestore.FieldValue.serverTimestamp() };
const batch = db.batch();
batch.set(msgRef, payload);
const fUpdate = { lastMessageAt: firebase.firestore.FieldValue.serverTimestamp(), lastMessageText: (currentProfile.displayName||currentProfile.username) + ' sent a voice note (' + formatMmSs(voice.durationSec) + ')' };
fUpdate['unreadFor.' + friendUid] = firebase.firestore.FieldValue.increment(1);
batch.set(db.collection('friendships').doc(pairId), fUpdate, { merge:true });
await batch.commit();
playPopSound(); bumpMessagesSent();
}
toggleReaction()
async function toggleReaction(pairId, messageId, emoji) {
if (!pairId) return;
const ref = db.collection('friendships').doc(pairId).collection('messages').doc(messageId);
try {
await db.runTransaction(async (tx) => {
const snap = await tx.get(ref);
if (!snap.exists) return;
const reactions = snap.data().reactions || {};
const mine = reactions[currentUser.uid];
const updates = {};
updates['reactions.' + currentUser.uid] = (mine === emoji) ? firebase.firestore.FieldValue.delete() : emoji;
tx.update(ref, updates);
});
} catch (e) { /* non-fatal */ }
}
BADGE_DEFS
const BADGE_DEFS = [
{ id:'slump', emoji:'🌧️', name:'The Slump', desc:'Survived 3 rough days (below 4) in a row', check:hasSlump },
{ id:'consistent', emoji:'🧘', name:'Mr. Consistent', desc:'A whole week of steady 5s and 6s', check:hasConsistent },
{ id:'party', emoji:'🎉', name:'Party Animal', desc:'Hit a perfect 10 on a Friday', check:hasPartyAnimal },
{ id:'moody', emoji:'🎭', name:'Moody', desc:'Swung 5+ points between two logged days', check:hasMoody },
{ id:'early_bird', emoji:'🐦', name:'Early Bird', desc:'Rated your day before 9am', check:hasEarlyBird },
{ id:'night_owl', emoji:'🦉', name:'Night Owl', desc:'Rated your day after 11pm', check:hasNightOwl },
{ id:'century', emoji:'💯', name:'Century Club', desc:'Logged 100 days total', check:hasCentury },
{ id:'tag_team', emoji:'🤝', name:'Tag Team', desc:'Connected with a friend', check:hasTagTeam },
{ id:'popular', emoji:'🌟', name:'Popular', desc:'Set your profile to Public', check:hasPopular },
{ id:'networker', emoji:'🕸️', name:'Networker', desc:'Made 3 or more friends', check:hasNetworker },
{ id:'chatterbox', emoji:'💬', name:'Chatterbox', desc:'Sent 50+ chat messages', check:hasChatterbox },
{ id:'historian', emoji:'📜', name:'Historian', desc:'Logged 365 days total', check:hasHistorian },
{ id:'comeback', emoji:'🔄', name:'Comeback', desc:'Followed a rough day (≤3) with an amazing one (9+)', check:hasComeback }
];
Premium: constant + redeem handler
// Fixed set of 10 valid Premium codes. Checked client-side only — there's
// no server here, so treat this like a shared house key, not real security.
const PREMIUM_CODES = ['SHIT-36EAE7','SHIT-6G9TRN','SHIT-9AC6SS','SHIT-KTR4TR','SHIT-LYGKAU','SHIT-MBQW5Q','SHIT-RAX5YL','SHIT-RNDV5W','SHIT-UNCT46','SHIT-ZCJ6NR'];
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;
});
Firestore rule: users update (isPremium lock)
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);
Firestore rule: message create (schema per type)
allow create: if isPairParticipant(pairId) && friendshipIsAccepted(pairId)
&& request.resource.data.senderUid == request.auth.uid
&& request.resource.data.senderName is string
&& request.resource.data.reactions == {}
&& request.resource.data.createdAt == request.time
&& request.resource.data.keys().hasOnly(
['type','text','dayShare','audio','audioMimeType','durationSec',
'senderUid','senderName','reactions','createdAt'])
&& (
(request.resource.data.type == 'text'
&& request.resource.data.text is string
&& request.resource.data.text.size() > 0
&& request.resource.data.text.size() <= 2000)
||
(request.resource.data.type == 'day_share'
&& request.resource.data.dayShare.keys().hasOnly(['date','rating','note','noteHidden'])
&& request.resource.data.dayShare.date is string
&& request.resource.data.dayShare.rating is number
&& request.resource.data.dayShare.rating >= 1
&& request.resource.data.dayShare.rating <= 10
&& request.resource.data.dayShare.note is string
&& request.resource.data.dayShare.note.size() <= 280
&& request.resource.data.dayShare.noteHidden is bool)
||
// 32kbps opus x 30s lands ~160-200KB base64; 360KB leaves headroom
// for browsers that ignore the bitrate hint, still well under the
// 1MiB Firestore doc cap once the other small fields are added.
(request.resource.data.type == 'voice'
&& request.resource.data.audio is string
&& request.resource.data.audio.size() <= 360000
&& request.resource.data.audioMimeType is string
&& request.resource.data.durationSec is number
&& request.resource.data.durationSec > 0
&& request.resource.data.durationSec <= 30)
);
Known limitations
Everything below is a real, verified finding from reading the code and rules together — not speculation about what a typical app "might" have wrong.
| Finding | Detail |
|---|---|
| Redeem button vs. rules | Functionally breaks as soon as the rules are deployed as given — see Security rules. This is the one to fix first. |
| Premium codes ship in the JS | All 10 codes are readable in view-source. Acknowledged in your own code comment as an accepted tradeoff, not a bug — but worth remembering if Premium ever gates something real. |
| Citations are self-reported | The rules let a client create its own badge docs with no server-side check of the underlying claim (same trust model as the profile's stat counters). |
| ratings.tags unvalidated | No hasOnly()/type check on tags, ratedAtHour, createdAt in the ratings rule — doesn't allow touching other users' data, just a looser shape check than the rest of the schema. |
| No group chat / dayMarkers | Neither exists in the code or rules — see the note in Data model if these are on the roadmap. |
| Stale header comment | index.html line 4 says the Firestore rules are "in comments at the end of this file" — they aren't anymore; they now live in the separate firestore.rules you pasted alongside it. Harmless, just worth updating so it doesn't send a future reader looking for something that isn't there. |
| Sound preference doesn't persist | soundEnabled lives in memory only — unlike theme and font, it resets to "on" every page load. |
Deployment
The app itself
It's a single static file, so GitHub Pages deployment is just: push index.html to the repo (the avatar image URLs already point at github.com/Jishnuec/shitty-human, so that's the natural home for it), then in the repo's Settings → Pages, point the Pages source at the branch/folder containing it. No build command, no output directory, no environment variables — the Firebase config is plain client-side data (see below), so there's nothing to inject at build time.
The rules
Deploy the Firestore rules with the Firebase CLI, from the same command already noted at the top of your rules file:
firebase deploy --only firestore:rules
Resolve the isPremium conflict in Security rules before running this — deploying the rules as given, ahead of a code fix, is exactly what breaks the redeem button.
On the exposed API key
The Firebase web config (apiKey, authDomain, projectId, etc.) is hard-coded in index.html and visible to anyone who views source. That's expected and fine — Firebase's web API key identifies the project, it isn't a secret, and it's designed to be public. firestore.rules is the actual security boundary, which is exactly why the isPremium issue above is the finding that matters, not the config object sitting in plain sight next to it.