From 56758f107caa18b96ef91a24cfdb33f6b303ec12 Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 14 Oct 2025 21:12:43 +0800 Subject: [PATCH] chore: refactor ActivityCalendar to use a calendar matrix and improve cell rendering --- .../ActivityCalendar/ActivityCalendar.tsx | 211 +++++------------- .../ActivityCalendar/CalendarCell.tsx | 76 +++++++ web/src/components/ActivityCalendar/types.ts | 19 ++ .../ActivityCalendar/useCalendarMatrix.ts | 71 ++++++ web/src/components/ActivityCalendar/utils.ts | 13 ++ 5 files changed, 232 insertions(+), 158 deletions(-) create mode 100644 web/src/components/ActivityCalendar/CalendarCell.tsx create mode 100644 web/src/components/ActivityCalendar/types.ts create mode 100644 web/src/components/ActivityCalendar/useCalendarMatrix.ts create mode 100644 web/src/components/ActivityCalendar/utils.ts diff --git a/web/src/components/ActivityCalendar/ActivityCalendar.tsx b/web/src/components/ActivityCalendar/ActivityCalendar.tsx index 602d3fadf..b55d4f502 100644 --- a/web/src/components/ActivityCalendar/ActivityCalendar.tsx +++ b/web/src/components/ActivityCalendar/ActivityCalendar.tsx @@ -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 = ( -
0 && "cursor-pointer hover:scale-110", - )} - onClick={count > 0 ? onClick : undefined} - > - {dayInfo.day} -
- ); - - if (!dayInfo.isCurrentMonth) { - return ( -
- {dayInfo.day} -
- ); - } - - return ( - - - -
{cellContent}
-
- -

{tooltipText}

-
-
-
- ); - }, -); +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); - - return { - year: yearValue, - month: monthValue, - days: daysArray, - weekDays: weekDaysOrdered, - maxCount: maxCountValue, - }; - }, [monthStr, data, weekStartDayOffset, t]); - const today = useMemo(() => dayjs().format("YYYY-MM-DD"), []); - const selectedDateFormatted = useMemo(() => dayjs(props.selectedDate).format("YYYY-MM-DD"), [props.selectedDate]); + const selectedDateFormatted = useMemo(() => dayjs(selectedDate).format("YYYY-MM-DD"), [selectedDate]); + + 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 { weeks, weekDays, maxCount } = useCalendarMatrix({ + month, + data, + weekDays: weekDaysRaw, + weekStartDayOffset, + today, + selectedDate: selectedDateFormatted, + }); return ( -
- {weekDays.map((day, index) => ( -
- {day} + +
+
+ {weekDays.map((label, index) => ( +
+ {label} +
+ ))}
- ))} - {days.map((dayInfo, index) => { - if (!dayInfo.isCurrentMonth) { - return ( - - ); - } - 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(); +
+ {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 ( - onClick?.(date)} - tooltipText={tooltipText} - /> - ); - })} -
+ return ( + + ); + }), + )} +
+
+ ); }), ); diff --git a/web/src/components/ActivityCalendar/CalendarCell.tsx b/web/src/components/ActivityCalendar/CalendarCell.tsx new file mode 100644 index 000000000..28add09bd --- /dev/null +++ b/web/src/components/ActivityCalendar/CalendarCell.tsx @@ -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 ( +
+ {day.label} +
+ ); + } + + 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 = ( + + ); + + if (!tooltipText) { + return button; + } + + return ( + + {button} + +

{tooltipText}

+
+
+ ); +}); + +CalendarCell.displayName = "CalendarCell"; diff --git a/web/src/components/ActivityCalendar/types.ts b/web/src/components/ActivityCalendar/types.ts new file mode 100644 index 000000000..0a23d1b32 --- /dev/null +++ b/web/src/components/ActivityCalendar/types.ts @@ -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; +} diff --git a/web/src/components/ActivityCalendar/useCalendarMatrix.ts b/web/src/components/ActivityCalendar/useCalendarMatrix.ts new file mode 100644 index 000000000..7ca65aac2 --- /dev/null +++ b/web/src/components/ActivityCalendar/useCalendarMatrix.ts @@ -0,0 +1,71 @@ +import dayjs from "dayjs"; +import { useMemo } from "react"; +import type { CalendarDayCell, CalendarMatrixResult } from "./types"; + +interface UseCalendarMatrixParams { + month: string; + data: Record; + 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]); +}; diff --git a/web/src/components/ActivityCalendar/utils.ts b/web/src/components/ActivityCalendar/utils.ts new file mode 100644 index 000000000..4e7548c6f --- /dev/null +++ b/web/src/components/ActivityCalendar/utils.ts @@ -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"; +};