PatchHub

← patchhub/habit-tracker

Pick an emoji for each habit

Published ✓ Safe by @maya 0 users2 votes 2 files changed (+240 −1)

Each habit gets a small Emoji button where you can choose from 48 built-in emojis or type or paste your own. You can also pick one while adding a new habit, change it later, or remove it.

What it does

- Each habit stores an optional emoji (a string, empty by default). Existing saved habits load with no emoji. The emoji is saved in the same browser storage as the other habit data. - Each habit row shows a button to the left of the habit name. It displays the chosen emoji, or the text "Emoji" with a dashed border if none is set. Its tooltip and accessible label read "Pick an emoji for "<name>"" or "Change emoji for "<name>"". - The add-habit form has the same button before the name input, for choosing an emoji for the new habit. After a habit is added, the pending emoji resets to none. - Clicking the button opens a popup dialog ("Choose an emoji") placed just below the button and kept inside the window. Clicking the button again closes it. - The popup shows a grid of 8 columns with 48 emojis: 💧 📚 🏃 🧘 💪 🏋️ 🚶 🚴 🏊 🥗 🍎 🥤 🍳 😴 🛏️ ⏰ 🦷 🧴 💊 🧹 🧺 🌱 🐕 ☕ 🚭 📵 💻 🧠 ✍️ 📝 📖 🎸 🎹 🎨 🗣️ 📞 🙏 ❤️ 😊 ☀️ 🌙 💰 🎯 ⭐ ✅ 🔥 🌟 🏆. The current emoji is highlighted and focused when the popup opens. - Below the grid is a text box with the placeholder "Or type/paste any emoji" and a "Use" button. Only the first emoji or grapheme is kept, including skin tones and joined emoji, and the result is capped at 32 characters. Empty input does nothing. - If the habit already has an emoji, a "No emoji" button appears in the popup and clears it. - The popup closes when the user picks an emoji, presses Escape (focus returns to the button), clicks outside it, resizes the window, or the list re-renders. After a change, the habit is saved, the list re-renders, and focus returns to that habit's emoji button. - The README gets a bullet describing the feature.

Safety check ✓ Safe

The changes are limited to front-end display code, styling, and a README note. There are no network requests, new dependencies, or access to anything beyond the app's own browser storage.

Conversation that created this patch
@maya
Let me pick an emoji for each habit.
AI
Every habit can now have its own emoji. - **How to pick one:** each habit has a small "Emoji" button to the left of its name. Click it to choose from a set of 48 habit-friendly emojis (water, books, running, sleep and so on). If you want one that isn't there, type or paste any emoji into the box and click "Use." - **When adding a habit:** there's the same button next to the "New habit" box, so you can choose the emoji before you click "Add habit." - **Changing or removing:** click the habit's emoji to swap it, or choose "No emoji" to clear it. Your existing habits are untouched and start without an emoji. Emojis are saved in your browser along with everything else. I also added a short note about this to the instructions file. I checked that the code loads without errors and that the typed-in emoji box keeps exactly one emoji, including ones with skin tones. I couldn't open the page in a browser here, so I haven't seen the new buttons or the emoji list on screen. It's worth a quick look on your end.
Code changes (for the curious, 2 files) · raw
README.md +1 −0
@@ -5,6 +5,7 @@ A simple habit tracker that runs entirely in your web browser. No installation,
## What it does
- **Add habits** – type a habit (like "Drink water" or "Read 10 pages") and click **Add habit**.
+- **Pick an emoji** – click the small **Emoji** button next to a habit (or next to the box when adding one) and choose from the list, or type/paste any emoji you like. Click it again any time to change it, or choose **No emoji** to remove it.
- **Check them off** – click **Mark done today** each day you do the habit. Click it again to undo.
- **See your streak** – each habit shows how many days in a row you've kept it up. If you haven't checked it off yet today, your streak from yesterday still counts until the day is over.
- **30-day grid** – a row of squares shows the last 30 days (oldest on the left, today on the right). Green means done. Click any square to fix a day you forgot to mark.
index.html +239 −1
@@ -144,6 +144,77 @@
margin-top: 24px;
text-align: center;
}
+
+ /* Emoji for each habit */
+ .emoji-btn {
+ flex: none;
+ width: 44px;
+ min-height: 40px;
+ padding: 0;
+ font-size: 1.4rem;
+ line-height: 1;
+ border-radius: 10px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ }
+ .emoji-btn.empty {
+ border-style: dashed;
+ color: var(--muted);
+ font-size: 0.75rem;
+ }
+ .emoji-btn:hover { border-color: var(--muted); }
+ .emoji-picker {
+ position: absolute;
+ z-index: 10;
+ width: 300px;
+ max-width: calc(100vw - 16px);
+ background: var(--card);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
+ padding: 10px;
+ }
+ .emoji-picker .choices {
+ display: grid;
+ grid-template-columns: repeat(8, 1fr);
+ gap: 2px;
+ }
+ .emoji-picker .choices button {
+ border: none;
+ background: none;
+ font-size: 1.3rem;
+ line-height: 1;
+ padding: 5px 0;
+ border-radius: 6px;
+ }
+ .emoji-picker .choices button:hover,
+ .emoji-picker .choices button:focus { background: var(--empty); }
+ .emoji-picker .choices button.selected { background: var(--empty); outline: 2px solid var(--accent); }
+ .emoji-picker form {
+ display: flex;
+ gap: 6px;
+ margin-top: 10px;
+ }
+ .emoji-picker input {
+ flex: 1;
+ min-width: 0;
+ padding: 6px 8px;
+ font: inherit;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ }
+ .emoji-picker form button { padding: 6px 10px; }
+ .emoji-picker .remove {
+ display: block;
+ width: 100%;
+ margin-top: 8px;
+ padding: 6px 8px;
+ color: var(--muted);
+ font-size: 0.9rem;
+ }
+ .emoji-picker .remove:hover { color: var(--danger); }
+
@media (max-width: 480px) {
.grid { gap: 2px; }
.check-today { min-width: 0; flex: 1; }
@@ -156,6 +227,7 @@
<p class="today-label" id="todayLabel"></p>
<form class="add" id="addForm" autocomplete="off">
+ <button type="button" class="emoji-btn empty" id="newEmojiBtn" aria-haspopup="dialog" aria-expanded="false">Emoji</button>
<input id="habitInput" type="text" maxlength="80" placeholder="New habit, e.g. &quot;Drink water&quot;" aria-label="New habit name">
<button type="submit" class="primary">Add habit</button>
</form>
@@ -172,6 +244,14 @@
var STORAGE_KEY = "habitTracker.v1";
var DAYS_SHOWN = 30;
+ var EMOJI_CHOICES = [
+ "💧", "📚", "🏃", "🧘", "💪", "🏋️", "🚶", "🚴",
+ "🏊", "🥗", "🍎", "🥤", "🍳", "😴", "🛏️", "⏰",
+ "🦷", "🧴", "💊", "🧹", "🧺", "🌱", "🐕", "☕",
+ "🚭", "📵", "💻", "🧠", "✍️", "📝", "📖", "🎸",
+ "🎹", "🎨", "🗣️", "📞", "🙏", "❤️", "😊", "☀️",
+ "🌙", "💰", "🎯", "⭐", "✅", "🔥", "🌟", "🏆"
+ ];
// ---------- Dates (always local time, as "YYYY-MM-DD") ----------
function dateKey(d) {
@@ -202,6 +282,7 @@
return {
id: String(h.id || makeId()),
name: h.name,
+ emoji: typeof h.emoji === "string" ? cleanEmoji(h.emoji) : "",
done: (h.done && typeof h.done === "object") ? h.done : {}
};
});
@@ -216,6 +297,31 @@
alert("Sorry, your habits could not be saved in this browser.");
}
}
+ // Keep just the first emoji (or character) someone typed or pasted.
+ function cleanEmoji(text) {
+ text = String(text || "").trim();
+ if (!text) return "";
+ var first = "";
+ if (window.Intl && Intl.Segmenter) {
+ first = new Intl.Segmenter(undefined, { granularity: "grapheme" })
+ .segment(text)[Symbol.iterator]().next().value.segment;
+ } else {
+ var chars = Array.from(text);
+ first = chars[0];
+ for (var i = 1; i < chars.length; i++) {
+ var c = chars[i];
+ var code = c.codePointAt(0);
+ if (c === "\uFE0F" || (code >= 0x1F3FB && code <= 0x1F3FF) || (code >= 0xE0020 && code <= 0xE007F)) {
+ first += c;
+ } else if (c === "\u200D" && i + 1 < chars.length) {
+ first += c + chars[++i];
+ } else {
+ break;
+ }
+ }
+ }
+ return first.slice(0, 32);
+ }
function makeId() {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
@@ -241,11 +347,113 @@
render();
}
+ // ---------- Emoji picker ----------
+ var picker = null; // { el, anchor }
+
+ function showEmojiOn(btn, emoji, habitName) {
+ btn.textContent = emoji || "Emoji";
+ btn.classList.toggle("empty", !emoji);
+ var what = habitName ? ' for "' + habitName + '"' : " for the new habit";
+ btn.title = (emoji ? "Change emoji" : "Pick an emoji") + what;
+ btn.setAttribute("aria-label", btn.title);
+ }
+
+ function closePicker(returnFocus) {
+ if (!picker) return;
+ var anchor = picker.anchor;
+ picker.el.remove();
+ picker = null;
+ anchor.setAttribute("aria-expanded", "false");
+ if (returnFocus && document.body.contains(anchor)) anchor.focus();
+ }
+
+ function openPicker(anchor, current, onPick) {
+ if (picker && picker.anchor === anchor) { closePicker(true); return; }
+ closePicker(false);
+
+ var el = document.createElement("div");
+ el.className = "emoji-picker";
+ el.setAttribute("role", "dialog");
+ el.setAttribute("aria-label", "Choose an emoji");
+
+ function choose(emoji) {
+ closePicker(true);
+ onPick(emoji);
+ }
+
+ var choices = document.createElement("div");
+ choices.className = "choices";
+ var focusTarget = null;
+ EMOJI_CHOICES.forEach(function (emoji) {
+ var b = document.createElement("button");
+ b.type = "button";
+ b.textContent = emoji;
+ b.setAttribute("aria-label", "Use " + emoji);
+ if (emoji === current) { b.className = "selected"; focusTarget = b; }
+ b.addEventListener("click", function () { choose(emoji); });
+ choices.appendChild(b);
+ });
+ el.appendChild(choices);
+
+ var form = document.createElement("form");
+ form.autocomplete = "off";
+ var input = document.createElement("input");
+ input.type = "text";
+ input.placeholder = "Or type/paste any emoji";
+ input.setAttribute("aria-label", "Type or paste your own emoji");
+ if (current && EMOJI_CHOICES.indexOf(current) === -1) input.value = current;
+ var useBtn = document.createElement("button");
+ useBtn.type = "submit";
+ useBtn.textContent = "Use";
+ form.appendChild(input);
+ form.appendChild(useBtn);
+ form.addEventListener("submit", function (e) {
+ e.preventDefault();
+ var emoji = cleanEmoji(input.value);
+ if (!emoji) { input.focus(); return; }
+ choose(emoji);
+ });
+ el.appendChild(form);
+
+ if (current) {
+ var removeBtn = document.createElement("button");
+ removeBtn.type = "button";
+ removeBtn.className = "remove";
+ removeBtn.textContent = "No emoji";
+ removeBtn.addEventListener("click", function () { choose(""); });
+ el.appendChild(removeBtn);
+ }
+
+ document.body.appendChild(el);
+ picker = { el: el, anchor: anchor };
+ anchor.setAttribute("aria-expanded", "true");
+
+ // Place it just below the button, kept inside the window.
+ var rect = anchor.getBoundingClientRect();
+ var pageW = document.documentElement.clientWidth;
+ var left = rect.left;
+ if (left + el.offsetWidth > pageW - 8) left = pageW - 8 - el.offsetWidth;
+ if (left < 8) left = 8;
+ el.style.left = (left + window.scrollX) + "px";
+ el.style.top = (rect.bottom + window.scrollY + 6) + "px";
+
+ (focusTarget || choices.firstChild).focus();
+ }
+
+ document.addEventListener("mousedown", function (e) {
+ if (picker && !picker.el.contains(e.target) && !picker.anchor.contains(e.target)) closePicker(false);
+ });
+ document.addEventListener("keydown", function (e) {
+ if (picker && e.key === "Escape") closePicker(true);
+ });
+ window.addEventListener("resize", function () { closePicker(false); });
+
// ---------- Rendering ----------
var listEl = document.getElementById("habitList");
var renderedDay = dateKey(daysAgo(0));
function render() {
+ closePicker(false);
renderedDay = dateKey(daysAgo(0));
document.getElementById("todayLabel").textContent =
"Today is " + new Date().toLocaleDateString(undefined, {
@@ -272,6 +480,23 @@
var top = document.createElement("div");
top.className = "habit-top";
+ var emojiBtn = document.createElement("button");
+ emojiBtn.type = "button";
+ emojiBtn.className = "emoji-btn";
+ emojiBtn.setAttribute("data-emoji-for", habit.id);
+ emojiBtn.setAttribute("aria-haspopup", "dialog");
+ emojiBtn.setAttribute("aria-expanded", "false");
+ showEmojiOn(emojiBtn, habit.emoji, habit.name);
+ emojiBtn.addEventListener("click", function () {
+ openPicker(emojiBtn, habit.emoji, function (emoji) {
+ habit.emoji = emoji;
+ save();
+ render();
+ var again = listEl.querySelector('[data-emoji-for="' + habit.id + '"]');
+ if (again) again.focus();
+ });
+ });
+
var name = document.createElement("div");
name.className = "habit-name";
name.textContent = habit.name;
@@ -318,6 +543,7 @@
render();
});
+ top.appendChild(emojiBtn);
top.appendChild(name);
top.appendChild(streakEl);
top.appendChild(checkBtn);
@@ -359,14 +585,26 @@
}
// ---------- Adding habits ----------
+ var newEmoji = "";
+ var newEmojiBtn = document.getElementById("newEmojiBtn");
+ showEmojiOn(newEmojiBtn, newEmoji);
+ newEmojiBtn.addEventListener("click", function () {
+ openPicker(newEmojiBtn, newEmoji, function (emoji) {
+ newEmoji = emoji;
+ showEmojiOn(newEmojiBtn, newEmoji);
+ });
+ });
+
document.getElementById("addForm").addEventListener("submit", function (e) {
e.preventDefault();
var input = document.getElementById("habitInput");
var name = input.value.trim();
if (!name) { input.focus(); return; }
− habits.push({ id: makeId(), name: name.slice(0, 80), done: {} });
+ habits.push({ id: makeId(), name: name.slice(0, 80), emoji: newEmoji, done: {} });
save();
input.value = "";
+ newEmoji = "";
+ showEmojiOn(newEmojiBtn, newEmoji);
render();
input.focus();
});