Skip to content

Commit 1b3318f

Browse files
boojackclaude
andcommitted
refactor(web): improve ActivityCalendar maintainability and add Calendar page
- Extract shared utilities and constants to eliminate code duplication - Create dedicated Calendar page with year view and month grid - Add date filter navigation with bidirectional URL sync - Fix useTodayDate memoization bug causing stale date references - Standardize naming conventions (get vs generate functions) - Add comprehensive type exports and proper store encapsulation - Implement size variants for compact calendar display 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent d14e66d commit 1b3318f

File tree

18 files changed

+475
-82
lines changed

18 files changed

+475
-82
lines changed

web/src/components/ActivityCalendar/ActivityCalendar.tsx

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { instanceStore } from "@/store";
66
import type { ActivityCalendarProps } from "@/types/statistics";
77
import { useTranslate } from "@/utils/i18n";
88
import { CalendarCell } from "./CalendarCell";
9+
import { getTooltipText, useTodayDate, useWeekdayLabels } from "./shared";
910
import { useCalendarMatrix } from "./useCalendarMatrix";
1011

1112
export const ActivityCalendar = memo(
@@ -14,14 +15,10 @@ export const ActivityCalendar = memo(
1415
const { month, selectedDate, data, onClick } = props;
1516
const weekStartDayOffset = instanceStore.state.generalSetting.weekStartDayOffset;
1617

17-
const today = useMemo(() => dayjs().format("YYYY-MM-DD"), []);
18+
const today = useTodayDate();
19+
const weekDaysRaw = useWeekdayLabels();
1820
const selectedDateFormatted = useMemo(() => dayjs(selectedDate).format("YYYY-MM-DD"), [selectedDate]);
1921

20-
const weekDaysRaw = useMemo(
21-
() => [t("days.sun"), t("days.mon"), t("days.tue"), t("days.wed"), t("days.thu"), t("days.fri"), t("days.sat")],
22-
[t],
23-
);
24-
2522
const { weeks, weekDays, maxCount } = useCalendarMatrix({
2623
month,
2724
data,
@@ -33,26 +30,19 @@ export const ActivityCalendar = memo(
3330

3431
return (
3532
<TooltipProvider>
36-
<div className="w-full flex flex-col gap-1">
37-
<div className="grid grid-cols-7 gap-1 text-xs text-muted-foreground">
33+
<div className="w-full flex flex-col gap-0.5">
34+
<div className="grid grid-cols-7 gap-0.5 text-xs text-muted-foreground">
3835
{weekDays.map((label, index) => (
39-
<div key={index} className="flex h-5 items-center justify-center text-muted-foreground/80">
36+
<div key={index} className="flex h-4 items-center justify-center text-muted-foreground/80">
4037
{label}
4138
</div>
4239
))}
4340
</div>
4441

45-
<div className="grid grid-cols-7 gap-1">
42+
<div className="grid grid-cols-7 gap-0.5">
4643
{weeks.map((week, weekIndex) =>
4744
week.days.map((day, dayIndex) => {
48-
const tooltipText =
49-
day.count === 0
50-
? day.date
51-
: t("memo.count-memos-in-date", {
52-
count: day.count,
53-
memos: day.count === 1 ? t("common.memo") : t("common.memos"),
54-
date: day.date,
55-
}).toLowerCase();
45+
const tooltipText = getTooltipText(day.count, day.date, t);
5646

5747
return (
5848
<CalendarCell

web/src/components/ActivityCalendar/CalendarCell.tsx

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,36 @@
11
import { memo } from "react";
22
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
33
import { cn } from "@/lib/utils";
4-
import type { CalendarDayCell } from "./types";
4+
import { DEFAULT_CELL_SIZE, SMALL_CELL_SIZE } from "./constants";
5+
import type { CalendarDayCell, CalendarSize } from "./types";
56
import { getCellIntensityClass } from "./utils";
67

7-
interface CalendarCellProps {
8+
export interface CalendarCellProps {
89
day: CalendarDayCell;
910
maxCount: number;
1011
tooltipText: string;
1112
onClick?: (date: string) => void;
13+
size?: CalendarSize;
1214
}
1315

1416
export const CalendarCell = memo((props: CalendarCellProps) => {
15-
const { day, maxCount, tooltipText, onClick } = props;
17+
const { day, maxCount, tooltipText, onClick, size = "default" } = props;
1618

1719
const handleClick = () => {
1820
if (day.count > 0 && onClick) {
1921
onClick(day.date);
2022
}
2123
};
2224

23-
const baseClasses =
24-
"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";
25+
const sizeConfig = size === "small" ? SMALL_CELL_SIZE : DEFAULT_CELL_SIZE;
26+
const smallExtraClasses = size === "small" ? `${SMALL_CELL_SIZE.dimensions} min-h-0` : "";
27+
28+
const baseClasses = cn(
29+
"aspect-square w-full border 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",
30+
sizeConfig.font,
31+
sizeConfig.borderRadius,
32+
smallExtraClasses,
33+
);
2534
const isInteractive = Boolean(onClick && day.count > 0);
2635
const ariaLabel = day.isSelected ? `${tooltipText} (selected)` : tooltipText;
2736

@@ -38,8 +47,8 @@ export const CalendarCell = memo((props: CalendarCellProps) => {
3847
const buttonClasses = cn(
3948
baseClasses,
4049
"border-transparent text-muted-foreground",
41-
day.isToday && "border-border",
42-
day.isSelected && "border-border font-medium",
50+
(day.isToday || day.isSelected) && "border-border",
51+
day.isSelected && "font-medium",
4352
day.isWeekend && "text-muted-foreground/80",
4453
intensityClass,
4554
isInteractive ? "cursor-pointer hover:scale-105" : "cursor-default",
@@ -59,7 +68,9 @@ export const CalendarCell = memo((props: CalendarCellProps) => {
5968
</button>
6069
);
6170

62-
if (!tooltipText) {
71+
const shouldShowTooltip = tooltipText && day.count > 0;
72+
73+
if (!shouldShowTooltip) {
6374
return button;
6475
}
6576

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { observer } from "mobx-react-lite";
2+
import { memo } from "react";
3+
import { cn } from "@/lib/utils";
4+
import { instanceStore } from "@/store";
5+
import { useTranslate } from "@/utils/i18n";
6+
import { CalendarCell } from "./CalendarCell";
7+
import { DEFAULT_CELL_SIZE, SMALL_CELL_SIZE } from "./constants";
8+
import { getTooltipText, useTodayDate, useWeekdayLabels } from "./shared";
9+
import type { CompactMonthCalendarProps } from "./types";
10+
import { useCalendarMatrix } from "./useCalendarMatrix";
11+
12+
export const CompactMonthCalendar = memo(
13+
observer((props: CompactMonthCalendarProps) => {
14+
const { month, data, maxCount, size = "default", onClick } = props;
15+
const t = useTranslate();
16+
17+
const weekStartDayOffset = instanceStore.state.generalSetting.weekStartDayOffset;
18+
19+
const today = useTodayDate();
20+
const weekDays = useWeekdayLabels();
21+
22+
const { weeks } = useCalendarMatrix({
23+
month,
24+
data,
25+
weekDays,
26+
weekStartDayOffset,
27+
today,
28+
selectedDate: "",
29+
});
30+
31+
const sizeConfig = size === "small" ? SMALL_CELL_SIZE : DEFAULT_CELL_SIZE;
32+
33+
return (
34+
<div className={cn("grid grid-cols-7", sizeConfig.gap)}>
35+
{weeks.map((week, weekIndex) =>
36+
week.days.map((day, dayIndex) => {
37+
const tooltipText = getTooltipText(day.count, day.date, t);
38+
39+
return (
40+
<CalendarCell
41+
key={`${weekIndex}-${dayIndex}-${day.date}`}
42+
day={day}
43+
maxCount={maxCount}
44+
tooltipText={tooltipText}
45+
onClick={onClick}
46+
size={size}
47+
/>
48+
);
49+
}),
50+
)}
51+
</div>
52+
);
53+
}),
54+
);
55+
56+
CompactMonthCalendar.displayName = "CompactMonthCalendar";
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export const DAYS_IN_WEEK = 7;
2+
export const MONTHS_IN_YEAR = 12;
3+
export const WEEKEND_DAYS = [0, 6] as const;
4+
export const MIN_COUNT = 1;
5+
6+
export const INTENSITY_THRESHOLDS = {
7+
HIGH: 0.75,
8+
MEDIUM: 0.5,
9+
LOW: 0.25,
10+
MINIMAL: 0,
11+
} as const;
12+
13+
export const SMALL_CELL_SIZE = {
14+
font: "text-[10px]",
15+
dimensions: "max-w-6 max-h-6",
16+
borderRadius: "rounded-sm",
17+
gap: "gap-px",
18+
} as const;
19+
20+
export const DEFAULT_CELL_SIZE = {
21+
font: "text-xs",
22+
borderRadius: "rounded",
23+
gap: "gap-0.5",
24+
} as const;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,21 @@
11
export { ActivityCalendar as default } from "./ActivityCalendar";
2+
export { CalendarCell, type CalendarCellProps } from "./CalendarCell";
3+
export { CompactMonthCalendar } from "./CompactMonthCalendar";
4+
export * from "./constants";
5+
export { getTooltipText, type TranslateFunction, useTodayDate, useWeekdayLabels } from "./shared";
6+
export type {
7+
CalendarDayCell,
8+
CalendarDayRow,
9+
CalendarMatrixResult,
10+
CalendarSize,
11+
CompactMonthCalendarProps,
12+
} from "./types";
13+
export { type UseCalendarMatrixParams, useCalendarMatrix } from "./useCalendarMatrix";
14+
export {
15+
calculateYearMaxCount,
16+
filterDataByYear,
17+
generateMonthsForYear,
18+
getCellIntensityClass,
19+
getMonthLabel,
20+
hasActivityData,
21+
} from "./utils";
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import dayjs from "dayjs";
2+
import { useMemo } from "react";
3+
import { useTranslate } from "@/utils/i18n";
4+
5+
export type TranslateFunction = ReturnType<typeof useTranslate>;
6+
7+
export const useWeekdayLabels = () => {
8+
const t = useTranslate();
9+
return useMemo(() => [t("days.sun"), t("days.mon"), t("days.tue"), t("days.wed"), t("days.thu"), t("days.fri"), t("days.sat")], [t]);
10+
};
11+
12+
export const useTodayDate = () => {
13+
return dayjs().format("YYYY-MM-DD");
14+
};
15+
16+
export const getTooltipText = (count: number, date: string, t: TranslateFunction): string => {
17+
if (count === 0) {
18+
return date;
19+
}
20+
21+
return t("memo.count-memos-in-date", {
22+
count,
23+
memos: count === 1 ? t("common.memo") : t("common.memos"),
24+
date,
25+
}).toLowerCase();
26+
};

web/src/components/ActivityCalendar/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
export type CalendarSize = "default" | "small";
2+
13
export interface CalendarDayCell {
24
date: string;
35
label: number;
@@ -17,3 +19,11 @@ export interface CalendarMatrixResult {
1719
weekDays: string[];
1820
maxCount: number;
1921
}
22+
23+
export interface CompactMonthCalendarProps {
24+
month: string;
25+
data: Record<string, number>;
26+
maxCount: number;
27+
size?: CalendarSize;
28+
onClick?: (date: string) => void;
29+
}
Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import dayjs from "dayjs";
22
import { useMemo } from "react";
3+
import { DAYS_IN_WEEK, MIN_COUNT, WEEKEND_DAYS } from "./constants";
34
import type { CalendarDayCell, CalendarMatrixResult } from "./types";
45

5-
interface UseCalendarMatrixParams {
6+
export interface UseCalendarMatrixParams {
67
month: string;
78
data: Record<string, number>;
89
weekDays: string[];
@@ -11,6 +12,39 @@ interface UseCalendarMatrixParams {
1112
selectedDate: string;
1213
}
1314

15+
const createCalendarDayCell = (
16+
current: dayjs.Dayjs,
17+
monthKey: string,
18+
data: Record<string, number>,
19+
today: string,
20+
selectedDate: string,
21+
): CalendarDayCell => {
22+
const isoDate = current.format("YYYY-MM-DD");
23+
const isCurrentMonth = current.format("YYYY-MM") === monthKey;
24+
const count = data[isoDate] ?? 0;
25+
26+
return {
27+
date: isoDate,
28+
label: current.date(),
29+
count,
30+
isCurrentMonth,
31+
isToday: isoDate === today,
32+
isSelected: isoDate === selectedDate,
33+
isWeekend: WEEKEND_DAYS.includes(current.day()),
34+
};
35+
};
36+
37+
const calculateCalendarBoundaries = (monthStart: dayjs.Dayjs, weekStartDayOffset: number) => {
38+
const monthEnd = monthStart.endOf("month");
39+
const startOffset = (monthStart.day() - weekStartDayOffset + DAYS_IN_WEEK) % DAYS_IN_WEEK;
40+
const endOffset = (weekStartDayOffset + (DAYS_IN_WEEK - 1) - monthEnd.day() + DAYS_IN_WEEK) % DAYS_IN_WEEK;
41+
const calendarStart = monthStart.subtract(startOffset, "day");
42+
const calendarEnd = monthEnd.add(endOffset, "day");
43+
const dayCount = calendarEnd.diff(calendarStart, "day") + 1;
44+
45+
return { calendarStart, dayCount };
46+
};
47+
1448
export const useCalendarMatrix = ({
1549
month,
1650
data,
@@ -21,51 +55,32 @@ export const useCalendarMatrix = ({
2155
}: UseCalendarMatrixParams): CalendarMatrixResult => {
2256
return useMemo(() => {
2357
const monthStart = dayjs(month).startOf("month");
24-
const monthEnd = monthStart.endOf("month");
2558
const monthKey = monthStart.format("YYYY-MM");
2659

27-
const orderedWeekDays = weekDays.slice(weekStartDayOffset).concat(weekDays.slice(0, weekStartDayOffset));
28-
29-
const startOffset = (monthStart.day() - weekStartDayOffset + 7) % 7;
30-
const endOffset = (weekStartDayOffset + 6 - monthEnd.day() + 7) % 7;
60+
const rotatedWeekDays = weekDays.slice(weekStartDayOffset).concat(weekDays.slice(0, weekStartDayOffset));
3161

32-
const calendarStart = monthStart.subtract(startOffset, "day");
33-
const calendarEnd = monthEnd.add(endOffset, "day");
34-
const dayCount = calendarEnd.diff(calendarStart, "day") + 1;
62+
const { calendarStart, dayCount } = calculateCalendarBoundaries(monthStart, weekStartDayOffset);
3563

3664
const weeks: CalendarMatrixResult["weeks"] = [];
3765
let maxCount = 0;
3866

3967
for (let index = 0; index < dayCount; index += 1) {
4068
const current = calendarStart.add(index, "day");
41-
const isoDate = current.format("YYYY-MM-DD");
42-
const weekIndex = Math.floor(index / 7);
69+
const weekIndex = Math.floor(index / DAYS_IN_WEEK);
4370

4471
if (!weeks[weekIndex]) {
4572
weeks[weekIndex] = { days: [] };
4673
}
4774

48-
const isCurrentMonth = current.format("YYYY-MM") === monthKey;
49-
const count = data[isoDate] ?? 0;
50-
51-
const dayCell: CalendarDayCell = {
52-
date: isoDate,
53-
label: current.date(),
54-
count,
55-
isCurrentMonth,
56-
isToday: isoDate === today,
57-
isSelected: isoDate === selectedDate,
58-
isWeekend: [0, 6].includes(current.day()),
59-
};
60-
75+
const dayCell = createCalendarDayCell(current, monthKey, data, today, selectedDate);
6176
weeks[weekIndex].days.push(dayCell);
62-
maxCount = Math.max(maxCount, count);
77+
maxCount = Math.max(maxCount, dayCell.count);
6378
}
6479

6580
return {
6681
weeks,
67-
weekDays: orderedWeekDays,
68-
maxCount: Math.max(maxCount, 1),
82+
weekDays: rotatedWeekDays,
83+
maxCount: Math.max(maxCount, MIN_COUNT),
6984
};
7085
}, [month, data, weekDays, weekStartDayOffset, today, selectedDate]);
7186
};

0 commit comments

Comments
 (0)