diff --git a/.beans/vibed-4rql--per-habit-streaks-each-habit-scores-its-own-streak.md b/.beans/vibed-4rql--per-habit-streaks-each-habit-scores-its-own-streak.md new file mode 100644 index 0000000..606b439 --- /dev/null +++ b/.beans/vibed-4rql--per-habit-streaks-each-habit-scores-its-own-streak.md @@ -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. diff --git a/bit-by-bit/README.md b/bit-by-bit/README.md index f76a2ed..95d2a31 100644 --- a/bit-by-bit/README.md +++ b/bit-by-bit/README.md @@ -18,20 +18,18 @@ next one. Each artwork is a pixel grid. On start, a seeded PRNG (mulberry32 + Fisher–Yates) 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 16–48 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 16–48 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 diff --git a/bit-by-bit/docs/ARCHITECTURE.md b/bit-by-bit/docs/ARCHITECTURE.md index 44dbfde..82d01c6 100644 --- a/bit-by-bit/docs/ARCHITECTURE.md +++ b/bit-by-bit/docs/ARCHITECTURE.md @@ -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. | diff --git a/bit-by-bit/docs/DESIGN.md b/bit-by-bit/docs/DESIGN.md index 4c2a76c..a31302c 100644 --- a/bit-by-bit/docs/DESIGN.md +++ b/bit-by-bit/docs/DESIGN.md @@ -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). diff --git a/bit-by-bit/index.html b/bit-by-bit/index.html index 8e5bc7c..92e9615 100644 --- a/bit-by-bit/index.html +++ b/bit-by-bit/index.html @@ -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 @@
Today - πŸ”₯ 0-day streak
-
πŸ”₯ 0streak
βœ… 0checks
πŸ–ΌοΈ 0finished
@@ -432,12 +430,12 @@

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.

-

How you earn pixels: 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 - whole list in a day is the sweet spot.

-

Streaks multiply everything. 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.

+

Each habit has its own streak. 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.

+

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.

Everything lives only in this browser's local storage, on this device. Nothing is ever sent anywhere.

@@ -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(); }