Streak Tracker
A habit-streak card — the live run with a tiered flame, this week as seven day cells, a spendable freeze wallet and an at-risk countdown, all derived from an injected instant.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/streak-tracker.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "StreakTracker" component: the habit-streak
card — the live run, this week, and the freeze wallet. lucide-react for icons,
zod for the contract. No date library.
Contract
- A zod schema file is the single source of truth and the props are its z.infer,
never a parallel interface. Entry: { date: "YYYY-MM-DD", status: "done" |
"missed" | "frozen" }. View: { status: "loading" | "empty" | "error" | "ready",
entries: Entry[], freezes: { available: number, capacity?: number },
timezone: string, now: "YYYY-MM-DDTHH:mm", cutoff?: "HH:mm" }.
- now is the injected clock and the component never calls Date.now(). A streak
is an argument about days, so the answer changes at midnight: a render-time
clock read would disagree with the server that rendered it a second earlier,
and a screenshot taken at 23:59 would be wrong by the time anyone reviewed it.
The producer resolves the visitor's zone once and hands over a wall-clock
string — no "Z", no offset.
- timezone is a display label only. It is never handed to Intl, because every
date in the contract is already a wall-clock day in that zone.
- The three statuses are not the whole alphabet: a day with NO entry means
nobody reported anything, which is not the same as a confessed "missed". Both
end a run, and both stay distinct on screen and in the accessible label.
- "frozen" means a freeze token covered the day: the run survives, but a frozen
day is not a completed day, so it does not increase the number.
- cutoff is when the habit day rolls over, default "00:00". A "03:00" cutoff
means 01:20 still belongs to YESTERDAY's day — the late-night session every
habit app has to account for.
- Display props: weekStartsOn (0=Sunday…6, default 1), locale (default a FIXED
"en-US" — pass your app's locale, never navigator.language), tiers (ascending
day counts at which the flame grows, default [7, 30, 100, 365]),
atRiskMinutes (default 240), onUseFreeze(date), onDayClick(date), onRetry,
emptyState, className. forwardRef to the root and spread the remaining native
div props.
Behavior
- Day arithmetic. Every Date is built from UTC parts and read back through
getUTC*, and the next day is + 86 400 000 ms — exact, because UTC has no DST.
Nothing reads the runtime's zone, so server and client markup are identical.
"YYYY-MM-DD" also compares lexicographically exactly as it does
chronologically, so "is this in the future" needs no Date at all.
- Place today. streakDay = the day half of now, minus one day when the time half
is earlier than the cutoff. minutesLeft = (cutoffMinutes − nowMinutes + 1440)
mod 1440, and a result of 0 means a whole fresh day, not zero: the modulo is
what makes a cutoff earlier than the current time mean "tomorrow" (20:35
against a 03:00 rollover is 6h 25m left, not −17h).
- Index the log. Entries with an unreadable date are counted out; entries dated
after the streak day are counted out too (shipping tomorrow's row must not buy
a free day); a day reported twice keeps the LAST entry, which is what a
re-sync usually means. All three counts are named in a footer strip ("Not
counted: 2 with an unreadable date, 2 dated after today, 2 repeated days") —
never silently dropped. A day that does not exist ("2026-09-31") normalises
the way the platform does, to 1 October, so it still lines up with the walk
instead of becoming a phantom key.
- The current run walks BACKWARDS from the streak day. If today is still
pending, start from yesterday instead — the day is not over, so it can neither
add to the run nor break it. Then: "done" adds one, "frozen" bridges without
adding, and anything else — "missed" or a day with no entry — ends the walk.
The loop needs no iteration cap: every step has to find an entry, and there
are only as many of those as there are logged days.
- Longest and total come from one forward scan over the sorted unique days: a
gap between two logged days resets the run exactly as a confessed miss does,
"done" grows it, "frozen" leaves it standing. Longest can never be smaller
than the current run, because both are read off the same rules.
- At risk = the streak day is still pending AND minutesLeft is inside
atRiskMinutes. It drives one line of copy, one tone swap and one pulsing day
cell — no timer starts, because the risk is a function of the injected
instant. A parent that wants a live countdown re-renders with a new now.
- The week strip is the calendar week containing the streak day, per
weekStartsOn. Each cell resolves to done / frozen / missed / pending / no
check-in / upcoming, and the cells are keyed by COLUMN, not by date, so a new
now that moves the week reuses the same DOM nodes and never drops focus out of
the strip.
- Freezes. Render the wallet as pips plus a reading ("2 of 3 freezes left"), and
give up on pips past six tokens rather than drawing a stripe of dots. "Use a
freeze" is rendered ONLY when onUseFreeze is wired — a button that cannot do
anything is a lie. It goes inert when the log is not ready, when the wallet is
empty, or when today is already settled, and inert means aria-disabled plus a
guard in the handler, never the disabled attribute: this button goes inert the
instant it is used, and the browser blurs a control that turns disabled under
the caret, dropping focus onto <body>. It also stays mounted while inert, for
the same reason. The reason it is inert lives in an sr-only element referenced
by aria-describedby, so the visible label still matches the accessible name.
- One spend per day. A ref holds the day a freeze was already spent on; the
click handler reads AND writes it synchronously, so a double press inside one
commit cannot spend two tokens before the consumer's data comes back. An
effect keyed on the incoming data clears the ref, so a refused spend can be
retried. The component never mutates the wallet itself — onUseFreeze reports,
the consumer owns the count.
- Four first-class branches. loading → the same geometry with pulsing
placeholders (aria-busy, no tab stops, no number invented before there is data
to count); error → a card with a message and a "Try again" button rendered
only when onRetry is passed; empty → the REAL week (weekday letters, today
dashed and pending, the days after it upcoming) with a replaceable day-one
line, because a week with nothing in it is still a week; ready → the full
card. A `now` that cannot be read at all falls back to the newest day on the
log — with no time of day, hence no countdown — and only when neither exists
does the component render an error card instead of guessing a day.
- ARIA. The strip is role="grid" with an aria-label naming its date range, one
role="row", and seven role="gridcell" cells; the streak day carries
aria-current="date". Each cell's whole identity is its aria-label ("Friday, 7
August: not logged yet, streak at risk") and its glyph is aria-hidden, so a
reader hears one sentence per day instead of a letter and a number. The line
about today is a role="status" region so a state change is announced rather
than silently swapped. The flame is decorative and aria-hidden — the number
beside it carries the meaning.
- Keyboard. Roving tabindex over the seven cells: exactly one is tabbable — the
focused day, else the streak day. Left/Right move one day and stay put at both
ends (a week is a closed row, wrapping would land on a day that is not in it),
Home/End jump to the first and last cell, Enter/Space activate the day but
only when onDayClick is wired and the day is not in the future. A cell only
steers the strip when the event target is the cell itself.
- Nothing to clean up, deliberately. No interval, no rAF, no observer, no
document listener: the only time source is a prop, and every animation is a
CSS keyframe. The single effect just releases the freeze guard.
Rendering & styling
- Semantic tokens only: bg-card (surface), bg-primary + text-primary-foreground
(a completed day, filled freeze pips), bg-primary/10 + ring-primary/40 (a
frozen day), bg-destructive/10 + text-destructive (a missed day and the
at-risk line), bg-muted (a day with no entry, skeletons), border-dashed
border-primary (today, pending), text-muted-foreground for every secondary
reading, ring-ring for focus. No hex, no oklch, no palette classes. cn()
merges the consumer className into the root of every status branch.
- The flame tier is a ramp, not five hand-picked looks: tier 0 is a cold ember
in muted, and each further tier raises the icon size, the primary alpha of the
glyph and the alpha of its halo. Only the icon grows — the halo keeps one
fixed box, so moving up a tier never reflows the header.
- Meaning never rides on colour alone: a done day also carries a check, a frozen
day a snowflake, a missed day a cross, and every other cell its date numeral.
- Motion is decorative: a slow flame flicker and a slow at-risk pulse, both
hoisted as @keyframes through a React 19 <style href precedence> tag and both
dropped under prefers-reduced-motion. With motion off the flame still shows
its tier and the at-risk line still says how long is left.
Customization levers
- Tier ladder: tiers is the whole gamification dial — [2, 4, 6, 8] flatters a
young streak, [30, 100, 365, 1000] makes the top flame mean something. Swap
the flame for a plant, a gem or a streak badge and the ramp keeps working; it
is one lookup table of size + alpha classes.
- Risk policy: atRiskMinutes decides when "not logged yet" becomes urgent (240
for a gentle nudge, 60 for a last call), and cutoff moves the deadline itself
— a 03:00 rollover forgives night owls without changing a line of the maths.
- Week shape: weekStartsOn moves the columns; swapping the calendar week for a
rolling "last 7 days" window is one line in the strip builder, since the cell
states are computed per day and not per column.
- Density: the card is header + today line + strip + freeze row, and each block
is independent — drop the strip for a compact KPI tile, drop the freeze row
entirely if your product has no tokens, or add a fifth stat to the header's
definition list without touching the walk.
- Semantics of a freeze: this component treats a frozen day as a bridge that
does not raise the count. If your product counts it as a completed day,
change one branch of the backward walk and one branch of the forward scan —
they are the only two places the rule lives.
- Extending the contract: a per-day note, a goal amount or a habit id belongs on
the entry and flows straight into the cell's title and aria-label. Logging a
day is deliberately the consumer's job: onDayClick reports, and the parent
decides what a click means (log, unlog, open a detail sheet).Concepts
- Injected instant — "today", "how much is left" and "is this day in the future" all come from the
nowprop, so a server render, a hydrated client and a screenshot taken minutes apart agree; the widget subscribes to nothing and there is no timer to leak, and a parent that wants a live countdown simply re-renders with a newer instant. - Cutoff rollover — the habit day is not the calendar day: with a
"03:00"cutoff, 01:20 is still yesterday's session, and the time remaining is(cutoff − now) mod 1440, which is what makes a deadline earlier in the clock read as "tomorrow" instead of a negative number. - Freeze as a bridge — a freeze token holds a run together without lengthening it: the backward walk steps over a frozen day without incrementing, so the hero number stays the count of days actually completed and the header says how many freezes are propping it up.
- Silence is not a confession — a day with no entry and a day marked
missedboth end a run, but they are drawn and announced differently, because "nobody reported anything" is a data gap and "I skipped it" is a fact about the habit. - Inert, never disabled — the freeze button turns inert the instant it is spent, so it stays mounted with
aria-disabledand a handler guard; the nativedisabledattribute would blur it under the user's caret and drop focus onto the body, and a ref read and written in the same handler keeps a double press from spending two tokens. - Column-keyed week — the seven cells are keyed by their position, not by their date, so a new instant that slides the week swaps the content of the same DOM nodes instead of unmounting the one holding focus.
Theme Customizer
A live design-token editor: a radius slider plus hue and saturation per colour role, previewed light and dark side by side on real controls, and exported as the :root / .dark CSS you paste.
Spinner
One indeterminate loader, seven shapes — three boxed (ring, dual-ring, bars) and four inline dot rows (dots, ellipsis, bounce, wave), colored by a single tone prop or plain currentColor.