Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, Check, Flame, Snowflake, X } from "lucide-react"

import { cn } from "@/lib/utils"
import type { StreakDayStatus, StreakEntry, StreakTrackerData } from "./streak-tracker.contract"

/* -------------------------------------------------------------------------- *
 * Why there is no `Date.now()` anywhere below
 *
 * A streak is an argument about days, and the answer changes at midnight — so a
 * component that reads the clock during render would disagree with the server
 * that rendered it a second earlier, and a screenshot taken at 23:59 would be

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/streak-tracker.json

Prompt

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 now prop, 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 missed both 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-disabled and a handler guard; the native disabled attribute 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.

On This Page