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>
This commit is contained in:
Steven 2025-12-15 19:47:46 +08:00
parent d14e66daf5
commit 1b3318f886
18 changed files with 475 additions and 82 deletions

View File

@ -6,6 +6,7 @@ import { instanceStore } from "@/store";
import type { ActivityCalendarProps } from "@/types/statistics"; import type { ActivityCalendarProps } from "@/types/statistics";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { CalendarCell } from "./CalendarCell"; import { CalendarCell } from "./CalendarCell";
import { getTooltipText, useTodayDate, useWeekdayLabels } from "./shared";
import { useCalendarMatrix } from "./useCalendarMatrix"; import { useCalendarMatrix } from "./useCalendarMatrix";
export const ActivityCalendar = memo( export const ActivityCalendar = memo(
@ -14,14 +15,10 @@ export const ActivityCalendar = memo(
const { month, selectedDate, data, onClick } = props; const { month, selectedDate, data, onClick } = props;
const weekStartDayOffset = instanceStore.state.generalSetting.weekStartDayOffset; const weekStartDayOffset = instanceStore.state.generalSetting.weekStartDayOffset;
const today = useMemo(() => dayjs().format("YYYY-MM-DD"), []); const today = useTodayDate();
const weekDaysRaw = useWeekdayLabels();
const selectedDateFormatted = useMemo(() => dayjs(selectedDate).format("YYYY-MM-DD"), [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({ const { weeks, weekDays, maxCount } = useCalendarMatrix({
month, month,
data, data,
@ -33,26 +30,19 @@ export const ActivityCalendar = memo(
return ( return (
<TooltipProvider> <TooltipProvider>
<div className="w-full flex flex-col gap-1"> <div className="w-full flex flex-col gap-0.5">
<div className="grid grid-cols-7 gap-1 text-xs text-muted-foreground"> <div className="grid grid-cols-7 gap-0.5 text-xs text-muted-foreground">
{weekDays.map((label, index) => ( {weekDays.map((label, index) => (
<div key={index} className="flex h-5 items-center justify-center text-muted-foreground/80"> <div key={index} className="flex h-4 items-center justify-center text-muted-foreground/80">
{label} {label}
</div> </div>
))} ))}
</div> </div>
<div className="grid grid-cols-7 gap-1"> <div className="grid grid-cols-7 gap-0.5">
{weeks.map((week, weekIndex) => {weeks.map((week, weekIndex) =>
week.days.map((day, dayIndex) => { week.days.map((day, dayIndex) => {
const tooltipText = const tooltipText = getTooltipText(day.count, day.date, t);
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 ( return (
<CalendarCell <CalendarCell

View File

@ -1,18 +1,20 @@
import { memo } from "react"; import { memo } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { CalendarDayCell } from "./types"; import { DEFAULT_CELL_SIZE, SMALL_CELL_SIZE } from "./constants";
import type { CalendarDayCell, CalendarSize } from "./types";
import { getCellIntensityClass } from "./utils"; import { getCellIntensityClass } from "./utils";
interface CalendarCellProps { export interface CalendarCellProps {
day: CalendarDayCell; day: CalendarDayCell;
maxCount: number; maxCount: number;
tooltipText: string; tooltipText: string;
onClick?: (date: string) => void; onClick?: (date: string) => void;
size?: CalendarSize;
} }
export const CalendarCell = memo((props: CalendarCellProps) => { export const CalendarCell = memo((props: CalendarCellProps) => {
const { day, maxCount, tooltipText, onClick } = props; const { day, maxCount, tooltipText, onClick, size = "default" } = props;
const handleClick = () => { const handleClick = () => {
if (day.count > 0 && onClick) { if (day.count > 0 && onClick) {
@ -20,8 +22,15 @@ export const CalendarCell = memo((props: CalendarCellProps) => {
} }
}; };
const baseClasses = const sizeConfig = size === "small" ? SMALL_CELL_SIZE : DEFAULT_CELL_SIZE;
"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 smallExtraClasses = size === "small" ? `${SMALL_CELL_SIZE.dimensions} min-h-0` : "";
const baseClasses = cn(
"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",
sizeConfig.font,
sizeConfig.borderRadius,
smallExtraClasses,
);
const isInteractive = Boolean(onClick && day.count > 0); const isInteractive = Boolean(onClick && day.count > 0);
const ariaLabel = day.isSelected ? `${tooltipText} (selected)` : tooltipText; const ariaLabel = day.isSelected ? `${tooltipText} (selected)` : tooltipText;
@ -38,8 +47,8 @@ export const CalendarCell = memo((props: CalendarCellProps) => {
const buttonClasses = cn( const buttonClasses = cn(
baseClasses, baseClasses,
"border-transparent text-muted-foreground", "border-transparent text-muted-foreground",
day.isToday && "border-border", (day.isToday || day.isSelected) && "border-border",
day.isSelected && "border-border font-medium", day.isSelected && "font-medium",
day.isWeekend && "text-muted-foreground/80", day.isWeekend && "text-muted-foreground/80",
intensityClass, intensityClass,
isInteractive ? "cursor-pointer hover:scale-105" : "cursor-default", isInteractive ? "cursor-pointer hover:scale-105" : "cursor-default",
@ -59,7 +68,9 @@ export const CalendarCell = memo((props: CalendarCellProps) => {
</button> </button>
); );
if (!tooltipText) { const shouldShowTooltip = tooltipText && day.count > 0;
if (!shouldShowTooltip) {
return button; return button;
} }

View File

@ -0,0 +1,56 @@
import { observer } from "mobx-react-lite";
import { memo } from "react";
import { cn } from "@/lib/utils";
import { instanceStore } from "@/store";
import { useTranslate } from "@/utils/i18n";
import { CalendarCell } from "./CalendarCell";
import { DEFAULT_CELL_SIZE, SMALL_CELL_SIZE } from "./constants";
import { getTooltipText, useTodayDate, useWeekdayLabels } from "./shared";
import type { CompactMonthCalendarProps } from "./types";
import { useCalendarMatrix } from "./useCalendarMatrix";
export const CompactMonthCalendar = memo(
observer((props: CompactMonthCalendarProps) => {
const { month, data, maxCount, size = "default", onClick } = props;
const t = useTranslate();
const weekStartDayOffset = instanceStore.state.generalSetting.weekStartDayOffset;
const today = useTodayDate();
const weekDays = useWeekdayLabels();
const { weeks } = useCalendarMatrix({
month,
data,
weekDays,
weekStartDayOffset,
today,
selectedDate: "",
});
const sizeConfig = size === "small" ? SMALL_CELL_SIZE : DEFAULT_CELL_SIZE;
return (
<div className={cn("grid grid-cols-7", sizeConfig.gap)}>
{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>
);
}),
);
CompactMonthCalendar.displayName = "CompactMonthCalendar";

View File

@ -0,0 +1,24 @@
export const DAYS_IN_WEEK = 7;
export const MONTHS_IN_YEAR = 12;
export const WEEKEND_DAYS = [0, 6] as const;
export const MIN_COUNT = 1;
export const INTENSITY_THRESHOLDS = {
HIGH: 0.75,
MEDIUM: 0.5,
LOW: 0.25,
MINIMAL: 0,
} as const;
export const SMALL_CELL_SIZE = {
font: "text-[10px]",
dimensions: "max-w-6 max-h-6",
borderRadius: "rounded-sm",
gap: "gap-px",
} as const;
export const DEFAULT_CELL_SIZE = {
font: "text-xs",
borderRadius: "rounded",
gap: "gap-0.5",
} as const;

View File

@ -1 +1,21 @@
export { ActivityCalendar as default } from "./ActivityCalendar"; export { ActivityCalendar as default } from "./ActivityCalendar";
export { CalendarCell, type CalendarCellProps } from "./CalendarCell";
export { CompactMonthCalendar } from "./CompactMonthCalendar";
export * from "./constants";
export { getTooltipText, type TranslateFunction, useTodayDate, useWeekdayLabels } from "./shared";
export type {
CalendarDayCell,
CalendarDayRow,
CalendarMatrixResult,
CalendarSize,
CompactMonthCalendarProps,
} from "./types";
export { type UseCalendarMatrixParams, useCalendarMatrix } from "./useCalendarMatrix";
export {
calculateYearMaxCount,
filterDataByYear,
generateMonthsForYear,
getCellIntensityClass,
getMonthLabel,
hasActivityData,
} from "./utils";

View File

@ -0,0 +1,26 @@
import dayjs from "dayjs";
import { useMemo } from "react";
import { useTranslate } from "@/utils/i18n";
export type TranslateFunction = ReturnType<typeof useTranslate>;
export const useWeekdayLabels = () => {
const t = useTranslate();
return useMemo(() => [t("days.sun"), t("days.mon"), t("days.tue"), t("days.wed"), t("days.thu"), t("days.fri"), t("days.sat")], [t]);
};
export const useTodayDate = () => {
return dayjs().format("YYYY-MM-DD");
};
export const getTooltipText = (count: number, date: string, t: TranslateFunction): string => {
if (count === 0) {
return date;
}
return t("memo.count-memos-in-date", {
count,
memos: count === 1 ? t("common.memo") : t("common.memos"),
date,
}).toLowerCase();
};

View File

@ -1,3 +1,5 @@
export type CalendarSize = "default" | "small";
export interface CalendarDayCell { export interface CalendarDayCell {
date: string; date: string;
label: number; label: number;
@ -17,3 +19,11 @@ export interface CalendarMatrixResult {
weekDays: string[]; weekDays: string[];
maxCount: number; maxCount: number;
} }
export interface CompactMonthCalendarProps {
month: string;
data: Record<string, number>;
maxCount: number;
size?: CalendarSize;
onClick?: (date: string) => void;
}

View File

@ -1,8 +1,9 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useMemo } from "react"; import { useMemo } from "react";
import { DAYS_IN_WEEK, MIN_COUNT, WEEKEND_DAYS } from "./constants";
import type { CalendarDayCell, CalendarMatrixResult } from "./types"; import type { CalendarDayCell, CalendarMatrixResult } from "./types";
interface UseCalendarMatrixParams { export interface UseCalendarMatrixParams {
month: string; month: string;
data: Record<string, number>; data: Record<string, number>;
weekDays: string[]; weekDays: string[];
@ -11,6 +12,39 @@ interface UseCalendarMatrixParams {
selectedDate: string; selectedDate: string;
} }
const createCalendarDayCell = (
current: dayjs.Dayjs,
monthKey: string,
data: Record<string, number>,
today: string,
selectedDate: string,
): CalendarDayCell => {
const isoDate = current.format("YYYY-MM-DD");
const isCurrentMonth = current.format("YYYY-MM") === monthKey;
const count = data[isoDate] ?? 0;
return {
date: isoDate,
label: current.date(),
count,
isCurrentMonth,
isToday: isoDate === today,
isSelected: isoDate === selectedDate,
isWeekend: WEEKEND_DAYS.includes(current.day()),
};
};
const calculateCalendarBoundaries = (monthStart: dayjs.Dayjs, weekStartDayOffset: number) => {
const monthEnd = monthStart.endOf("month");
const startOffset = (monthStart.day() - weekStartDayOffset + DAYS_IN_WEEK) % DAYS_IN_WEEK;
const endOffset = (weekStartDayOffset + (DAYS_IN_WEEK - 1) - monthEnd.day() + DAYS_IN_WEEK) % DAYS_IN_WEEK;
const calendarStart = monthStart.subtract(startOffset, "day");
const calendarEnd = monthEnd.add(endOffset, "day");
const dayCount = calendarEnd.diff(calendarStart, "day") + 1;
return { calendarStart, dayCount };
};
export const useCalendarMatrix = ({ export const useCalendarMatrix = ({
month, month,
data, data,
@ -21,51 +55,32 @@ export const useCalendarMatrix = ({
}: UseCalendarMatrixParams): CalendarMatrixResult => { }: UseCalendarMatrixParams): CalendarMatrixResult => {
return useMemo(() => { return useMemo(() => {
const monthStart = dayjs(month).startOf("month"); const monthStart = dayjs(month).startOf("month");
const monthEnd = monthStart.endOf("month");
const monthKey = monthStart.format("YYYY-MM"); const monthKey = monthStart.format("YYYY-MM");
const orderedWeekDays = weekDays.slice(weekStartDayOffset).concat(weekDays.slice(0, weekStartDayOffset)); const rotatedWeekDays = weekDays.slice(weekStartDayOffset).concat(weekDays.slice(0, weekStartDayOffset));
const startOffset = (monthStart.day() - weekStartDayOffset + 7) % 7; const { calendarStart, dayCount } = calculateCalendarBoundaries(monthStart, weekStartDayOffset);
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"] = []; const weeks: CalendarMatrixResult["weeks"] = [];
let maxCount = 0; let maxCount = 0;
for (let index = 0; index < dayCount; index += 1) { for (let index = 0; index < dayCount; index += 1) {
const current = calendarStart.add(index, "day"); const current = calendarStart.add(index, "day");
const isoDate = current.format("YYYY-MM-DD"); const weekIndex = Math.floor(index / DAYS_IN_WEEK);
const weekIndex = Math.floor(index / 7);
if (!weeks[weekIndex]) { if (!weeks[weekIndex]) {
weeks[weekIndex] = { days: [] }; weeks[weekIndex] = { days: [] };
} }
const isCurrentMonth = current.format("YYYY-MM") === monthKey; const dayCell = createCalendarDayCell(current, monthKey, data, today, selectedDate);
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); weeks[weekIndex].days.push(dayCell);
maxCount = Math.max(maxCount, count); maxCount = Math.max(maxCount, dayCell.count);
} }
return { return {
weeks, weeks,
weekDays: orderedWeekDays, weekDays: rotatedWeekDays,
maxCount: Math.max(maxCount, 1), maxCount: Math.max(maxCount, MIN_COUNT),
}; };
}, [month, data, weekDays, weekStartDayOffset, today, selectedDate]); }, [month, data, weekDays, weekStartDayOffset, today, selectedDate]);
}; };

View File

@ -1,13 +1,57 @@
import dayjs from "dayjs";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter";
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
import { INTENSITY_THRESHOLDS, MIN_COUNT, MONTHS_IN_YEAR } from "./constants";
import type { CalendarDayCell } from "./types"; import type { CalendarDayCell } from "./types";
dayjs.extend(isSameOrAfter);
dayjs.extend(isSameOrBefore);
export const getCellIntensityClass = (day: CalendarDayCell, maxCount: number): string => { export const getCellIntensityClass = (day: CalendarDayCell, maxCount: number): string => {
if (!day.isCurrentMonth || day.count === 0 || maxCount <= 0) { if (!day.isCurrentMonth || day.count === 0) {
return "bg-transparent"; return "bg-transparent";
} }
const ratio = day.count / maxCount; const ratio = day.count / maxCount;
if (ratio > 0.75) return "bg-primary text-primary-foreground border-primary"; if (ratio > INTENSITY_THRESHOLDS.HIGH) return "bg-primary text-primary-foreground border-primary";
if (ratio > 0.5) return "bg-primary/80 text-primary-foreground border-primary/90"; if (ratio > INTENSITY_THRESHOLDS.MEDIUM) return "bg-primary/80 text-primary-foreground border-primary/90";
if (ratio > 0.25) return "bg-primary/60 text-primary-foreground border-primary/70"; if (ratio > INTENSITY_THRESHOLDS.LOW) return "bg-primary/60 text-primary-foreground border-primary/70";
return "bg-primary/40 text-primary"; return "bg-primary/40 text-primary";
}; };
export const generateMonthsForYear = (year: number): string[] => {
return Array.from({ length: MONTHS_IN_YEAR }, (_, i) => dayjs(`${year}-01-01`).add(i, "month").format("YYYY-MM"));
};
export const calculateYearMaxCount = (data: Record<string, number>): number => {
let max = 0;
for (const count of Object.values(data)) {
max = Math.max(max, count);
}
return Math.max(max, MIN_COUNT);
};
export const getMonthLabel = (month: string): string => {
return dayjs(month).format("MMM YYYY");
};
export const filterDataByYear = (data: Record<string, number>, year: number): Record<string, number> => {
if (!data) return {};
const filtered: Record<string, number> = {};
const yearStart = dayjs(`${year}-01-01`);
const yearEnd = dayjs(`${year}-12-31`);
for (const [dateStr, count] of Object.entries(data)) {
const date = dayjs(dateStr);
if (date.isSameOrAfter(yearStart, "day") && date.isSameOrBefore(yearEnd, "day")) {
filtered[dateStr] = count;
}
}
return filtered;
};
export const hasActivityData = (data: Record<string, number>): boolean => {
return Object.values(data).some((count) => count > 0);
};

View File

@ -12,10 +12,10 @@ import {
XIcon, XIcon,
} from "lucide-react"; } from "lucide-react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { memoFilterStore } from "@/store"; import { memoFilterStore } from "@/store";
import { FilterFactor, getMemoFilterKey, MemoFilter, stringifyFilters } from "@/store/memoFilter"; import { FilterFactor, getMemoFilterKey, MemoFilter, parseFilterQuery, stringifyFilters } from "@/store/memoFilter";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
interface FilterConfig { interface FilterConfig {
@ -60,15 +60,32 @@ const FILTER_CONFIGS: Record<FilterFactor, FilterConfig> = {
const MemoFilters = observer(() => { const MemoFilters = observer(() => {
const t = useTranslate(); const t = useTranslate();
const [, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const filters = memoFilterStore.filters; const filters = memoFilterStore.filters;
const lastSyncedUrlRef = useRef("");
const lastSyncedStoreRef = useRef("");
useEffect(() => { useEffect(() => {
const searchParams = new URLSearchParams(); const filterParam = searchParams.get("filter") || "";
if (filters.length > 0) { if (filterParam !== lastSyncedUrlRef.current) {
searchParams.set("filter", stringifyFilters(filters)); lastSyncedUrlRef.current = filterParam;
const newFilters = parseFilterQuery(filterParam);
memoFilterStore.setFilters(newFilters);
lastSyncedStoreRef.current = stringifyFilters(newFilters);
}
}, [searchParams]);
useEffect(() => {
const storeString = stringifyFilters(filters);
if (storeString !== lastSyncedStoreRef.current && storeString !== lastSyncedUrlRef.current) {
lastSyncedStoreRef.current = storeString;
const newParams = new URLSearchParams();
if (filters.length > 0) {
newParams.set("filter", storeString);
}
setSearchParams(newParams, { replace: true });
lastSyncedUrlRef.current = filters.length > 0 ? storeString : "";
} }
setSearchParams(searchParams);
}, [filters, setSearchParams]); }, [filters, setSearchParams]);
const handleRemoveFilter = (filter: MemoFilter) => { const handleRemoveFilter = (filter: MemoFilter) => {

View File

@ -1,4 +1,4 @@
import { BellIcon, EarthIcon, LibraryIcon, PaperclipIcon, UserCircleIcon } from "lucide-react"; import { BellIcon, CalendarIcon, EarthIcon, LibraryIcon, PaperclipIcon, UserCircleIcon } from "lucide-react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect } from "react"; import { useEffect } from "react";
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
@ -43,6 +43,12 @@ const Navigation = observer((props: Props) => {
title: t("common.memos"), title: t("common.memos"),
icon: <LibraryIcon className="w-6 h-auto shrink-0" />, icon: <LibraryIcon className="w-6 h-auto shrink-0" />,
}; };
const calendarNavLink: NavLinkItem = {
id: "header-calendar",
path: Routes.CALENDAR,
title: t("common.calendar"),
icon: <CalendarIcon className="w-6 h-auto shrink-0" />,
};
const exploreNavLink: NavLinkItem = { const exploreNavLink: NavLinkItem = {
id: "header-explore", id: "header-explore",
path: Routes.EXPLORE, path: Routes.EXPLORE,
@ -79,7 +85,7 @@ const Navigation = observer((props: Props) => {
}; };
const navLinks: NavLinkItem[] = currentUser const navLinks: NavLinkItem[] = currentUser
? [homeNavLink, exploreNavLink, attachmentsNavLink, inboxNavLink] ? [homeNavLink, calendarNavLink, exploreNavLink, attachmentsNavLink, inboxNavLink]
: [exploreNavLink, signInNavLink]; : [exploreNavLink, signInNavLink];
return ( return (

View File

@ -1,42 +1,35 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useCallback, useState } from "react"; import { useMemo, useState } from "react";
import memoFilterStore from "@/store/memoFilter"; import { CompactMonthCalendar } from "@/components/ActivityCalendar";
import { useDateFilterNavigation } from "@/hooks";
import type { StatisticsData } from "@/types/statistics"; import type { StatisticsData } from "@/types/statistics";
import ActivityCalendar from "../ActivityCalendar";
import { MonthNavigator } from "./MonthNavigator"; import { MonthNavigator } from "./MonthNavigator";
export type StatisticsViewContext = "home" | "explore" | "archived" | "profile"; export type StatisticsViewContext = "home" | "explore" | "archived" | "profile";
interface Props { interface Props {
// Context for the statistics view (affects which stat cards are shown)
context?: StatisticsViewContext; context?: StatisticsViewContext;
// Statistics data computed from filtered memos (use useFilteredMemoStats)
statisticsData: StatisticsData; statisticsData: StatisticsData;
} }
const StatisticsView = observer((props: Props) => { const StatisticsView = observer((props: Props) => {
const { statisticsData } = props; const { statisticsData } = props;
const { activityStats } = statisticsData; const { activityStats } = statisticsData;
const [selectedDate] = useState(new Date()); const navigateToDateFilter = useDateFilterNavigation();
const [visibleMonthString, setVisibleMonthString] = useState(dayjs().format("YYYY-MM")); const [visibleMonthString, setVisibleMonthString] = useState(dayjs().format("YYYY-MM"));
const handleCalendarClick = useCallback((date: string) => { const maxCount = useMemo(() => {
memoFilterStore.removeFilter((f) => f.factor === "displayTime"); const counts = Object.values(activityStats);
memoFilterStore.addFilter({ factor: "displayTime", value: date }); return Math.max(...counts, 1);
}, []); }, [activityStats]);
return ( return (
<div className="group w-full mt-2 space-y-1 text-muted-foreground animate-fade-in"> <div className="group w-full mt-2 space-y-1 text-muted-foreground animate-fade-in">
<MonthNavigator visibleMonth={visibleMonthString} onMonthChange={setVisibleMonthString} /> <MonthNavigator visibleMonth={visibleMonthString} onMonthChange={setVisibleMonthString} />
<div className="w-full animate-scale-in"> <div className="w-full animate-scale-in">
<ActivityCalendar <CompactMonthCalendar month={visibleMonthString} data={activityStats} maxCount={maxCount} onClick={navigateToDateFilter} />
month={visibleMonthString}
selectedDate={selectedDate.toDateString()}
data={activityStats}
onClick={handleCalendarClick}
/>
</div> </div>
</div> </div>
); );

View File

@ -1,5 +1,6 @@
export * from "./useAsyncEffect"; export * from "./useAsyncEffect";
export * from "./useCurrentUser"; export * from "./useCurrentUser";
export * from "./useDateFilterNavigation";
export * from "./useFilteredMemoStats"; export * from "./useFilteredMemoStats";
export * from "./useLoading"; export * from "./useLoading";
export * from "./useMemoFilters"; export * from "./useMemoFilters";

View File

@ -0,0 +1,17 @@
import { useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { stringifyFilters } from "@/store/memoFilter";
export const useDateFilterNavigation = () => {
const navigate = useNavigate();
const navigateToDateFilter = useCallback(
(date: string) => {
const filterQuery = stringifyFilters([{ factor: "displayTime", value: date }]);
navigate(`/?filter=${filterQuery}`);
},
[navigate],
);
return navigateToDateFilter;
};

View File

@ -26,6 +26,7 @@
"avatar": "Avatar", "avatar": "Avatar",
"basic": "Basic", "basic": "Basic",
"beta": "Beta", "beta": "Beta",
"calendar": "Calendar",
"cancel": "Cancel", "cancel": "Cancel",
"change": "Change", "change": "Change",
"clear": "Clear", "clear": "Clear",
@ -93,6 +94,7 @@
"statistics": "Statistics", "statistics": "Statistics",
"tags": "Tags", "tags": "Tags",
"title": "Title", "title": "Title",
"today": "Today",
"tree-mode": "Tree mode", "tree-mode": "Tree mode",
"type": "Type", "type": "Type",
"unpin": "Unpin", "unpin": "Unpin",

145
web/src/pages/Calendar.tsx Normal file
View File

@ -0,0 +1,145 @@
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useMemo, useState } from "react";
import {
CompactMonthCalendar,
calculateYearMaxCount,
filterDataByYear,
generateMonthsForYear,
getMonthLabel,
} from "@/components/ActivityCalendar";
import MobileHeader from "@/components/MobileHeader";
import { Button } from "@/components/ui/button";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useDateFilterNavigation } from "@/hooks";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useFilteredMemoStats } from "@/hooks/useFilteredMemoStats";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
const MIN_YEAR = 2000;
const MAX_YEAR = new Date().getFullYear() + 1;
const Calendar = observer(() => {
const currentUser = useCurrentUser();
const t = useTranslate();
const navigateToDateFilter = useDateFilterNavigation();
const [selectedYear, setSelectedYear] = useState(new Date().getFullYear());
const { statistics, loading } = useFilteredMemoStats({
userName: currentUser?.name,
});
const yearData = useMemo(() => filterDataByYear(statistics.activityStats, selectedYear), [statistics.activityStats, selectedYear]);
const months = useMemo(() => generateMonthsForYear(selectedYear), [selectedYear]);
const yearMaxCount = useMemo(() => calculateYearMaxCount(yearData), [yearData]);
const currentYear = useMemo(() => new Date().getFullYear(), []);
const isCurrentYear = selectedYear === currentYear;
const handlePrevYear = () => {
if (selectedYear > MIN_YEAR) {
setSelectedYear(selectedYear - 1);
}
};
const handleNextYear = () => {
if (selectedYear < MAX_YEAR) {
setSelectedYear(selectedYear + 1);
}
};
const handleToday = () => {
setSelectedYear(currentYear);
};
const canGoPrev = selectedYear > MIN_YEAR;
const canGoNext = selectedYear < MAX_YEAR;
return (
<section className="relative w-full min-h-full flex flex-col justify-start items-center bg-background">
<MobileHeader />
<div className="relative w-full flex flex-col items-center px-3 sm:px-4 md:px-6 lg:px-8 pb-8">
<div className="w-full max-w-7xl flex flex-col gap-3 sm:gap-4 py-3 sm:py-4">
<div className="flex items-center justify-between pb-2">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-foreground tracking-tight leading-none">{selectedYear}</h1>
<div className="inline-flex items-center gap-2 shrink-0">
<Button
variant="ghost"
size="icon"
onClick={handlePrevYear}
disabled={!canGoPrev}
aria-label="Previous year"
className="rounded-full hover:bg-accent/50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<ChevronLeftIcon />
</Button>
<Button
variant={isCurrentYear ? "secondary" : "ghost"}
onClick={handleToday}
disabled={isCurrentYear}
aria-label={t("common.today")}
className={cn(
"h-9 px-4 rounded-full font-medium text-sm transition-colors",
isCurrentYear
? "bg-accent text-accent-foreground cursor-default"
: "hover:bg-accent/50 text-muted-foreground hover:text-foreground",
)}
>
{t("common.today")}
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleNextYear}
disabled={!canGoNext}
aria-label="Next year"
className="rounded-full hover:bg-accent/50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<ChevronRightIcon />
</Button>
</div>
</div>
{loading ? (
<div className="w-full flex items-center justify-center py-12">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="text-sm text-muted-foreground">Loading calendar...</p>
</div>
</div>
) : (
<TooltipProvider>
<div className="w-full animate-fade-in">
<div className="grid gap-2 sm:gap-2.5 md:gap-3 lg:gap-3 grid-cols-2 sm:grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4">
{months.map((month) => (
<div
key={month}
className="flex flex-col gap-1 sm:gap-1.5 rounded-lg border bg-card p-1.5 sm:p-2 md:p-2.5 shadow-sm hover:shadow-md hover:border-border/60 transition-all duration-200"
>
<div className="text-xs font-semibold text-foreground text-center tracking-tight">{getMonthLabel(month)}</div>
<CompactMonthCalendar
month={month}
data={yearData}
maxCount={yearMaxCount}
size="small"
onClick={navigateToDateFilter}
/>
</div>
))}
</div>
</div>
</TooltipProvider>
)}
</div>
</div>
</section>
);
});
export default Calendar;

View File

@ -9,6 +9,7 @@ import Loading from "@/pages/Loading";
const AdminSignIn = lazy(() => import("@/pages/AdminSignIn")); const AdminSignIn = lazy(() => import("@/pages/AdminSignIn"));
const Archived = lazy(() => import("@/pages/Archived")); const Archived = lazy(() => import("@/pages/Archived"));
const AuthCallback = lazy(() => import("@/pages/AuthCallback")); const AuthCallback = lazy(() => import("@/pages/AuthCallback"));
const Calendar = lazy(() => import("@/pages/Calendar"));
const Explore = lazy(() => import("@/pages/Explore")); const Explore = lazy(() => import("@/pages/Explore"));
const Inboxes = lazy(() => import("@/pages/Inboxes")); const Inboxes = lazy(() => import("@/pages/Inboxes"));
const MemoDetail = lazy(() => import("@/pages/MemoDetail")); const MemoDetail = lazy(() => import("@/pages/MemoDetail"));
@ -24,6 +25,7 @@ const MemoDetailRedirect = lazy(() => import("./MemoDetailRedirect"));
export enum Routes { export enum Routes {
ROOT = "/", ROOT = "/",
ATTACHMENTS = "/attachments", ATTACHMENTS = "/attachments",
CALENDAR = "/calendar",
INBOX = "/inbox", INBOX = "/inbox",
ARCHIVED = "/archived", ARCHIVED = "/archived",
SETTING = "/setting", SETTING = "/setting",
@ -118,6 +120,14 @@ const router = createBrowserRouter([
</Suspense> </Suspense>
), ),
}, },
{
path: Routes.CALENDAR,
element: (
<Suspense fallback={<Loading />}>
<Calendar />
</Suspense>
),
},
{ {
path: Routes.INBOX, path: Routes.INBOX,
element: ( element: (

View File

@ -52,6 +52,7 @@ class MemoFilterState extends StandardState {
filters: observable, filters: observable,
shortcut: observable, shortcut: observable,
hasActiveFilters: computed, hasActiveFilters: computed,
setFilters: action,
addFilter: action, addFilter: action,
removeFilter: action, removeFilter: action,
removeFiltersByFactor: action, removeFiltersByFactor: action,
@ -75,6 +76,10 @@ class MemoFilterState extends StandardState {
return this.filters.filter((f) => f.factor === factor); return this.filters.filter((f) => f.factor === factor);
} }
setFilters(filters: MemoFilter[]): void {
this.filters = filters;
}
addFilter(filter: MemoFilter): void { addFilter(filter: MemoFilter): void {
this.filters = uniqBy([...this.filters, filter], getMemoFilterKey); this.filters = uniqBy([...this.filters, filter], getMemoFilterKey);
} }
@ -120,6 +125,7 @@ const memoFilterStore = (() => {
return state.hasActiveFilters; return state.hasActiveFilters;
}, },
getFiltersByFactor: (factor: FilterFactor): MemoFilter[] => state.getFiltersByFactor(factor), getFiltersByFactor: (factor: FilterFactor): MemoFilter[] => state.getFiltersByFactor(factor),
setFilters: (filters: MemoFilter[]): void => state.setFilters(filters),
addFilter: (filter: MemoFilter): void => state.addFilter(filter), addFilter: (filter: MemoFilter): void => state.addFilter(filter),
removeFilter: (predicate: (f: MemoFilter) => boolean): void => state.removeFilter(predicate), removeFilter: (predicate: (f: MemoFilter) => boolean): void => state.removeFilter(predicate),
removeFiltersByFactor: (factor: FilterFactor): void => state.removeFiltersByFactor(factor), removeFiltersByFactor: (factor: FilterFactor): void => state.removeFiltersByFactor(factor),