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";
+};