mirror of https://github.com/usememos/memos.git
chore: refactor ActivityCalendar to use a calendar matrix and improve cell rendering
This commit is contained in:
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);
|
||||
|
||||
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 (
|
||||
<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();
|
||||
<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={date}
|
||||
dayInfo={dayInfo}
|
||||
count={count}
|
||||
maxCount={maxCount}
|
||||
isToday={isToday}
|
||||
isSelected={isSelected}
|
||||
onClick={() => onClick?.(date)}
|
||||
tooltipText={tooltipText}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
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…
Reference in New Issue