A month calendar where each click marks a day done or not, computing the current and the best streak and refusing future days. Use this when a habit or practice is logged day by day and the run of consecutive days matters.
registry/patterns/habit-streak-calendar/habit-streak-calendar.tsx
"use client"
import * as React from "react"
import { CalendarGrid } from "@/components/ui/calendar-grid"
import {
addDays,
resolveLocale,
today as todayISO,
} from "@/components/ui/date-time-core"
export interface Streak {
current: number
best: number
}
export interface HabitStreakCalendarProps {
value?: string[]
defaultValue?: string[]
onValueChange?: (days: string[], streak: Streak) => void
today?: string
locale?: string
}
/** Current streak (ending today, or yesterday if today is not done yet) and the best ever. */
export function streaks(days: string[], today: string): Streak {
const set = new Set(days)
let best = 0
let run = 0
const sorted = [...set].sort()
sorted.forEach((d, i) => {
run = i > 0 && addDays(sorted[i - 1], 1) === d ? run + 1 : 1
best = Math.max(best, run)
})
let current = 0
let d = set.has(today) ? today : addDays(today, -1)
while (set.has(d)) {
current++
d = addDays(d, -1)
}
return { current, best }
}
export function HabitStreakCalendar({
value,
defaultValue = [],
onValueChange,
today,
locale,
}: HabitStreakCalendarProps) {
const loc = resolveLocale(locale)
const now = today ?? todayISO()
const [inner, setInner] = React.useState<string[]>(defaultValue)
const days = value ?? inner
function toggle(d: string) {
const next = days.includes(d) ? days.filter((x) => x !== d) : [...days, d]
if (value === undefined) setInner(next)
onValueChange?.(next, streaks(next, now))
}
const s = streaks(days, now)
return (
<div className="flex flex-col gap-3">
<CalendarGrid
label="Habit log"
locale={loc}
today={now}
value={null}
onValueChange={(d) => d && toggle(d)}
markers={Object.fromEntries(days.map((d) => [d, "done"]))}
max={now}
className="border"
/>
<p role="status" className="text-sm">
Current streak:{" "}
<strong>
{s.current} day{s.current === 1 ? "" : "s"}
</strong>
. Best: {s.best}.
</p>
</div>
)
}