chore: refactor ActivityCalendar to use a calendar matrix and improve cell rendering

pull/5122/head^2
Steven 3 weeks ago
parent 5011eb5d70
commit 56758f107c

@ -1,178 +1,73 @@
import dayjs from "dayjs";
import { observer } from "mobx-react-lite";
import { memo, useMemo } from "react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { TooltipProvider } from "@/components/ui/tooltip";
import { workspaceStore } from "@/store";
import type { ActivityCalendarProps, CalendarDay } from "@/types/statistics";
import type { ActivityCalendarProps } from "@/types/statistics";
import { useTranslate } from "@/utils/i18n";
const getCellOpacity = (ratio: number): string => {
if (ratio === 0) return "";
if (ratio > 0.75) return "bg-primary text-primary-foreground";
if (ratio > 0.5) return "bg-primary/80 text-primary-foreground";
if (ratio > 0.25) return "bg-primary/60 text-primary-foreground";
return "bg-primary/40 text-primary";
};
const CalendarCell = memo(
({
dayInfo,
count,
maxCount,
isToday,
isSelected,
onClick,
tooltipText,
}: {
dayInfo: CalendarDay;
count: number;
maxCount: number;
isToday: boolean;
isSelected: boolean;
onClick?: () => void;
tooltipText: string;
}) => {
const cellContent = (
<div
className={cn(
"w-6 h-6 text-xs lg:text-[13px] flex justify-center items-center cursor-default",
"rounded-lg border-2 text-muted-foreground transition-all duration-200",
dayInfo.isCurrentMonth && getCellOpacity(count / maxCount),
dayInfo.isCurrentMonth && isToday && "border-border",
dayInfo.isCurrentMonth && isSelected && "font-medium border-border",
dayInfo.isCurrentMonth && !isToday && !isSelected && "border-transparent",
count > 0 && "cursor-pointer hover:scale-110",
)}
onClick={count > 0 ? onClick : undefined}
>
{dayInfo.day}
</div>
);
if (!dayInfo.isCurrentMonth) {
return (
<div
className={cn("w-6 h-6 text-xs lg:text-[13px] flex justify-center items-center cursor-default opacity-60 text-muted-foreground")}
>
{dayInfo.day}
</div>
);
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="shrink-0">{cellContent}</div>
</TooltipTrigger>
<TooltipContent>
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
);
import { CalendarCell } from "./CalendarCell";
import { useCalendarMatrix } from "./useCalendarMatrix";
export const ActivityCalendar = memo(
observer((props: ActivityCalendarProps) => {
const t = useTranslate();
const { month: monthStr, data, onClick } = props;
const { month, selectedDate, data, onClick } = props;
const weekStartDayOffset = workspaceStore.state.generalSetting.weekStartDayOffset;
const { days, weekDays, maxCount } = useMemo(() => {
const yearValue = dayjs(monthStr).toDate().getFullYear();
const monthValue = dayjs(monthStr).toDate().getMonth();
const dayInMonth = new Date(yearValue, monthValue + 1, 0).getDate();
const firstDay = (((new Date(yearValue, monthValue, 1).getDay() - weekStartDayOffset) % 7) + 7) % 7;
const lastDay = new Date(yearValue, monthValue, dayInMonth).getDay() - weekStartDayOffset;
const prevMonthDays = new Date(yearValue, monthValue, 0).getDate();
const WEEK_DAYS = [t("days.sun"), t("days.mon"), t("days.tue"), t("days.wed"), t("days.thu"), t("days.fri"), t("days.sat")];
const weekDaysOrdered = WEEK_DAYS.slice(weekStartDayOffset).concat(WEEK_DAYS.slice(0, weekStartDayOffset));
const daysArray: CalendarDay[] = [];
// Previous month's days
for (let i = firstDay - 1; i >= 0; i--) {
daysArray.push({ day: prevMonthDays - i, isCurrentMonth: false });
}
// Current month's days
for (let i = 1; i <= dayInMonth; i++) {
const date = dayjs(`${yearValue}-${monthValue + 1}-${i}`).format("YYYY-MM-DD");
daysArray.push({ day: i, isCurrentMonth: true, date });
}
// Next month's days
for (let i = 1; i < 7 - lastDay; i++) {
daysArray.push({ day: i, isCurrentMonth: false });
}
const maxCountValue = Math.max(...Object.values(data), 1);
const today = useMemo(() => dayjs().format("YYYY-MM-DD"), []);
const selectedDateFormatted = useMemo(() => dayjs(selectedDate).format("YYYY-MM-DD"), [selectedDate]);
return {
year: yearValue,
month: monthValue,
days: daysArray,
weekDays: weekDaysOrdered,
maxCount: maxCountValue,
};
}, [monthStr, data, weekStartDayOffset, t]);
const weekDaysRaw = useMemo(
() => [t("days.sun"), t("days.mon"), t("days.tue"), t("days.wed"), t("days.thu"), t("days.fri"), t("days.sat")],
[t],
);
const today = useMemo(() => dayjs().format("YYYY-MM-DD"), []);
const selectedDateFormatted = useMemo(() => dayjs(props.selectedDate).format("YYYY-MM-DD"), [props.selectedDate]);
const { weeks, weekDays, maxCount } = useCalendarMatrix({
month,
data,
weekDays: weekDaysRaw,
weekStartDayOffset,
today,
selectedDate: selectedDateFormatted,
});
return (
<div className={cn("w-full h-auto shrink-0 grid grid-cols-7 grid-flow-row gap-1")}>
{weekDays.map((day, index) => (
<div key={index} className={cn("w-6 h-5 text-xs flex justify-center items-center cursor-default opacity-60")}>
{day}
<TooltipProvider>
<div className="w-full flex flex-col gap-1">
<div className="grid grid-cols-7 gap-1 text-xs text-muted-foreground">
{weekDays.map((label, index) => (
<div key={index} className="flex h-5 items-center justify-center text-muted-foreground/80">
{label}
</div>
))}
</div>
))}
{days.map((dayInfo, index) => {
if (!dayInfo.isCurrentMonth) {
return (
<CalendarCell
key={`prev-next-${index}`}
dayInfo={dayInfo}
count={0}
maxCount={maxCount}
isToday={false}
isSelected={false}
tooltipText=""
/>
);
}
const date = dayInfo.date!;
const count = data[date] || 0;
const isToday = today === date;
const isSelected = selectedDateFormatted === date;
const tooltipText =
count === 0
? date
: t("memo.count-memos-in-date", {
count: count,
memos: count === 1 ? t("common.memo") : t("common.memos"),
date: date,
}).toLowerCase();
return (
<CalendarCell
key={date}
dayInfo={dayInfo}
count={count}
maxCount={maxCount}
isToday={isToday}
isSelected={isSelected}
onClick={() => onClick?.(date)}
tooltipText={tooltipText}
/>
);
})}
</div>
<div className="grid grid-cols-7 gap-1">
{weeks.map((week, weekIndex) =>
week.days.map((day, dayIndex) => {
const tooltipText =
day.count === 0
? day.date
: t("memo.count-memos-in-date", {
count: day.count,
memos: day.count === 1 ? t("common.memo") : t("common.memos"),
date: day.date,
}).toLowerCase();
return (
<CalendarCell
key={`${weekIndex}-${dayIndex}-${day.date}`}
day={day}
maxCount={maxCount}
tooltipText={tooltipText}
onClick={onClick}
/>
);
}),
)}
</div>
</div>
</TooltipProvider>
);
}),
);

@ -0,0 +1,76 @@
import { memo } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { CalendarDayCell } from "./types";
import { getCellIntensityClass } from "./utils";
interface CalendarCellProps {
day: CalendarDayCell;
maxCount: number;
tooltipText: string;
onClick?: (date: string) => void;
}
export const CalendarCell = memo((props: CalendarCellProps) => {
const { day, maxCount, tooltipText, onClick } = props;
const handleClick = () => {
if (day.count > 0 && onClick) {
onClick(day.date);
}
};
const baseClasses =
"w-full h-7 rounded-md border text-xs flex items-center justify-center text-center transition-transform duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:ring-offset-1 focus-visible:ring-offset-background select-none";
const isInteractive = Boolean(onClick && day.count > 0);
const ariaLabel = day.isSelected ? `${tooltipText} (selected)` : tooltipText;
if (!day.isCurrentMonth) {
return (
<div className={cn(baseClasses, "border-transparent text-muted-foreground/60 bg-transparent pointer-events-none opacity-80")}>
{day.label}
</div>
);
}
const intensityClass = getCellIntensityClass(day, maxCount);
const buttonClasses = cn(
baseClasses,
"border-transparent text-muted-foreground",
day.isToday && "border-border",
day.isSelected && "border-border font-medium",
day.isWeekend && "text-muted-foreground/80",
intensityClass,
isInteractive ? "cursor-pointer hover:scale-105" : "cursor-default",
);
const button = (
<button
type="button"
onClick={handleClick}
tabIndex={isInteractive ? 0 : -1}
aria-label={ariaLabel}
aria-current={day.isToday ? "date" : undefined}
aria-disabled={!isInteractive}
className={buttonClasses}
>
{day.label}
</button>
);
if (!tooltipText) {
return button;
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="top">
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
);
});
CalendarCell.displayName = "CalendarCell";

@ -0,0 +1,19 @@
export interface CalendarDayCell {
date: string;
label: number;
count: number;
isCurrentMonth: boolean;
isToday: boolean;
isSelected: boolean;
isWeekend: boolean;
}
export interface CalendarDayRow {
days: CalendarDayCell[];
}
export interface CalendarMatrixResult {
weeks: CalendarDayRow[];
weekDays: string[];
maxCount: number;
}

@ -0,0 +1,71 @@
import dayjs from "dayjs";
import { useMemo } from "react";
import type { CalendarDayCell, CalendarMatrixResult } from "./types";
interface UseCalendarMatrixParams {
month: string;
data: Record<string, number>;
weekDays: string[];
weekStartDayOffset: number;
today: string;
selectedDate: string;
}
export const useCalendarMatrix = ({
month,
data,
weekDays,
weekStartDayOffset,
today,
selectedDate,
}: UseCalendarMatrixParams): CalendarMatrixResult => {
return useMemo(() => {
const monthStart = dayjs(month).startOf("month");
const monthEnd = monthStart.endOf("month");
const monthKey = monthStart.format("YYYY-MM");
const orderedWeekDays = weekDays.slice(weekStartDayOffset).concat(weekDays.slice(0, weekStartDayOffset));
const startOffset = (monthStart.day() - weekStartDayOffset + 7) % 7;
const endOffset = (weekStartDayOffset + 6 - monthEnd.day() + 7) % 7;
const calendarStart = monthStart.subtract(startOffset, "day");
const calendarEnd = monthEnd.add(endOffset, "day");
const dayCount = calendarEnd.diff(calendarStart, "day") + 1;
const weeks: CalendarMatrixResult["weeks"] = [];
let maxCount = 0;
for (let index = 0; index < dayCount; index += 1) {
const current = calendarStart.add(index, "day");
const isoDate = current.format("YYYY-MM-DD");
const weekIndex = Math.floor(index / 7);
if (!weeks[weekIndex]) {
weeks[weekIndex] = { days: [] };
}
const isCurrentMonth = current.format("YYYY-MM") === monthKey;
const count = data[isoDate] ?? 0;
const dayCell: CalendarDayCell = {
date: isoDate,
label: current.date(),
count,
isCurrentMonth,
isToday: isoDate === today,
isSelected: isoDate === selectedDate,
isWeekend: [0, 6].includes(current.day()),
};
weeks[weekIndex].days.push(dayCell);
maxCount = Math.max(maxCount, count);
}
return {
weeks,
weekDays: orderedWeekDays,
maxCount: Math.max(maxCount, 1),
};
}, [month, data, weekDays, weekStartDayOffset, today, selectedDate]);
};

@ -0,0 +1,13 @@
import type { CalendarDayCell } from "./types";
export const getCellIntensityClass = (day: CalendarDayCell, maxCount: number): string => {
if (!day.isCurrentMonth || day.count === 0 || maxCount <= 0) {
return "bg-transparent";
}
const ratio = day.count / maxCount;
if (ratio > 0.75) return "bg-primary text-primary-foreground border-primary";
if (ratio > 0.5) return "bg-primary/80 text-primary-foreground border-primary/90";
if (ratio > 0.25) return "bg-primary/60 text-primary-foreground border-primary/70";
return "bg-primary/40 text-primary";
};
Loading…
Cancel
Save