mirror of https://github.com/usememos/memos.git
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:
parent
d14e66daf5
commit
1b3318f886
|
|
@ -6,6 +6,7 @@ import { instanceStore } from "@/store";
|
|||
import type { ActivityCalendarProps } from "@/types/statistics";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { CalendarCell } from "./CalendarCell";
|
||||
import { getTooltipText, useTodayDate, useWeekdayLabels } from "./shared";
|
||||
import { useCalendarMatrix } from "./useCalendarMatrix";
|
||||
|
||||
export const ActivityCalendar = memo(
|
||||
|
|
@ -14,14 +15,10 @@ export const ActivityCalendar = memo(
|
|||
const { month, selectedDate, data, onClick } = props;
|
||||
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 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,
|
||||
|
|
@ -33,26 +30,19 @@ export const ActivityCalendar = memo(
|
|||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="w-full flex flex-col gap-1">
|
||||
<div className="grid grid-cols-7 gap-1 text-xs text-muted-foreground">
|
||||
<div className="w-full flex flex-col gap-0.5">
|
||||
<div className="grid grid-cols-7 gap-0.5 text-xs text-muted-foreground">
|
||||
{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}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
<div className="grid grid-cols-7 gap-0.5">
|
||||
{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();
|
||||
const tooltipText = getTooltipText(day.count, day.date, t);
|
||||
|
||||
return (
|
||||
<CalendarCell
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
import { memo } from "react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
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";
|
||||
|
||||
interface CalendarCellProps {
|
||||
export interface CalendarCellProps {
|
||||
day: CalendarDayCell;
|
||||
maxCount: number;
|
||||
tooltipText: string;
|
||||
onClick?: (date: string) => void;
|
||||
size?: CalendarSize;
|
||||
}
|
||||
|
||||
export const CalendarCell = memo((props: CalendarCellProps) => {
|
||||
const { day, maxCount, tooltipText, onClick } = props;
|
||||
const { day, maxCount, tooltipText, onClick, size = "default" } = props;
|
||||
|
||||
const handleClick = () => {
|
||||
if (day.count > 0 && onClick) {
|
||||
|
|
@ -20,8 +22,15 @@ export const CalendarCell = memo((props: CalendarCellProps) => {
|
|||
}
|
||||
};
|
||||
|
||||
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 sizeConfig = size === "small" ? SMALL_CELL_SIZE : DEFAULT_CELL_SIZE;
|
||||
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 ariaLabel = day.isSelected ? `${tooltipText} (selected)` : tooltipText;
|
||||
|
||||
|
|
@ -38,8 +47,8 @@ export const CalendarCell = memo((props: CalendarCellProps) => {
|
|||
const buttonClasses = cn(
|
||||
baseClasses,
|
||||
"border-transparent text-muted-foreground",
|
||||
day.isToday && "border-border",
|
||||
day.isSelected && "border-border font-medium",
|
||||
(day.isToday || day.isSelected) && "border-border",
|
||||
day.isSelected && "font-medium",
|
||||
day.isWeekend && "text-muted-foreground/80",
|
||||
intensityClass,
|
||||
isInteractive ? "cursor-pointer hover:scale-105" : "cursor-default",
|
||||
|
|
@ -59,7 +68,9 @@ export const CalendarCell = memo((props: CalendarCellProps) => {
|
|||
</button>
|
||||
);
|
||||
|
||||
if (!tooltipText) {
|
||||
const shouldShowTooltip = tooltipText && day.count > 0;
|
||||
|
||||
if (!shouldShowTooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -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;
|
||||
|
|
@ -1 +1,21 @@
|
|||
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";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
export type CalendarSize = "default" | "small";
|
||||
|
||||
export interface CalendarDayCell {
|
||||
date: string;
|
||||
label: number;
|
||||
|
|
@ -17,3 +19,11 @@ export interface CalendarMatrixResult {
|
|||
weekDays: string[];
|
||||
maxCount: number;
|
||||
}
|
||||
|
||||
export interface CompactMonthCalendarProps {
|
||||
month: string;
|
||||
data: Record<string, number>;
|
||||
maxCount: number;
|
||||
size?: CalendarSize;
|
||||
onClick?: (date: string) => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import dayjs from "dayjs";
|
||||
import { useMemo } from "react";
|
||||
import { DAYS_IN_WEEK, MIN_COUNT, WEEKEND_DAYS } from "./constants";
|
||||
import type { CalendarDayCell, CalendarMatrixResult } from "./types";
|
||||
|
||||
interface UseCalendarMatrixParams {
|
||||
export interface UseCalendarMatrixParams {
|
||||
month: string;
|
||||
data: Record<string, number>;
|
||||
weekDays: string[];
|
||||
|
|
@ -11,6 +12,39 @@ interface UseCalendarMatrixParams {
|
|||
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 = ({
|
||||
month,
|
||||
data,
|
||||
|
|
@ -21,51 +55,32 @@ export const useCalendarMatrix = ({
|
|||
}: 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 rotatedWeekDays = 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 { calendarStart, dayCount } = calculateCalendarBoundaries(monthStart, weekStartDayOffset);
|
||||
|
||||
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);
|
||||
const weekIndex = Math.floor(index / DAYS_IN_WEEK);
|
||||
|
||||
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()),
|
||||
};
|
||||
|
||||
const dayCell = createCalendarDayCell(current, monthKey, data, today, selectedDate);
|
||||
weeks[weekIndex].days.push(dayCell);
|
||||
maxCount = Math.max(maxCount, count);
|
||||
maxCount = Math.max(maxCount, dayCell.count);
|
||||
}
|
||||
|
||||
return {
|
||||
weeks,
|
||||
weekDays: orderedWeekDays,
|
||||
maxCount: Math.max(maxCount, 1),
|
||||
weekDays: rotatedWeekDays,
|
||||
maxCount: Math.max(maxCount, MIN_COUNT),
|
||||
};
|
||||
}, [month, data, weekDays, weekStartDayOffset, today, selectedDate]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
dayjs.extend(isSameOrAfter);
|
||||
dayjs.extend(isSameOrBefore);
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
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";
|
||||
if (ratio > INTENSITY_THRESHOLDS.HIGH) return "bg-primary text-primary-foreground border-primary";
|
||||
if (ratio > INTENSITY_THRESHOLDS.MEDIUM) return "bg-primary/80 text-primary-foreground border-primary/90";
|
||||
if (ratio > INTENSITY_THRESHOLDS.LOW) return "bg-primary/60 text-primary-foreground border-primary/70";
|
||||
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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ import {
|
|||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
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";
|
||||
|
||||
interface FilterConfig {
|
||||
|
|
@ -60,15 +60,32 @@ const FILTER_CONFIGS: Record<FilterFactor, FilterConfig> = {
|
|||
|
||||
const MemoFilters = observer(() => {
|
||||
const t = useTranslate();
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const filters = memoFilterStore.filters;
|
||||
const lastSyncedUrlRef = useRef("");
|
||||
const lastSyncedStoreRef = useRef("");
|
||||
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (filters.length > 0) {
|
||||
searchParams.set("filter", stringifyFilters(filters));
|
||||
const filterParam = searchParams.get("filter") || "";
|
||||
if (filterParam !== lastSyncedUrlRef.current) {
|
||||
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]);
|
||||
|
||||
const handleRemoveFilter = (filter: MemoFilter) => {
|
||||
|
|
|
|||
|
|
@ -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 { useEffect } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
|
|
@ -43,6 +43,12 @@ const Navigation = observer((props: Props) => {
|
|||
title: t("common.memos"),
|
||||
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 = {
|
||||
id: "header-explore",
|
||||
path: Routes.EXPLORE,
|
||||
|
|
@ -79,7 +85,7 @@ const Navigation = observer((props: Props) => {
|
|||
};
|
||||
|
||||
const navLinks: NavLinkItem[] = currentUser
|
||||
? [homeNavLink, exploreNavLink, attachmentsNavLink, inboxNavLink]
|
||||
? [homeNavLink, calendarNavLink, exploreNavLink, attachmentsNavLink, inboxNavLink]
|
||||
: [exploreNavLink, signInNavLink];
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,42 +1,35 @@
|
|||
import dayjs from "dayjs";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useCallback, useState } from "react";
|
||||
import memoFilterStore from "@/store/memoFilter";
|
||||
import { useMemo, useState } from "react";
|
||||
import { CompactMonthCalendar } from "@/components/ActivityCalendar";
|
||||
import { useDateFilterNavigation } from "@/hooks";
|
||||
import type { StatisticsData } from "@/types/statistics";
|
||||
import ActivityCalendar from "../ActivityCalendar";
|
||||
import { MonthNavigator } from "./MonthNavigator";
|
||||
|
||||
export type StatisticsViewContext = "home" | "explore" | "archived" | "profile";
|
||||
|
||||
interface Props {
|
||||
// Context for the statistics view (affects which stat cards are shown)
|
||||
context?: StatisticsViewContext;
|
||||
// Statistics data computed from filtered memos (use useFilteredMemoStats)
|
||||
statisticsData: StatisticsData;
|
||||
}
|
||||
|
||||
const StatisticsView = observer((props: Props) => {
|
||||
const { statisticsData } = props;
|
||||
const { activityStats } = statisticsData;
|
||||
const [selectedDate] = useState(new Date());
|
||||
const navigateToDateFilter = useDateFilterNavigation();
|
||||
const [visibleMonthString, setVisibleMonthString] = useState(dayjs().format("YYYY-MM"));
|
||||
|
||||
const handleCalendarClick = useCallback((date: string) => {
|
||||
memoFilterStore.removeFilter((f) => f.factor === "displayTime");
|
||||
memoFilterStore.addFilter({ factor: "displayTime", value: date });
|
||||
}, []);
|
||||
const maxCount = useMemo(() => {
|
||||
const counts = Object.values(activityStats);
|
||||
return Math.max(...counts, 1);
|
||||
}, [activityStats]);
|
||||
|
||||
return (
|
||||
<div className="group w-full mt-2 space-y-1 text-muted-foreground animate-fade-in">
|
||||
<MonthNavigator visibleMonth={visibleMonthString} onMonthChange={setVisibleMonthString} />
|
||||
|
||||
<div className="w-full animate-scale-in">
|
||||
<ActivityCalendar
|
||||
month={visibleMonthString}
|
||||
selectedDate={selectedDate.toDateString()}
|
||||
data={activityStats}
|
||||
onClick={handleCalendarClick}
|
||||
/>
|
||||
<CompactMonthCalendar month={visibleMonthString} data={activityStats} maxCount={maxCount} onClick={navigateToDateFilter} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * from "./useAsyncEffect";
|
||||
export * from "./useCurrentUser";
|
||||
export * from "./useDateFilterNavigation";
|
||||
export * from "./useFilteredMemoStats";
|
||||
export * from "./useLoading";
|
||||
export * from "./useMemoFilters";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
"avatar": "Avatar",
|
||||
"basic": "Basic",
|
||||
"beta": "Beta",
|
||||
"calendar": "Calendar",
|
||||
"cancel": "Cancel",
|
||||
"change": "Change",
|
||||
"clear": "Clear",
|
||||
|
|
@ -93,6 +94,7 @@
|
|||
"statistics": "Statistics",
|
||||
"tags": "Tags",
|
||||
"title": "Title",
|
||||
"today": "Today",
|
||||
"tree-mode": "Tree mode",
|
||||
"type": "Type",
|
||||
"unpin": "Unpin",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -9,6 +9,7 @@ import Loading from "@/pages/Loading";
|
|||
const AdminSignIn = lazy(() => import("@/pages/AdminSignIn"));
|
||||
const Archived = lazy(() => import("@/pages/Archived"));
|
||||
const AuthCallback = lazy(() => import("@/pages/AuthCallback"));
|
||||
const Calendar = lazy(() => import("@/pages/Calendar"));
|
||||
const Explore = lazy(() => import("@/pages/Explore"));
|
||||
const Inboxes = lazy(() => import("@/pages/Inboxes"));
|
||||
const MemoDetail = lazy(() => import("@/pages/MemoDetail"));
|
||||
|
|
@ -24,6 +25,7 @@ const MemoDetailRedirect = lazy(() => import("./MemoDetailRedirect"));
|
|||
export enum Routes {
|
||||
ROOT = "/",
|
||||
ATTACHMENTS = "/attachments",
|
||||
CALENDAR = "/calendar",
|
||||
INBOX = "/inbox",
|
||||
ARCHIVED = "/archived",
|
||||
SETTING = "/setting",
|
||||
|
|
@ -118,6 +120,14 @@ const router = createBrowserRouter([
|
|||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: Routes.CALENDAR,
|
||||
element: (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Calendar />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: Routes.INBOX,
|
||||
element: (
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ class MemoFilterState extends StandardState {
|
|||
filters: observable,
|
||||
shortcut: observable,
|
||||
hasActiveFilters: computed,
|
||||
setFilters: action,
|
||||
addFilter: action,
|
||||
removeFilter: action,
|
||||
removeFiltersByFactor: action,
|
||||
|
|
@ -75,6 +76,10 @@ class MemoFilterState extends StandardState {
|
|||
return this.filters.filter((f) => f.factor === factor);
|
||||
}
|
||||
|
||||
setFilters(filters: MemoFilter[]): void {
|
||||
this.filters = filters;
|
||||
}
|
||||
|
||||
addFilter(filter: MemoFilter): void {
|
||||
this.filters = uniqBy([...this.filters, filter], getMemoFilterKey);
|
||||
}
|
||||
|
|
@ -120,6 +125,7 @@ const memoFilterStore = (() => {
|
|||
return state.hasActiveFilters;
|
||||
},
|
||||
getFiltersByFactor: (factor: FilterFactor): MemoFilter[] => state.getFiltersByFactor(factor),
|
||||
setFilters: (filters: MemoFilter[]): void => state.setFilters(filters),
|
||||
addFilter: (filter: MemoFilter): void => state.addFilter(filter),
|
||||
removeFilter: (predicate: (f: MemoFilter) => boolean): void => state.removeFilter(predicate),
|
||||
removeFiltersByFactor: (factor: FilterFactor): void => state.removeFiltersByFactor(factor),
|
||||
|
|
|
|||
Loading…
Reference in New Issue