chore: prevent unnecessary API calls when timestamp unchanged in MemoDetailSidebar

- Add same value check before updating createTime/updateTime
- Skip request if new timestamp equals current timestamp
- Simplify callback handlers and improve code readability
- Use .some() instead of .filter().length for cleaner code
This commit is contained in:
Johnny 2026-01-31 15:12:27 +08:00
parent f7a81296fb
commit 97ba15450f
4 changed files with 207 additions and 155 deletions

View File

@ -1,20 +1,43 @@
import { memo } from "react";
import { memo, useMemo } from "react";
import { useInstance } from "@/contexts/InstanceContext";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { CalendarCell } from "./CalendarCell";
import { useTodayDate, useWeekdayLabels } from "./hooks";
import type { MonthCalendarProps } from "./types";
import type { CalendarSize, MonthCalendarProps } from "./types";
import { useCalendarMatrix } from "./useCalendar";
import { getTooltipText } from "./utils";
const GRID_STYLES: Record<CalendarSize, { gap: string; headerText: string }> = {
small: { gap: "gap-1.5", headerText: "text-[10px]" },
default: { gap: "gap-2", headerText: "text-xs" },
};
interface WeekdayHeaderProps {
weekDays: string[];
size: CalendarSize;
}
const WeekdayHeader = memo(({ weekDays, size }: WeekdayHeaderProps) => (
<div className={cn("grid grid-cols-7 mb-1", GRID_STYLES[size].gap, GRID_STYLES[size].headerText)} role="row">
{weekDays.map((label, index) => (
<div
key={index}
className="flex h-4 items-center justify-center font-medium uppercase tracking-wide text-muted-foreground/60"
role="columnheader"
aria-label={label}
>
{label}
</div>
))}
</div>
));
WeekdayHeader.displayName = "WeekdayHeader";
export const MonthCalendar = memo((props: MonthCalendarProps) => {
const { month, data, maxCount, size = "default", onClick, className } = props;
const t = useTranslate();
const { generalSetting } = useInstance();
const weekStartDayOffset = generalSetting.weekStartDayOffset;
const today = useTodayDate();
const weekDays = useWeekdayLabels();
@ -22,41 +45,29 @@ export const MonthCalendar = memo((props: MonthCalendarProps) => {
month,
data,
weekDays,
weekStartDayOffset,
weekStartDayOffset: generalSetting.weekStartDayOffset,
today,
selectedDate: "",
});
const gridGap = size === "small" ? "gap-x-3 gap-y-3" : "gap-x-3.5 gap-y-3.5";
const flatDays = useMemo(() => weeks.flatMap((week) => week.days), [weeks]);
return (
<div className={cn("flex flex-col gap-2", className)}>
<div className={cn("grid grid-cols-7", gridGap, "text-muted-foreground mb-1", size === "small" ? "text-[10px]" : "text-xs")}>
{rotatedWeekDays.map((label, index) => (
<div key={index} className="flex h-4 items-center justify-center text-muted-foreground/70 font-medium uppercase tracking-wide">
{label}
</div>
<div className={cn("flex flex-col", className)} role="grid" aria-label={`Calendar for ${month}`}>
<WeekdayHeader weekDays={rotatedWeekDays} size={size} />
<div className={cn("grid grid-cols-7", GRID_STYLES[size].gap)} role="rowgroup">
{flatDays.map((day) => (
<CalendarCell
key={day.date}
day={day}
maxCount={maxCount}
tooltipText={getTooltipText(day.count, day.date, t)}
onClick={onClick}
size={size}
/>
))}
</div>
<div className={cn("grid grid-cols-7 px-2", gridGap)}>
{weeks.map((week, weekIndex) =>
week.days.map((day, dayIndex) => {
const tooltipText = getTooltipText(day.count, day.date, t);
return (
<CalendarCell
key={`${weekIndex}-${dayIndex}-${day.date}`}
day={day}
maxCount={maxCount}
tooltipText={tooltipText}
onClick={onClick}
size={size}
/>
);
}),
)}
</div>
</div>
);
});

View File

@ -1,21 +1,90 @@
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { useMemo } from "react";
import {
calculateYearMaxCount,
filterDataByYear,
generateMonthsForYear,
getMonthLabel,
MonthCalendar,
} from "@/components/ActivityCalendar";
import { memo, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { TooltipProvider } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { getMaxYear, MIN_YEAR } from "./constants";
import { MonthCalendar } from "./MonthCalendar";
import type { YearCalendarProps } from "./types";
import { calculateYearMaxCount, filterDataByYear, generateMonthsForYear, getMonthLabel } from "./utils";
export const YearCalendar = ({ selectedYear, data, onYearChange, onDateClick, className }: YearCalendarProps) => {
interface YearNavigationProps {
selectedYear: number;
currentYear: number;
onPrev: () => void;
onNext: () => void;
onToday: () => void;
canGoPrev: boolean;
canGoNext: boolean;
}
const YearNavigation = memo(({ selectedYear, currentYear, onPrev, onNext, onToday, canGoPrev, canGoNext }: YearNavigationProps) => {
const t = useTranslate();
const isCurrentYear = selectedYear === currentYear;
return (
<div className="flex items-center justify-between px-1">
<h2 className="text-2xl font-semibold text-foreground tracking-tight">{selectedYear}</h2>
<nav className="inline-flex items-center gap-0.5 rounded-lg border border-border/30 bg-muted/10 p-0.5" aria-label="Year navigation">
<Button
variant="ghost"
size="sm"
onClick={onPrev}
disabled={!canGoPrev}
aria-label="Previous year"
className="h-7 w-7 p-0 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/40"
>
<ChevronLeftIcon className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={onToday}
disabled={isCurrentYear}
aria-label={t("common.today")}
className={cn(
"h-7 px-2.5 rounded-md text-[10px] font-medium uppercase tracking-wider",
isCurrentYear ? "text-muted-foreground/50 cursor-default" : "text-muted-foreground hover:text-foreground hover:bg-muted/40",
)}
>
{t("common.today")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={onNext}
disabled={!canGoNext}
aria-label="Next year"
className="h-7 w-7 p-0 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/40"
>
<ChevronRightIcon className="w-4 h-4" />
</Button>
</nav>
</div>
);
});
YearNavigation.displayName = "YearNavigation";
interface MonthCardProps {
month: string;
data: Record<string, number>;
maxCount: number;
onDateClick: (date: string) => void;
}
const MonthCard = memo(({ month, data, maxCount, onDateClick }: MonthCardProps) => (
<article className="flex flex-col gap-2 rounded-xl border border-border/20 bg-muted/5 p-3 transition-colors hover:bg-muted/10">
<header className="text-[10px] font-medium text-muted-foreground/80 uppercase tracking-widest">{getMonthLabel(month)}</header>
<MonthCalendar month={month} data={data} maxCount={maxCount} size="small" onClick={onDateClick} />
</article>
));
MonthCard.displayName = "MonthCard";
export const YearCalendar = memo(({ selectedYear, data, onYearChange, onDateClick, className }: YearCalendarProps) => {
const currentYear = useMemo(() => new Date().getFullYear(), []);
const yearData = useMemo(() => filterDataByYear(data, selectedYear), [data, selectedYear]);
const months = useMemo(() => generateMonthsForYear(selectedYear), [selectedYear]);
@ -23,75 +92,28 @@ export const YearCalendar = ({ selectedYear, data, onYearChange, onDateClick, cl
const canGoPrev = selectedYear > MIN_YEAR;
const canGoNext = selectedYear < getMaxYear();
const isCurrentYear = selectedYear === currentYear;
const handlePrevYear = () => canGoPrev && onYearChange(selectedYear - 1);
const handleNextYear = () => canGoNext && onYearChange(selectedYear + 1);
const handleToday = () => onYearChange(currentYear);
return (
<div className={cn("w-full flex flex-col gap-6 px-4 sm:px-0 py-4 select-none", className)}>
<div className="flex items-center justify-between px-1">
<div className="flex items-baseline gap-3">
<h2 className="text-2xl md:text-3xl font-semibold text-foreground tracking-tight leading-none">{selectedYear}</h2>
</div>
<div className="inline-flex items-center gap-1 shrink-0 rounded-lg border border-border/20 bg-muted/20 p-1">
<Button
variant="ghost"
size="sm"
onClick={handlePrevYear}
disabled={!canGoPrev}
aria-label="Previous year"
className="h-8 w-8 p-0 rounded-md hover:bg-muted/30 text-muted-foreground hover:text-foreground"
>
<ChevronLeftIcon className="w-5 h-5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleToday}
disabled={isCurrentYear}
aria-label={t("common.today")}
className={cn(
"h-8 px-3 rounded-md text-[11px] font-semibold uppercase tracking-[0.18em] transition-colors",
isCurrentYear ? "bg-muted/30 text-muted-foreground cursor-default" : "hover:bg-muted/30 text-foreground",
)}
>
{t("common.today")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleNextYear}
disabled={!canGoNext}
aria-label="Next year"
className="h-8 w-8 p-0 rounded-md hover:bg-muted/30 text-muted-foreground hover:text-foreground"
>
<ChevronRightIcon className="w-5 h-5" />
</Button>
</div>
</div>
<section className={cn("w-full flex flex-col gap-5 px-4 py-4 select-none", className)} aria-label={`Year ${selectedYear} calendar`}>
<YearNavigation
selectedYear={selectedYear}
currentYear={currentYear}
onPrev={() => canGoPrev && onYearChange(selectedYear - 1)}
onNext={() => canGoNext && onYearChange(selectedYear + 1)}
onToday={() => onYearChange(currentYear)}
canGoPrev={canGoPrev}
canGoNext={canGoNext}
/>
<TooltipProvider>
<div className="w-full animate-fade-in">
<div className="grid gap-6 md:gap-7 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{months.map((month) => (
<div
key={month}
className="flex flex-col gap-3 rounded-2xl border border-border/20 bg-muted/10 p-4 shadow-sm hover:shadow-md transition-shadow cursor-default"
>
<div className="text-[11px] font-semibold text-muted-foreground uppercase tracking-[0.22em] pl-1">
{getMonthLabel(month)}
</div>
<MonthCalendar month={month} data={yearData} maxCount={yearMaxCount} size="small" onClick={onDateClick} />
</div>
))}
</div>
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 animate-fade-in">
{months.map((month) => (
<MonthCard key={month} month={month} data={yearData} maxCount={yearMaxCount} onDateClick={onDateClick} />
))}
</div>
</TooltipProvider>
</div>
</section>
);
};
});
YearCalendar.displayName = "YearCalendar";

View File

@ -18,28 +18,25 @@ interface Props {
const MemoDetailSidebar = ({ memo, className, parentPage }: Props) => {
const t = useTranslate();
const property = create(Memo_PropertySchema, memo.property || {});
const hasSpecialProperty = property.hasLink || property.hasTaskList || property.hasCode || property.hasIncompleteTasks;
const shouldShowRelationGraph = memo.relations.filter((r) => r.type === MemoRelation_Type.REFERENCE).length > 0;
const { mutate: updateMemo } = useUpdateMemo();
const property = create(Memo_PropertySchema, memo.property || {});
const hasSpecialProperty = property.hasLink || property.hasTaskList || property.hasCode;
const hasReferenceRelations = memo.relations.some((r) => r.type === MemoRelation_Type.REFERENCE);
const handleUpdateTimestamp = (field: "createTime" | "updateTime", date: Date) => {
const timestamp = timestampFromDate(date);
const currentTimestamp = memo[field];
const newTimestamp = timestampFromDate(date);
if (isEqual(currentTimestamp, newTimestamp)) {
return;
}
updateMemo(
{
update: {
name: memo.name,
[field]: timestamp,
},
update: { name: memo.name, [field]: newTimestamp },
updateMask: [field === "createTime" ? "create_time" : "update_time"],
},
{
onSuccess: () => {
toast.success("Updated successfully");
},
onError: (error) => {
toast.error(error.message);
},
onSuccess: () => toast.success("Updated successfully"),
onError: (error) => toast.error(error.message),
},
);
};
@ -49,7 +46,7 @@ const MemoDetailSidebar = ({ memo, className, parentPage }: Props) => {
className={cn("relative w-full h-auto max-h-screen overflow-auto hide-scrollbar flex flex-col justify-start items-start", className)}
>
<div className="flex flex-col justify-start items-start w-full gap-4 h-auto shrink-0 flex-nowrap hide-scrollbar">
{shouldShowRelationGraph && (
{hasReferenceRelations && (
<div className="relative w-full h-36 border border-border rounded-lg bg-muted overflow-hidden">
<MemoRelationForceGraph className="w-full h-full" memo={memo} parentPage={parentPage} />
<div className="absolute top-2 left-2 text-xs text-muted-foreground/60 font-medium gap-1 flex flex-row items-center">
@ -58,16 +55,19 @@ const MemoDetailSidebar = ({ memo, className, parentPage }: Props) => {
</div>
</div>
)}
<div className="w-full space-y-1">
<p className="text-xs font-medium text-muted-foreground/60 uppercase tracking-wide px-1">{t("common.created-at")}</p>
<EditableTimestamp timestamp={memo.createTime} onChange={(date) => handleUpdateTimestamp("createTime", date)} />
</div>
{!isEqual(memo.createTime, memo.updateTime) && (
<div className="w-full space-y-1">
<p className="text-xs font-medium text-muted-foreground/60 uppercase tracking-wide px-1">{t("common.last-updated-at")}</p>
<EditableTimestamp timestamp={memo.updateTime} onChange={(date) => handleUpdateTimestamp("updateTime", date)} />
</div>
)}
{hasSpecialProperty && (
<div className="w-full space-y-2">
<p className="text-xs font-medium text-muted-foreground/60 uppercase tracking-wide px-1">{t("common.properties")}</p>
@ -93,6 +93,7 @@ const MemoDetailSidebar = ({ memo, className, parentPage }: Props) => {
</div>
</div>
)}
{memo.tags.length > 0 && (
<div className="w-full space-y-2">
<div className="flex flex-row justify-start items-center gap-1.5 px-1">

View File

@ -1,45 +1,56 @@
import dayjs from "dayjs";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { useState } from "react";
import { memo, useCallback, useMemo, useState } from "react";
import { YearCalendar } from "@/components/ActivityCalendar";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import i18n from "@/i18n";
import { addMonths, formatMonth, getMonthFromDate, getYearFromDate, setYearAndMonth } from "@/lib/calendar-utils";
import type { MonthNavigatorProps } from "@/types/statistics";
export const MonthNavigator = ({ visibleMonth, onMonthChange, activityStats }: MonthNavigatorProps) => {
export const MonthNavigator = memo(({ visibleMonth, onMonthChange, activityStats }: MonthNavigatorProps) => {
const [isOpen, setIsOpen] = useState(false);
const currentMonth = dayjs(visibleMonth).toDate();
const currentYear = getYearFromDate(visibleMonth);
const currentMonthNum = getMonthFromDate(visibleMonth);
const handlePrevMonth = () => {
onMonthChange(addMonths(visibleMonth, -1));
};
const { currentMonth, currentYear, currentMonthNum } = useMemo(
() => ({
currentMonth: dayjs(visibleMonth).toDate(),
currentYear: getYearFromDate(visibleMonth),
currentMonthNum: getMonthFromDate(visibleMonth),
}),
[visibleMonth],
);
const handleNextMonth = () => {
onMonthChange(addMonths(visibleMonth, 1));
};
const monthLabel = useMemo(() => currentMonth.toLocaleString(i18n.language, { year: "numeric", month: "long" }), [currentMonth]);
const handleDateClick = (date: string) => {
onMonthChange(formatMonth(date));
setIsOpen(false);
};
const handlePrevMonth = useCallback(() => onMonthChange(addMonths(visibleMonth, -1)), [visibleMonth, onMonthChange]);
const handleNextMonth = useCallback(() => onMonthChange(addMonths(visibleMonth, 1)), [visibleMonth, onMonthChange]);
const handleYearChange = (year: number) => {
onMonthChange(setYearAndMonth(year, currentMonthNum));
};
const handleDateClick = useCallback(
(date: string) => {
onMonthChange(formatMonth(date));
setIsOpen(false);
},
[onMonthChange],
);
const handleYearChange = useCallback(
(year: number) => onMonthChange(setYearAndMonth(year, currentMonthNum)),
[currentMonthNum, onMonthChange],
);
return (
<div className="w-full mb-2 flex flex-row justify-between items-center gap-2">
<header className="w-full mb-2 flex items-center justify-between gap-2">
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<button className="py-1 text-sm text-foreground font-medium transition-colors flex items-center select-none">
{currentMonth.toLocaleString(i18n.language, { year: "numeric", month: "long" })}
<button
type="button"
className="py-0.5 text-sm text-foreground font-medium transition-colors hover:text-foreground/80 select-none"
>
{monthLabel}
</button>
</DialogTrigger>
<DialogContent
className="p-0 border border-border/20 bg-background md:max-w-6xl w-[min(100vw-24px,1200px)] max-h-[85vh] overflow-auto rounded-2xl shadow-2xl"
className="p-0 border border-border/20 bg-background md:max-w-6xl w-[min(100vw-24px,1200px)] max-h-[85vh] overflow-auto rounded-xl shadow-xl"
size="2xl"
showCloseButton={false}
>
@ -47,22 +58,29 @@ export const MonthNavigator = ({ visibleMonth, onMonthChange, activityStats }: M
<YearCalendar selectedYear={currentYear} data={activityStats} onYearChange={handleYearChange} onDateClick={handleDateClick} />
</DialogContent>
</Dialog>
<div className="flex justify-end items-center shrink-0">
<button
className="h-8 w-8 rounded-lg hover:border-border/40 hover:bg-muted/30 text-muted-foreground hover:text-foreground transition-all"
<nav className="flex items-center shrink-0" aria-label="Month navigation">
<Button
variant="ghost"
size="sm"
onClick={handlePrevMonth}
aria-label="Previous month"
className="h-7 w-7 p-0 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/40"
>
<ChevronLeftIcon className="w-4 h-4 mx-auto" />
</button>
<button
className="h-8 w-8 rounded-lg hover:border-border/40 hover:bg-muted/30 text-muted-foreground hover:text-foreground transition-all"
<ChevronLeftIcon className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleNextMonth}
aria-label="Next month"
className="h-7 w-7 p-0 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/40"
>
<ChevronRightIcon className="w-4 h-4 mx-auto" />
</button>
</div>
</div>
<ChevronRightIcon className="w-4 h-4" />
</Button>
</nav>
</header>
);
};
});
MonthNavigator.displayName = "MonthNavigator";