mirror of https://github.com/usememos/memos.git
refactor: polish ActivityCalendar components with modern design
This commit is contained in:
parent
5d677828a6
commit
b826e90276
|
|
@ -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";
|
||||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue