Shitty Human — Docs
Single-file web app · engineering reference

Shitty Human, documented

A daily mood tracker that grew a friend system, real-time chat, voice notes, an achievement system, and a premium tier — all inside one 2,894-line HTML file talking directly to Firebase. This document describes exactly what that file does, not what a mood-tracking app typically does.

1HTML file
2,894lines
4collections
13citations
3msg types
0backend servers
Sources: your uploaded index.html (184,890 bytes) and the Firestore rules you pasted alongside it.
Orientation

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.

Daily ratingFriends1:1 chat Voice notesReactionsCitations / badges ComparePremium codesThemes & fonts
⚠ Before you deploy: the redeem-code button and the rules disagree

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.

Orientation

Tech stack

Nothing here needs a build step. There's no package.json, no bundler, no framework — index.html is deployed as-is.

LayerWhat's used
Markup / logicOne file, index.html — 2,894 lines, ~181 KB. HTML, CSS, and JS all inline, no external .js/.css files.
AuthFirebase Authentication, compat SDK 12.15.0, email/password provider (see Authentication for why it's email/password with no real emails).
DatabaseCloud Firestore, compat SDK 12.15.0 — real-time listeners (onSnapshot) for friendships and the active chat thread, plain get() reads everywhere else.
ChartsChart.js 4.5.0 (UMD build) — line, bar, and doughnut charts on the Stats and Compare tabs.
FontsGoogle 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.
AudioZero audio files for UI sound effects — every chime is a Web Audio oscillator generated on the fly (see Chat & voice notes).
HostingStatic hosting on GitHub Pages. Avatar images are fetched from raw.githubusercontent.com/Jishnuec/shitty-human/master/Profile/…, which is also the deploy source repo.
CDNEverything 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:

firebase-app-compat.jsfirebase-auth-compat.js firebase-firestore-compat.jschart.umd.min.js
Orientation

Architecture

The file is one linear document with four regions, in this order:

lines 1–466 — <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.
↓
lines 467–476 — a tiny inline <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.
↓
lines 478–884 — the <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).
↓
lines 880–883 — the four CDN <script src> tags from the table above.
↓
lines 885–2892 — one big (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 & auth

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.

users/{uid}profile doc · doubles as friend directory
username, usernameLower3–15 chars, [a-zA-Z0-9_]. Immutable after signup.
displayNameeditable; defaults to username
avatarIdone of 6 philosopher ids — see Profile & privacy
memberNumberclaimed once from meta/counters, then frozen
profileVisibility'public' | 'friends' | 'private' — default 'friends'
shareRatings, shareNotesbooleans gating what friends see when you share a day
isPremium, premiumSincesee the callout in Overview
messagesSent, daysLoggedCountclient-incremented counters, shown on the profile card
deletedsoft-delete flag — see Account deletion
createdAtserver timestamp, immutable
↳ badges/{badgeId}one doc per unlocked citation — just { unlockedAt }. Doc id is the citation id, e.g. century.
friendships/{pairId}pairId = sorted "uidA_uidB"
usersthe two uids, sorted — same order as the id
requestedBy, status'pending' → 'accepted' → (optionally) 'ended' → back to 'pending' on re-friend
createdAt, respondedAt, endedAt, endedBytimestamps for each transition
lastMessageAt, lastMessageTextpreview shown in the friend switcher
unreadFor.{uid}per-user unread counter, bumped with FieldValue.increment(1)
autoMigratedset true only by the 2-user auto-friend path — see Code reference
↳ messages/{id}type: 'text' | 'day_share' | 'voice', plus senderUid, senderName, reactions (map of uid→emoji), createdAt
ratings/{uid_date}one doc per user per calendar day
uid, username, datedoc id is literally uid + '_' + date, e.g. abc123_2026-08-06
ratinginteger 1–10
note≤ 600 chars stored; ≤ 280 chars shown when shared to chat
privateper-entry override — hides this specific day from friends even if the profile shares ratings
tagsup to 3, from a fixed 12-option list — see Rating your day
ratedAtHour, createdAtset once, only on the first save of the day (powers the Early Bird / Night Owl citations)
updatedAtserver timestamp, every save
meta/counterssingle document
userCountincremented inside a transaction on every signup; the resulting number becomes that user's memberNumber
Note on scope — dayMarkers and groups

Your 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).

Note — ratings.tags isn't shape-checked

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.

Data & auth

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:

usernameToEmail()index.html · line 1214
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 submit handlerindex.html · lines 1296–1336
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().

Data & auth

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.

HelperWhat 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
⚠ The isPremium lock vs. the redeem button

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:

users/{uid} update rulefirestore.rules · lines 72–81
      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 click handlerindex.html · lines 1747–1760
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 isPremium with the Admin SDK, which bypasses these rules entirely.
  • It's just a fun toggle with no real stakes → delete the isPremium == resource.data.isPremium line 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:

messages create rulefirestore.rules · lines 156–190
        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.

Features

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 handler + celebrateRating()index.html · lines 2029–2064
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.

Features

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.

TransitionWho can trigger itResult
Send requesteither user, via username searchstatus: 'pending'
Acceptthe invited side only (rule checks request.auth.uid != requestedBy)status: 'accepted'
Decline / canceleither side, while still pendingdoc hard-deleted (no messages exist yet)
Unfriendeither side, once acceptedstatus: 'ended' — soft-ended, not deleted, so chat history stays readable
Re-friendeither side, after endedsame 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.

Features

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():

sendChatMessage()index.html · lines 2639–2650
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:

sendVoiceMessage()index.html · lines 2665–2676
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();
}
MediaRecorder32kbps opus30s hard cap base64 in Firestore≤360KB enforced by rules

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])):

toggleReaction()index.html · lines 2619–2633
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 */ }
}
Features

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).

Features

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:

BADGE_DEFSindex.html · lines 2207–2221
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 }
];
CitationUnlocks when…
🌧️The Slump3 rough days (rating ≤ 3) in a row
🧘Mr. Consistenta full week of steady 5s and 6s
🎉Party Animala perfect 10 lands on a Friday
🎭Moodytwo logged days swing 5+ points apart
🐦Early Birda day is rated before 9am
🦉Night Owla day is rated after 11pm
💯Century Club100 total days logged
🤝Tag Teamyou connect with at least one friend
🌟Popularprofile visibility is set to Public
🕸️Networker3 or more friends
💬Chatterbox50+ chat messages sent
📜Historian365 total days logged
🔄Comebacka 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.

Features

Premium

Premium's one real effect in the UI: it lets you preview the name/description/emoji of citations you haven't unlocked yet (otherwise they render as ❔ / "???" / "Premium members can preview this"). It's unlocked with a code:

PREMIUM_CODESindex.html · lines 957–959
// 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 click handlerindex.html · lines 1747–1760
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;
});
✦ Client-side only, by design

Your own code comments already say it plainly: "Checked client-side only — there's no server here, so treat this like a shared house key, not real security." Because the full code list ships inside the JS bundle, anyone who opens dev tools can read all ten and redeem one — or just run the update() call directly from the console, code or not (modulo the rules conflict above). If Premium ever needs to gate something with real value, it needs a server to check the code and grant access, the same as the isPremium fix in Security rules.

Features

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.

SettingValueWho 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.

Features

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.

Features

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:

  1. The users/{uid} doc is updated: deleted: true, displayName set to "Former Shitty Human", profileVisibility forced to 'public' (so the tombstone state is consistently visible rather than ambiguously hidden).
  2. The Firebase Auth user itself is deleted via currentUser.delete() — the login is genuinely gone.
  3. Ratings, past messages, and friendships are not purged — they stay in Firestore, just attached to a profile now marked deleted and rebranded.
Reference

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:

✓ A very specific easter egg

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()

usernameToEmail()index.html · line 1214
function usernameToEmail(username) { return username.trim().toLowerCase().replace(/[^a-z0-9_]/g,'') + '@dayvibe.local'; }

Signup handler

signupForm submit handlerindex.html · lines 1296–1336
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 handler + celebrateRating()index.html · lines 2029–2064
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()

sendChatMessage()index.html · lines 2639–2650
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()

sendVoiceMessage()index.html · lines 2665–2676
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()

toggleReaction()index.html · lines 2619–2633
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

BADGE_DEFSindex.html · lines 2207–2221
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

PREMIUM_CODESindex.html · lines 957–959
// 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 click handlerindex.html · lines 1747–1760
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)

users/{uid} update rulefirestore.rules · lines 72–81
      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)

messages create rulefirestore.rules · lines 156–190
        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)
          );
Reference

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.

FindingDetail
Redeem button vs. rulesFunctionally 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 JSAll 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-reportedThe 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 unvalidatedNo 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 / dayMarkersNeither exists in the code or rules — see the note in Data model if these are on the roadmap.
Stale header commentindex.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 persistsoundEnabled lives in memory only — unlike theme and font, it resets to "on" every page load.
Reference

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:

terminalfirebase-tools
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.