diff --git a/web/src/components/ActivityCalendar/ActivityCalendar.tsx b/web/src/components/ActivityCalendar/ActivityCalendar.tsx index 8a230d64e..64469f431 100644 --- a/web/src/components/ActivityCalendar/ActivityCalendar.tsx +++ b/web/src/components/ActivityCalendar/ActivityCalendar.tsx @@ -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 ( -
-
+
+
{weekDays.map((label, index) => ( -
+
{label}
))}
-
+
{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 ( 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) => { ); - if (!tooltipText) { + const shouldShowTooltip = tooltipText && day.count > 0; + + if (!shouldShowTooltip) { return button; } diff --git a/web/src/components/ActivityCalendar/CompactMonthCalendar.tsx b/web/src/components/ActivityCalendar/CompactMonthCalendar.tsx new file mode 100644 index 000000000..bf070a22a --- /dev/null +++ b/web/src/components/ActivityCalendar/CompactMonthCalendar.tsx @@ -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 ( +
+ {weeks.map((week, weekIndex) => + week.days.map((day, dayIndex) => { + const tooltipText = getTooltipText(day.count, day.date, t); + + return ( + + ); + }), + )} +
+ ); + }), +); + +CompactMonthCalendar.displayName = "CompactMonthCalendar"; diff --git a/web/src/components/ActivityCalendar/constants.ts b/web/src/components/ActivityCalendar/constants.ts new file mode 100644 index 000000000..0c24e882c --- /dev/null +++ b/web/src/components/ActivityCalendar/constants.ts @@ -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; diff --git a/web/src/components/ActivityCalendar/index.ts b/web/src/components/ActivityCalendar/index.ts index 925dcc7bb..bb1fd2055 100644 --- a/web/src/components/ActivityCalendar/index.ts +++ b/web/src/components/ActivityCalendar/index.ts @@ -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"; diff --git a/web/src/components/ActivityCalendar/shared.ts b/web/src/components/ActivityCalendar/shared.ts new file mode 100644 index 000000000..5b9aa5ef7 --- /dev/null +++ b/web/src/components/ActivityCalendar/shared.ts @@ -0,0 +1,26 @@ +import dayjs from "dayjs"; +import { useMemo } from "react"; +import { useTranslate } from "@/utils/i18n"; + +export type TranslateFunction = ReturnType; + +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(); +}; diff --git a/web/src/components/ActivityCalendar/types.ts b/web/src/components/ActivityCalendar/types.ts index 0a23d1b32..9b6366d12 100644 --- a/web/src/components/ActivityCalendar/types.ts +++ b/web/src/components/ActivityCalendar/types.ts @@ -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; + maxCount: number; + size?: CalendarSize; + onClick?: (date: string) => void; +} diff --git a/web/src/components/ActivityCalendar/useCalendarMatrix.ts b/web/src/components/ActivityCalendar/useCalendarMatrix.ts index 7ca65aac2..0fae3651c 100644 --- a/web/src/components/ActivityCalendar/useCalendarMatrix.ts +++ b/web/src/components/ActivityCalendar/useCalendarMatrix.ts @@ -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; weekDays: string[]; @@ -11,6 +12,39 @@ interface UseCalendarMatrixParams { selectedDate: string; } +const createCalendarDayCell = ( + current: dayjs.Dayjs, + monthKey: string, + data: Record, + 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]); }; diff --git a/web/src/components/ActivityCalendar/utils.ts b/web/src/components/ActivityCalendar/utils.ts index 4e7548c6f..a5a20e089 100644 --- a/web/src/components/ActivityCalendar/utils.ts +++ b/web/src/components/ActivityCalendar/utils.ts @@ -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): 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, year: number): Record => { + if (!data) return {}; + + const filtered: Record = {}; + 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): boolean => { + return Object.values(data).some((count) => count > 0); +}; diff --git a/web/src/components/MemoFilters.tsx b/web/src/components/MemoFilters.tsx index 60a4b1b1c..83775f26b 100644 --- a/web/src/components/MemoFilters.tsx +++ b/web/src/components/MemoFilters.tsx @@ -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 = { 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) => { diff --git a/web/src/components/Navigation.tsx b/web/src/components/Navigation.tsx index 435ac2bec..d3b08b73c 100644 --- a/web/src/components/Navigation.tsx +++ b/web/src/components/Navigation.tsx @@ -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: , }; + const calendarNavLink: NavLinkItem = { + id: "header-calendar", + path: Routes.CALENDAR, + title: t("common.calendar"), + icon: , + }; 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 ( diff --git a/web/src/components/StatisticsView/StatisticsView.tsx b/web/src/components/StatisticsView/StatisticsView.tsx index 59091107a..138ce885e 100644 --- a/web/src/components/StatisticsView/StatisticsView.tsx +++ b/web/src/components/StatisticsView/StatisticsView.tsx @@ -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 (
- +
); diff --git a/web/src/hooks/index.ts b/web/src/hooks/index.ts index bfd97a59b..4856dc193 100644 --- a/web/src/hooks/index.ts +++ b/web/src/hooks/index.ts @@ -1,5 +1,6 @@ export * from "./useAsyncEffect"; export * from "./useCurrentUser"; +export * from "./useDateFilterNavigation"; export * from "./useFilteredMemoStats"; export * from "./useLoading"; export * from "./useMemoFilters"; diff --git a/web/src/hooks/useDateFilterNavigation.ts b/web/src/hooks/useDateFilterNavigation.ts new file mode 100644 index 000000000..169f98bc6 --- /dev/null +++ b/web/src/hooks/useDateFilterNavigation.ts @@ -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; +}; diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 757bc0db4..2f494e882 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -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", diff --git a/web/src/pages/Calendar.tsx b/web/src/pages/Calendar.tsx new file mode 100644 index 000000000..4c322a98c --- /dev/null +++ b/web/src/pages/Calendar.tsx @@ -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 ( +
+ +
+
+
+

{selectedYear}

+ +
+ + + + + +
+
+ + {loading ? ( +
+
+
+

Loading calendar...

+
+
+ ) : ( + +
+
+ {months.map((month) => ( +
+
{getMonthLabel(month)}
+ +
+ ))} +
+
+
+ )} +
+
+
+ ); +}); + +export default Calendar; diff --git a/web/src/router/index.tsx b/web/src/router/index.tsx index e2456c612..017665877 100644 --- a/web/src/router/index.tsx +++ b/web/src/router/index.tsx @@ -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([ ), }, + { + path: Routes.CALENDAR, + element: ( + }> + + + ), + }, { path: Routes.INBOX, element: ( diff --git a/web/src/store/memoFilter.ts b/web/src/store/memoFilter.ts index eb860d898..49e3a884e 100644 --- a/web/src/store/memoFilter.ts +++ b/web/src/store/memoFilter.ts @@ -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),