refactor: polish ActivityCalendar components with modern design

This commit is contained in:
Johnny 2025-12-29 23:40:35 +08:00
parent 5d677828a6
commit b826e90276
17 changed files with 193 additions and 329 deletions

View File

@ -1,63 +0,0 @@
import dayjs from "dayjs";
import { memo, useMemo } from "react";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useInstance } from "@/contexts/InstanceContext";
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((props: ActivityCalendarProps) => {
const t = useTranslate();
const { month, selectedDate, data, onClick } = props;
const { generalSetting } = useInstance();
const weekStartDayOffset = generalSetting.weekStartDayOffset;
const today = useTodayDate();
const weekDaysRaw = useWeekdayLabels();
const selectedDateFormatted = useMemo(() => dayjs(selectedDate).format("YYYY-MM-DD"), [selectedDate]);
const { weeks, weekDays, maxCount } = useCalendarMatrix({
month,
data,
weekDays: weekDaysRaw,
weekStartDayOffset,
today,
selectedDate: selectedDateFormatted,
});
return (
<TooltipProvider>
<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-4 items-center justify-center text-muted-foreground/80">
{label}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-0.5">
{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}
/>
);
}),
)}
</div>
</div>
</TooltipProvider>
);
});
ActivityCalendar.displayName = "ActivityCalendar";

View File

@ -26,7 +26,7 @@ export const CalendarCell = memo((props: CalendarCellProps) => {
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",
"aspect-square w-full flex items-center justify-center text-center transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:ring-offset-2 select-none",
sizeConfig.font,
sizeConfig.borderRadius,
smallExtraClasses,
@ -35,23 +35,17 @@ export const CalendarCell = memo((props: CalendarCellProps) => {
const ariaLabel = day.isSelected ? `${tooltipText} (selected)` : tooltipText;
if (!day.isCurrentMonth) {
return (
<div className={cn(baseClasses, "border-transparent text-muted-foreground/60 bg-transparent pointer-events-none opacity-80")}>
{day.label}
</div>
);
return <div className={cn(baseClasses, "text-muted-foreground/30 bg-transparent cursor-default")}>{day.label}</div>;
}
const intensityClass = getCellIntensityClass(day, maxCount);
const buttonClasses = cn(
baseClasses,
"border-transparent text-muted-foreground",
(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",
day.isToday && "ring-2 ring-primary/30 ring-offset-1 font-semibold z-10",
day.isSelected && "ring-2 ring-primary ring-offset-1 font-bold z-10",
isInteractive ? "cursor-pointer hover:scale-110 hover:shadow-md hover:z-20" : "cursor-default",
);
const button = (

View File

@ -1,73 +0,0 @@
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

@ -1,32 +0,0 @@
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

@ -4,12 +4,13 @@ import { cn } from "@/lib/utils";
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";
import { useTodayDate, useWeekdayLabels } from "./hooks";
import type { MonthCalendarProps } from "./types";
import { useCalendarMatrix } from "./useCalendar";
import { getTooltipText } from "./utils";
export const CompactMonthCalendar = memo((props: CompactMonthCalendarProps) => {
const { month, data, maxCount, size = "default", onClick } = props;
export const MonthCalendar = memo((props: MonthCalendarProps) => {
const { month, data, maxCount, size = "default", onClick, className } = props;
const t = useTranslate();
const { generalSetting } = useInstance();
@ -30,10 +31,10 @@ export const CompactMonthCalendar = memo((props: CompactMonthCalendarProps) => {
const sizeConfig = size === "small" ? SMALL_CELL_SIZE : DEFAULT_CELL_SIZE;
return (
<div className="flex flex-col gap-1">
<div className={cn("grid grid-cols-7 gap-0.5 text-muted-foreground", size === "small" ? "text-[10px]" : "text-xs")}>
<div className={cn("flex flex-col gap-2", className)}>
<div className={cn("grid grid-cols-7", sizeConfig.gap, "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/50">
<div key={index} className="flex h-4 items-center justify-center text-muted-foreground/60 font-medium">
{label}
</div>
))}
@ -61,4 +62,4 @@ export const CompactMonthCalendar = memo((props: CompactMonthCalendarProps) => {
);
});
CompactMonthCalendar.displayName = "CompactMonthCalendar";
MonthCalendar.displayName = "MonthCalendar";

View File

@ -1,17 +0,0 @@
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

@ -0,0 +1,93 @@
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { useMemo } from "react";
import {
calculateYearMaxCount,
filterDataByYear,
generateMonthsForYear,
getMonthLabel,
MonthCalendar,
} from "@/components/ActivityCalendar";
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 type { YearCalendarProps } from "./types";
export const YearCalendar = ({ selectedYear, data, onYearChange, onDateClick, className }: YearCalendarProps) => {
const t = useTranslate();
const currentYear = useMemo(() => new Date().getFullYear(), []);
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();
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 p-2 md:p-0 select-none", className)}>
<div className="flex items-center justify-between pb-4 px-2 pt-2">
<h2 className="text-3xl font-bold text-foreground tracking-tight leading-none">{selectedYear}</h2>
<div className="inline-flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={handlePrevYear}
disabled={!canGoPrev}
aria-label="Previous year"
className="h-9 w-9 p-0 rounded-md hover:bg-secondary/80 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-9 px-4 rounded-md text-sm font-medium transition-colors",
isCurrentYear ? "bg-secondary/50 text-muted-foreground cursor-default" : "hover:bg-secondary/80 text-foreground",
)}
>
{t("common.today")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleNextYear}
disabled={!canGoNext}
aria-label="Next year"
className="h-9 w-9 p-0 rounded-md hover:bg-secondary/80 text-muted-foreground hover:text-foreground"
>
<ChevronRightIcon className="w-5 h-5" />
</Button>
</div>
</div>
<TooltipProvider>
<div className="w-full animate-fade-in">
<div className="grid gap-6 grid-cols-1 sm:grid-cols-2 md:grid-cols-3">
{months.map((month) => (
<div
key={month}
className="flex flex-col gap-3 rounded-lg p-3 hover:bg-secondary/40 transition-colors cursor-default border border-transparent hover:border-border/50"
>
<div className="text-xs font-bold text-foreground/80 uppercase tracking-widest pl-1">{getMonthLabel(month)}</div>
<MonthCalendar month={month} data={yearData} maxCount={yearMaxCount} size="small" onClick={onDateClick} />
</div>
))}
</div>
</div>
</TooltipProvider>
</div>
);
};

View File

@ -13,15 +13,23 @@ export const INTENSITY_THRESHOLDS = {
MINIMAL: 0,
} as const;
export const CELL_STYLES = {
HIGH: "bg-primary text-primary-foreground shadow-sm",
MEDIUM: "bg-primary/80 text-primary-foreground shadow-sm",
LOW: "bg-primary/60 text-primary-foreground shadow-sm",
MINIMAL: "bg-primary/40 text-foreground",
EMPTY: "bg-secondary/30 text-muted-foreground hover:bg-secondary/50",
} as const;
export const SMALL_CELL_SIZE = {
font: "text-[10px]",
dimensions: "max-w-6 max-h-6",
borderRadius: "rounded-sm",
gap: "gap-px",
font: "text-xs",
dimensions: "w-8 h-8 mx-auto",
borderRadius: "rounded-md",
gap: "gap-1",
} as const;
export const DEFAULT_CELL_SIZE = {
font: "text-xs",
borderRadius: "rounded",
gap: "gap-0.5",
borderRadius: "rounded-md",
gap: "gap-1.5",
} as const;

View File

@ -2,8 +2,6 @@ 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]);
@ -12,15 +10,3 @@ export const useWeekdayLabels = () => {
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,26 +1,4 @@
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 {
calculateYearMaxCount,
filterDataByYear,
generateMonthsForYear,
getCellIntensityClass,
getMonthLabel,
hasActivityData,
} from "./utils";
export * from "./MonthCalendar";
export * from "./types";
export * from "./utils";
export * from "./YearCalendar";

View File

@ -20,23 +20,16 @@ export interface CalendarMatrixResult {
maxCount: number;
}
export interface CompactMonthCalendarProps {
export interface MonthCalendarProps {
month: string;
data: Record<string, number>;
maxCount: number;
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 {
export interface YearCalendarProps {
selectedYear: number;
data: Record<string, number>;
onYearChange: (year: number) => void;

View File

@ -45,6 +45,9 @@ const calculateCalendarBoundaries = (monthStart: dayjs.Dayjs, weekStartDayOffset
return { calendarStart, dayCount };
};
/**
* Generates a matrix of calendar days for a given month, handling week alignment and data mapping.
*/
export const useCalendarMatrix = ({
month,
data,
@ -54,16 +57,20 @@ export const useCalendarMatrix = ({
selectedDate,
}: UseCalendarMatrixParams): CalendarMatrixResult => {
return useMemo(() => {
// Determine the start of the month and its formatted key (YYYY-MM)
const monthStart = dayjs(month).startOf("month");
const monthKey = monthStart.format("YYYY-MM");
// Rotate week labels based on the user's preferred start of the week
const rotatedWeekDays = weekDays.slice(weekStartDayOffset).concat(weekDays.slice(0, weekStartDayOffset));
// Calculate the start and end dates for the calendar grid to ensure full weeks
const { calendarStart, dayCount } = calculateCalendarBoundaries(monthStart, weekStartDayOffset);
const weeks: CalendarMatrixResult["weeks"] = [];
let maxCount = 0;
// Iterate through each day in the calendar grid
for (let index = 0; index < dayCount; index += 1) {
const current = calendarStart.add(index, "day");
const weekIndex = Math.floor(index / DAYS_IN_WEEK);
@ -72,6 +79,7 @@ export const useCalendarMatrix = ({
weeks[weekIndex] = { days: [] };
}
// Create the day cell object with data and status flags
const dayCell = createCalendarDayCell(current, monthKey, data, today, selectedDate);
weeks[weekIndex].days.push(dayCell);
maxCount = Math.max(maxCount, dayCell.count);

View File

@ -1,22 +1,25 @@
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 { useTranslate } from "@/utils/i18n";
import { CELL_STYLES, INTENSITY_THRESHOLDS, MIN_COUNT, MONTHS_IN_YEAR } from "./constants";
import type { CalendarDayCell } from "./types";
dayjs.extend(isSameOrAfter);
dayjs.extend(isSameOrBefore);
export type TranslateFunction = ReturnType<typeof useTranslate>;
export const getCellIntensityClass = (day: CalendarDayCell, maxCount: number): string => {
if (!day.isCurrentMonth || day.count === 0) {
return "bg-transparent";
return CELL_STYLES.EMPTY;
}
const ratio = day.count / maxCount;
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";
if (ratio > INTENSITY_THRESHOLDS.HIGH) return CELL_STYLES.HIGH;
if (ratio > INTENSITY_THRESHOLDS.MEDIUM) return CELL_STYLES.MEDIUM;
if (ratio > INTENSITY_THRESHOLDS.LOW) return CELL_STYLES.LOW;
return CELL_STYLES.MINIMAL;
};
export const generateMonthsForYear = (year: number): string[] => {
@ -32,7 +35,7 @@ export const calculateYearMaxCount = (data: Record<string, number>): number => {
};
export const getMonthLabel = (month: string): string => {
return dayjs(month).format("MMM YYYY");
return dayjs(month).format("MMM");
};
export const filterDataByYear = (data: Record<string, number>, year: number): Record<string, number> => {
@ -55,3 +58,15 @@ export const filterDataByYear = (data: Record<string, number>, year: number): Re
export const hasActivityData = (data: Record<string, number>): boolean => {
return Object.values(data).some((count) => count > 0);
};
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

@ -13,7 +13,6 @@ export interface MemoExplorerFeatures {
statistics?: boolean;
shortcuts?: boolean;
tags?: boolean;
statisticsContext?: MemoExplorerContext;
}
interface Props {
@ -32,7 +31,6 @@ const getDefaultFeatures = (context: MemoExplorerContext): MemoExplorerFeatures
statistics: true,
shortcuts: false, // Global explore doesn't use shortcuts
tags: true,
statisticsContext: "explore",
};
case "archived":
return {
@ -40,7 +38,6 @@ const getDefaultFeatures = (context: MemoExplorerContext): MemoExplorerFeatures
statistics: true,
shortcuts: false, // Archived doesn't typically use shortcuts
tags: true,
statisticsContext: "archived",
};
case "profile":
return {
@ -48,7 +45,6 @@ const getDefaultFeatures = (context: MemoExplorerContext): MemoExplorerFeatures
statistics: true,
shortcuts: false, // Profile view doesn't use shortcuts
tags: true,
statisticsContext: "profile",
};
case "home":
default:
@ -57,7 +53,6 @@ const getDefaultFeatures = (context: MemoExplorerContext): MemoExplorerFeatures
statistics: true,
shortcuts: true,
tags: true,
statisticsContext: "home",
};
}
};
@ -81,7 +76,7 @@ const MemoExplorer = (props: Props) => {
>
{features.search && <SearchBar />}
<div className="mt-1 px-1 w-full">
{features.statistics && <StatisticsView context={features.statisticsContext} statisticsData={statisticsData} />}
{features.statistics && <StatisticsView statisticsData={statisticsData} />}
{features.shortcuts && currentUser && <ShortcutsSection />}
{features.tags && <TagsSection readonly={context === "explore"} tagCount={tagCount} />}
</div>

View File

@ -1,24 +1,17 @@
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { ChevronDownIcon, 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 { YearCalendar } from "@/components/ActivityCalendar";
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 }: MonthNavigatorProps) => {
const currentUser = useCurrentUser();
export const MonthNavigator = ({ visibleMonth, onMonthChange, activityStats }: MonthNavigatorProps) => {
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(addMonths(visibleMonth, -1));
};
@ -37,28 +30,33 @@ export const MonthNavigator = ({ visibleMonth, onMonthChange }: MonthNavigatorPr
};
return (
<div className="w-full mb-1 flex flex-row justify-between items-center gap-1">
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<span className="relative text-sm text-muted-foreground cursor-pointer hover:text-foreground transition-colors">
<div className="w-full mb-2 flex flex-row justify-between items-center gap-1">
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<button className="px-2 py-1 -ml-2 rounded-md hover:bg-secondary/50 text-sm text-foreground font-semibold transition-colors flex items-center gap-1 select-none group">
{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" />
<ChevronDownIcon className="w-3.5 h-3.5 text-muted-foreground group-hover:text-foreground transition-colors" />
</button>
</DialogTrigger>
<DialogContent className="p-0 border-none bg-background md:max-w-4xl" size="2xl" showCloseButton={false}>
<DialogTitle className="sr-only">Select Month</DialogTitle>
<YearCalendar selectedYear={currentYear} data={activityStats} onYearChange={handleYearChange} onDateClick={handleDateClick} />
</DialogContent>
</Dialog>
<div className="flex justify-end items-center shrink-0 gap-0.5">
<button
className="p-1 rounded-md hover:bg-secondary/50 text-muted-foreground hover:text-foreground transition-all"
onClick={handlePrevMonth}
aria-label="Previous month"
>
<ChevronLeftIcon className="w-4 h-4" />
</button>
<button className="cursor-pointer hover:opacity-80 transition-opacity" onClick={handleNextMonth} aria-label="Next month">
<ChevronRightIcon className="w-5 h-auto shrink-0 opacity-40" />
<button
className="p-1 rounded-md hover:bg-secondary/50 text-muted-foreground hover:text-foreground transition-all"
onClick={handleNextMonth}
aria-label="Next month"
>
<ChevronRightIcon className="w-4 h-4" />
</button>
</div>
</div>

View File

@ -1,14 +1,11 @@
import dayjs from "dayjs";
import { useMemo, useState } from "react";
import { CompactMonthCalendar } from "@/components/ActivityCalendar";
import { MonthCalendar } from "@/components/ActivityCalendar";
import { useDateFilterNavigation } from "@/hooks";
import type { StatisticsData } from "@/types/statistics";
import { MonthNavigator } from "./MonthNavigator";
export type StatisticsViewContext = "home" | "explore" | "archived" | "profile";
interface Props {
context?: StatisticsViewContext;
statisticsData: StatisticsData;
}
@ -24,11 +21,11 @@ const StatisticsView = (props: Props) => {
}, [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="group w-full mt-2 flex flex-col gap-1 text-muted-foreground animate-fade-in">
<MonthNavigator visibleMonth={visibleMonthString} onMonthChange={setVisibleMonthString} activityStats={activityStats} />
<div className="w-full animate-scale-in">
<CompactMonthCalendar month={visibleMonthString} data={activityStats} maxCount={maxCount} onClick={navigateToDateFilter} />
<MonthCalendar month={visibleMonthString} data={activityStats} maxCount={maxCount} onClick={navigateToDateFilter} />
</div>
</div>
);

View File

@ -1,14 +1,3 @@
export interface ActivityData {
date: string;
count: number;
}
export interface CalendarDay {
day: number;
isCurrentMonth: boolean;
date?: string;
}
export interface StatisticsViewProps {
className?: string;
}
@ -16,13 +5,7 @@ export interface StatisticsViewProps {
export interface MonthNavigatorProps {
visibleMonth: string;
onMonthChange: (month: string) => void;
}
export interface ActivityCalendarProps {
month: string;
selectedDate: string;
data: Record<string, number>;
onClick?: (date: string) => void;
activityStats: Record<string, number>;
}
export interface StatisticsData {