Skip to content

Commit ea3371b

Browse files
committed
chore: add ActivityCalendar components
1 parent 78aa413 commit ea3371b

File tree

12 files changed

+212
-169
lines changed

12 files changed

+212
-169
lines changed
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
2+
import { useMemo } from "react";
3+
import { Button } from "@/components/ui/button";
4+
import { useTranslate } from "@/utils/i18n";
5+
6+
interface CalendarHeaderProps {
7+
selectedYear: number;
8+
onYearChange: (year: number) => void;
9+
canGoPrev: boolean;
10+
canGoNext: boolean;
11+
}
12+
13+
export const CalendarHeader = ({ selectedYear, onYearChange, canGoPrev, canGoNext }: CalendarHeaderProps) => {
14+
const t = useTranslate();
15+
const currentYear = useMemo(() => new Date().getFullYear(), []);
16+
const isCurrentYear = selectedYear === currentYear;
17+
18+
const handlePrevYear = () => {
19+
if (canGoPrev) {
20+
onYearChange(selectedYear - 1);
21+
}
22+
};
23+
24+
const handleNextYear = () => {
25+
if (canGoNext) {
26+
onYearChange(selectedYear + 1);
27+
}
28+
};
29+
30+
const handleToday = () => {
31+
onYearChange(currentYear);
32+
};
33+
34+
return (
35+
<div className="flex items-center justify-between pb-2">
36+
<h2 className="text-2xl font-bold text-foreground tracking-tight leading-none">{selectedYear}</h2>
37+
38+
<div className="inline-flex items-center gap-2 shrink-0">
39+
<Button
40+
variant="ghost"
41+
size="icon"
42+
onClick={handlePrevYear}
43+
disabled={!canGoPrev}
44+
aria-label="Previous year"
45+
className="rounded-full hover:bg-accent/50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
46+
>
47+
<ChevronLeftIcon />
48+
</Button>
49+
50+
<Button
51+
variant={isCurrentYear ? "secondary" : "ghost"}
52+
onClick={handleToday}
53+
disabled={isCurrentYear}
54+
aria-label={t("common.today")}
55+
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"
56+
>
57+
{t("common.today")}
58+
</Button>
59+
60+
<Button
61+
variant="ghost"
62+
size="icon"
63+
onClick={handleNextYear}
64+
disabled={!canGoNext}
65+
aria-label="Next year"
66+
className="rounded-full hover:bg-accent/50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
67+
>
68+
<ChevronRightIcon />
69+
</Button>
70+
</div>
71+
</div>
72+
);
73+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { useMemo } from "react";
2+
import { calculateYearMaxCount, filterDataByYear, generateMonthsForYear } from "@/components/ActivityCalendar";
3+
import { TooltipProvider } from "@/components/ui/tooltip";
4+
import { cn } from "@/lib/utils";
5+
import { CalendarHeader } from "./CalendarHeader";
6+
import { getMaxYear, MIN_YEAR } from "./constants";
7+
import { MonthCard } from "./MonthCard";
8+
import type { CalendarPopoverProps } from "./types";
9+
10+
export const CalendarPopover = ({ selectedYear, data, onYearChange, onDateClick, className }: CalendarPopoverProps) => {
11+
const yearData = useMemo(() => filterDataByYear(data, selectedYear), [data, selectedYear]);
12+
const months = useMemo(() => generateMonthsForYear(selectedYear), [selectedYear]);
13+
const yearMaxCount = useMemo(() => calculateYearMaxCount(yearData), [yearData]);
14+
const canGoPrev = selectedYear > MIN_YEAR;
15+
const canGoNext = selectedYear < getMaxYear();
16+
17+
return (
18+
<div className={cn("w-full max-w-4xl flex flex-col gap-3 p-3", className)}>
19+
<CalendarHeader selectedYear={selectedYear} onYearChange={onYearChange} canGoPrev={canGoPrev} canGoNext={canGoNext} />
20+
21+
<TooltipProvider>
22+
<div className="w-full animate-fade-in">
23+
<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">
24+
{months.map((month) => (
25+
<MonthCard key={month} month={month} data={yearData} maxCount={yearMaxCount} onClick={onDateClick} />
26+
))}
27+
</div>
28+
</div>
29+
</TooltipProvider>
30+
</div>
31+
);
32+
};
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { CompactMonthCalendar, getMonthLabel } from "@/components/ActivityCalendar";
2+
import { cn } from "@/lib/utils";
3+
import type { MonthCardProps } from "./types";
4+
5+
export const MonthCard = ({ month, data, maxCount, onClick, className }: MonthCardProps) => {
6+
return (
7+
<div
8+
className={cn(
9+
"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",
10+
className,
11+
)}
12+
>
13+
<div className="text-xs font-semibold text-foreground text-center tracking-tight">{getMonthLabel(month)}</div>
14+
<CompactMonthCalendar month={month} data={data} maxCount={maxCount} size="small" onClick={onClick} />
15+
</div>
16+
);
17+
};

web/src/components/ActivityCalendar/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ export const MONTHS_IN_YEAR = 12;
33
export const WEEKEND_DAYS = [0, 6] as const;
44
export const MIN_COUNT = 1;
55

6+
export const MIN_YEAR = 2000;
7+
export const getMaxYear = () => new Date().getFullYear() + 1;
8+
69
export const INTENSITY_THRESHOLDS = {
710
HIGH: 0.75,
811
MEDIUM: 0.5,

web/src/components/ActivityCalendar/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
export { ActivityCalendar as default } from "./ActivityCalendar";
22
export { CalendarCell, type CalendarCellProps } from "./CalendarCell";
3+
export { CalendarHeader } from "./CalendarHeader";
4+
export { CalendarPopover } from "./CalendarPopover";
35
export { CompactMonthCalendar } from "./CompactMonthCalendar";
46
export * from "./constants";
7+
export { MonthCard } from "./MonthCard";
58
export { getTooltipText, type TranslateFunction, useTodayDate, useWeekdayLabels } from "./shared";
69
export type {
710
CalendarDayCell,
811
CalendarDayRow,
912
CalendarMatrixResult,
13+
CalendarPopoverProps,
1014
CalendarSize,
1115
CompactMonthCalendarProps,
16+
MonthCardProps,
1217
} from "./types";
1318
export { type UseCalendarMatrixParams, useCalendarMatrix } from "./useCalendarMatrix";
1419
export {

web/src/components/ActivityCalendar/types.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,19 @@ export interface CompactMonthCalendarProps {
2727
size?: CalendarSize;
2828
onClick?: (date: string) => void;
2929
}
30+
31+
export interface MonthCardProps {
32+
month: string;
33+
data: Record<string, number>;
34+
maxCount: number;
35+
onClick?: (date: string) => void;
36+
className?: string;
37+
}
38+
39+
export interface CalendarPopoverProps {
40+
selectedYear: number;
41+
data: Record<string, number>;
42+
onYearChange: (year: number) => void;
43+
onDateClick: (date: string) => void;
44+
className?: string;
45+
}

web/src/components/Navigation.tsx

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { BellIcon, CalendarIcon, EarthIcon, LibraryIcon, PaperclipIcon, UserCircleIcon } from "lucide-react";
1+
import { BellIcon, EarthIcon, LibraryIcon, PaperclipIcon, UserCircleIcon } from "lucide-react";
22
import { NavLink } from "react-router-dom";
33
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
44
import useCurrentUser from "@/hooks/useCurrentUser";
@@ -34,12 +34,6 @@ const Navigation = (props: Props) => {
3434
title: t("common.memos"),
3535
icon: <LibraryIcon className="w-6 h-auto shrink-0" />,
3636
};
37-
const calendarNavLink: NavLinkItem = {
38-
id: "header-calendar",
39-
path: Routes.CALENDAR,
40-
title: t("common.calendar"),
41-
icon: <CalendarIcon className="w-6 h-auto shrink-0" />,
42-
};
4337
const exploreNavLink: NavLinkItem = {
4438
id: "header-explore",
4539
path: Routes.EXPLORE,
@@ -76,7 +70,7 @@ const Navigation = (props: Props) => {
7670
};
7771

7872
const navLinks: NavLinkItem[] = currentUser
79-
? [homeNavLink, calendarNavLink, exploreNavLink, attachmentsNavLink, inboxNavLink]
73+
? [homeNavLink, exploreNavLink, attachmentsNavLink, inboxNavLink]
8074
: [exploreNavLink, signInNavLink];
8175

8276
return (

web/src/components/StatisticsView/MonthNavigator.tsx

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,58 @@
1-
import dayjs from "dayjs";
21
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
2+
import { useState } from "react";
3+
import { CalendarPopover } from "@/components/ActivityCalendar";
4+
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
5+
import useCurrentUser from "@/hooks/useCurrentUser";
6+
import { useFilteredMemoStats } from "@/hooks/useFilteredMemoStats";
37
import i18n from "@/i18n";
8+
import { addMonths, formatMonth, getMonthFromDate, getYearFromDate, setYearAndMonth } from "@/lib/calendar-utils";
49
import type { MonthNavigatorProps } from "@/types/statistics";
510

611
export const MonthNavigator = ({ visibleMonth, onMonthChange }: MonthNavigatorProps) => {
7-
const currentMonth = dayjs(visibleMonth).toDate();
12+
const currentUser = useCurrentUser();
13+
const [isOpen, setIsOpen] = useState(false);
14+
const currentMonth = new Date(visibleMonth);
15+
const currentYear = getYearFromDate(visibleMonth);
16+
const currentMonthNum = getMonthFromDate(visibleMonth);
17+
18+
const { statistics } = useFilteredMemoStats({
19+
userName: currentUser?.name,
20+
});
821

922
const handlePrevMonth = () => {
10-
onMonthChange(dayjs(visibleMonth).subtract(1, "month").format("YYYY-MM"));
23+
onMonthChange(addMonths(visibleMonth, -1));
1124
};
1225

1326
const handleNextMonth = () => {
14-
onMonthChange(dayjs(visibleMonth).add(1, "month").format("YYYY-MM"));
27+
onMonthChange(addMonths(visibleMonth, 1));
28+
};
29+
30+
const handleDateClick = (date: string) => {
31+
onMonthChange(formatMonth(date));
32+
setIsOpen(false);
33+
};
34+
35+
const handleYearChange = (year: number) => {
36+
onMonthChange(setYearAndMonth(year, currentMonthNum));
1537
};
1638

1739
return (
1840
<div className="w-full mb-1 flex flex-row justify-between items-center gap-1">
19-
<span className="relative text-sm text-muted-foreground">
20-
{currentMonth.toLocaleString(i18n.language, { year: "numeric", month: "long" })}
21-
</span>
41+
<Popover open={isOpen} onOpenChange={setIsOpen}>
42+
<PopoverTrigger asChild>
43+
<span className="relative text-sm text-muted-foreground cursor-pointer hover:text-foreground transition-colors">
44+
{currentMonth.toLocaleString(i18n.language, { year: "numeric", month: "long" })}
45+
</span>
46+
</PopoverTrigger>
47+
<PopoverContent className="p-0" align="start">
48+
<CalendarPopover
49+
selectedYear={currentYear}
50+
data={statistics.activityStats}
51+
onYearChange={handleYearChange}
52+
onDateClick={handleDateClick}
53+
/>
54+
</PopoverContent>
55+
</Popover>
2256
<div className="flex justify-end items-center shrink-0 gap-1">
2357
<button className="cursor-pointer hover:opacity-80 transition-opacity" onClick={handlePrevMonth} aria-label="Previous month">
2458
<ChevronLeftIcon className="w-5 h-auto shrink-0 opacity-40" />

web/src/lib/calendar-utils.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import dayjs from "dayjs";
2+
3+
export const MONTH_DATE_FORMAT = "YYYY-MM" as const;
4+
5+
export const formatMonth = (date: Date | string): string => {
6+
return dayjs(date).format(MONTH_DATE_FORMAT);
7+
};
8+
9+
export const getYearFromDate = (date: Date | string): number => {
10+
return dayjs(date).year();
11+
};
12+
13+
export const getMonthFromDate = (date: Date | string): number => {
14+
return dayjs(date).month();
15+
};
16+
17+
export const addMonths = (date: Date | string, count: number): string => {
18+
return dayjs(date).add(count, "month").format(MONTH_DATE_FORMAT);
19+
};
20+
21+
export const setYearAndMonth = (year: number, month: number): string => {
22+
return dayjs().year(year).month(month).format(MONTH_DATE_FORMAT);
23+
};

0 commit comments

Comments
 (0)