chore: add ActivityCalendar components

This commit is contained in:
Johnny 2025-12-28 19:59:36 +08:00
parent 78aa41336a
commit ea3371badb
12 changed files with 212 additions and 169 deletions

View File

@ -0,0 +1,73 @@
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { useMemo } from "react";
import { Button } from "@/components/ui/button";
import { useTranslate } from "@/utils/i18n";
interface CalendarHeaderProps {
selectedYear: number;
onYearChange: (year: number) => void;
canGoPrev: boolean;
canGoNext: boolean;
}
export const CalendarHeader = ({ selectedYear, onYearChange, canGoPrev, canGoNext }: CalendarHeaderProps) => {
const t = useTranslate();
const currentYear = useMemo(() => new Date().getFullYear(), []);
const isCurrentYear = selectedYear === currentYear;
const handlePrevYear = () => {
if (canGoPrev) {
onYearChange(selectedYear - 1);
}
};
const handleNextYear = () => {
if (canGoNext) {
onYearChange(selectedYear + 1);
}
};
const handleToday = () => {
onYearChange(currentYear);
};
return (
<div className="flex items-center justify-between pb-2">
<h2 className="text-2xl font-bold text-foreground tracking-tight leading-none">{selectedYear}</h2>
<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="bg-accent text-accent-foreground hover:bg-accent/50 text-muted-foreground hover:text-foreground h-9 px-4 rounded-full font-medium text-sm transition-colors cursor-default"
>
{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>
);
};

View File

@ -0,0 +1,32 @@
import { useMemo } from "react";
import { calculateYearMaxCount, filterDataByYear, generateMonthsForYear } from "@/components/ActivityCalendar";
import { TooltipProvider } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { CalendarHeader } from "./CalendarHeader";
import { getMaxYear, MIN_YEAR } from "./constants";
import { MonthCard } from "./MonthCard";
import type { CalendarPopoverProps } from "./types";
export const CalendarPopover = ({ selectedYear, data, onYearChange, onDateClick, className }: CalendarPopoverProps) => {
const yearData = useMemo(() => filterDataByYear(data, selectedYear), [data, selectedYear]);
const months = useMemo(() => generateMonthsForYear(selectedYear), [selectedYear]);
const yearMaxCount = useMemo(() => calculateYearMaxCount(yearData), [yearData]);
const canGoPrev = selectedYear > MIN_YEAR;
const canGoNext = selectedYear < getMaxYear();
return (
<div className={cn("w-full max-w-4xl flex flex-col gap-3 p-3", className)}>
<CalendarHeader selectedYear={selectedYear} onYearChange={onYearChange} canGoPrev={canGoPrev} canGoNext={canGoNext} />
<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) => (
<MonthCard key={month} month={month} data={yearData} maxCount={yearMaxCount} onClick={onDateClick} />
))}
</div>
</div>
</TooltipProvider>
</div>
);
};

View File

@ -0,0 +1,17 @@
import { CompactMonthCalendar, getMonthLabel } from "@/components/ActivityCalendar";
import { cn } from "@/lib/utils";
import type { MonthCardProps } from "./types";
export const MonthCard = ({ month, data, maxCount, onClick, className }: MonthCardProps) => {
return (
<div
className={cn(
"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",
className,
)}
>
<div className="text-xs font-semibold text-foreground text-center tracking-tight">{getMonthLabel(month)}</div>
<CompactMonthCalendar month={month} data={data} maxCount={maxCount} size="small" onClick={onClick} />
</div>
);
};

View File

@ -3,6 +3,9 @@ export const MONTHS_IN_YEAR = 12;
export const WEEKEND_DAYS = [0, 6] as const;
export const MIN_COUNT = 1;
export const MIN_YEAR = 2000;
export const getMaxYear = () => new Date().getFullYear() + 1;
export const INTENSITY_THRESHOLDS = {
HIGH: 0.75,
MEDIUM: 0.5,

View File

@ -1,14 +1,19 @@
export { ActivityCalendar as default } from "./ActivityCalendar";
export { CalendarCell, type CalendarCellProps } from "./CalendarCell";
export { CalendarHeader } from "./CalendarHeader";
export { CalendarPopover } from "./CalendarPopover";
export { CompactMonthCalendar } from "./CompactMonthCalendar";
export * from "./constants";
export { MonthCard } from "./MonthCard";
export { getTooltipText, type TranslateFunction, useTodayDate, useWeekdayLabels } from "./shared";
export type {
CalendarDayCell,
CalendarDayRow,
CalendarMatrixResult,
CalendarPopoverProps,
CalendarSize,
CompactMonthCalendarProps,
MonthCardProps,
} from "./types";
export { type UseCalendarMatrixParams, useCalendarMatrix } from "./useCalendarMatrix";
export {

View File

@ -27,3 +27,19 @@ export interface CompactMonthCalendarProps {
size?: CalendarSize;
onClick?: (date: string) => void;
}
export interface MonthCardProps {
month: string;
data: Record<string, number>;
maxCount: number;
onClick?: (date: string) => void;
className?: string;
}
export interface CalendarPopoverProps {
selectedYear: number;
data: Record<string, number>;
onYearChange: (year: number) => void;
onDateClick: (date: string) => void;
className?: string;
}

View File

@ -1,4 +1,4 @@
import { BellIcon, CalendarIcon, EarthIcon, LibraryIcon, PaperclipIcon, UserCircleIcon } from "lucide-react";
import { BellIcon, EarthIcon, LibraryIcon, PaperclipIcon, UserCircleIcon } from "lucide-react";
import { NavLink } from "react-router-dom";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import useCurrentUser from "@/hooks/useCurrentUser";
@ -34,12 +34,6 @@ const Navigation = (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,
@ -76,7 +70,7 @@ const Navigation = (props: Props) => {
};
const navLinks: NavLinkItem[] = currentUser
? [homeNavLink, calendarNavLink, exploreNavLink, attachmentsNavLink, inboxNavLink]
? [homeNavLink, exploreNavLink, attachmentsNavLink, inboxNavLink]
: [exploreNavLink, signInNavLink];
return (

View File

@ -1,24 +1,58 @@
import dayjs from "dayjs";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { useState } from "react";
import { CalendarPopover } from "@/components/ActivityCalendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useFilteredMemoStats } from "@/hooks/useFilteredMemoStats";
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 }: MonthNavigatorProps) => {
const currentMonth = dayjs(visibleMonth).toDate();
const currentUser = useCurrentUser();
const [isOpen, setIsOpen] = useState(false);
const currentMonth = new Date(visibleMonth);
const currentYear = getYearFromDate(visibleMonth);
const currentMonthNum = getMonthFromDate(visibleMonth);
const { statistics } = useFilteredMemoStats({
userName: currentUser?.name,
});
const handlePrevMonth = () => {
onMonthChange(dayjs(visibleMonth).subtract(1, "month").format("YYYY-MM"));
onMonthChange(addMonths(visibleMonth, -1));
};
const handleNextMonth = () => {
onMonthChange(dayjs(visibleMonth).add(1, "month").format("YYYY-MM"));
onMonthChange(addMonths(visibleMonth, 1));
};
const handleDateClick = (date: string) => {
onMonthChange(formatMonth(date));
setIsOpen(false);
};
const handleYearChange = (year: number) => {
onMonthChange(setYearAndMonth(year, currentMonthNum));
};
return (
<div className="w-full mb-1 flex flex-row justify-between items-center gap-1">
<span className="relative text-sm text-muted-foreground">
{currentMonth.toLocaleString(i18n.language, { year: "numeric", month: "long" })}
</span>
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<span className="relative text-sm text-muted-foreground cursor-pointer hover:text-foreground transition-colors">
{currentMonth.toLocaleString(i18n.language, { year: "numeric", month: "long" })}
</span>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<CalendarPopover
selectedYear={currentYear}
data={statistics.activityStats}
onYearChange={handleYearChange}
onDateClick={handleDateClick}
/>
</PopoverContent>
</Popover>
<div className="flex justify-end items-center shrink-0 gap-1">
<button className="cursor-pointer hover:opacity-80 transition-opacity" onClick={handlePrevMonth} aria-label="Previous month">
<ChevronLeftIcon className="w-5 h-auto shrink-0 opacity-40" />

View File

@ -0,0 +1,23 @@
import dayjs from "dayjs";
export const MONTH_DATE_FORMAT = "YYYY-MM" as const;
export const formatMonth = (date: Date | string): string => {
return dayjs(date).format(MONTH_DATE_FORMAT);
};
export const getYearFromDate = (date: Date | string): number => {
return dayjs(date).year();
};
export const getMonthFromDate = (date: Date | string): number => {
return dayjs(date).month();
};
export const addMonths = (date: Date | string, count: number): string => {
return dayjs(date).add(count, "month").format(MONTH_DATE_FORMAT);
};
export const setYearAndMonth = (year: number, month: number): string => {
return dayjs().year(year).month(month).format(MONTH_DATE_FORMAT);
};

View File

@ -1,144 +0,0 @@
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
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 = () => {
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,7 +9,6 @@ import Home from "@/pages/Home";
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"));
@ -115,14 +114,6 @@ const router = createBrowserRouter([
</Suspense>
),
},
{
path: Routes.CALENDAR,
element: (
<Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<Calendar />
</Suspense>
),
},
{
path: Routes.INBOX,
element: (

View File

@ -1,7 +1,6 @@
export const ROUTES = {
ROOT: "/",
ATTACHMENTS: "/attachments",
CALENDAR: "/calendar",
INBOX: "/inbox",
ARCHIVED: "/archived",
SETTING: "/setting",