lans.cloud is a collection of ~59 free single-purpose web tools — classroom timers, D&D calculators, Dragon Ball power level generators — all targeting long-tail searches a young domain can actually win. The site is a few weeks old, and this week something changed: Google Search Console finally had enough data to be useful. Impressions went 9 → 38 → 81 → 158 per day, and the first clicks landed.
Which raised the obvious question: now that Google is telling us exactly which pages it likes, why are we still guessing what to build?
Letting the ranking data write the backlog
The morning started with a Search Console study — a page×query breakdown, 86 rows. Instead of turning that into more SEO copy tweaks (we did two small ones, where the data justified it), I asked my agent a different question: which of our best-ranking pages would people actually come back to — and what's missing that would make them?
The method it settled on, which I've now captured as a reusable project skill:
- Rank candidates by real data: average position × click evidence × recurrence of the use case × feature headroom. A page at position 7 that a youth soccer coach needs every single Saturday beats a page at position 3 someone visits once, ever.
- Inventory before ideating. A subagent swept the actual feature set of all 13 candidate tools — what persists, what exports, what has a fullscreen mode — before proposing anything.
- Check the graveyard. Ideas already rejected (D&D homebrew CR estimation: no licensed source) or demand-gated stay dead. Pages ranking ~50+ on already-aligned copy get backlinks and patience, not more churn.
The inventory produced the headline finding of the day, and it's the kind you only get by looking instead of assuming:
The pages catching clicks didn't remember their users. Our classroom tools had saved setups, localStorage persistence, and print modes everywhere. The fandom pages winning the actual clicks — the DBZ power level calculator got our first tool-page click ever — persisted nothing. A returning fan started from scratch every visit.
That asymmetry became a ranked top-10 report, the report became a queue, and the queue became ten shipped waves. Same day.
What shipped
The playing time calculator got its paper artifact. Our best page (position 7.7, most impressions on the site) computes fair substitution rotations for youth sports — and had no print button. Coaches take paper to the touchline. It now prints a one-page lineup card, copies the plan as text for the parents' group chat, saves named teams, and handles a fixed goalkeeper (keeper plays the whole game, the fair rotation runs over everyone else — which changes which shift counts are mathematically fair, so the math had to move too).
The fandom tools learned to remember. The DBZ calculator persists your fighter, downloads a scouter-card PNG, and hands the fighter into the comparator with one click. The comparator saves named matchups. And the JoJo Stand generator got the design detail I'm most pleased with: a favorites gallery that stores seeds, not snapshots.
export type StandSeed =
| { kind: 'awaken'; input: string } // name → deterministic Stand
| { kind: 'random'; seed: number } // the RNG seed we rolled
| { kind: 'custom'; name: string; stats: StandStats };
Random rolls used to call generateStand(Math.random) — unreproducible by construction. Now they roll a 32-bit seed first and generate from a seeded RNG, so a saved Stand revives through the same generator instead of being a stored copy that could drift from the code that made it.
The jobs chart got "week 3 reality" features. Absences are a data layer applied at render time, on top of the existing manual-override layer — mark a student absent and their jobs go to the fairest available classmate (on-break students first), with a visible marker on every covered cell. Unmark, and the original rotation is back untouched, because nothing was ever rewritten.
The scoreboard became a weekly economy. Undo for mis-taps (including in fullscreen, where mis-taps happen in front of 28 children), copy-able final standings, and a bank: end of day, today's points move into a running "This week" total and the board resets for tomorrow.
The D&D encounter calculator got a bestiary. All 334 monsters of the 5e SRD (names and challenge ratings only, CC-BY-4.0, extracted from the community 5e-database project) checked in as data with invariant tests — type "Goblin" and you get a named group with CR 1/4 filled in. Monsters outside the SRD still work the old way; we don't invent stats we can't source.
The Owlbear
Every wave here ended the same way: not with green unit tests, but with a headless Chrome script driving the deployed production page and asserting on what actually happened — localStorage contents, clipboard text, rendered DOM.
On the last wave, that step earned its keep. The monster picker was a native <datalist> with an exact-match-adds-the-monster handler on change. Unit tests passed. TypeScript passed. The build passed. Then the verification script typed "Owlbear" into the live page, and the assertion failed with this:
FAIL: Owlbear group not added:
[{"cr":"1","count":1},{"cr":"1/4","count":4},{"cr":"0","count":1,"label":"Owl"}]
Typing O-w-l… the input momentarily contained "Owl" — which is also an SRD monster (CR 0). Exact match fired on the third keystroke, added an Owl, cleared the input, and ate the rest of the word. The bestiary is full of these landmines: any name that prefixes another name breaks match-on-keystroke.
The fix distinguishes a real datalist pick from typing — browsers report a datalist selection as inputType: "insertReplacementText" — with Enter as the explicit fallback:
onChange={(e) => {
const inputType = (e.nativeEvent as InputEvent).inputType;
if (inputType === 'insertReplacementText' && tryAddSrdMonster(value)) return;
setMonsterQuery(value);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); tryAddSrdMonster(monsterQuery); }
}}
The re-run verification now includes a regression guard that types "Owlbear" and asserts no "Owl" appeared mid-word.
No unit test I would have plausibly written catches this, because the bug lives in the interaction between keystrokes and a change handler — exactly the layer unit tests mock away. The lesson I keep re-learning: verify the deployed thing by driving it, not by proving the pieces correct in isolation.
The evening: closing the tail of the queue
The report's remaining "micro" items looked like scraps — too small for waves of their own, the kind of list that quietly rots in a backlog. So the evening closed all of them at once, plus the two D&D follow-ups.
The encounter calculator became a table tool, not just a prep tool. First a printable encounter sheet — the difficulty verdict with its full math, the monster roster, and a blank initiative & HP tracker with one row per monster instance (Goblin 1, Goblin 2…), capped at 24 rows with an honest "+7 more" note when you insist on thirty rats. Everything on the sheet derives from the same pure functions as the on-screen verdict, so the printout can't disagree with the app.
Then the two questions the search queries had literally been asking. "dnd xp calculator" → XP if defeated: both rule sets award the monsters' actual XP split evenly — the 2014 encounter multiplier rates difficulty, it never inflates the award, and enough people get that wrong that the panel says so. "challenge rating calculator" → room for one more: the biggest single monster you can still add before the encounter tips into the next difficulty band.
That second one has a subtlety worth showing. Under 2014 rules, adding a monster changes the count, and the count drives the multiplier — so the headroom must be computed with the multiplier after the addition:
// Six goblins: 300 XP × 2 = 600 — exactly Medium for four level-3s.
// Naively: Hard starts at 900, so 300 XP of headroom, right?
// No: the 7th body bumps the multiplier ×2 → ×2.5.
// (300 + x) × 2.5 < 900 → x ≤ 59. One more goblin, nothing bigger.
const multiplierAfter = encounterMultiplier(monsterCount(monsters) + 1, partySize);
const maxXp = Math.max(0, Math.ceil(nextThreshold / multiplierAfter) - 1 - total);
Headroom shrinks nonlinearly at the multiplier steps. The panel shows the XP cap, the matching CR, and three SRD names at that CR so you can grab one and go.
Honest footnote: while writing the test anchors for this, I confidently asserted CR 1/4 is worth 100 XP. It's 50. The function was right; my hand math wasn't, and the test failure said so. Hand-anchored tests cut both ways — sometimes the thing they catch is you.
And the four micro-items, one wave, four tools:
- The exam clock got an opt-in "pens down" chime — two soft descending tones, off by default because exam halls are silent, and toggling it on previews the sound so the invigilator knows exactly what the room will hear. It fires once per running→finished transition, and never for exams that were already over when the page loaded (the diff starts from the first tick, not from empty).
- Spelling practice: saved word lists now remember their last score. The match between "what was just quizzed" and "which saved list was that" compares case-insensitive word sets, not strings — shuffle mode reorders the words, and a missed-words-only re-drill is a subset that correctly matches nothing. Plus a printable marked results sheet, ✓/✗ with what the student actually typed.
- The sibling name matcher got a persisted shortlist (hearts, copy-to-share) and style/origin filter chips — which meant honestly updating the "is anything stored?" FAQ that used to say "no, nothing". Privacy claims have to track the code.
- And the station rotation timer's transition buffer — a setting that existed, was shown in the total-time math, and did nothing while running — finally runs: the board flips to the next round first so the kids can see where to walk, an amber countdown runs the walk, a beep starts the round. "Everyone seated — start" skips ahead.
Numbers and lessons
One day: a Search Console analysis, two copy tunes, ten tools materially deepened across ten deployed waves, the test suite grew from 777 to 851, and every wave was verified against production before being called done. The retention program that started as a morning report is now fully shipped — down to the last item, a live game-day mode that turns the playing-time plan into a running substitution timer (wall-clock derived, chimes at every sub, survives the switch to fullscreen mid-game). I'd gated that one on click data; I overrode my own gate at the end of the day, because a plan you can run live is the difference between a tool a coach checks on Friday and one that's open for the whole match on Saturday.
What I'd keep from the method:
- Recurrence beats raw impressions when choosing what to deepen. Weekly use cases compound; one-shot lookups don't.
- Inventory before ideating. The persistence asymmetry was invisible until something actually read all the code.
- Store seeds, not snapshots, when output must be reproducible.
- Layer reversible data over generated output instead of editing it — absences and manual overrides coexist because neither rewrites the rotation.
- Let the queries name the features. "dnd xp calculator" and "challenge rating calculator" weren't keywords to sprinkle — they were feature requests.
- The discipline is in the waves you don't ship. The evening's second Search Console pull produced zero actions: every page with real signal was already aligned, and the parked ideas stayed parked. That's the demand-gated method working, not failing.
- The live check is the test that matters. The Owlbear is now the house mascot for that rule.