diff --git a/public/images/iterative-improvement/banner.webp b/public/images/iterative-improvement/banner.webp new file mode 100644 index 00000000..21057491 Binary files /dev/null and b/public/images/iterative-improvement/banner.webp differ diff --git a/public/images/iterative-improvement/onboarding-0.gif b/public/images/iterative-improvement/onboarding-0.gif new file mode 100644 index 00000000..3afef468 Binary files /dev/null and b/public/images/iterative-improvement/onboarding-0.gif differ diff --git a/public/images/iterative-improvement/onboarding-1.gif b/public/images/iterative-improvement/onboarding-1.gif new file mode 100644 index 00000000..164b75bd Binary files /dev/null and b/public/images/iterative-improvement/onboarding-1.gif differ diff --git a/public/images/iterative-improvement/onboarding-2.gif b/public/images/iterative-improvement/onboarding-2.gif new file mode 100644 index 00000000..60910aa7 Binary files /dev/null and b/public/images/iterative-improvement/onboarding-2.gif differ diff --git a/public/images/iterative-improvement/ui-shot-0.png b/public/images/iterative-improvement/ui-shot-0.png new file mode 100644 index 00000000..e7be279b Binary files /dev/null and b/public/images/iterative-improvement/ui-shot-0.png differ diff --git a/public/images/iterative-improvement/ui-shot-1.png b/public/images/iterative-improvement/ui-shot-1.png new file mode 100644 index 00000000..d1daddd7 Binary files /dev/null and b/public/images/iterative-improvement/ui-shot-1.png differ diff --git a/public/images/iterative-improvement/ui-shot-2.png b/public/images/iterative-improvement/ui-shot-2.png new file mode 100644 index 00000000..1c15fafd Binary files /dev/null and b/public/images/iterative-improvement/ui-shot-2.png differ diff --git a/public/images/iterative-improvement/ui-shot-3.png b/public/images/iterative-improvement/ui-shot-3.png new file mode 100644 index 00000000..1cd1328c Binary files /dev/null and b/public/images/iterative-improvement/ui-shot-3.png differ diff --git a/public/iterative-improvement/landing/iter-0.html b/public/iterative-improvement/landing/iter-0.html new file mode 100644 index 00000000..e619ba8f --- /dev/null +++ b/public/iterative-improvement/landing/iter-0.html @@ -0,0 +1,118 @@ + + + + + +Cadence - The Best Habit Tracking App + + + +
+
🚀
+

Welcome to Cadence

+

The ultimate AI-powered habit tracking solution for teams. Unlock your team's full potential today with our innovative platform!

+ + +
+ +
+

Our Amazing Features

+
+
+

Easy to Use

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Our platform is super easy and intuitive.

+
+
+
+

Lightning Fast

+

Lorem ipsum dolor sit amet. Track all your habits in real time with blazing fast performance.

+
+
+
🎯
+

Goal Oriented

+

Lorem ipsum dolor sit amet, consectetur. Reach your goals faster than ever before with Cadence.

+
+
+
🔒
+

Secure

+

Lorem ipsum dolor. Your data is 100% safe and secure with our enterprise grade security.

+
+
+ +
+

Ready to get started?

+

Join thousands of happy teams today!

+ +
+ + + + diff --git a/public/iterative-improvement/landing/iter-2.html b/public/iterative-improvement/landing/iter-2.html new file mode 100644 index 00000000..e3f4b174 --- /dev/null +++ b/public/iterative-improvement/landing/iter-2.html @@ -0,0 +1,192 @@ + + + + + +Cadence — Habit tracking for teams + + + + + +
+ ● Now with Slack huddle reminders +

Build habits your whole team keeps.

+

Cadence turns daily routines into shared streaks, so teams stay consistent — without the nagging.

+ Start free trial +
Free for 14 days · No credit card required
+ +
+ ★★★★★ + 4.9 / 5 · loved by 2,000+ teams +
+ +
+
Design team · today4 of 5 done
+
+ + Daily standup🔥 23 +
+
+ + Design review🔥 12 +
+
+ + Inbox zero🔥 8 +
+
+ + Weekly retrodue 5pm +
+
+
+ +
+

Trusted by teams at

+
+ NorthwindLoopVelaOnsetPacejet +
+
+ +
+
Why Cadence
+

Consistency, made shared.

+
+
+
+

Shared streaks

+

See who's on track and keep momentum across the team.

+
+
+
+

Gentle nudges

+

Adaptive reminders that fit each person's schedule.

+
+
+
+

Weekly recaps

+

Auto summaries show progress and where you're slipping.

+
+
+
+

Works where you do

+

Slack, calendar and mobile — log a habit in two taps.

+
+
+
+ +
+
"Our standup attendance went from a coin flip to a 6-week streak. Cadence just made it stick."
+
+ +
Maya Okonkwo
Head of Ops, Loop
+
+
+ +
+

Start your team's streak today

+

Set up your first habit in under two minutes.

+ Start free trial +
+ + + + diff --git a/public/iterative-improvement/landing/iter-3.html b/public/iterative-improvement/landing/iter-3.html new file mode 100644 index 00000000..4d27a6f3 --- /dev/null +++ b/public/iterative-improvement/landing/iter-3.html @@ -0,0 +1,231 @@ + + + + + +Cadence — Habit tracking for teams + + + + + +
+ New · Slack huddle reminders +

Build habits your whole team keeps.

+

Cadence turns daily routines into shared streaks, so teams stay consistent — without the nagging.

+ Start free trial +
Free for 14 days · No credit card required
+ +
+ ★★★★★ + 4.9 / 5 · loved by 2,000+ teams +
+ +
+
Design team · Today4 of 5 done
+
+ + Daily standup🔥 23 +
+
+ + Design review🔥 12 +
+
+ + Inbox zero🔥 8 +
+ +
+
+ +
+
92%
habit completion
+
2,000+
teams
+
4.9★
avg rating
+
+ +
+

Trusted by teams at

+
+ NorthwindLoopVelaOnset +
+
+ +
+
Why Cadence
+

Consistency, made shared.

+
+
+
+

Shared streaks

+

See who's on track and keep momentum across the team.

+
+
+
+

Gentle nudges

+

Adaptive reminders that fit each person's schedule.

+
+
+
+

Weekly recaps

+

Auto summaries show progress and where you're slipping.

+
+
+
+

Works where you do

+

Slack, calendar and mobile — log a habit in two taps.

+
+
+
+ +
+
+ +
Our standup attendance went from a coin flip to a 6-week streak. Cadence just made it stick.
+
+ +
Maya Okonkwo
Head of Ops, Loop
+
+
+
+ +
+

Start your team's streak today.

+

Set up your first habit in under two minutes.

+ Start free trial +
Free for 14 days · No credit card required
+
+ + + + diff --git a/src/app/(rest)/blog/[slug]/demos/[demo]/page.tsx b/src/app/(rest)/blog/[slug]/demos/[demo]/page.tsx new file mode 100644 index 00000000..94887440 --- /dev/null +++ b/src/app/(rest)/blog/[slug]/demos/[demo]/page.tsx @@ -0,0 +1,54 @@ +import type { Metadata } from 'next' +import { notFound } from 'next/navigation' +import { META } from '~/lib/constants/metadata' +import { createMetadata } from '~/lib/utils/create-metadata' +import { + DashboardDemoSection, + LandingDemoSection, + OnboardingDemoSection +} from '~/ui/blog/iterative-improvement/demos' + +// Only the iterative-self-improvement post has live demo pages. +const DEMO_SLUG = 'iterative-improvement' + +const DEMOS = { + dashboard: { Section: DashboardDemoSection, title: 'The filterable dashboard' }, + landing: { Section: LandingDemoSection, title: 'The landing page' }, + onboarding: { Section: OnboardingDemoSection, title: 'The onboarding flow' } +} as const + +type DemoKey = keyof typeof DEMOS + +export const dynamicParams = false + +export function generateStaticParams() { + return Object.keys(DEMOS).map(demo => ({ demo, slug: DEMO_SLUG })) +} + +type Props = { params: Promise<{ slug: string; demo: string }> } + +export async function generateMetadata({ params }: Props): Promise { + const { demo } = await params + const entry = DEMOS[demo as DemoKey] + return createMetadata({ + description: 'Live, interactive proof for "Give your agent a mirror".', + pathname: `/blog/${DEMO_SLUG}/demos/${demo}`, + title: `${entry ? entry.title : 'Demo'} | ${META.title}` + }) +} + +export default async function Page({ params }: Props) { + const { slug, demo } = await params + const entry = DEMOS[demo as DemoKey] + if (slug !== DEMO_SLUG || !entry) notFound() + + const { Section } = entry + + return ( +
+
+
+
+
+ ) +} diff --git a/src/lib/posts/iterative-improvement.mdx b/src/lib/posts/iterative-improvement.mdx new file mode 100644 index 00000000..0ac1b449 --- /dev/null +++ b/src/lib/posts/iterative-improvement.mdx @@ -0,0 +1,180 @@ +import { AUTHORS, CATEGORIES } from '~/lib/constants/blog' +import { PerfIterationFigure } from '~/ui/blog/iterative-improvement' + +export const metadata = { + title: "Can polish be automated?", + subtitle: "Writing an agent skill to allow long-running agents to iteratively improve their outputs", + date: "2026-06-01", + isNew: true, + author: AUTHORS.TED_SPARE, + category: CATEGORIES.EXPERIMENT, + description: "Writing an agent skill to allow long-running agents to iteratively improve their outputs", + bannerImageUrl: "/images/iterative-improvement/banner.webp", +} + +## Introduction + +Coding agents are deeply rooted in an iterative workflow: a builder prompts the agent, sees the output rendered, provides feedback, and the cycle continues until some acceptance criterion is met. For matters of taste, it is our strong belief that the developer’s viewpoint gets encoded through this iteration process and thus we *should* not try to automate it. + +This post focuses on the practical issue of iteration loops where the desired state *is* known ahead of time, and the builder’s time might be better spent elsewhere. A good task for a computer. + +To this end, we stumbled on a pattern unique to agent harnesses with all of: + +- Vision understanding and screenshot ability +- Computer-use ability +- Sub-agent support + +which is true of most leading-edge systems today. We tested it with [Pi](https://pi.dev). + +In a [previous post](https://rubriclabs.com/blog/contract-engineering), we wrote about how [coding agents](https://rubriclabs.com/blog/how-does-claude-code-actually-work) have passed a threshold where, given complete requirements, they can generally one-shot a good result. This collapses a huge class of problems and leaves a more interesting class of new ones. For instance, a senior engineer might know to specify that a filterable React table should be memoized, but the average person does not. + +As more people take part in software creation, how can we empower them to *punch up*, getting expert performance out of the tools they didn’t know to ask for? This is the goal of this post. + +--- + +## Initial Prompt + +The idea is quite simple: + +1. the coding agent (e.g. Claude Code) writes code to implement a feature, +2. we commission a separate coding agent to critique the output, and then +3. the original model responds to the feedback + +and so on. Think: the smallest-possible inner loop of [self-healing codebases](https://x.com/ycombinator/status/2056908727400423481). We’ll share the prompt we used and walk through three examples, in order of increasing difficulty, to illustrate how it works. + +Before we begin, your intuition might be: if quality can be squeezed out of these models by simply getting them to critique themselves, wouldn’t the AI labs have RL’d every drop into their one-shot performance? In theory, yes, but these models are [optimized for multiple rewards](https://arxiv.org/abs/2605.25604), some of which might be time-to-accepted-solution or lines-of-code-changed (the fewer, the better). Such a compromising regime naturally leads to solutions that balance multiple factors. Claude Code is less like a tenured professor and more like a bazaar stall-keeper. + +In the same way that getting [2022 models to think before speaking](https://arxiv.org/abs/2201.11903) yielded [radically better](https://openai.com/index/learning-to-reason-with-llms/) math scores, we can push coding agents to iterate before presenting and expect better results. + +--- + +## Example 1: SaaS Landing Page + +As a start, we (actually, Claude Code, in a very meta way) wrote this [SKILL.md](https://skill.md) to test the thesis, starting with the challenge of building a distinct landing page for a fictional SaaS: + +```md +--- +name: iterate-ui +description: Build a UI, screenshot it, critique the screenshot, and improve it. +--- + +# Iterate on UI + +1. Build the feature. +2. Run the dev server and take a screenshot. +3. Look at the screenshot and list what looks bad. +4. Fix the worst issues. +5. Repeat until it looks good. +``` + +You’ll notice the prompt is very UI-specific, which is more an artifact of our upstream prompt than anything else, but it serves well as a high-water mark to gauge how well our prompt generalizes in the coming examples. + +And here is the result of three rounds of iteration on the top-level prompt “build a mobile landing page for Cadence, a habit tracker for teams”: + +
+
+ Landing page iteration 0 — generic purple slop + Landing page mid iteration + Landing page final, polished +
+ Original prompt: “build a mobile landing page for Cadence, a habit tracker for teams.” +
+ +The one-shot result is likely familiar to you as an all-too-common design language in 2026, characteristic of vibecoding. The third iteration is better (if not intriguing): clear visual hierarchy, good colour contrast, and better narrative arc. + +See the live results [here](/blog/iterative-improvement/demos/landing). + +In the next example, we explore a more quantifiable pursuit: performance optimization. + +## Example 2: React Component Performance + +In this example, we ask the coding agent to build a simple table with search, sorting, and column-summing. We render it with 10 000 (fictional) transactions and measure how long it takes to load, and then we automatically enter a search query and measure how long it takes to filter results. + +The naive solution (with data recomputed on every keystroke) is extremely slow. At your own risk, you can try it out [here](/blog/iterative-improvement/demos/dashboard). + +Before iterating, the agent is instructed to lock off behaviour by writing a suite of unit tests. Tests must continue to pass for an iteration to be accepted. + +```md +# Iterate on performance + +1. Define the metric (e.g. p50 filter latency in ms) and write a benchmark that prints it. +2. Write/keep a test suite that pins correct behavior. It must pass before and after every change. +3. Each pass: form one hypothesis, change one thing, re-run tests + benchmark. + Keep the change only if tests pass AND the metric improved. Otherwise revert. +4. Stop when you hit the target or gains flatten. Log before/after numbers each pass. +``` + +After two iterations, the model thinks to memoize computed data, debounce the search input, and virtualize the table (render only visible rows plus a small buffer). Here, the improvement is drastic. + +
+ + Original prompt: “build a React transactions dashboard that lists 10 000 transactions with a search filter, sortable columns, and a running summary of totals.” +
+ +In a third and final example, we seek to merge the previous two via a wider-reaching task: building a product walkthrough. + +## Example 3: Product Onboarding Flow + +To improve an onboarding flow requires all of + +1. a working model of the product being shown, +2. clicking through the flow, taking screenshots or screen recordings, and +3. comparing the flow against the product. + +This is obviously quite complex and open-ended so we did not expect the agent to do well. Results were surprising. + +
+
+ Onboarding walkthrough iteration 0 + Onboarding walkthrough iteration 1 + Onboarding walkthrough iteration 2 — smooth +
+ Original prompt: “scaffold a multi-step onboarding flow for Cadence — create an account, set up a team, pick some habits, and connect a tool.” +
+ +The onboarding flow gains a progress indicator, inline validation, sensible defaults, and skippable steps as the agent removes the friction it kept hitting. + +You can test the onboarding flows [here](/blog/iterative-improvement/demos/onboarding). + +## Putting it all together + +Below is a combined skill which attempts to capture the best of each of the above exercises. + +```md +--- +name: iterate +description: Improve your own work across iterations. After building anything, define how to judge it, measure the current state, fix the highest-impact problem, and re-measure — until it meets the bar or gains flatten. +when_to_use: After producing any artifact (a UI, a component, an API, a flow) where the first attempt is unlikely to be the best one. +--- + +# Iterate + +The first version is a draft. Your job is the loop that follows. + +## 1. Define the bar +Before improving anything, write down how this artifact will be judged: +- **Visual** → a 1–5 rubric (hierarchy, contrast, type scale, spacing, CTA clarity, + trust signals, "does it look generic?"). Always screenshot at a fixed viewport. +- **Performance** → one measurable metric (e.g. p50 latency in ms) plus a benchmark + that prints it, and a test suite that pins correct behavior. +- **Flow / interactive** → a concrete task to complete; success = the artifact can be + used end-to-end without confusion. Drive it with browser/computer use, not a glance. + +## 2. Measure the current state +Run it and capture evidence (screenshot, benchmark number, walkthrough recording). +Write down the top problems, ranked by impact. + +## 3. Change one thing +Fix only the highest-impact problem this pass. Don't refactor opportunistically. + +## 4. Re-measure and gate +Re-capture the same evidence at the same settings. +- Keep the change only if it improved the bar AND broke no guardrail (tests stay green). +- Otherwise revert and try the next hypothesis. + +## 5. Stop deliberately +Stop when you meet the bar, run out of budget (cap the passes), or gains flatten. +Keep a short changelog: what was wrong, what you changed, before → after. +``` + +We are confident it can be improved. diff --git a/src/ui/blog/iterative-improvement/demos/dashboard-demo.tsx b/src/ui/blog/iterative-improvement/demos/dashboard-demo.tsx new file mode 100644 index 00000000..f75e32be --- /dev/null +++ b/src/ui/blog/iterative-improvement/demos/dashboard-demo.tsx @@ -0,0 +1,101 @@ +'use client' + +import { type ComponentType, memo, useMemo, useState } from 'react' +import { getTransactions, type Transaction } from './dashboard/data' +import IterationZero from './dashboard/iteration-0' +import IterationOne from './dashboard/iteration-1' +import IterationTwo from './dashboard/iteration-2' +import type { IterDashboardProps } from './dashboard/types' +import { DemoSection } from './demo-row' + +// Section 2 — the filterable dashboard. Each column runs the SAME deterministic +// 10k-row dataset through a different implementation. Nothing heavy mounts until +// the reader clicks "Run", and every column reports its own real, measured +// mount + filter latency so the speedup is verifiable, not asserted. +export const DashboardDemoSection = ({ bare = false }: { bare?: boolean }) => ( + , caption: 'naive', label: 'Iteration 0' }, + { + body: , + caption: 'memoized', + label: 'Iteration 1' + }, + { + body: , + caption: 'virtualized', + label: 'Iteration 2' + } + ]} + id="dashboard" + title="2 · The filterable dashboard" + /> +) + +type Loaded = { mountStart: number; data: Transaction[] } + +const DashboardColumn = ({ component }: { component: ComponentType }) => { + const [loaded, setLoaded] = useState(null) + const [mountMs, setMountMs] = useState(null) + const [filterMs, setFilterMs] = useState(null) + + // Memoize the heavy dashboard so badge-state updates (mount/filter latency) + // don't force it to re-render — only real keystrokes drive its work. + const Dashboard = useMemo(() => memo(component), [component]) + + const run = () => { + setMountMs(null) + setFilterMs(null) + setLoaded({ data: getTransactions(), mountStart: performance.now() }) + } + + if (loaded === null) { + return ( +
+

+ Mounts a live 10k-row dashboard and measures real mount + filter latency. +

+ +
+ ) + } + + return ( +
+
+ + + +
+ +
+ ) +} + +const Badge = ({ label, ms, tint }: { label: string; ms: number | null; tint?: boolean }) => ( + + {label}: {ms === null ? '—' : `${ms.toFixed(ms < 10 ? 1 : 0)} ms`} + +) diff --git a/src/ui/blog/iterative-improvement/demos/dashboard/chrome.tsx b/src/ui/blog/iterative-improvement/demos/dashboard/chrome.tsx new file mode 100644 index 00000000..1cba5026 --- /dev/null +++ b/src/ui/blog/iterative-improvement/demos/dashboard/chrome.tsx @@ -0,0 +1,57 @@ +'use client' + +import { memo } from 'react' +import type { Transaction } from './data' +import type { Summary } from './transactions' + +// Shared, presentational pieces of the dashboard. The perf differences between +// iterations live in the container components, not here. + +export const ROW_H = 28 // px per row — fixed so the virtualized column can window +export const VIEWPORT_H = 360 // px scroll window inside each column + +export const StatBar = ({ summary }: { summary: Summary }) => ( +
+ + + +
+) + +const Stat = ({ label, value }: { label: string; value: string }) => ( + + {label} + {value} + +) + +export const FilterInput = ({ + value, + onChange +}: { + value: string + onChange: (next: string) => void +}) => ( + onChange(e.target.value)} + placeholder="Filter by category, merchant…" + value={value} + /> +) + +export const Row = memo(({ t, pctl }: { t: Transaction; pctl: number }) => ( +
+ {t.date.slice(5)} + {t.description} + + {t.amount.toFixed(2)} + + {pctl} +
+)) diff --git a/src/ui/blog/iterative-improvement/demos/dashboard/data.ts b/src/ui/blog/iterative-improvement/demos/dashboard/data.ts new file mode 100644 index 00000000..8ca63ead --- /dev/null +++ b/src/ui/blog/iterative-improvement/demos/dashboard/data.ts @@ -0,0 +1,85 @@ +// Deterministic seeded dataset shared by all three dashboard iterations, so the +// naive / memoized / virtualized columns are compared on identical input. +// Ported from /tmp/iter-improve/demo2-perf/src/data.ts. + +export interface Transaction { + id: number + date: string // ISO yyyy-mm-dd + description: string + category: string + merchant: string + amount: number // signed: negative = debit, positive = credit +} + +export const CATEGORIES = [ + 'Groceries', + 'Dining', + 'Transport', + 'Utilities', + 'Entertainment', + 'Health', + 'Travel', + 'Income', + 'Shopping', + 'Rent' +] + +const MERCHANTS = [ + 'Acme Foods', + 'Metro Transit', + 'PowerCo', + 'Streamly', + 'MediCare Plus', + 'SkyJet', + 'Globex Payroll', + 'ShopMart', + 'Brick Properties', + 'Cafe Luna' +] + +// Small deterministic PRNG (mulberry32) so data is identical across runs, which +// keeps the side-by-side comparison fair. +function mulberry32(seed: number): () => number { + let a = seed >>> 0 + return () => { + a |= 0 + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +export function generateTransactions(count: number, seed = 42): Transaction[] { + const rand = mulberry32(seed) + const out: Transaction[] = new Array(count) + const startDate = Date.UTC(2023, 0, 1) + const dayMs = 86400000 + for (let i = 0; i < count; i++) { + const catIdx = Math.floor(rand() * CATEGORIES.length) + const category = CATEGORIES[catIdx]! + const merchant = MERCHANTS[catIdx]! + const isIncome = category === 'Income' + const magnitude = Math.round((rand() * 980 + 5) * 100) / 100 + const amount = isIncome ? magnitude : -magnitude + const dayOffset = Math.floor(rand() * 730) + const date = new Date(startDate + dayOffset * dayMs).toISOString().slice(0, 10) + out[i] = { + amount, + category, + date, + description: `${merchant} #${i} ${category}`, + id: i, + merchant + } + } + return out +} + +// Lazily generated 10k-row singleton, shared by every column so the comparison +// is fair and the data is only built once on the client. +let cached: Transaction[] | null = null +export function getTransactions(): Transaction[] { + if (cached === null) cached = generateTransactions(10000) + return cached +} diff --git a/src/ui/blog/iterative-improvement/demos/dashboard/iteration-0.tsx b/src/ui/blog/iterative-improvement/demos/dashboard/iteration-0.tsx new file mode 100644 index 00000000..2acd7e90 --- /dev/null +++ b/src/ui/blog/iterative-improvement/demos/dashboard/iteration-0.tsx @@ -0,0 +1,50 @@ +'use client' + +import { useState } from 'react' +import { FilterInput, Row, StatBar, VIEWPORT_H } from './chrome' +import { + computePercentilesNaive, + computeSummary, + filterTransactions, + sortTransactions +} from './transactions' +import type { IterDashboardProps } from './types' +import { usePerfMeasure } from './use-perf' + +// ITERATION 0 — naive baseline. Perf sins (deliberate, do not "fix"): +// - All derived data (filter, sort, summary, percentiles) recomputed in the +// render body on EVERY keystroke, with no useMemo. +// - O(n^2) percentile computation over the FULL dataset every render. +// - Renders ALL matching rows into the DOM (no virtualization). +export default function IterationZero({ data, mountStart, onMount, onFilter }: IterDashboardProps) { + const [query, setQuery] = useState('') + + // ---- expensive work, in render, every time ---- + const filtered = filterTransactions(data, query) + const sorted = sortTransactions(filtered, 'date', 'desc') + const summary = computeSummary(sorted) + // percentile rank over the whole history, recomputed from scratch every + // keystroke. O(n^2). + const percentiles = computePercentilesNaive(data) + + const markFilterStart = usePerfMeasure({ mountStart, onFilter, onMount, signature: query }) + + return ( +
+ + { + markFilterStart() + setQuery(next) + }} + value={query} + /> +
{sorted.length} rows
+
+ {sorted.map(t => ( + + ))} +
+
+ ) +} diff --git a/src/ui/blog/iterative-improvement/demos/dashboard/iteration-1.tsx b/src/ui/blog/iterative-improvement/demos/dashboard/iteration-1.tsx new file mode 100644 index 00000000..ef3ff181 --- /dev/null +++ b/src/ui/blog/iterative-improvement/demos/dashboard/iteration-1.tsx @@ -0,0 +1,49 @@ +'use client' + +import { useMemo, useState } from 'react' +import { FilterInput, Row, StatBar, VIEWPORT_H } from './chrome' +import { + computePercentilesFast, + computeSummary, + filterTransactions, + sortTransactions +} from './transactions' +import type { IterDashboardProps } from './types' +import { usePerfMeasure } from './use-perf' + +// ITERATION 1 — stop recomputing in render. +// - All derived data wrapped in useMemo with precise deps, so a re-render that +// doesn't change inputs reuses the previous result. +// - The whole-history percentile depends only on `data`, so it is computed +// ONCE instead of on every keystroke, and uses the O(n log n) algorithm. +// (Still renders every matching row — addressed in iteration 2.) +export default function IterationOne({ data, mountStart, onMount, onFilter }: IterDashboardProps) { + const [query, setQuery] = useState('') + + const filtered = useMemo(() => filterTransactions(data, query), [data, query]) + const sorted = useMemo(() => sortTransactions(filtered, 'date', 'desc'), [filtered]) + const summary = useMemo(() => computeSummary(sorted), [sorted]) + // depends only on the dataset → computed once + const percentiles = useMemo(() => computePercentilesFast(data), [data]) + + const markFilterStart = usePerfMeasure({ mountStart, onFilter, onMount, signature: query }) + + return ( +
+ + { + markFilterStart() + setQuery(next) + }} + value={query} + /> +
{sorted.length} rows
+
+ {sorted.map(t => ( + + ))} +
+
+ ) +} diff --git a/src/ui/blog/iterative-improvement/demos/dashboard/iteration-2.tsx b/src/ui/blog/iterative-improvement/demos/dashboard/iteration-2.tsx new file mode 100644 index 00000000..a52f0d3b --- /dev/null +++ b/src/ui/blog/iterative-improvement/demos/dashboard/iteration-2.tsx @@ -0,0 +1,65 @@ +'use client' + +import { useMemo, useState } from 'react' +import { FilterInput, ROW_H, Row, StatBar, VIEWPORT_H } from './chrome' +import { + computePercentilesFast, + computeSummary, + filterTransactions, + sortTransactions +} from './transactions' +import type { IterDashboardProps } from './types' +import { usePerfMeasure } from './use-perf' + +const OVERSCAN = 8 // rows rendered above/below the viewport + +// ITERATION 2 — stop rendering rows nobody can see. +// - Virtualized (windowed) list: only the rows inside the scroll viewport +// (plus a small overscan) are committed to the DOM; top/bottom spacers +// preserve scroll height. A 10k-row result renders ~25 rows, not thousands. +// - Carries forward iteration 1's memoized derived data + O(n log n) percentile. +export default function IterationTwo({ data, mountStart, onMount, onFilter }: IterDashboardProps) { + const [query, setQuery] = useState('') + const [scrollTop, setScrollTop] = useState(0) + + const filtered = useMemo(() => filterTransactions(data, query), [data, query]) + const sorted = useMemo(() => sortTransactions(filtered, 'date', 'desc'), [filtered]) + const summary = useMemo(() => computeSummary(sorted), [sorted]) + const percentiles = useMemo(() => computePercentilesFast(data), [data]) + + const markFilterStart = usePerfMeasure({ mountStart, onFilter, onMount, signature: query }) + + // ---- windowing math ---- + const total = sorted.length + const start = Math.max(0, Math.floor(scrollTop / ROW_H) - OVERSCAN) + const windowSize = Math.ceil(VIEWPORT_H / ROW_H) + OVERSCAN * 2 + const end = Math.min(total, start + windowSize) + const visibleRows = sorted.slice(start, end) + const topPad = start * ROW_H + const bottomPad = (total - end) * ROW_H + + return ( +
+ + { + markFilterStart() + setQuery(next) + }} + value={query} + /> +
{total} rows
+
setScrollTop(e.currentTarget.scrollTop)} + style={{ height: VIEWPORT_H }} + > +
+ {visibleRows.map(t => ( + + ))} +
+
+
+ ) +} diff --git a/src/ui/blog/iterative-improvement/demos/dashboard/transactions.ts b/src/ui/blog/iterative-improvement/demos/dashboard/transactions.ts new file mode 100644 index 00000000..d1a1c55e --- /dev/null +++ b/src/ui/blog/iterative-improvement/demos/dashboard/transactions.ts @@ -0,0 +1,128 @@ +// Filter / sort / summary / percentile logic shared by the dashboard iterations. +// Ported from /tmp/iter-improve/demo2-perf/src/transactions.ts. The naive O(n^2) +// percentile is kept deliberately so iteration 0 is genuinely slow. + +import type { Transaction } from './data' + +export type SortKey = 'date' | 'amount' | 'description' | 'category' +export type SortDir = 'asc' | 'desc' + +export interface Summary { + count: number + total: number // net (sum of signed amounts) + income: number // sum of positive amounts + expense: number // sum of negative amounts (negative number) + avgAbs: number // average of absolute amounts + byCategory: Record // net per category +} + +export function filterTransactions(txns: Transaction[], query: string): Transaction[] { + const q = query.trim().toLowerCase() + if (!q) return txns + return txns.filter( + t => + t.description.toLowerCase().includes(q) || + t.category.toLowerCase().includes(q) || + t.merchant.toLowerCase().includes(q) + ) +} + +export function sortTransactions(txns: Transaction[], key: SortKey, dir: SortDir): Transaction[] { + const factor = dir === 'asc' ? 1 : -1 + // copy so we never mutate the input + const copy = txns.slice() + copy.sort((a, b) => { + let cmp: number + switch (key) { + case 'amount': + cmp = a.amount - b.amount + break + case 'date': + cmp = a.date < b.date ? -1 : a.date > b.date ? 1 : 0 + break + case 'description': + cmp = a.description < b.description ? -1 : a.description > b.description ? 1 : 0 + break + case 'category': + cmp = a.category < b.category ? -1 : a.category > b.category ? 1 : 0 + break + } + if (cmp === 0) cmp = a.id - b.id // stable tiebreak + return cmp * factor + }) + return copy +} + +export function computeSummary(txns: Transaction[]): Summary { + let total = 0 + let income = 0 + let expense = 0 + let absSum = 0 + const byCategory: Record = {} + for (const t of txns) { + total += t.amount + if (t.amount >= 0) income += t.amount + else expense += t.amount + absSum += Math.abs(t.amount) + byCategory[t.category] = (byCategory[t.category] ?? 0) + t.amount + } + // round to cents to avoid float drift + const r = (n: number) => Math.round(n * 100) / 100 + for (const k of Object.keys(byCategory)) byCategory[k] = r(byCategory[k]!) + return { + avgAbs: txns.length ? r(absSum / txns.length) : 0, + byCategory, + count: txns.length, + expense: r(expense), + income: r(income), + total: r(total) + } +} + +/** + * Percentile rank of each transaction's absolute amount within the set: + * fraction of rows with a strictly smaller |amount|, rounded to whole percent. + * NAIVE O(n^2): for every row, scan every other row. + */ +export function computePercentilesNaive(txns: Transaction[]): Map { + const out = new Map() + const n = txns.length + for (let i = 0; i < n; i++) { + const ti = txns[i]! + const ai = Math.abs(ti.amount) + let less = 0 + for (let j = 0; j < n; j++) { + if (Math.abs(txns[j]!.amount) < ai) less++ + } + out.set(ti.id, n ? Math.round((less / n) * 100) : 0) + } + return out +} + +/** + * Same result as computePercentilesNaive but O(n log n): sort the absolute + * amounts once, then binary-search the count of strictly-smaller values. + */ +export function computePercentilesFast(txns: Transaction[]): Map { + const n = txns.length + const amounts = new Float64Array(n) + for (let i = 0; i < n; i++) amounts[i] = Math.abs(txns[i]!.amount) + const sorted = Array.from(amounts).sort((a, b) => a - b) + // lowerBound: index of first element >= x === count of elements strictly < x + const lowerBound = (x: number): number => { + let lo = 0 + let hi = n + while (lo < hi) { + const mid = (lo + hi) >>> 1 + if (sorted[mid]! < x) lo = mid + 1 + else hi = mid + } + return lo + } + const out = new Map() + for (let i = 0; i < n; i++) { + const less = lowerBound(amounts[i]!) + out.set(txns[i]!.id, n ? Math.round((less / n) * 100) : 0) + } + return out +} diff --git a/src/ui/blog/iterative-improvement/demos/dashboard/types.ts b/src/ui/blog/iterative-improvement/demos/dashboard/types.ts new file mode 100644 index 00000000..c237e586 --- /dev/null +++ b/src/ui/blog/iterative-improvement/demos/dashboard/types.ts @@ -0,0 +1,10 @@ +import type { Transaction } from './data' + +// Contract every iteration column implements. The parent mounts one of these on +// "Run" and feeds back the real mount + filter latency through the callbacks. +export interface IterDashboardProps { + data: Transaction[] + mountStart: number + onMount: (ms: number) => void + onFilter: (ms: number) => void +} diff --git a/src/ui/blog/iterative-improvement/demos/dashboard/use-perf.ts b/src/ui/blog/iterative-improvement/demos/dashboard/use-perf.ts new file mode 100644 index 00000000..06bcc861 --- /dev/null +++ b/src/ui/blog/iterative-improvement/demos/dashboard/use-perf.ts @@ -0,0 +1,56 @@ +'use client' + +import { useLayoutEffect, useRef } from 'react' + +// Schedules cb after the next paint (two rAFs) and returns a canceller. +function afterPaint(cb: () => void): () => void { + let inner = 0 + const outer = requestAnimationFrame(() => { + inner = requestAnimationFrame(cb) + }) + return () => { + cancelAnimationFrame(outer) + cancelAnimationFrame(inner) + } +} + +// Real latency measurement shared by every dashboard column. +// +// mountStart is the timestamp the parent captured at click time; the first +// layout effect plus a double rAF measures the cost of mounting + first paint. +// `signature` is the committed filter state — each time it changes we measure +// the time from the keystroke (markFilterStart, called in the input handler +// before setState) to the filtered list committing and painting. +export function usePerfMeasure({ + mountStart, + signature, + onMount, + onFilter +}: { + mountStart: number + signature: unknown + onMount: (ms: number) => void + onFilter: (ms: number) => void +}): () => void { + const filterStart = useRef(null) + const firstRun = useRef(true) + + // biome-ignore lint/correctness/useExhaustiveDependencies: mount cost is measured exactly once + useLayoutEffect(() => afterPaint(() => onMount(performance.now() - mountStart)), []) + + // biome-ignore lint/correctness/useExhaustiveDependencies: re-measure only when the committed filter changes + useLayoutEffect(() => { + if (firstRun.current) { + firstRun.current = false + return + } + const start = filterStart.current + if (start === null) return + filterStart.current = null + return afterPaint(() => onFilter(performance.now() - start)) + }, [signature]) + + return () => { + filterStart.current = performance.now() + } +} diff --git a/src/ui/blog/iterative-improvement/demos/demo-row.tsx b/src/ui/blog/iterative-improvement/demos/demo-row.tsx new file mode 100644 index 00000000..dc46049e --- /dev/null +++ b/src/ui/blog/iterative-improvement/demos/demo-row.tsx @@ -0,0 +1,54 @@ +import type { ReactNode } from 'react' + +// Shared chrome for a demo section: a titled block with a responsive +// 3-column grid (stacks on mobile). Each column gets a label + body. +export type DemoColumn = { + label: string + caption?: string + body: ReactNode +} + +export const DemoSection = ({ + id, + title, + blurb, + columns, + bare +}: { + id: string + title: string + blurb: string + columns: [DemoColumn, DemoColumn, DemoColumn] + // When true, render only the 3-column grid of cards (no heading/blurb) for + // the standalone per-demo pages. + bare?: boolean +}) => { + const grid = ( +
+ {columns.map(col => ( +
+
+ {col.label} + {col.caption ? {col.caption} : null} +
+ {col.body} +
+ ))} +
+ ) + + if (bare) return grid + + return ( +
+
+

{title}

+

{blurb}

+
+ {grid} +
+ ) +} diff --git a/src/ui/blog/iterative-improvement/demos/index.ts b/src/ui/blog/iterative-improvement/demos/index.ts new file mode 100644 index 00000000..0c2303e8 --- /dev/null +++ b/src/ui/blog/iterative-improvement/demos/index.ts @@ -0,0 +1,3 @@ +export { DashboardDemoSection } from './dashboard-demo' +export { LandingDemoSection } from './landing-demo' +export { OnboardingDemoSection } from './onboarding-demo' diff --git a/src/ui/blog/iterative-improvement/demos/landing-demo.tsx b/src/ui/blog/iterative-improvement/demos/landing-demo.tsx new file mode 100644 index 00000000..09117421 --- /dev/null +++ b/src/ui/blog/iterative-improvement/demos/landing-demo.tsx @@ -0,0 +1,56 @@ +import { DemoSection } from './demo-row' + +// Shows the three landing-page iterations side by side. Each iteration is the +// exact self-contained HTML artifact, iframed at a mobile viewport so readers +// see the real page and can scroll it. +export const LandingDemoSection = ({ bare = false }: { bare?: boolean }) => { + return ( + + ), + caption: 'one-shot', + label: 'Iteration 0' + }, + { + body: ( + + ), + caption: 'de-slopped', + label: 'Iteration 2' + }, + { + body: ( + + ), + caption: 'polished', + label: 'Iteration 3' + } + ]} + /> + ) +} + +const IterationFrame = ({ src, title }: { src: string; title: string }) => ( +