Most Active Days

Calendar activity-ring card.

Calendar

Activity rings on a calendar.

Most active days

32,459

total steps
January

Jan

Feb

Mar

Apr

May

Jun

Jul

Aug

Sep

Oct

Nov

Dec

function MostActiveDaysDemo() {
  return <MostActiveDaysCard totalSteps={MOST_ACTIVE_DAYS_TOTAL_STEPS} />;
}
function MostActiveDaysDemo() {
  return <MostActiveDaysCard totalSteps={MOST_ACTIVE_DAYS_TOTAL_STEPS} />;
}

Installation

npx shadcn@latest add https://boardcn.dev/r/most-active-days-card.json
npx shadcn@latest add https://boardcn.dev/r/most-active-days-card.json

BoardCN dependencies

The CLI installs these for you — you do not need to add them yourself.

Source

The file the CLI copies into your project.

components/charts/most-active-days-card.tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { WeekRangePill } from "@/components/blocks/medical/week-range-pill";
import {
  ACTIVITY_MONTHS,
  ACTIVITY_YEAR,
  activityRingPct,
} from "@/components/charts/activity-rings-card";
import type { ActivityDay } from "@/components/charts/activity-rings-card";
import { cx } from "@/utils/cx";

const MINI_RINGS = [
  { radius: 12, color: "var(--color-chart-3-active)" },
  { radius: 8.4, color: "var(--color-chart-2)" },
  { radius: 4.8, color: "var(--color-chart-4)" },
] as const;

function MiniActivityRings({ month, day }: ActivityDay) {
  // The BoardCN preview represents dates after its July 10 checkpoint as
  // future days. Their tracks remain visible, but progress is intentionally
  // omitted.
  const isFutureDay = month > 6 || (month === 6 && day > 10);

  return (
    <svg viewBox="0 0 28 28" className="size-7 shrink-0" aria-hidden="true">
      {MINI_RINGS.map((ring, index) => {
        const circumference = 2 * Math.PI * ring.radius;
        const progress = circumference * activityRingPct(month, day, index);

        return (
          <g key={ring.radius} transform="rotate(-90 14 14)">
            <circle
              cx={14}
              cy={14}
              r={ring.radius}
              fill="none"
              stroke={ring.color}
              strokeWidth={2.2}
              opacity={0.16}
            />
            {!isFutureDay && (
              <circle
                cx={14}
                cy={14}
                r={ring.radius}
                fill="none"
                stroke={ring.color}
                strokeWidth={2.2}
                strokeLinecap="round"
                strokeDasharray={`${progress} ${circumference - progress}`}
              />
            )}
          </g>
        );
      })}
    </svg>
  );
}

export type MostActiveDaysCardProps = {
  /** Headline total shown above the calendar. */
  totalSteps: number;
  /** Currently selected calendar date. */
  selectedDay?: ActivityDay | null;
  /** Called when a day is selected. Pair with ActivityRingsCard to update it. */
  onSelectDay?: (day: ActivityDay) => void;
  className?: string;
};

export function MostActiveDaysCard({
  totalSteps,
  selectedDay = null,
  onSelectDay,
  className,
}: MostActiveDaysCardProps) {
  const scrollerRef = useRef<HTMLDivElement>(null);
  const monthRefs = useRef<Array<HTMLDivElement | null>>([]);
  const [visibleMonth, setVisibleMonth] = useState(0);

  const scrollToMonth = (
    requestedMonth: number,
    behavior: ScrollBehavior = "smooth",
  ) => {
    const month = Math.min(11, Math.max(0, requestedMonth));
    const scroller = scrollerRef.current;
    const monthElement = monthRefs.current[month];

    if (!scroller || !monthElement) return;

    const monthOffset =
      monthElement.getBoundingClientRect().top -
      scroller.getBoundingClientRect().top +
      scroller.scrollTop;

    scroller.scrollTo({
      top: Math.max(0, monthOffset - 9),
      behavior,
    });
  };

  useEffect(() => {
    const frame = requestAnimationFrame(() => scrollToMonth(6, "smooth"));
    return () => cancelAnimationFrame(frame);
  }, []);

  useEffect(() => {
    const scroller = scrollerRef.current;
    if (!scroller) return;

    let frame = 0;

    const updateVisibleMonth = () => {
      const scrollerTop = scroller.getBoundingClientRect().top;
      let activeMonth = 0;

      monthRefs.current.forEach((monthElement, month) => {
        if (
          monthElement &&
          monthElement.getBoundingClientRect().top - scrollerTop <= 24
        ) {
          activeMonth = month;
        }
      });

      setVisibleMonth(activeMonth);
    };

    const onScroll = () => {
      cancelAnimationFrame(frame);
      frame = requestAnimationFrame(updateVisibleMonth);
    };

    scroller.addEventListener("scroll", onScroll, { passive: true });
    return () => {
      cancelAnimationFrame(frame);
      scroller.removeEventListener("scroll", onScroll);
    };
  }, []);

  return (
    <section
      className={cx(
        "flex h-[330px] w-full min-w-0 flex-col gap-4 rounded-[20px] bg-background-secondary-default p-2.5",
        className,
      )}
    >
      <div className="flex w-full items-start justify-between gap-2 px-1.5 pt-1.5">
        <div className="flex min-w-0 flex-1 flex-col gap-0.5">
          <p className="text-body-medium whitespace-nowrap text-text-secondary">
            Most active days
          </p>
          <div className="flex items-baseline gap-1">
            <p className="text-title-1-medium whitespace-nowrap text-text-primary tabular-nums">
              {totalSteps.toLocaleString()}
            </p>
            <span className="text-caption-1-medium whitespace-nowrap text-text-secondary">
              total steps
            </span>
          </div>
        </div>

        <WeekRangePill
          label={ACTIVITY_MONTHS[visibleMonth]}
          onPrev={() => scrollToMonth(visibleMonth - 1)}
          onNext={() => scrollToMonth(visibleMonth + 1)}
          className="w-[128px]"
        />
      </div>

      <div className="min-h-0 w-full flex-1 overflow-hidden rounded-2lg bg-background-inner-default px-2.5">
        <div
          ref={scrollerRef}
          className="h-full w-full overflow-y-auto overscroll-contain"
        >
          <div className="relative flex flex-col gap-4 py-2.5">
            {ACTIVITY_MONTHS.map((monthName, month) => {
              const dayCount = new Date(ACTIVITY_YEAR, month + 1, 0).getDate();

              return (
                <div
                  key={monthName}
                  data-month={month}
                  ref={(element) => {
                    monthRefs.current[month] = element;
                  }}
                  className="flex flex-col gap-2.5"
                >
                  <p className="pl-1 text-title-3-medium text-text-primary">
                    {monthName.slice(0, 3)}
                  </p>
                  <div className="grid grid-cols-7 gap-x-[5px]">
                    {Array.from({ length: dayCount }, (_, index) => index + 1).map(
                      (day) => {
                        const selected =
                          selectedDay?.month === month && selectedDay.day === day;

                        return (
                          <button
                            key={day}
                            type="button"
                            onClick={() => onSelectDay?.({ month, day })}
                            aria-pressed={selected}
                            aria-label={`Activity for ${monthName} ${day}`}
                            className={cx(
                              "flex h-[78px] cursor-pointer flex-col items-center justify-center gap-2.5 rounded-2lg border-2 p-2.5",
                              "outline-none transition-colors duration-150 ease focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-border-focus-ring",
                              selected
                                ? "border-border-button-hover bg-background-primary-default"
                                : "border-transparent hover:bg-background-primary-hover",
                            )}
                          >
                            <span className="text-body-medium whitespace-nowrap text-text-primary">
                              {day}
                            </span>
                            <MiniActivityRings month={month} day={day} />
                          </button>
                        );
                      },
                    )}
                  </div>
                </div>
              );
            })}
          </div>
        </div>
      </div>
    </section>
  );
}
"use client";

import { useEffect, useRef, useState } from "react";
import { WeekRangePill } from "@/components/blocks/medical/week-range-pill";
import {
  ACTIVITY_MONTHS,
  ACTIVITY_YEAR,
  activityRingPct,
} from "@/components/charts/activity-rings-card";
import type { ActivityDay } from "@/components/charts/activity-rings-card";
import { cx } from "@/utils/cx";

const MINI_RINGS = [
  { radius: 12, color: "var(--color-chart-3-active)" },
  { radius: 8.4, color: "var(--color-chart-2)" },
  { radius: 4.8, color: "var(--color-chart-4)" },
] as const;

function MiniActivityRings({ month, day }: ActivityDay) {
  // The BoardCN preview represents dates after its July 10 checkpoint as
  // future days. Their tracks remain visible, but progress is intentionally
  // omitted.
  const isFutureDay = month > 6 || (month === 6 && day > 10);

  return (
    <svg viewBox="0 0 28 28" className="size-7 shrink-0" aria-hidden="true">
      {MINI_RINGS.map((ring, index) => {
        const circumference = 2 * Math.PI * ring.radius;
        const progress = circumference * activityRingPct(month, day, index);

        return (
          <g key={ring.radius} transform="rotate(-90 14 14)">
            <circle
              cx={14}
              cy={14}
              r={ring.radius}
              fill="none"
              stroke={ring.color}
              strokeWidth={2.2}
              opacity={0.16}
            />
            {!isFutureDay && (
              <circle
                cx={14}
                cy={14}
                r={ring.radius}
                fill="none"
                stroke={ring.color}
                strokeWidth={2.2}
                strokeLinecap="round"
                strokeDasharray={`${progress} ${circumference - progress}`}
              />
            )}
          </g>
        );
      })}
    </svg>
  );
}

export type MostActiveDaysCardProps = {
  /** Headline total shown above the calendar. */
  totalSteps: number;
  /** Currently selected calendar date. */
  selectedDay?: ActivityDay | null;
  /** Called when a day is selected. Pair with ActivityRingsCard to update it. */
  onSelectDay?: (day: ActivityDay) => void;
  className?: string;
};

export function MostActiveDaysCard({
  totalSteps,
  selectedDay = null,
  onSelectDay,
  className,
}: MostActiveDaysCardProps) {
  const scrollerRef = useRef<HTMLDivElement>(null);
  const monthRefs = useRef<Array<HTMLDivElement | null>>([]);
  const [visibleMonth, setVisibleMonth] = useState(0);

  const scrollToMonth = (
    requestedMonth: number,
    behavior: ScrollBehavior = "smooth",
  ) => {
    const month = Math.min(11, Math.max(0, requestedMonth));
    const scroller = scrollerRef.current;
    const monthElement = monthRefs.current[month];

    if (!scroller || !monthElement) return;

    const monthOffset =
      monthElement.getBoundingClientRect().top -
      scroller.getBoundingClientRect().top +
      scroller.scrollTop;

    scroller.scrollTo({
      top: Math.max(0, monthOffset - 9),
      behavior,
    });
  };

  useEffect(() => {
    const frame = requestAnimationFrame(() => scrollToMonth(6, "smooth"));
    return () => cancelAnimationFrame(frame);
  }, []);

  useEffect(() => {
    const scroller = scrollerRef.current;
    if (!scroller) return;

    let frame = 0;

    const updateVisibleMonth = () => {
      const scrollerTop = scroller.getBoundingClientRect().top;
      let activeMonth = 0;

      monthRefs.current.forEach((monthElement, month) => {
        if (
          monthElement &&
          monthElement.getBoundingClientRect().top - scrollerTop <= 24
        ) {
          activeMonth = month;
        }
      });

      setVisibleMonth(activeMonth);
    };

    const onScroll = () => {
      cancelAnimationFrame(frame);
      frame = requestAnimationFrame(updateVisibleMonth);
    };

    scroller.addEventListener("scroll", onScroll, { passive: true });
    return () => {
      cancelAnimationFrame(frame);
      scroller.removeEventListener("scroll", onScroll);
    };
  }, []);

  return (
    <section
      className={cx(
        "flex h-[330px] w-full min-w-0 flex-col gap-4 rounded-[20px] bg-background-secondary-default p-2.5",
        className,
      )}
    >
      <div className="flex w-full items-start justify-between gap-2 px-1.5 pt-1.5">
        <div className="flex min-w-0 flex-1 flex-col gap-0.5">
          <p className="text-body-medium whitespace-nowrap text-text-secondary">
            Most active days
          </p>
          <div className="flex items-baseline gap-1">
            <p className="text-title-1-medium whitespace-nowrap text-text-primary tabular-nums">
              {totalSteps.toLocaleString()}
            </p>
            <span className="text-caption-1-medium whitespace-nowrap text-text-secondary">
              total steps
            </span>
          </div>
        </div>

        <WeekRangePill
          label={ACTIVITY_MONTHS[visibleMonth]}
          onPrev={() => scrollToMonth(visibleMonth - 1)}
          onNext={() => scrollToMonth(visibleMonth + 1)}
          className="w-[128px]"
        />
      </div>

      <div className="min-h-0 w-full flex-1 overflow-hidden rounded-2lg bg-background-inner-default px-2.5">
        <div
          ref={scrollerRef}
          className="h-full w-full overflow-y-auto overscroll-contain"
        >
          <div className="relative flex flex-col gap-4 py-2.5">
            {ACTIVITY_MONTHS.map((monthName, month) => {
              const dayCount = new Date(ACTIVITY_YEAR, month + 1, 0).getDate();

              return (
                <div
                  key={monthName}
                  data-month={month}
                  ref={(element) => {
                    monthRefs.current[month] = element;
                  }}
                  className="flex flex-col gap-2.5"
                >
                  <p className="pl-1 text-title-3-medium text-text-primary">
                    {monthName.slice(0, 3)}
                  </p>
                  <div className="grid grid-cols-7 gap-x-[5px]">
                    {Array.from({ length: dayCount }, (_, index) => index + 1).map(
                      (day) => {
                        const selected =
                          selectedDay?.month === month && selectedDay.day === day;

                        return (
                          <button
                            key={day}
                            type="button"
                            onClick={() => onSelectDay?.({ month, day })}
                            aria-pressed={selected}
                            aria-label={`Activity for ${monthName} ${day}`}
                            className={cx(
                              "flex h-[78px] cursor-pointer flex-col items-center justify-center gap-2.5 rounded-2lg border-2 p-2.5",
                              "outline-none transition-colors duration-150 ease focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-border-focus-ring",
                              selected
                                ? "border-border-button-hover bg-background-primary-default"
                                : "border-transparent hover:bg-background-primary-hover",
                            )}
                          >
                            <span className="text-body-medium whitespace-nowrap text-text-primary">
                              {day}
                            </span>
                            <MiniActivityRings month={month} day={day} />
                          </button>
                        );
                      },
                    )}
                  </div>
                </div>
              );
            })}
          </div>
        </div>
      </div>
    </section>
  );
}

Props

Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.

MostActiveDaysCard

PropTypeDefaultDescription
totalStepsrequirednumberHeadline total shown above the calendar.
classNamestring
onSelectDay(day: ActivityDay) => voidCalled when a day is selected. Pair with ActivityRingsCard to update it.
selectedDayActivityDaynullCurrently selected calendar date.