{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "streak-calendar",
  "title": "Streak Calendar",
  "description": "React streak calendar for gamification—week, month, and git-style grids to visualize consistency. Native support for streak freezes. Ideal streak UI design for habit tracking and personal progress. Built on shadcn/ui and Tailwind CSS. Open source.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/trophy/ui/streak-calendar.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as TooltipPrimitive from \"@radix-ui/react-tooltip\"\nimport { Check, Snowflake } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n// Types (inlined - only fields used by this component)\ninterface StreakPeriod {\n  periodStart: string\n  periodEnd: string\n  usedFreeze?: boolean\n}\n\nfunction formatDateKey(date: Date): string {\n  const year = date.getFullYear()\n  const month = String(date.getMonth() + 1).padStart(2, \"0\")\n  const day = String(date.getDate()).padStart(2, \"0\")\n  return `${year}-${month}-${day}`\n}\n\n// Helper to check if a date falls within a streak period\nfunction isDateInPeriod(date: Date, period: StreakPeriod): boolean {\n  const d = formatDateKey(date)\n  return d >= period.periodStart && d <= period.periodEnd\n}\n\n// Helper to check if date used a freeze\nfunction didUseFreezeOnDate(date: Date, periods: StreakPeriod[]): boolean {\n  for (const period of periods) {\n    if (isDateInPeriod(date, period) && period.usedFreeze) {\n      return true\n    }\n  }\n  return false\n}\n\n// Helper to check if date was active in streak\nfunction wasDateActive(date: Date, periods: StreakPeriod[]): boolean {\n  for (const period of periods) {\n    if (isDateInPeriod(date, period)) {\n      return true\n    }\n  }\n  return false\n}\n\n// Get days in month\nfunction getDaysInMonth(year: number, month: number): number {\n  return new Date(year, month + 1, 0).getDate()\n}\n\n// Get day of week for first day of month (0 = Sunday)\nfunction getFirstDayOfMonth(year: number, month: number): number {\n  return new Date(year, month, 1).getDay()\n}\n\n// Props\ninterface StreakCalendarProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Streak periods */\n  streak: StreakPeriod[]\n  /** Calendar layout variant */\n  view?: \"week\" | \"month\" | \"year\"\n  /** Month to display (default: current month) */\n  month?: Date\n  /** Date used for week view (default: today) */\n  referenceDate?: Date\n  /** Show freeze indicators */\n  showFreezes?: boolean\n  /** Start of week: 0 = Sunday, 1 = Monday */\n  startOfWeek?: 0 | 1\n  /** Callback when a day is clicked */\n  onDayClick?: (date: Date, wasActive: boolean) => void\n}\n\nconst WEEKDAYS_SUNDAY = [\"Su\", \"Mo\", \"Tu\", \"We\", \"Th\", \"Fr\", \"Sa\"]\nconst WEEKDAYS_MONDAY = [\"Mo\", \"Tu\", \"We\", \"Th\", \"Fr\", \"Sa\", \"Su\"]\n\nconst TooltipProvider = TooltipPrimitive.Provider\nconst Tooltip = TooltipPrimitive.Root\nconst TooltipTrigger = TooltipPrimitive.Trigger\nconst TooltipContent = React.forwardRef<\n  React.ElementRef<typeof TooltipPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>\n>(({ className, sideOffset = 4, ...props }, ref) => (\n  <TooltipPrimitive.Content\n    ref={ref}\n    sideOffset={sideOffset}\n    className={cn(\n      \"bg-popover text-popover-foreground z-50 overflow-hidden rounded-md border px-3 py-1.5 text-xs shadow-md\",\n      \"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95\",\n      \"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n      className\n    )}\n    {...props}\n  />\n))\nTooltipContent.displayName = TooltipPrimitive.Content.displayName\n\nfunction getDateKey(date: Date): string {\n  return formatDateKey(date)\n}\n\nfunction getWeekStart(date: Date, startOfWeek: 0 | 1): Date {\n  const next = new Date(date)\n  next.setHours(0, 0, 0, 0)\n  const day = next.getDay()\n  const offset = startOfWeek === 1 ? (day + 6) % 7 : day\n  next.setDate(next.getDate() - offset)\n  return next\n}\n\nfunction getWeekDates(reference: Date, startOfWeek: 0 | 1): Date[] {\n  const start = getWeekStart(reference, startOfWeek)\n  return Array.from({ length: 7 }, (_, index) => {\n    const date = new Date(start)\n    date.setDate(start.getDate() + index)\n    return date\n  })\n}\n\nfunction getWeekdayIndex(date: Date, startOfWeek: 0 | 1): number {\n  const day = date.getDay()\n  return startOfWeek === 1 ? (day + 6) % 7 : day\n}\n\nfunction getGitDates(endDate: Date): Date[] {\n  const end = new Date(endDate)\n  end.setHours(0, 0, 0, 0)\n  return Array.from({ length: 365 }, (_, index) => {\n    const date = new Date(end)\n    date.setDate(end.getDate() - (364 - index))\n    return date\n  })\n}\n\nfunction getGitCells(\n  endDate: Date,\n  startOfWeek: 0 | 1\n): {\n  cells: (Date | null)[]\n  dates: Date[]\n} {\n  const dates = getGitDates(endDate)\n  const firstDate = dates[0]\n  if (!firstDate) return { cells: [], dates: [] }\n\n  const leadingEmptyCells = getWeekdayIndex(firstDate, startOfWeek)\n  const cells: (Date | null)[] = [\n    ...Array.from({ length: leadingEmptyCells }, () => null),\n    ...dates,\n  ]\n\n  return { cells, dates }\n}\n\nconst StreakCalendar = React.forwardRef<HTMLDivElement, StreakCalendarProps>(\n  (\n    {\n      className,\n      streak,\n      view = \"week\",\n      month = new Date(),\n      referenceDate = new Date(),\n      showFreezes = true,\n      startOfWeek = 0,\n      onDayClick,\n      ...props\n    },\n    ref\n  ) => {\n    const year = month.getFullYear()\n    const monthIndex = month.getMonth()\n    const daysInMonth = getDaysInMonth(year, monthIndex)\n    const firstDayOfMonth = getFirstDayOfMonth(year, monthIndex)\n\n    // Adjust first day based on start of week preference\n    const adjustedFirstDay =\n      startOfWeek === 1\n        ? (firstDayOfMonth + 6) % 7 // Monday start\n        : firstDayOfMonth // Sunday start\n\n    const weekdays = startOfWeek === 1 ? WEEKDAYS_MONDAY : WEEKDAYS_SUNDAY\n    const today = new Date()\n    today.setHours(0, 0, 0, 0)\n\n    const periods = streak ?? []\n    const weekDates = getWeekDates(referenceDate, startOfWeek)\n    const { cells: gitCells, dates: gitDates } = getGitCells(today, startOfWeek)\n    const gitColumnCount = Math.ceil(gitCells.length / 7)\n    const gitGridTemplateColumns = `repeat(${gitColumnCount}, 1rem)`\n    const gitMonthLabels: Array<{ column: number; label: string }> = []\n    const seenGitMonths = new Set<string>()\n\n    gitCells.forEach((date, cellIndex) => {\n      if (!date) return\n      const monthKey = `${date.getFullYear()}-${date.getMonth()}`\n      if (!seenGitMonths.has(monthKey) && date.getDate() === 1) {\n        seenGitMonths.add(monthKey)\n        gitMonthLabels.push({\n          column: Math.floor(cellIndex / 7),\n          label: date.toLocaleDateString(\"en-US\", { month: \"short\" }),\n        })\n      }\n    })\n\n    const firstDate = gitDates[0]\n    if (firstDate) {\n      const firstMonthKey = `${firstDate.getFullYear()}-${firstDate.getMonth()}`\n      if (!seenGitMonths.has(firstMonthKey)) {\n        gitMonthLabels.unshift({\n          column: 0,\n          label: firstDate.toLocaleDateString(\"en-US\", { month: \"short\" }),\n        })\n      }\n    }\n\n    // Generate calendar days\n    const days: (number | null)[] = []\n\n    // Add empty cells for days before the first of the month\n    for (let i = 0; i < adjustedFirstDay; i++) {\n      days.push(null)\n    }\n\n    // Add days of the month\n    for (let day = 1; day <= daysInMonth; day++) {\n      days.push(day)\n    }\n\n    const monthName = month.toLocaleDateString(\"en-US\", { month: \"long\" })\n    const gitEndMonthName = today.toLocaleDateString(\"en-US\", {\n      month: \"long\",\n    })\n\n    const freezeColorStyles = {\n      \"--freeze-color\": \"var(--info)\",\n      \"--freeze-foreground-color\": \"var(--info-foreground)\",\n    } as React.CSSProperties\n\n    const getCellState = (date: Date) => {\n      const isToday = date.getTime() === today.getTime()\n      const isFuture = date > today\n      const isActive = wasDateActive(date, periods)\n      const usedFreeze = showFreezes && didUseFreezeOnDate(date, periods)\n      return { isToday, isFuture, isActive, usedFreeze }\n    }\n\n    return (\n      <div\n        ref={ref}\n        role=\"grid\"\n        aria-label={`Streak calendar ${view} view`}\n        className={cn(\n          \"w-full\",\n          view === \"month\" ? \"max-w-sm\" : \"max-w-3xl\",\n          className\n        )}\n        {...props}\n      >\n        {view === \"month\" && (\n          <>\n            <div className=\"mb-4 text-center\">\n              <h3 className=\"text-lg font-semibold\" id=\"streak-calendar-title\">\n                {monthName} {year}\n              </h3>\n            </div>\n            <div role=\"row\" className=\"mb-2 grid grid-cols-7 gap-1\">\n              {weekdays.map((day) => (\n                <div\n                  key={day}\n                  role=\"columnheader\"\n                  className=\"text-muted-foreground text-center text-xs font-medium\"\n                >\n                  {day}\n                </div>\n              ))}\n            </div>\n            <div role=\"rowgroup\" className=\"grid grid-cols-7 gap-1\">\n              {days.map((day, index) => {\n                if (day === null) {\n                  return (\n                    <div key={`empty-${index}`} className=\"aspect-square\" />\n                  )\n                }\n                const date = new Date(year, monthIndex, day)\n                const { isToday, isFuture, isActive, usedFreeze } =\n                  getCellState(date)\n                const dateLabel = date.toLocaleDateString(\"en-US\", {\n                  weekday: \"long\",\n                  month: \"long\",\n                  day: \"numeric\",\n                })\n                const statusLabel = usedFreeze\n                  ? \"freeze used\"\n                  : isActive\n                    ? \"streak active\"\n                    : isFuture\n                      ? \"future\"\n                      : \"no activity\"\n                return (\n                  <button\n                    key={day}\n                    type=\"button\"\n                    role=\"gridcell\"\n                    aria-label={`${dateLabel}${isToday ? \", today\" : \"\"}, ${statusLabel}`}\n                    aria-current={isToday ? \"date\" : undefined}\n                    onClick={() => onDayClick?.(date, isActive)}\n                    disabled={isFuture}\n                    className={cn(\n                      \"relative flex aspect-square items-center justify-center rounded-lg text-sm transition-colors\",\n                      \"hover:bg-muted focus-visible:ring-ring p-1 focus-visible:ring-2 focus-visible:outline-none\",\n                      isToday &&\n                        \"ring-primary !bg-primary-foreground !text-primary ring-2 ring-inset\",\n                      isFuture && \"text-muted-foreground/50 cursor-not-allowed\",\n                      isActive &&\n                        !usedFreeze &&\n                        \"bg-primary text-primary-foreground hover:bg-primary/90\",\n                      usedFreeze &&\n                        \"bg-[var(--freeze-color)] text-[var(--freeze-foreground-color)] hover:opacity-90\"\n                    )}\n                    style={usedFreeze ? freezeColorStyles : undefined}\n                  >\n                    {day}\n                  </button>\n                )\n              })}\n            </div>\n          </>\n        )}\n\n        {view === \"week\" && (\n          <div role=\"rowgroup\" className=\"grid grid-cols-7 gap-2\">\n            {weekDates.map((date) => {\n              const { isToday, isFuture, isActive, usedFreeze } =\n                getCellState(date)\n              const dayLabel = date.toLocaleDateString(\"en-US\", {\n                weekday: \"short\",\n              })\n              return (\n                <div\n                  key={getDateKey(date)}\n                  className=\"flex flex-col items-center gap-2\"\n                >\n                  <button\n                    type=\"button\"\n                    role=\"gridcell\"\n                    aria-current={isToday ? \"date\" : undefined}\n                    aria-label={`${date.toLocaleDateString(\"en-US\", {\n                      weekday: \"long\",\n                      month: \"long\",\n                      day: \"numeric\",\n                    })}, ${usedFreeze ? \"freeze used\" : isActive ? \"streak active\" : isFuture ? \"future\" : \"no activity\"}`}\n                    onClick={() => onDayClick?.(date, isActive)}\n                    disabled={isFuture}\n                    className={cn(\n                      \"relative flex h-12 w-12 items-center justify-center rounded-full border border-2 transition-colors\",\n                      \"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none\",\n                      !isFuture && \"hover:opacity-90\",\n                      isToday && \"!bg-primary-foreground !text-primary\",\n                      isFuture &&\n                        \"border-border/40 bg-muted/20 text-muted-foreground/40 cursor-not-allowed\",\n                      isActive &&\n                        !usedFreeze &&\n                        \"border-primary bg-primary text-primary-foreground\",\n                      usedFreeze &&\n                        \"border-[var(--freeze-color)] bg-[var(--freeze-color)] text-[var(--freeze-foreground-color)]\",\n                      !isActive &&\n                        !usedFreeze &&\n                        !isFuture &&\n                        \"border-border/60 bg-muted/30\"\n                    )}\n                    style={usedFreeze ? freezeColorStyles : undefined}\n                  >\n                    {usedFreeze ? (\n                      <Snowflake\n                        className={cn(\n                          \"h-5 w-5\",\n                          isToday && \"!text-muted-foreground/20\"\n                        )}\n                      />\n                    ) : (\n                      isActive && (\n                        <Check\n                          className={cn(\n                            \"h-5 w-5\",\n                            isToday && \"!text-muted-foreground/20\"\n                          )}\n                        />\n                      )\n                    )}\n                  </button>\n                  <span\n                    className={cn(\n                      \"text-sm\",\n                      isFuture ? \"text-muted-foreground/50\" : \"text-foreground\"\n                    )}\n                  >\n                    {dayLabel}\n                  </span>\n                </div>\n              )\n            })}\n          </div>\n        )}\n\n        {view === \"year\" && (\n          <>\n            <div className=\"mb-4 text-center\">\n              <h3 className=\"text-lg font-semibold\" id=\"streak-calendar-title\">\n                Last 365 days\n              </h3>\n              <p className=\"text-muted-foreground text-xs\">\n                Ending {gitEndMonthName} {today.getDate()}\n              </p>\n            </div>\n            <div className=\"overflow-x-auto\">\n              <div className=\"inline-block pr-16\">\n                <div\n                  aria-hidden=\"true\"\n                  className=\"mb-2 grid gap-1\"\n                  style={{ gridTemplateColumns: gitGridTemplateColumns }}\n                >\n                  {gitMonthLabels.map((month) => (\n                    <span\n                      key={`${month.column}-${month.label}`}\n                      className=\"text-muted-foreground text-xs whitespace-nowrap\"\n                      style={{ gridColumnStart: month.column + 1 }}\n                    >\n                      {month.label}\n                    </span>\n                  ))}\n                </div>\n                <TooltipProvider>\n                  <div\n                    role=\"rowgroup\"\n                    className=\"grid grid-flow-col grid-rows-7 gap-1\"\n                    style={{ gridTemplateColumns: gitGridTemplateColumns }}\n                  >\n                    {gitCells.map((date, index) => {\n                      if (!date) {\n                        return (\n                          <div key={`git-empty-${index}`} className=\"h-4 w-4\" />\n                        )\n                      }\n                      const { isToday, isActive, usedFreeze } =\n                        getCellState(date)\n                      const dateLabel = date.toLocaleDateString(\"en-US\", {\n                        weekday: \"long\",\n                        month: \"long\",\n                        day: \"numeric\",\n                        year: \"numeric\",\n                      })\n                      return (\n                        <Tooltip key={getDateKey(date)}>\n                          <TooltipTrigger asChild>\n                            <button\n                              type=\"button\"\n                              role=\"gridcell\"\n                              aria-current={isToday ? \"date\" : undefined}\n                              aria-label={`${dateLabel}, ${usedFreeze ? \"freeze used\" : isActive ? \"streak active\" : \"no activity\"}`}\n                              onClick={() => onDayClick?.(date, isActive)}\n                              className={cn(\n                                \"border-border/40 h-4 w-4 rounded-[0.4rem] border transition-colors\",\n                                \"hover:ring-ring hover:ring-1\",\n                                isToday &&\n                                  \"!bg-primary-foreground !text-primary border-primary\",\n                                isActive &&\n                                  !usedFreeze &&\n                                  \"bg-primary border-primary/80\",\n                                usedFreeze &&\n                                  \"border-[var(--freeze-color)] bg-[var(--freeze-color)]\",\n                                !isActive && !usedFreeze && \"bg-muted/40\"\n                              )}\n                              style={usedFreeze ? freezeColorStyles : undefined}\n                            />\n                          </TooltipTrigger>\n                          <TooltipContent side=\"top\">\n                            {dateLabel}\n                          </TooltipContent>\n                        </Tooltip>\n                      )\n                    })}\n                  </div>\n                </TooltipProvider>\n              </div>\n            </div>\n          </>\n        )}\n      </div>\n    )\n  }\n)\nStreakCalendar.displayName = \"StreakCalendar\"\n\nexport { StreakCalendar }\nexport type { StreakCalendarProps, StreakPeriod }\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}