feat(bit-by-bit): score streaks per-habit instead of day-level

Replace the global day-level streak + triangular(checks) multiplier
with per-habit streaks: each habit tracks its own consecutive-day
streak and reveals pixels equal to that streak length when checked.
Removes the global streak header/stat chip in favor of an inline
streak badge on each habit row.
main
Elijah Voigt 4 days ago
parent c6a87a3336
commit 1cd5348d86

@ -0,0 +1,19 @@
---
# vibed-4rql
title: 'Per-habit streaks: each habit scores its own streak value'
status: completed
type: feature
priority: normal
created_at: 2026-07-16T16:31:23Z
updated_at: 2026-07-16T16:37:00Z
---
Replace the single day-level streak + triangular(checks) reveal formula with per-habit streaks: each habit tracks its own consecutive-day streak, and checking it reveals pixels equal to that habit's current streak length (1st day = 1 pixel, 2nd consecutive day = 2, etc.), with no cross-habit multiplier. Remove the global streak header/stat chip entirely (per user decision) and show each habit's own streak inline on its row instead. Update DESIGN.md, README.md, ARCHITECTURE.md, and index.html (mechanics + About text + UI).
## Summary of Changes
- index.html: replaced the day-level triangular(checks) × streak-multiplier reveal with per-habit streaks. Added `habitStreakEndingOn(log, habitId, day)` and `currentHabitStreak(log, habitId, today)` (display, forgives an unchecked-yet today); `revealedUnits()` now sums each checked habit's own streak length per day. Removed `triangular()`, `streakOnDay()`, and the global `computeStreak()`.
- Removed the global "🔥 N-day streak" header label and stat chip entirely (per user decision); each habit row now shows its own inline streak badge (e.g. 🔥3) when > 0.
- Updated the in-app About text and CSS (`.habit-streak`, removed `.streak-label`).
- Updated docs/DESIGN.md (§2 mockup, §3.2, §3.4, §9), docs/ARCHITECTURE.md, and README.md to describe per-habit streak scoring instead of day-level/triangular + multiplier.
- Validated with a Playwright smoke test (seeded localStorage, checkbox clicks) confirming reveal math, streak badges, and toast deltas; no console errors or non-file:// requests. Also captured a headless screenshot to confirm the UI layout.

@ -18,20 +18,18 @@ next one.
Each artwork is a pixel grid. On start, a seeded PRNG (mulberry32 +
FisherYates) fixes a random reveal order that stays stable across sessions.
Checking off habits reveals pixels, and **the more you do in a single day, the
bigger the reward** — the first habit each day is worth 1 unit, the second 2,
the third 3, and so on (so a 4-habit day earns 1 + 2 + 3 + 4 = 10 units).
**Streaks multiply everything:** each day's units are scaled by the length of
your active streak, so on a 3-day streak the first check is worth 3 units and
the second 6, and on a 5-day streak they're worth 5 and 10. The per-image
reveal rate is sized so a "perfect month" of doing everything daily just fills
the picture. Pixels never un-reveal across days; missing a day just pauses
progress. Includes 8 built-in pixel artworks, or upload any image to pixelate
it into a 1648 cell grid.
Dark/light theme follows your OS by default, with a manual override. Streaks
count any day you checked at least one habit; days where you did everything get
a ✨ perfect-day sparkle.
**Every habit keeps its own streak** — checking a habit reveals pixels equal to
its own current streak length: 1 pixel the first day, 2 the next consecutive
day, 3 the day after, and so on. There's no bonus for doing several habits in
one day; each habit scores purely on its own history. Bigger images just take
longer to fill in. Pixels never un-reveal across days; missing a day resets
that habit's streak, but never erases pixels already earned. Includes 8
built-in pixel artworks, or upload any image to pixelate it into a 1648 cell
grid.
Dark/light theme follows your OS by default, with a manual override. Each
habit shows its own streak inline on its row; days where you did everything
get a ✨ perfect-day sparkle.
## Run it

@ -5,7 +5,7 @@ Everything lives in one file, `index.html`, organized into sections:
| Component | Responsibility |
|---|---|
| **State** | Single JSON document under localStorage key `bitbybit.v1`; load/save with corruption guards; versioned for future migration. |
| **Mechanics** | Habit check/uncheck logging, daily rollover, streak math, seeded (mulberry32) reveal-order permutation, and log-derived reveal: each day reveals `triangular(checks)` pixels (the Nth check reveals N), multiplied by that day's streak length. |
| **Mechanics** | Habit check/uncheck logging, daily rollover, per-habit streak math, seeded (mulberry32) reveal-order permutation, and log-derived reveal: each habit reveals pixels equal to its own current streak length (1 the first day, 2 the next consecutive day, and so on), independent of any other habit. |
| **Artwork** | Preset pixel-art definitions (palette + row strings); upload pipeline (offscreen canvas downsample → pixel grid); gallery of finished pieces. |
| **Rendering** | Canvas renderer for the artwork (revealed pixels, checkerboard for hidden, pop-in animation for deltas); DOM renderer for checklist, stats, settings. |
| **UI wiring** | Onboarding, settings dialog (habits / artwork / data / about), theme cycling (auto→light→dark via `data-theme` + CSS custom properties), export/import/wipe, confetti celebration, and a page footer disclosing authorship. |

@ -34,15 +34,14 @@ stay revealed.
│ │
├──────────────────────────────┤
│ Today · Sunday, July 13 │
│ 🔥 5-day streak │
│ ┌──────────────────────┐ │
│ │ 💧 Drink water [✓] │ │ today's habit checklist —
│ │ 🏃 Go for a run [ ] │ │ big, thumb-friendly rows
│ │ 📖 Read 10 pages [ ] │ │
│ │ 💧 Drink water 🔥5[✓]│ │ today's habit checklist — each row shows
│ │ 🏃 Go for a run [ ] │ │ its own streak; big, thumb-friendly rows
│ │ 📖 Read 10 pages 🔥2[]│ │
│ └──────────────────────┘ │
├──────────────────────────────┤
streak · total checks · │ small stat chips
images finished │
│ total checks · images │ small stat chips
│ finished
└──────────────────────────────┘
```
@ -74,24 +73,23 @@ stay revealed.
- On image start, generate a random permutation of opaque pixel indices using a
**seeded PRNG (mulberry32)** with a stored seed. This is the reveal order —
stable across sessions and re-renders.
- **Escalating daily reward, 1 unit = 1 pixel:** the _Nth_ habit checked on a
given day reveals `N` pixels (1 + 2 + 3 + … ), so a day of `k` checks reveals
`triangular(k) = k·(k+1)/2` pixels before the streak multiplier. Doing more
in one day is rewarded super-linearly; the toast and pop for a later check
reveal noticeably more pixels than an earlier one.
- **Streak multiplier:** a day's pixels are multiplied by that day's **streak
length** — the number of consecutive days with ≥1 check ending on (and
including) that day. On a 3-day streak the 1st check of the day reveals `3`
pixels and the 2nd `6`; on a 5-day streak, `5` and `10`. A day's streak length
counts *backwards* from itself, so checking or unchecking a later day never
changes an earlier day's multiplier.
- **Per-habit streaks, 1 unit = 1 pixel:** each habit has its own streak —
the number of consecutive days (ending on, and including, a given day) that
habit was checked. Checking a habit reveals pixels equal to **that habit's
own streak length** on the day checked: 1 pixel the first day, 2 pixels the
next consecutive day, 3 the day after, and so on. There is no cross-habit
multiplier or bonus for doing several habits in one day — every habit scores
purely on its own history. A habit's streak counts *backwards* from the day
it's evaluated on, so checking or unchecking a different day (or a different
habit) never changes it.
- **Revealed pixels are derived from the log, not stored.** Revealed pixel total =
`min(opaqueCount, Σ streakLen(d) · triangular(len(log[d])))` over days
`d ≥ startedAt`. No separate per-image calibration constant — bigger images
simply take longer to fill in, smaller ones finish sooner. Because reveal
is a pure function of the log, same-day uncheck only shrinks today's
contribution — past days can never be un-revealed, and reloads/undo stay
perfectly consistent with no separate counter to keep in sync.
`min(opaqueCount, Σ Σ streakLen(habit, d))` over days `d ≥ startedAt` and
habits `habit` checked on day `d`. No separate per-image calibration
constant — bigger images simply take longer to fill in, smaller ones finish
sooner. Because reveal is a pure function of the log, same-day uncheck only
shrinks that habit's contribution for today — past days can never be
un-revealed, and reloads/undo stay perfectly consistent with no separate
counter to keep in sync.
- Unrevealed cells render as a subtle dim checkerboard ("static"). Revealed
cells show their color and **pop in with a tiny scale/settle animation** when
newly revealed (animate only the delta, not on every render).
@ -115,14 +113,13 @@ stay revealed.
- "Today" = local date `YYYY-MM-DD` (from local time, not UTC). Recompute on an
interval (~30s) and on `visibilitychange` so a tab left open overnight rolls
over correctly (checklist resets, floor for uncheck updates).
- **Streak:** consecutive days (ending today or yesterday) where **all habits
then existing** were checked… simplify: a day counts if **every habit checked
that day ≥ 1** is too fuzzy — use: day counts if the user checked **all
currently-listed habits** is wrong for history. **Rule:** a day counts toward
the streak if **at least one habit was checked** that day; show a separate
"perfect day" sparkle ✨ on days where all habits were done. Streak of
any-activity is kinder and simpler; perfect days are the bonus flair.
- Stat chips: 🔥 current streak, ✅ total checks all-time, 🖼️ images finished.
- **Streak:** per-habit, not global — each habit's streak is the count of
consecutive days (ending today or yesterday, so it stays "alive" until a day
is fully missed) that habit itself was checked. Shown inline on that habit's
row (e.g. 🔥3), not as a single app-wide number. Separately, show a "perfect
day" sparkle ✨ on days where *every* currently-listed habit was checked —
purely a flair indicator, unrelated to streak math.
- Stat chips: ✅ total checks all-time, 🖼️ images finished.
## 4. Data Model
@ -222,17 +219,19 @@ Single localStorage key: `bitbybit.v1`. Entire state is one JSON document:
## 9. Edge Cases (must handle)
1. First run / empty state → onboarding.
2. Same-day uncheck only shrinks today's (streak-multiplied) contribution;
2. Same-day uncheck only shrinks that habit's contribution for today;
previous days' revealed pixels are never lost (reveal is derived from the log,
and each day's streak multiplier counts backwards from itself).
3. Habit deleted after being checked today → today's log keeps the id; counts
still work.
and each habit's streak counts backwards from the day it's evaluated on).
3. Habit deleted after being checked today → today's log keeps the id; its
streak/counts keep working off the log id even though it no longer appears
in the habit list or UI.
4. Day rollover with tab open → checklist resets without reload.
5. Import of garbage / wrong-version / truncated JSON → friendly rejection.
6. localStorage full or unavailable (private mode) → app still renders; banner
warns that progress won't persist.
7. Image with very few opaque pixels (e.g., 20) finishes early — fine; celebrate.
8. A big escalating reward overshooting the last pixels just clamps to 100%.
9. Legacy saves carrying an old `checks` field load fine — it is ignored and
reveal is recomputed from the log (older images may reveal more than before,
since the reward is now triangular per day and multiplied by streak length).
9. Legacy saves carrying an old `checks` field, or images started under the
previous day-level/triangular reward, load fine — it is ignored and reveal
is always recomputed from the log under the current per-habit-streak rule
(revealed totals may shift from what an old version would have shown).

@ -126,7 +126,6 @@
box-shadow:var(--shadow); display:flex; flex-direction:column; gap:var(--space-3); }
.today-header{ display:flex; justify-content:space-between; align-items:baseline; flex-wrap:wrap; gap:var(--space-2); }
.today-label{ font-weight:700; }
.streak-label{ color:var(--accent-strong); font-weight:700; font-size:0.9rem; }
.habit-list{ list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:var(--space-2); }
.habit-row{ display:flex; align-items:center; gap:var(--space-3); border-radius:var(--radius-md);
background:var(--bg-sunken); min-height:44px; transition:background .2s ease; }
@ -136,7 +135,8 @@
.habit-label{ display:flex; align-items:center; gap:var(--space-3); flex:1; min-height:44px; padding:var(--space-2) var(--space-3);
cursor:pointer; }
.habit-emoji{ font-size:1.35rem; }
.habit-name{ font-size:1rem; }
.habit-name{ font-size:1rem; flex:1; }
.habit-streak{ color:var(--accent-strong); font-weight:700; font-size:0.85rem; flex-shrink:0; }
.habit-checkbox{ width:26px; height:26px; margin:0 var(--space-3) 0 0; accent-color:var(--accent-strong); cursor:pointer; flex-shrink:0; }
.empty-state{ color:var(--text-dim); font-style:italic; text-align:center; margin:var(--space-2) 0; }
@ -282,14 +282,12 @@
<section class="today-card">
<div class="today-header">
<span id="todayLabel" class="today-label">Today</span>
<span id="streakLabel" class="streak-label">🔥 0-day streak</span>
</div>
<ul id="habitList" class="habit-list"></ul>
<p id="emptyHabits" class="empty-state" hidden>No habits yet — plant your first one in Settings 🌱</p>
</section>
<section class="stats-row">
<div class="stat-chip">🔥 <span id="statStreak">0</span><small>streak</small></div>
<div class="stat-chip"><span id="statChecks">0</span><small>checks</small></div>
<div class="stat-chip">🖼️ <span id="statImages">0</span><small>finished</small></div>
</section>
@ -432,12 +430,12 @@
<p>Paint by Habits is a cozy pixel-art habit tracker. Check off your daily habits and watch a hidden
picture reveal itself, one tiny square at a time. Missing a day never erases progress —
pixels stay revealed forever.</p>
<p><strong>How you earn pixels:</strong> each habit you check off today reveals pixels — your 1st
check reveals 1 pixel, your 2nd reveals 2, your 3rd reveals 3, and so on. Checking off your
<em>whole</em> list in a day is the sweet spot.</p>
<p><strong>Streaks multiply everything.</strong> Every consecutive day you show up bumps the multiplier:
on a 3-day streak your 1st check of the day reveals 3 pixels and your 2nd reveals 6; on a 5-day streak
they reveal 5 and 10. Come back tomorrow and finish the day's list, and the picture floods in.</p>
<p><strong>Each habit has its own streak.</strong> A habit's pixel value is its current streak
length — check it off once, that's 1 pixel; keep checking it every consecutive day and it's
worth 2, then 3, then 4, and so on. Missing a day resets that habit's streak back to 1 the next
time you check it, but never erases pixels you've already earned.</p>
<p>Every habit scores on its own — there's no bonus for doing them all in one day, just each
one quietly climbing the longer you keep it up.</p>
<p>Everything lives only in this browser's local storage, on this device. Nothing is ever sent
anywhere.</p>
<p>
@ -634,23 +632,22 @@
return order;
}
// Escalating daily reward with a streak multiplier, 1 unit = 1 pixel:
// • Within a day, the Nth habit checked reveals N pixels (1st = 1, 2nd = 2,
// ...), so k checks in a day reveal triangular(k) = k·(k+1)/2 pixels.
// • Each of those pixels is then multiplied by that day's streak length
// (consecutive active days ending on it): on a 3-day streak the 1st
// check reveals 3 pixels, the 2nd 6; on a 5-day streak, 5 and 10.
// Per-habit streaks, 1 unit = 1 pixel: each habit tracks its own streak of
// consecutive days it was checked. Checking a habit reveals pixels equal to
// its streak length as of that check: 1 pixel the first day, 2 the next
// consecutive day, 3 the day after, and so on. There is no cross-habit
// multiplier — every habit scores purely on its own history.
// Revealed pixels are re-derived from the log every time rather than stored,
// so undo/redo and reloads stay perfectly consistent and past days can never
// be un-revealed by unchecking today (a day's streak length counts backwards
// from itself, so it is unaffected by checking or unchecking later days).
function triangular(k){ return k * (k + 1) / 2; }
// be un-revealed by unchecking today (a habit's streak on a given day counts
// backwards from that day, so it is unaffected by checking or unchecking
// that habit on later days).
// Streak length for a single day: consecutive days with ≥1 check, ending at
// (and including) `day`. 0 if `day` itself has no checks.
function streakOnDay(log, day){
// A single habit's streak ending on (and including) `day`: consecutive days
// with that habit checked. 0 if the habit wasn't checked on `day` itself.
function habitStreakEndingOn(log, habitId, day){
var streak = 0, cursor = day;
while (log[cursor] && log[cursor].length > 0) {
while (log[cursor] && log[cursor].indexOf(habitId) !== -1) {
streak++;
cursor = addDaysStr(cursor, -1);
}
@ -661,8 +658,10 @@
var units = 0;
for (var d in state.log) {
if (d >= image.startedAt) {
var checks = (state.log[d] || []).length;
units += streakOnDay(state.log, d) * triangular(checks);
var checked = state.log[d] || [];
for (var i = 0; i < checked.length; i++) {
units += habitStreakEndingOn(state.log, checked[i], d);
}
}
}
return units;
@ -749,15 +748,13 @@
return new Date(p[0], p[1] - 1, p[2]);
}
function computeStreak(log, today){
var streak = 0;
var cursor = today;
if (!log[cursor] || log[cursor].length === 0) cursor = addDaysStr(cursor, -1);
while (log[cursor] && log[cursor].length > 0) {
streak++;
cursor = addDaysStr(cursor, -1);
}
return streak;
// A habit's current streak for display: same as habitStreakEndingOn, but if
// the habit hasn't been checked yet today the streak is still shown as
// "alive" (counted through yesterday) rather than dropping to 0 the instant
// the day rolls over — it only truly breaks once a day passes with no check.
function currentHabitStreak(log, habitId, today){
var day = habitStreakEndingOn(log, habitId, today) > 0 ? today : addDaysStr(today, -1);
return habitStreakEndingOn(log, habitId, day);
}
function isPerfectDay(log, today, habits){
@ -1238,8 +1235,6 @@
var label = 'Today · ' + d.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
if (isPerfectDay(state.log, today, state.habits)) label += ' ✨';
q('todayLabel').textContent = label;
var streak = computeStreak(state.log, today);
q('streakLabel').textContent = streak > 0 ? ('🔥 ' + streak + '-day streak') : '🔥 Start your streak today';
}
function renderHabits(){
@ -1265,6 +1260,13 @@
nameSpan.textContent = h.name;
label.appendChild(emojiSpan);
label.appendChild(nameSpan);
var streak = currentHabitStreak(state.log, h.id, today);
if (streak > 0) {
var streakSpan = document.createElement('span');
streakSpan.className = 'habit-streak';
streakSpan.textContent = '🔥' + streak;
label.appendChild(streakSpan);
}
var cb = document.createElement('input');
cb.type = 'checkbox';
@ -1283,7 +1285,6 @@
}
function renderStats(){
q('statStreak').textContent = computeStreak(state.log, todayStr());
q('statChecks').textContent = totalChecksAllTime(state.log);
q('statImages').textContent = state.gallery.filter(function(g){ return g.complete; }).length;
}
@ -1325,7 +1326,7 @@
list.splice(idx, 1);
if (list.length === 0) delete state.log[today]; // don't keep empty day arrays around
// Reveal is derived from the log, so removing today's entry only shrinks
// today's triangular contribution — past days stay revealed automatically.
// that habit's contribution for today — past days stay revealed automatically.
saveNow();
renderHabits(); renderArt(); renderStats(); renderTodayHeader();
}

Loading…
Cancel
Save