refactor(calendar): collapse the activity calendar to the month grid

Only the month view is rendered anywhere, so drop the unused year
calendar, the size variants and the helpers that existed for them.

- Build the month grid in one place (monthDays.ts) with the week-start
  rotation and out-of-month padding, and test it.
- Keep every chip visual in cellStyles.ts. A selected day now owns its
  fill outright so the empty-cell hover tint cannot swap the accent out.
- MonthCalendar derives maxCount from its own data instead of taking it
  as a prop; the index exports only what callers use.
This commit is contained in:
johnnyjoygh 2026-09-02 22:55:51 +08:00
parent aba1f7384d
commit 882664d9d6
14 changed files with 221 additions and 493 deletions

View file

@ -1,89 +1,53 @@
import { memo } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { DEFAULT_CELL_SIZE, SMALL_CELL_SIZE } from "./constants";
import type { CalendarDayCell, CalendarSize } from "./types";
import { getCalendarCellStateClass, getCellIntensityClass } from "./utils";
import { getChipClassName } from "./cellStyles";
import type { CalendarDayCell } from "./types";
export interface CalendarCellProps {
day: CalendarDayCell;
maxCount: number;
tooltipText: string;
onClick?: (date: string) => void;
size?: CalendarSize;
disableTooltip?: boolean;
}
export const CalendarCell = memo((props: CalendarCellProps) => {
const { day, maxCount, tooltipText, onClick, size = "default", disableTooltip = false } = props;
/** The cell spans its whole column and takes the pointer; the chip inside it carries the fill. */
const CELL_CLASSES = "group/day flex w-full items-center justify-center select-none";
const handleClick = () => {
if (onClick) {
onClick(day.date);
}
};
const sizeConfig = size === "small" ? SMALL_CELL_SIZE : DEFAULT_CELL_SIZE;
// Two elements with two jobs: the cell spans its whole column and takes the pointer, the
// chip inside it is the square that carries the fill and sets the row's height.
const cellClasses = "group/day flex w-full items-center justify-center select-none";
const chipClasses = cn(
"relative flex aspect-square w-full items-center justify-center text-center transition-[background-color,color,filter,box-shadow] duration-150 ease-out",
sizeConfig.font,
sizeConfig.borderRadius,
sizeConfig.maxSize,
);
export const CalendarCell = memo(({ day, maxCount, tooltipText, onClick }: CalendarCellProps) => {
const isInteractive = Boolean(onClick);
const ariaLabel = day.isSelected ? `${tooltipText} (selected)` : tooltipText;
if (!day.isCurrentMonth) {
return (
<div className={cn(cellClasses, "cursor-default")}>
<span className={cn(chipClasses, "bg-transparent text-muted-foreground/25")}>{day.label}</span>
<div className={cn(CELL_CLASSES, "cursor-default")}>
<span className={getChipClassName(day, maxCount, false)}>{day.label}</span>
</div>
);
}
const intensityClass = getCellIntensityClass(day, maxCount);
const chip = (
<span
className={cn(
chipClasses,
intensityClass,
getCalendarCellStateClass(day),
isInteractive &&
"group-hover/day:brightness-[0.97] group-focus-visible/day:ring-2 group-focus-visible/day:ring-ring/40 group-focus-visible/day:ring-inset",
)}
>
{day.label}
{day.isToday && (
<span
aria-hidden="true"
className="absolute bottom-[3px] left-1/2 size-[3px] -translate-x-1/2 rounded-full bg-blue-600/80 dark:bg-blue-300/80"
/>
)}
</span>
);
const button = (
<button
type="button"
onClick={handleClick}
onClick={() => onClick?.(day.date)}
tabIndex={isInteractive ? 0 : -1}
aria-label={ariaLabel}
aria-label={day.isSelected ? `${tooltipText} (selected)` : tooltipText}
aria-current={day.isToday ? "date" : undefined}
aria-disabled={!isInteractive}
className={cn(cellClasses, "p-0 focus-visible:outline-none", isInteractive ? "cursor-pointer" : "cursor-default")}
className={cn(CELL_CLASSES, "p-0 focus-visible:outline-none", isInteractive ? "cursor-pointer" : "cursor-default")}
>
{chip}
<span className={getChipClassName(day, maxCount, isInteractive)}>
{day.label}
{day.isToday && (
<span
aria-hidden="true"
className="absolute bottom-[3px] left-1/2 size-[3px] -translate-x-1/2 rounded-full bg-blue-600/80 dark:bg-blue-300/80"
/>
)}
</span>
</button>
);
const shouldShowTooltip = day.count > 0 && tooltipText && !disableTooltip;
if (!shouldShowTooltip) {
if (day.count === 0) {
return button;
}

View file

@ -1,82 +1,67 @@
import dayjs from "dayjs";
import { memo, useMemo } from "react";
import { useInstance } from "@/contexts/InstanceContext";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { CalendarCell } from "./CalendarCell";
import { useTodayDate, useWeekdayLabels } from "./hooks";
import type { CalendarSize, MonthCalendarProps } from "./types";
import { useCalendarMatrix } from "./useCalendar";
import { getTooltipText } from "./utils";
import { rotateWeekdays, useMonthDays } from "./monthDays";
import type { MonthCalendarProps } from "./types";
import { calculateMaxCount, getTooltipText } from "./utils";
const GRID_STYLES: Record<CalendarSize, { gap: string; headerText: string }> = {
small: { gap: "gap-1", headerText: "text-[9px]" },
default: { gap: "gap-1", headerText: "text-2xs" },
const useWeekdayLabels = (weekStartDayOffset: number) => {
const t = useTranslate();
return useMemo(
() =>
rotateWeekdays(
[
t("common.days.sun"),
t("common.days.mon"),
t("common.days.tue"),
t("common.days.wed"),
t("common.days.thu"),
t("common.days.fri"),
t("common.days.sat"),
],
weekStartDayOffset,
),
[t, weekStartDayOffset],
);
};
interface WeekdayHeaderProps {
weekDays: string[];
size: CalendarSize;
}
const WeekdayHeader = memo(({ weekDays, size }: WeekdayHeaderProps) => (
<div className={cn("mb-1.5 grid grid-cols-7", GRID_STYLES[size].gap, GRID_STYLES[size].headerText)} role="row">
{weekDays.map((label, index) => (
<div
key={index}
className="flex h-5 items-center justify-center font-medium uppercase tracking-[0.04em] text-muted-foreground/50"
role="columnheader"
aria-label={label}
>
{Array.from(label)[0]}
</div>
))}
</div>
));
WeekdayHeader.displayName = "WeekdayHeader";
export const MonthCalendar = memo((props: MonthCalendarProps) => {
const {
month,
data,
maxCount,
size = "default",
onClick,
selectedDate,
className,
disableTooltips = false,
timeBasis = "create_time",
} = props;
export const MonthCalendar = memo(({ month, data, selectedDate, onClick, timeBasis = "create_time" }: MonthCalendarProps) => {
const t = useTranslate();
const { generalSetting } = useInstance();
const today = useTodayDate();
const weekDays = useWeekdayLabels();
const gridStyle = GRID_STYLES[size];
const { weeks, weekDays: rotatedWeekDays } = useCalendarMatrix({
const weekDays = useWeekdayLabels(generalSetting.weekStartDayOffset);
const maxCount = useMemo(() => calculateMaxCount(data), [data]);
const days = useMonthDays({
month,
data,
weekDays,
weekStartDayOffset: generalSetting.weekStartDayOffset,
today,
selectedDate: selectedDate ?? "",
today: dayjs().format("YYYY-MM-DD"),
selectedDate,
});
const flatDays = useMemo(() => weeks.flatMap((week) => week.days), [weeks]);
return (
<div className={cn("flex flex-col", className)} role="grid" aria-label={`Calendar for ${month}`}>
<WeekdayHeader weekDays={rotatedWeekDays} size={size} />
<div className="flex flex-col" role="group" aria-label={`Calendar for ${month}`}>
{/* Every day button already announces its full date, so the initials are decoration. */}
<div className="mb-1.5 grid grid-cols-7 gap-1 text-2xs" aria-hidden="true">
{weekDays.map((label, index) => (
<div
key={index}
className="flex h-5 items-center justify-center font-medium uppercase tracking-[0.04em] text-muted-foreground/50"
>
{Array.from(label)[0]}
</div>
))}
</div>
<div className={cn("grid grid-cols-7", gridStyle.gap)} role="rowgroup">
{flatDays.map((day) => (
<div className="grid grid-cols-7 gap-1">
{days.map((day) => (
<CalendarCell
key={day.date}
day={day}
maxCount={maxCount}
tooltipText={getTooltipText(day.count, day.date, t, timeBasis)}
onClick={onClick}
size={size}
disableTooltip={disableTooltips}
/>
))}
</div>

View file

@ -1,122 +0,0 @@
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { memo, useMemo } from "react";
import { Button } from "@/components/ui/button";
import type { MemoTimeBasis } from "@/contexts/ViewContext";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { getMaxYear, MIN_YEAR } from "./constants";
import { MonthCalendar } from "./MonthCalendar";
import type { CalendarData, YearCalendarProps } from "./types";
import { calculateMaxCount, filterDataByYear, generateMonthsForYear, getMonthLabel } from "./utils";
interface YearNavigationProps {
selectedYear: number;
currentYear: number;
onPrev: () => void;
onNext: () => void;
onToday: () => void;
canGoPrev: boolean;
canGoNext: boolean;
}
const YearNavigation = memo(({ selectedYear, currentYear, onPrev, onNext, onToday, canGoPrev, canGoNext }: YearNavigationProps) => {
const t = useTranslate();
const isCurrentYear = selectedYear === currentYear;
return (
<div className="flex items-center justify-between px-1">
<h2 className="text-xl font-semibold tracking-[-0.02em] text-foreground/90">{selectedYear}</h2>
<nav className="inline-flex items-center gap-1" aria-label="Year navigation">
<Button
variant="ghost"
size="icon-sm"
onClick={onPrev}
disabled={!canGoPrev}
aria-label="Previous year"
className="size-7 rounded-md text-muted-foreground/65 hover:bg-muted/50 hover:text-foreground/90"
>
<ChevronLeftIcon className="size-[15px] rtl:rotate-180" strokeWidth={1.75} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={onToday}
disabled={isCurrentYear}
aria-label={t("common.today")}
className={cn(
"h-7 rounded-md px-2.5 text-[11px] font-medium",
isCurrentYear
? "cursor-default text-muted-foreground/40"
: "text-muted-foreground/65 hover:bg-muted/50 hover:text-foreground/90",
)}
>
{t("common.today")}
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onNext}
disabled={!canGoNext}
aria-label="Next year"
className="size-7 rounded-md text-muted-foreground/65 hover:bg-muted/50 hover:text-foreground/90"
>
<ChevronRightIcon className="size-[15px] rtl:rotate-180" strokeWidth={1.75} />
</Button>
</nav>
</div>
);
});
YearNavigation.displayName = "YearNavigation";
interface MonthCardProps {
month: string;
data: CalendarData;
maxCount: number;
onDateClick: (date: string) => void;
timeBasis?: MemoTimeBasis;
}
const MonthCard = memo(({ month, data, maxCount, onDateClick, timeBasis }: MonthCardProps) => (
<article className="flex flex-col gap-2 rounded-lg bg-muted/10 p-3 transition-colors hover:bg-muted/20">
<header className="text-[10px] font-medium uppercase leading-4 tracking-[0.08em] text-muted-foreground/60">
{getMonthLabel(month)}
</header>
<MonthCalendar month={month} data={data} maxCount={maxCount} size="small" onClick={onDateClick} disableTooltips timeBasis={timeBasis} />
</article>
));
MonthCard.displayName = "MonthCard";
export const YearCalendar = memo(({ selectedYear, data, onYearChange, onDateClick, className, timeBasis }: YearCalendarProps) => {
const currentYear = useMemo(() => new Date().getFullYear(), []);
const yearData = useMemo(() => filterDataByYear(data, selectedYear), [data, selectedYear]);
const months = useMemo(() => generateMonthsForYear(selectedYear), [selectedYear]);
const yearMaxCount = useMemo(() => calculateMaxCount(yearData), [yearData]);
const canGoPrev = selectedYear > MIN_YEAR;
const canGoNext = selectedYear < getMaxYear();
return (
<section className={cn("flex w-full flex-col gap-4 px-5 py-5 select-none", className)} aria-label={`Year ${selectedYear} calendar`}>
<YearNavigation
selectedYear={selectedYear}
currentYear={currentYear}
onPrev={() => canGoPrev && onYearChange(selectedYear - 1)}
onNext={() => canGoNext && onYearChange(selectedYear + 1)}
onToday={() => onYearChange(currentYear)}
canGoPrev={canGoPrev}
canGoNext={canGoNext}
/>
<div className="grid grid-cols-1 gap-3.5 animate-fade-in sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{months.map((month) => (
<MonthCard key={month} month={month} data={yearData} maxCount={yearMaxCount} onDateClick={onDateClick} timeBasis={timeBasis} />
))}
</div>
</section>
);
});
YearCalendar.displayName = "YearCalendar";

View file

@ -0,0 +1,42 @@
import { cn } from "@/lib/utils";
import type { CalendarDayCell } from "./types";
/**
* The square chip inside a cell carries every visual. `max-w-[30px]` caps it so a wider
* container spends its surplus on hit area rather than row height; the 224px rail's ~25px
* columns sit below the cap and are unaffected.
*/
const CHIP_BASE =
"relative flex aspect-square w-full max-w-[30px] items-center justify-center rounded-md text-center text-xs font-normal leading-none tracking-[-0.01em] tabular-nums transition-[background-color,color,filter,box-shadow] duration-150 ease-out";
/** Hover reads off the cell (`group/day`), not the chip, so the whole column responds to the pointer. */
const INTERACTIVE_CHIP =
"group-hover/day:brightness-[0.97] group-focus-visible/day:ring-2 group-focus-visible/day:ring-ring/40 group-focus-visible/day:ring-inset";
const OUTSIDE_MONTH_CHIP = "bg-transparent text-muted-foreground/25";
const EMPTY_CHIP = "bg-transparent text-foreground/75 group-hover/day:bg-muted/40";
/** A picked day is a checked filter like a view or tag row: it takes the accent, not a ring. */
const SELECTED_CHIP = "z-10 bg-primary font-medium text-primary-foreground";
/** Activity tints, ordered from the fraction of `maxCount` a day must exceed to earn them. */
const INTENSITY_TINTS: ReadonlyArray<readonly [threshold: number, className: string]> = [
[0.75, "bg-blue-400/60 text-foreground/85"],
[0.5, "bg-blue-400/45 text-foreground/80"],
[0.25, "bg-blue-400/30 text-foreground/80"],
[0, "bg-blue-400/18 text-foreground/75"],
];
const getFillClass = (day: CalendarDayCell, maxCount: number): string => {
if (!day.isCurrentMonth) return OUTSIDE_MONTH_CHIP;
// Selected owns the fill outright: layering the empty-cell hover tint on top would swap
// the accent out on hover and leave primary-foreground text on a muted chip.
if (day.isSelected) return SELECTED_CHIP;
if (day.count === 0) return EMPTY_CHIP;
const ratio = day.count / maxCount;
const tint = INTENSITY_TINTS.find(([threshold]) => ratio > threshold);
return tint?.[1] ?? EMPTY_CHIP;
};
export const getChipClassName = (day: CalendarDayCell, maxCount: number, isInteractive: boolean): string =>
cn(CHIP_BASE, getFillClass(day, maxCount), isInteractive && day.isCurrentMonth && INTERACTIVE_CHIP);

View file

@ -1,42 +0,0 @@
export const DAYS_IN_WEEK = 7;
export const MONTHS_IN_YEAR = 12;
export const MIN_COUNT = 1;
export const MIN_YEAR = 1970;
export const getMaxYear = () => new Date().getFullYear() + 1;
export const INTENSITY_THRESHOLDS = {
HIGH: 0.75,
MEDIUM: 0.5,
LOW: 0.25,
MINIMAL: 0,
} as const;
export const CELL_STYLES = {
HIGH: "bg-blue-400/60 text-foreground/85",
MEDIUM: "bg-blue-400/45 text-foreground/80",
LOW: "bg-blue-400/30 text-foreground/80",
MINIMAL: "bg-blue-400/18 text-foreground/75",
// Hover reads off the cell, not the chip, so the whole column responds to the pointer.
EMPTY: "bg-transparent text-foreground/75 group-hover/day:bg-muted/40",
} as const;
/**
* `maxSize` caps the square chip that carries a day's fill, so a chip measures
* `min(column, maxSize)`. Narrow containers are untouched — the 224px rail's ~25px columns
* stay below the cap — while wider ones spend the surplus on hit area rather than height,
* which is what stops a widened sidebar from doubling the calendar's height.
*/
export const SMALL_CELL_SIZE = {
font: "text-[10px] font-normal leading-none tracking-[-0.01em] tabular-nums",
maxSize: "max-w-[24px]",
borderRadius: "rounded-md",
gap: "gap-1",
} as const;
export const DEFAULT_CELL_SIZE = {
font: "text-xs font-normal leading-none tracking-[-0.01em] tabular-nums",
maxSize: "max-w-[30px]",
borderRadius: "rounded-md",
gap: "gap-1",
} as const;

View file

@ -1,23 +0,0 @@
import dayjs from "dayjs";
import { useMemo } from "react";
import { useTranslate } from "@/utils/i18n";
export const useWeekdayLabels = () => {
const t = useTranslate();
return useMemo(
() => [
t("common.days.sun"),
t("common.days.mon"),
t("common.days.tue"),
t("common.days.wed"),
t("common.days.thu"),
t("common.days.fri"),
t("common.days.sat"),
],
[t],
);
};
export const useTodayDate = () => {
return dayjs().format("YYYY-MM-DD");
};

View file

@ -1,4 +1,2 @@
export * from "./MonthCalendar";
export * from "./types";
export * from "./utils";
export * from "./YearCalendar";
export { MonthCalendar } from "./MonthCalendar";
export type { CalendarData, CalendarDayCell, MonthCalendarProps } from "./types";

View file

@ -0,0 +1,59 @@
import dayjs from "dayjs";
import { useMemo } from "react";
import type { CalendarData, CalendarDayCell } from "./types";
const DAYS_IN_WEEK = 7;
export interface MonthDaysParams {
/** `YYYY-MM` */
month: string;
data: CalendarData;
/** 0 = Sunday … 6 = Saturday; the instance's preferred first day of the week. */
weekStartDayOffset: number;
/** ISO date, `YYYY-MM-DD`. */
today: string;
selectedDate?: string;
}
/** Rotate a Sunday-first list of weekday labels so it starts on `weekStartDayOffset`. */
export const rotateWeekdays = <T>(labels: T[], weekStartDayOffset: number): T[] => {
const offset = ((weekStartDayOffset % DAYS_IN_WEEK) + DAYS_IN_WEEK) % DAYS_IN_WEEK;
return labels.slice(offset).concat(labels.slice(0, offset));
};
/**
* Every day of a month's grid in reading order, padded at both ends with out-of-month days so
* the list is a whole number of weeks aligned to `weekStartDayOffset`.
*/
export const buildMonthDays = ({ month, data, weekStartDayOffset, today, selectedDate }: MonthDaysParams): CalendarDayCell[] => {
const monthStart = dayjs(month).startOf("month");
const monthEnd = monthStart.endOf("month");
const monthKey = monthStart.format("YYYY-MM");
const leadingDays = (monthStart.day() - weekStartDayOffset + DAYS_IN_WEEK) % DAYS_IN_WEEK;
const trailingDays = (weekStartDayOffset + DAYS_IN_WEEK - 1 - monthEnd.day() + DAYS_IN_WEEK) % DAYS_IN_WEEK;
const gridStart = monthStart.subtract(leadingDays, "day");
const gridEnd = monthEnd.add(trailingDays, "day");
const dayCount = gridEnd.diff(gridStart, "day") + 1;
return Array.from({ length: dayCount }, (_, index) => {
const current = gridStart.add(index, "day");
const date = current.format("YYYY-MM-DD");
return {
date,
label: current.date(),
count: data[date] ?? 0,
isCurrentMonth: current.format("YYYY-MM") === monthKey,
isToday: date === today,
isSelected: date === selectedDate,
};
});
};
export const useMonthDays = (params: MonthDaysParams): CalendarDayCell[] => {
const { month, data, weekStartDayOffset, today, selectedDate } = params;
return useMemo(
() => buildMonthDays({ month, data, weekStartDayOffset, today, selectedDate }),
[month, data, weekStartDayOffset, today, selectedDate],
);
};

View file

@ -1,43 +1,26 @@
import type { MemoTimeBasis } from "@/contexts/ViewContext";
export type CalendarSize = "default" | "small";
/** Memo counts keyed by ISO date (`YYYY-MM-DD`). */
export type CalendarData = Record<string, number>;
export interface CalendarDayCell {
/** ISO date, `YYYY-MM-DD`. */
date: string;
/** Day of month, the numeral drawn in the cell. */
label: number;
count: number;
/** False for the leading/trailing days that pad the grid out to whole weeks. */
isCurrentMonth: boolean;
isToday: boolean;
isSelected: boolean;
}
export interface CalendarDayRow {
days: CalendarDayCell[];
}
export interface CalendarMatrixResult {
weeks: CalendarDayRow[];
weekDays: string[];
}
export interface MonthCalendarProps {
/** Month to render, `YYYY-MM`. */
month: string;
data: CalendarData;
maxCount: number;
size?: CalendarSize;
onClick?: (date: string) => void;
/** ISO date of the day currently used as a filter, if any. */
selectedDate?: string;
className?: string;
disableTooltips?: boolean;
timeBasis?: MemoTimeBasis;
}
export interface YearCalendarProps {
selectedYear: number;
data: CalendarData;
onYearChange: (year: number) => void;
onDateClick: (date: string) => void;
className?: string;
onClick?: (date: string) => void;
timeBasis?: MemoTimeBasis;
}

View file

@ -1,90 +0,0 @@
import dayjs from "dayjs";
import { useMemo } from "react";
import { DAYS_IN_WEEK } from "./constants";
import type { CalendarData, CalendarDayCell, CalendarMatrixResult } from "./types";
export interface UseCalendarMatrixParams {
month: string;
data: CalendarData;
weekDays: string[];
weekStartDayOffset: number;
today: string;
selectedDate: string;
}
const createCalendarDayCell = (
current: dayjs.Dayjs,
monthKey: string,
data: CalendarData,
today: string,
selectedDate: string,
): CalendarDayCell => {
const isoDate = current.format("YYYY-MM-DD");
const isCurrentMonth = current.format("YYYY-MM") === monthKey;
const count = data[isoDate] ?? 0;
return {
date: isoDate,
label: current.date(),
count,
isCurrentMonth,
isToday: isoDate === today,
isSelected: isoDate === selectedDate,
};
};
const calculateCalendarBoundaries = (monthStart: dayjs.Dayjs, weekStartDayOffset: number) => {
const monthEnd = monthStart.endOf("month");
const startOffset = (monthStart.day() - weekStartDayOffset + DAYS_IN_WEEK) % DAYS_IN_WEEK;
const endOffset = (weekStartDayOffset + (DAYS_IN_WEEK - 1) - monthEnd.day() + DAYS_IN_WEEK) % DAYS_IN_WEEK;
const calendarStart = monthStart.subtract(startOffset, "day");
const calendarEnd = monthEnd.add(endOffset, "day");
const dayCount = calendarEnd.diff(calendarStart, "day") + 1;
return { calendarStart, dayCount };
};
/**
* Generates a matrix of calendar days for a given month, handling week alignment and data mapping.
*/
export const useCalendarMatrix = ({
month,
data,
weekDays,
weekStartDayOffset,
today,
selectedDate,
}: UseCalendarMatrixParams): CalendarMatrixResult => {
return useMemo(() => {
// Determine the start of the month and its formatted key (YYYY-MM)
const monthStart = dayjs(month).startOf("month");
const monthKey = monthStart.format("YYYY-MM");
// Rotate week labels based on the user's preferred start of the week
const rotatedWeekDays = weekDays.slice(weekStartDayOffset).concat(weekDays.slice(0, weekStartDayOffset));
// Calculate the start and end dates for the calendar grid to ensure full weeks
const { calendarStart, dayCount } = calculateCalendarBoundaries(monthStart, weekStartDayOffset);
const weeks: CalendarMatrixResult["weeks"] = [];
// Iterate through each day in the calendar grid
for (let index = 0; index < dayCount; index += 1) {
const current = calendarStart.add(index, "day");
const weekIndex = Math.floor(index / DAYS_IN_WEEK);
if (!weeks[weekIndex]) {
weeks[weekIndex] = { days: [] };
}
// Create the day cell object with data and status flags
const dayCell = createCalendarDayCell(current, monthKey, data, today, selectedDate);
weeks[weekIndex].days.push(dayCell);
}
return {
weeks,
weekDays: rotatedWeekDays,
};
}, [month, data, weekDays, weekStartDayOffset, today, selectedDate]);
};

View file

@ -1,64 +1,11 @@
import dayjs from "dayjs";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter";
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
import type { MemoTimeBasis } from "@/contexts/ViewContext";
import { useTranslate } from "@/utils/i18n";
import { CELL_STYLES, INTENSITY_THRESHOLDS, MIN_COUNT, MONTHS_IN_YEAR } from "./constants";
import type { CalendarData, CalendarDayCell } from "./types";
dayjs.extend(isSameOrAfter);
dayjs.extend(isSameOrBefore);
import type { useTranslate } from "@/utils/i18n";
import type { CalendarData } from "./types";
export type TranslateFunction = ReturnType<typeof useTranslate>;
export const getCellIntensityClass = (day: CalendarDayCell, maxCount: number): string => {
if (!day.isCurrentMonth || day.count === 0) {
return CELL_STYLES.EMPTY;
}
const ratio = day.count / maxCount;
if (ratio > INTENSITY_THRESHOLDS.HIGH) return CELL_STYLES.HIGH;
if (ratio > INTENSITY_THRESHOLDS.MEDIUM) return CELL_STYLES.MEDIUM;
if (ratio > INTENSITY_THRESHOLDS.LOW) return CELL_STYLES.LOW;
return CELL_STYLES.MINIMAL;
};
export const getCalendarCellStateClass = (day: Pick<CalendarDayCell, "isSelected">): string =>
// A picked day is a checked filter like a view or tag row: it takes the accent, not a ring.
day.isSelected ? "z-10 bg-primary font-medium text-primary-foreground" : "";
export const generateMonthsForYear = (year: number): string[] => {
return Array.from({ length: MONTHS_IN_YEAR }, (_, i) => dayjs(`${year}-01-01`).add(i, "month").format("YYYY-MM"));
};
export const calculateMaxCount = (data: CalendarData): number => {
let max = 0;
for (const count of Object.values(data)) {
max = Math.max(max, count);
}
return Math.max(max, MIN_COUNT);
};
export const getMonthLabel = (month: string): string => {
return dayjs(month).format("MMM");
};
export const filterDataByYear = (data: Record<string, number>, year: number): Record<string, number> => {
if (!data) return {};
const filtered: Record<string, number> = {};
const yearStart = dayjs(`${year}-01-01`);
const yearEnd = dayjs(`${year}-12-31`);
for (const [dateStr, count] of Object.entries(data)) {
const date = dayjs(dateStr);
if (date.isSameOrAfter(yearStart, "day") && date.isSameOrBefore(yearEnd, "day")) {
filtered[dateStr] = count;
}
}
return filtered;
};
/** Largest daily count in the data, floored at 1 so intensity ratios never divide by zero. */
export const calculateMaxCount = (data: CalendarData): number => Math.max(1, ...Object.values(data));
export const getTooltipText = (count: number, date: string, t: TranslateFunction, timeBasis: MemoTimeBasis = "create_time"): string => {
if (count === 0) {

View file

@ -1,6 +1,6 @@
import dayjs from "dayjs";
import { useState } from "react";
import { calculateMaxCount, MonthCalendar } from "@/components/ActivityCalendar";
import { MonthCalendar } from "@/components/ActivityCalendar";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { useDateFilterNavigation } from "@/hooks";
import type { StatisticsData } from "@/types/statistics";
@ -29,7 +29,6 @@ const StatisticsView = (props: Props) => {
<MonthCalendar
month={visibleMonthString}
data={activityStats}
maxCount={calculateMaxCount(activityStats)}
selectedDate={selectedDate}
onClick={(date) => {
navigateToDateFilter(date);

View file

@ -2,22 +2,6 @@ import dayjs from "dayjs";
export const MONTH_DATE_FORMAT = "YYYY-MM" as const;
export const formatMonth = (date: Date | string): string => {
return dayjs(date).format(MONTH_DATE_FORMAT);
};
export const getYearFromDate = (date: Date | string): number => {
return dayjs(date).year();
};
export const getMonthFromDate = (date: Date | string): number => {
return dayjs(date).month();
};
export const addMonths = (date: Date | string, count: number): string => {
return dayjs(date).add(count, "month").format(MONTH_DATE_FORMAT);
};
export const setYearAndMonth = (year: number, month: number): string => {
return dayjs().year(year).month(month).format(MONTH_DATE_FORMAT);
};

View file

@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { buildMonthDays, rotateWeekdays } from "@/components/ActivityCalendar/monthDays";
// May 2025 starts on a Thursday and ends on a Saturday.
const base = { month: "2025-05", data: {}, weekStartDayOffset: 0, today: "2025-05-16" };
describe("buildMonthDays", () => {
it("pads a Sunday-first grid out to whole weeks", () => {
const days = buildMonthDays(base);
expect(days).toHaveLength(35);
expect(days[0].date).toBe("2025-04-27");
expect(days.at(-1)?.date).toBe("2025-05-31");
expect(days.filter((day) => day.isCurrentMonth)).toHaveLength(31);
expect(days.slice(0, 4).every((day) => !day.isCurrentMonth)).toBe(true);
});
it("realigns the padding when the week starts on Monday", () => {
const days = buildMonthDays({ ...base, weekStartDayOffset: 1 });
expect(days).toHaveLength(35);
expect(days[0].date).toBe("2025-04-28");
expect(days.at(-1)?.date).toBe("2025-06-01");
});
it("maps counts and flags onto the matching dates", () => {
const days = buildMonthDays({ ...base, data: { "2025-05-02": 3 }, selectedDate: "2025-05-20" });
const byDate = Object.fromEntries(days.map((day) => [day.date, day]));
expect(byDate["2025-05-02"]).toMatchObject({ label: 2, count: 3 });
expect(byDate["2025-05-16"].isToday).toBe(true);
expect(byDate["2025-05-20"].isSelected).toBe(true);
expect(days.filter((day) => day.isToday || day.isSelected)).toHaveLength(2);
});
});
describe("rotateWeekdays", () => {
const labels = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
it("keeps Sunday first at offset 0 and moves Monday first at offset 1", () => {
expect(rotateWeekdays(labels, 0)).toEqual(labels);
expect(rotateWeekdays(labels, 1)).toEqual(["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]);
});
});