Compare commits

...

4 Commits

Author SHA1 Message Date
Elijah Voigt c6a87a3336 chore(bit-by-bit): record escalating daily pixel reward tickets
Beans records for the triangular per-day reveal work (Nth habit check
of the day reveals N units): vibed-fdph (completed, with the shipped
implementation summary) and a duplicate vibed-i315 opened in the same
session. Retained for the historical task record.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
6 days ago
Elijah Voigt e42c3c731c chore: plan hltb CLI (HowLongToBeat search tool)
Milestone vibed-f05l and its seven subtasks laying out a new Rust CLI
binary that queries howlongtobeat.com and prints completion times.
Covers project init, request/response types, CLI arg parsing, the
HTTP search call, output formatting, and a validation/smoke-test pass.
No implementation yet — planning only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
6 days ago
Elijah Voigt 2a2c607e70 chore(bit-by-bit): enable frontend-design plugin
Adds .claude/settings.json opting bit-by-bit into the frontend-design
plugin for future UI/design work on the pixel-art tracker.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
6 days ago
Elijah Voigt cf5e7460c7 fix(bit-by-bit): make pixel reveal 1:1 with streak-weighted units
Drop the pixelsPerCheck calibration constant that scaled units to a
fixed "perfect month" pace regardless of image size. 1 unit now equals
1 pixel directly (Nth check of the day reveals N pixels, multiplied by
that day's streak length), so bigger images genuinely take longer and
the numbers in the UI match what's actually happening. Also removes
the now-obsolete "~N days to finish" upload estimate and corrects the
About copy, which previously implied the streak multiplier applied
directly to pixel counts.
6 days ago

@ -0,0 +1,24 @@
---
# vibed-0t6e
title: Add API request types
status: todo
type: task
created_at: 2026-04-11T22:34:39Z
updated_at: 2026-04-11T22:34:39Z
parent: vibed-f05l
blocked_by:
- vibed-ca4q
---
Implement the full `SearchRequest` struct tree for the HLTB POST body.
## Todo
- [ ] `SearchRequest` with searchType, searchTerms, searchPage, size, searchOptions, useCache
- [ ] `SearchOptions` with games, users, lists, filter, sort, randomizer
- [ ] `GamesOptions` with userId, platform, sortCategory, rangeCategory, rangeTime, gameplay, rangeYear, modifier
- [ ] `RangeTime { min: Option<u32>, max: Option<u32> }`
- [ ] `Gameplay` with perspective, flow, genre, subGenre
- [ ] `RangeYear { min: &str, max: &str }`
- [ ] `UsersOptions` and `ListsOptions` with sortCategory
- [ ] All structs derive `serde::Serialize` with correct `#[serde(rename = ...)]` annotations
- [ ] `cargo check` passes

@ -0,0 +1,21 @@
---
# vibed-54u4
title: Add CLI argument parsing
status: todo
type: task
created_at: 2026-04-11T22:34:31Z
updated_at: 2026-04-11T22:34:31Z
parent: vibed-f05l
blocked_by:
- vibed-ca4q
---
Implement the `Args` struct using `clap` derive API.
## Todo
- [ ] Add `use clap::Parser;` and derive `Args` struct
- [ ] Variadic positional `title: Vec<String>` with `num_args = 1..`
- [ ] `--json` bool flag (output raw JSON)
- [ ] `--all` bool flag (show all results, not just top match)
- [ ] Call `Args::parse()` in `main` and join title words for display
- [ ] `cargo check` passes

@ -0,0 +1,25 @@
---
# vibed-90il
title: Validation & smoke test
status: todo
type: task
created_at: 2026-04-11T22:35:32Z
updated_at: 2026-04-11T22:35:32Z
parent: vibed-f05l
blocked_by:
- vibed-iq16
- vibed-mlgv
---
Run all validation commands and live smoke tests against the real API.
## Todo
- [ ] `cargo fmt --check` passes
- [ ] `cargo check` passes
- [ ] `cargo clippy -- -D warnings` passes
- [ ] `cargo build --release` succeeds
- [ ] `./target/release/hltb halo` returns results
- [ ] `./target/release/hltb the witcher 3` works without quotes
- [ ] `./target/release/hltb --all zelda` shows numbered list
- [ ] `./target/release/hltb --json hollow knight` outputs valid JSON
- [ ] `./target/release/hltb xyzzy_no_game_zzzq` prints "No results found."

@ -0,0 +1,16 @@
---
# vibed-ca4q
title: Initialize Rust project
status: todo
type: task
created_at: 2026-04-11T22:34:25Z
updated_at: 2026-04-11T22:34:25Z
parent: vibed-f05l
---
Create `Cargo.toml` and `src/main.rs` skeleton for the `hltb` binary.
## Todo
- [ ] Write `Cargo.toml` with package metadata, [[bin]], dependencies (clap, reqwest, serde, serde_json, tokio), and release profile
- [ ] Write minimal `src/main.rs` with a stub `main()`
- [ ] Run `cargo check` and confirm it passes

@ -0,0 +1,10 @@
---
# vibed-f05l
title: hltb CLI — HowLongToBeat search tool
status: todo
type: milestone
created_at: 2026-04-11T22:33:37Z
updated_at: 2026-04-11T22:33:37Z
---
A Rust CLI binary that searches HowLongToBeat.com and prints completion times (Main Story / Main + Extras / Completionist) for any game. Single binary named `hltb`, uses reqwest + clap + serde + tokio.

@ -0,0 +1,29 @@
---
# vibed-fdph
title: Escalating daily pixel rewards (triangular per-day)
status: completed
type: feature
priority: normal
created_at: 2026-07-14T22:38:42Z
updated_at: 2026-07-14T22:46:49Z
---
Reward doing more habits in a single day: the Nth habit checked in a day reveals N units of pixels (1+2+3+... = triangular). Derive revealed pixels from the log per-day triangular sum instead of a flat checks counter; recalibrate pixelsPerCheck so a perfect month still finishes an image. Update DESIGN.md, README.md, ARCHITECTURE.md.
## Summary of Changes
Escalating daily reward implemented in `index.html`:
- Reveal is now **derived from the log** instead of a stored `checks` counter. New helpers `triangular(k)` and `revealedUnits(image)` sum `k·(k+1)/2` units per day (days ≥ startedAt). The Nth habit checked in a day is worth N units, so a 4-check day = 1+2+3+4 = 10 units.
- `getRevealInfo`: `revealedCount = min(opaque, floor(units · pixelsPerCheck))`.
- `pixelsPerCheckFor` recalibrated to `max(1, round(opaque / (triangular(habitCount) · 30)))` so a perfect month still fills the image (verified: ~30 days).
- `onHabitToggle`: snapshots `before` prior to mutating the log; uncheck path no longer touches a counter (past days can't un-reveal). Removed unused `computeFloor`.
- Dropped vestigial `checks` field from `normalizeImage`/`buildImageFromDef`; legacy saves load fine (field ignored, reveal recomputed).
- Upload day-estimate updated to use `triangular(habitCount)·ppc` per perfect day.
- Docs updated: DESIGN.md §3.2 + §9, data model, README.md, ARCHITECTURE.md.
Validation: no external refs (grep http/https/fetch clean), `node --check` passes, pure-function simulation confirms 3/6/9/12-px escalation and ~30-day perfect-month finish. Not opened in a live browser (none available in this environment).
## Browser Verification (headless Firefox 152)
Drove the real app's DOM in headless Firefox with a seeded 4-habit / 4x4-opaque state (pixelsPerCheck=1). Checking habits 14 revealed running totals 1, 3, 6, 10 (deltas +1, +2, +3, +4) — escalating exactly as specified, day total = 10. Unchecking the 4th dropped 10→6 (only today's contribution shrank). No console or window errors (`errors: []`).

@ -0,0 +1,10 @@
---
# vibed-i315
title: Escalating daily pixel rewards (triangular per-day)
status: in-progress
type: feature
created_at: 2026-07-14T22:38:38Z
updated_at: 2026-07-14T22:38:38Z
---
Reward doing more habits in a single day: the Nth habit checked in a day reveals N units of pixels (1+2+3+... = triangular). Derive revealed pixels from the log per-day triangular sum instead of a flat checks counter; recalibrate pixelsPerCheck so a perfect month still finishes an image. Update DESIGN.md, README.md, ARCHITECTURE.md.

@ -0,0 +1,22 @@
---
# vibed-iq16
title: Implement HTTP search function
status: todo
type: task
created_at: 2026-04-11T22:35:13Z
updated_at: 2026-04-11T22:35:13Z
parent: vibed-f05l
blocked_by:
- vibed-0t6e
- vibed-mn9a
---
Implement `async fn search(query: &str) -> Result<SearchResponse, AppError>` using reqwest.
## Todo
- [ ] Define `AppError { Network(reqwest::Error), Parse(serde_json::Error) }` with Display + From impls
- [ ] Implement `search()`: split query on whitespace for searchTerms, build full SearchRequest body
- [ ] POST to `https://howlongtobeat.com/api/search` with headers: User-Agent (Firefox), Referer, Content-Type
- [ ] Use `.json(&body)` for request and `.json::<SearchResponse>()` for response
- [ ] Wire into `main`: call search, match Ok/Err, print error to stderr and exit(1) on failure
- [ ] `cargo check` passes

@ -0,0 +1,24 @@
---
# vibed-mlgv
title: Implement display formatting
status: todo
type: task
created_at: 2026-04-11T22:35:25Z
updated_at: 2026-04-11T22:35:25Z
parent: vibed-f05l
blocked_by:
- vibed-mn9a
- vibed-54u4
---
Implement output formatting functions and the main display dispatcher.
## Todo
- [ ] `fn format_duration(seconds: u32) -> String`: return "N/A" for 0, else "Xh YYm"
- [ ] `fn print_game(game: &GameResult, prefix: Option<&str>)`: print name then 3 time lines with fixed-width labels
- [ ] `fn display(resp: SearchResponse, args: &Args)`: check for zero results, dispatch to --json / --all / default paths
- [ ] Default path: show only `data[0]`
- [ ] `--all` path: numbered list of all results
- [ ] `--json` path: `serde_json::to_string_pretty` of `data[0]` (or full vec with --all)
- [ ] Zero results: print "No results found." and return
- [ ] `cargo check` passes

@ -0,0 +1,19 @@
---
# vibed-mn9a
title: Add API response types
status: todo
type: task
created_at: 2026-04-11T22:35:02Z
updated_at: 2026-04-11T22:35:02Z
parent: vibed-f05l
blocked_by:
- vibed-ca4q
---
Implement `SearchResponse` and `GameResult` deserialization types.
## Todo
- [ ] `SearchResponse { count: u32, data: Vec<GameResult> }` with `Deserialize`
- [ ] `GameResult` with game_id, game_name, comp_main, comp_plus, comp_100, profile_steam, similarity — derive both `Deserialize` and `Serialize` (needed for --json output)
- [ ] Note: comp_main/plus/100 are in **seconds**; 0 means N/A
- [ ] `cargo check` passes

@ -0,0 +1,5 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true
}
}

@ -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, `pixelsPerCheck` calibration, and log-derived reveal: each day earns `triangular(checks)` base units (the Nth check is worth N), multiplied by that day's streak length. |
| **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. |
| **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. |

@ -74,27 +74,21 @@ 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:** the _Nth_ habit checked on a given day is worth
`N` base units (1 + 2 + 3 + … ), so a day of `k` checks earns `triangular(k) =
k·(k+1)/2` base units. 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 units are multiplied by that day's **streak
- **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 is worth `3`
units and the 2nd `6`; on a 5-day streak, `5` and `10`. A day's streak length
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.
- `pixelsPerCheck` is computed **once, at image start**:
`max(1, round(opaqueCount / (triangular(30) · triangular(habitCount))))`
calibrated so a "perfect month" (all habits every day for 30 days, whose
streak-weighted units total `triangular(30) · triangular(habitCount)`) just
fills the image. Locked for the life of the image even if habits change
(prevents gaming/confusion). Clamped to ≥1 so the very first check always
reveals at least one pixel; small images simply fill in sooner than a month.
- **Revealed pixels are derived from the log, not stored.** Revealed unit total =
`Σ streakLen(d) · triangular(len(log[d]))` over days `d ≥ startedAt`; revealed
pixel count = `min(opaqueCount, floor(units · pixelsPerCheck))`. Because reveal
- **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.
@ -112,8 +106,8 @@ stay revealed.
rocket, sunflower, friendly ghost, coffee mug.
2. **Upload:** file input (image/*) → draw to offscreen canvas at a chosen grid
size (16 / 24 / 32 / 48) → sample each cell's center region; alpha < 128
transparent. Show a live preview + "~N days to finish" estimate before
confirming. Reject nothing — any image works; it just gets pixelated.
transparent. Show a live preview + opaque pixel count before confirming.
Reject nothing — any image works; it just gets pixelated.
- Switching artwork mid-image warns that current progress will be archived as
unfinished (kept in gallery, marked incomplete) — or user cancels.
@ -147,7 +141,6 @@ Single localStorage key: `bitbybit.v1`. Entire state is one JSON document:
"w": 16, "h": 16,
"pixels": [null, "#e84d4d", ...], // row-major, length w*h, null = transparent
"seed": 123456789,
"pixelsPerCheck": 2,
"startedAt": "2026-07-01"
},
"log": { // immutable history of check-offs

@ -432,13 +432,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> every habit you check off reveals more of the picture — so
the more you do, the more appears. Each check within a day is worth more than the last: your 1st
check paints a little, your 2nd paints more, your 3rd more still. Checking off your <em>whole</em>
list in a day is the sweet spot.</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 is worth 3× and your 2nd is worth 6×; on a 5-day streak
they're worth 5× and 10×. Come back tomorrow and finish the day's list, and the picture floods in.</p>
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>Everything lives only in this browser's local storage, on this device. Nothing is ever sent
anywhere.</p>
<p>
@ -494,7 +493,6 @@
w: img.w | 0, h: img.h | 0,
pixels: img.pixels.slice(),
seed: (img.seed == null ? 1 : (img.seed >>> 0)),
pixelsPerCheck: Math.max(1, img.pixelsPerCheck | 0 || 1),
startedAt: /^\d{4}-\d{2}-\d{2}$/.test(img.startedAt) ? img.startedAt : todayStr()
};
}
@ -636,12 +634,12 @@
return order;
}
// Escalating daily reward with a streak multiplier:
// • Within a day, the Nth habit checked is worth N "units" (1 + 2 + 3 + … =
// a triangular number), so k checks in a day earn triangular(k) base units.
// • The whole day's units are then multiplied by that day's streak length
// (consecutive active days ending on it): on a 3-day streak the 1st check
// is worth 3 units, the 2nd 6, and so on.
// 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.
// 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
@ -673,23 +671,11 @@
function getRevealInfo(image){
var order = getRevealOrder(image);
var opaque = order.length;
var revealedCount = Math.min(opaque, Math.floor(revealedUnits(image) * image.pixelsPerCheck));
var revealedCount = Math.min(opaque, revealedUnits(image));
var set = new Set(order.slice(0, revealedCount));
return { order: order, opaque: opaque, revealedCount: revealedCount, set: set };
}
// Pixels revealed per unit, fixed at image start. Calibrated so a "perfect
// month" — checking all habits every day for 30 days — just fills the image.
// With the streak multiplier a perfect month earns
// Σ_{day=1..30} day × triangular(habitCount) = triangular(30) × triangular(habitCount)
// units, so pixelsPerCheck ≈ opaqueCount / (triangular(30) × triangular(habitCount)).
// Clamped to ≥1 so the very first check always reveals at least one pixel;
// small images just fill in comfortably sooner than a month.
function pixelsPerCheckFor(opaqueCount, habitCount){
var perfectMonthUnits = triangular(30) * triangular(Math.max(1, habitCount));
return Math.max(1, Math.round(opaqueCount / perfectMonthUnits));
}
function randomSeed(){
// Never return 0: normalizeImage treats a missing seed as 1, and a 0 seed
// would reshuffle after a save/load round-trip.
@ -712,13 +698,10 @@
}
function buildImageFromDef(def){
var opaque = def.pixels.filter(function(p){ return p != null; }).length;
var habitCount = Math.max(1, state.habits.length);
return {
name: def.name, source: def.source, w: def.w, h: def.h,
pixels: def.pixels.slice(),
seed: randomSeed(),
pixelsPerCheck: pixelsPerCheckFor(opaque, habitCount),
startedAt: todayStr()
};
}
@ -1469,12 +1452,7 @@
// Selecting an upload supersedes any highlighted preset tile.
Array.prototype.forEach.call(q('obArtGrid').children, function(c){ c.classList.remove('selected'); });
obSelectedDef = { name: '', source: 'upload', w: result.w, h: result.h, pixels: result.pixels };
var habitCount = Math.max(1, state.habits.length);
var ppc = pixelsPerCheckFor(opaque, habitCount);
var targetTri = opaque / (ppc * triangular(habitCount));
var estDays = Math.max(1, Math.ceil((Math.sqrt(1 + 8 * targetTri) - 1) / 2));
q('obUploadEstimate').textContent = '~' + estDays + ' day' + (estDays === 1 ? '' : 's') +
' to finish (' + opaque + ' opaque pixels).';
q('obUploadEstimate').textContent = opaque + ' opaque pixel' + (opaque === 1 ? '' : 's') + ' to reveal.';
q('obStart').disabled = false;
}).catch(function(){
obUploadResult = null;
@ -1655,16 +1633,7 @@
return;
}
lastUploadResult = result;
var habitCount = Math.max(1, state.habits.length);
var ppc = pixelsPerCheckFor(opaque, habitCount);
// With the streak multiplier, D perfect days in a row reveal
// ppc × triangular(habitCount) × triangular(D) pixels
// (each day's units scaled by its streak length 1..D). Solve
// triangular(D) ≥ opaque / (ppc × triangular(habitCount)) for D.
var targetTri = opaque / (ppc * triangular(habitCount));
var estDays = Math.max(1, Math.ceil((Math.sqrt(1 + 8 * targetTri) - 1) / 2));
q('uploadEstimate').textContent = '~' + estDays + ' day' + (estDays === 1 ? '' : 's') + ' to finish, ' +
'checking in daily (' + opaque + ' opaque pixels).';
q('uploadEstimate').textContent = opaque + ' opaque pixel' + (opaque === 1 ? '' : 's') + ' to reveal.';
q('useUploadBtn').disabled = false;
}).catch(function(){
lastUploadResult = null;

Loading…
Cancel
Save