From 882664d9d6f4177cc6b4bfb1c847f8cc1b3e57c1 Mon Sep 17 00:00:00 2001 From: johnnyjoygh Date: Wed, 2 Sep 2026 22:55:51 +0800 Subject: [PATCH] refactor(calendar): collapse the activity calendar to the month grid Only the month view is rendered anywhere, so drop the unused year calendar, the size variants and the helpers that existed for them. - Build the month grid in one place (monthDays.ts) with the week-start rotation and out-of-month padding, and test it. - Keep every chip visual in cellStyles.ts. A selected day now owns its fill outright so the empty-cell hover tint cannot swap the accent out. - MonthCalendar derives maxCount from its own data instead of taking it as a prop; the index exports only what callers use. --- .../ActivityCalendar/CalendarCell.tsx | 76 +++-------- .../ActivityCalendar/MonthCalendar.tsx | 99 ++++++-------- .../ActivityCalendar/YearCalendar.tsx | 122 ------------------ .../components/ActivityCalendar/cellStyles.ts | 42 ++++++ .../components/ActivityCalendar/constants.ts | 42 ------ web/src/components/ActivityCalendar/hooks.ts | 23 ---- web/src/components/ActivityCalendar/index.ts | 6 +- .../components/ActivityCalendar/monthDays.ts | 59 +++++++++ web/src/components/ActivityCalendar/types.ts | 31 +---- .../ActivityCalendar/useCalendar.ts | 90 ------------- web/src/components/ActivityCalendar/utils.ts | 61 +-------- .../StatisticsView/StatisticsView.tsx | 3 +- web/src/lib/calendar-utils.ts | 16 --- web/tests/calendar-month-days.test.ts | 44 +++++++ 14 files changed, 221 insertions(+), 493 deletions(-) delete mode 100644 web/src/components/ActivityCalendar/YearCalendar.tsx create mode 100644 web/src/components/ActivityCalendar/cellStyles.ts delete mode 100644 web/src/components/ActivityCalendar/constants.ts delete mode 100644 web/src/components/ActivityCalendar/hooks.ts create mode 100644 web/src/components/ActivityCalendar/monthDays.ts delete mode 100644 web/src/components/ActivityCalendar/useCalendar.ts create mode 100644 web/tests/calendar-month-days.test.ts diff --git a/web/src/components/ActivityCalendar/CalendarCell.tsx b/web/src/components/ActivityCalendar/CalendarCell.tsx index 0b2f1de4..3bde8207 100644 --- a/web/src/components/ActivityCalendar/CalendarCell.tsx +++ b/web/src/components/ActivityCalendar/CalendarCell.tsx @@ -1,89 +1,53 @@ import { memo } from "react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; -import { DEFAULT_CELL_SIZE, SMALL_CELL_SIZE } from "./constants"; -import type { CalendarDayCell, CalendarSize } from "./types"; -import { getCalendarCellStateClass, getCellIntensityClass } from "./utils"; +import { getChipClassName } from "./cellStyles"; +import type { CalendarDayCell } from "./types"; export interface CalendarCellProps { day: CalendarDayCell; maxCount: number; tooltipText: string; onClick?: (date: string) => void; - size?: CalendarSize; - disableTooltip?: boolean; } -export const CalendarCell = memo((props: CalendarCellProps) => { - const { day, maxCount, tooltipText, onClick, size = "default", disableTooltip = false } = props; +/** The cell spans its whole column and takes the pointer; the chip inside it carries the fill. */ +const CELL_CLASSES = "group/day flex w-full items-center justify-center select-none"; - const handleClick = () => { - if (onClick) { - onClick(day.date); - } - }; - - const sizeConfig = size === "small" ? SMALL_CELL_SIZE : DEFAULT_CELL_SIZE; - - // Two elements with two jobs: the cell spans its whole column and takes the pointer, the - // chip inside it is the square that carries the fill and sets the row's height. - const cellClasses = "group/day flex w-full items-center justify-center select-none"; - const chipClasses = cn( - "relative flex aspect-square w-full items-center justify-center text-center transition-[background-color,color,filter,box-shadow] duration-150 ease-out", - sizeConfig.font, - sizeConfig.borderRadius, - sizeConfig.maxSize, - ); +export const CalendarCell = memo(({ day, maxCount, tooltipText, onClick }: CalendarCellProps) => { const isInteractive = Boolean(onClick); - const ariaLabel = day.isSelected ? `${tooltipText} (selected)` : tooltipText; if (!day.isCurrentMonth) { return ( -
- {day.label} +
+ {day.label}
); } - const intensityClass = getCellIntensityClass(day, maxCount); - - const chip = ( - - {day.label} - {day.isToday && ( - - ); - const button = ( ); - const shouldShowTooltip = day.count > 0 && tooltipText && !disableTooltip; - - if (!shouldShowTooltip) { + if (day.count === 0) { return button; } diff --git a/web/src/components/ActivityCalendar/MonthCalendar.tsx b/web/src/components/ActivityCalendar/MonthCalendar.tsx index 60e01eaa..1cc58157 100644 --- a/web/src/components/ActivityCalendar/MonthCalendar.tsx +++ b/web/src/components/ActivityCalendar/MonthCalendar.tsx @@ -1,82 +1,67 @@ +import dayjs from "dayjs"; import { memo, useMemo } from "react"; import { useInstance } from "@/contexts/InstanceContext"; -import { cn } from "@/lib/utils"; import { useTranslate } from "@/utils/i18n"; import { CalendarCell } from "./CalendarCell"; -import { useTodayDate, useWeekdayLabels } from "./hooks"; -import type { CalendarSize, MonthCalendarProps } from "./types"; -import { useCalendarMatrix } from "./useCalendar"; -import { getTooltipText } from "./utils"; +import { rotateWeekdays, useMonthDays } from "./monthDays"; +import type { MonthCalendarProps } from "./types"; +import { calculateMaxCount, getTooltipText } from "./utils"; -const GRID_STYLES: Record = { - small: { gap: "gap-1", headerText: "text-[9px]" }, - default: { gap: "gap-1", headerText: "text-2xs" }, +const useWeekdayLabels = (weekStartDayOffset: number) => { + const t = useTranslate(); + return useMemo( + () => + rotateWeekdays( + [ + t("common.days.sun"), + t("common.days.mon"), + t("common.days.tue"), + t("common.days.wed"), + t("common.days.thu"), + t("common.days.fri"), + t("common.days.sat"), + ], + weekStartDayOffset, + ), + [t, weekStartDayOffset], + ); }; -interface WeekdayHeaderProps { - weekDays: string[]; - size: CalendarSize; -} - -const WeekdayHeader = memo(({ weekDays, size }: WeekdayHeaderProps) => ( -
- {weekDays.map((label, index) => ( -
- {Array.from(label)[0]} -
- ))} -
-)); -WeekdayHeader.displayName = "WeekdayHeader"; - -export const MonthCalendar = memo((props: MonthCalendarProps) => { - const { - month, - data, - maxCount, - size = "default", - onClick, - selectedDate, - className, - disableTooltips = false, - timeBasis = "create_time", - } = props; +export const MonthCalendar = memo(({ month, data, selectedDate, onClick, timeBasis = "create_time" }: MonthCalendarProps) => { const t = useTranslate(); const { generalSetting } = useInstance(); - const today = useTodayDate(); - const weekDays = useWeekdayLabels(); - const gridStyle = GRID_STYLES[size]; - - const { weeks, weekDays: rotatedWeekDays } = useCalendarMatrix({ + const weekDays = useWeekdayLabels(generalSetting.weekStartDayOffset); + const maxCount = useMemo(() => calculateMaxCount(data), [data]); + const days = useMonthDays({ month, data, - weekDays, weekStartDayOffset: generalSetting.weekStartDayOffset, - today, - selectedDate: selectedDate ?? "", + today: dayjs().format("YYYY-MM-DD"), + selectedDate, }); - const flatDays = useMemo(() => weeks.flatMap((week) => week.days), [weeks]); - return ( -
- +
+ {/* Every day button already announces its full date, so the initials are decoration. */} + -
- {flatDays.map((day) => ( +
+ {days.map((day) => ( ))}
diff --git a/web/src/components/ActivityCalendar/YearCalendar.tsx b/web/src/components/ActivityCalendar/YearCalendar.tsx deleted file mode 100644 index e731ed88..00000000 --- a/web/src/components/ActivityCalendar/YearCalendar.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; -import { memo, useMemo } from "react"; -import { Button } from "@/components/ui/button"; -import type { MemoTimeBasis } from "@/contexts/ViewContext"; -import { cn } from "@/lib/utils"; -import { useTranslate } from "@/utils/i18n"; -import { getMaxYear, MIN_YEAR } from "./constants"; -import { MonthCalendar } from "./MonthCalendar"; -import type { CalendarData, YearCalendarProps } from "./types"; -import { calculateMaxCount, filterDataByYear, generateMonthsForYear, getMonthLabel } from "./utils"; - -interface YearNavigationProps { - selectedYear: number; - currentYear: number; - onPrev: () => void; - onNext: () => void; - onToday: () => void; - canGoPrev: boolean; - canGoNext: boolean; -} - -const YearNavigation = memo(({ selectedYear, currentYear, onPrev, onNext, onToday, canGoPrev, canGoNext }: YearNavigationProps) => { - const t = useTranslate(); - const isCurrentYear = selectedYear === currentYear; - - return ( -
-

{selectedYear}

- - -
- ); -}); -YearNavigation.displayName = "YearNavigation"; - -interface MonthCardProps { - month: string; - data: CalendarData; - maxCount: number; - onDateClick: (date: string) => void; - timeBasis?: MemoTimeBasis; -} - -const MonthCard = memo(({ month, data, maxCount, onDateClick, timeBasis }: MonthCardProps) => ( -
-
- {getMonthLabel(month)} -
- -
-)); -MonthCard.displayName = "MonthCard"; - -export const YearCalendar = memo(({ selectedYear, data, onYearChange, onDateClick, className, timeBasis }: YearCalendarProps) => { - const currentYear = useMemo(() => new Date().getFullYear(), []); - const yearData = useMemo(() => filterDataByYear(data, selectedYear), [data, selectedYear]); - const months = useMemo(() => generateMonthsForYear(selectedYear), [selectedYear]); - const yearMaxCount = useMemo(() => calculateMaxCount(yearData), [yearData]); - - const canGoPrev = selectedYear > MIN_YEAR; - const canGoNext = selectedYear < getMaxYear(); - - return ( -
- canGoPrev && onYearChange(selectedYear - 1)} - onNext={() => canGoNext && onYearChange(selectedYear + 1)} - onToday={() => onYearChange(currentYear)} - canGoPrev={canGoPrev} - canGoNext={canGoNext} - /> - -
- {months.map((month) => ( - - ))} -
-
- ); -}); - -YearCalendar.displayName = "YearCalendar"; diff --git a/web/src/components/ActivityCalendar/cellStyles.ts b/web/src/components/ActivityCalendar/cellStyles.ts new file mode 100644 index 00000000..e0a7375b --- /dev/null +++ b/web/src/components/ActivityCalendar/cellStyles.ts @@ -0,0 +1,42 @@ +import { cn } from "@/lib/utils"; +import type { CalendarDayCell } from "./types"; + +/** + * The square chip inside a cell carries every visual. `max-w-[30px]` caps it so a wider + * container spends its surplus on hit area rather than row height; the 224px rail's ~25px + * columns sit below the cap and are unaffected. + */ +const CHIP_BASE = + "relative flex aspect-square w-full max-w-[30px] items-center justify-center rounded-md text-center text-xs font-normal leading-none tracking-[-0.01em] tabular-nums transition-[background-color,color,filter,box-shadow] duration-150 ease-out"; + +/** Hover reads off the cell (`group/day`), not the chip, so the whole column responds to the pointer. */ +const INTERACTIVE_CHIP = + "group-hover/day:brightness-[0.97] group-focus-visible/day:ring-2 group-focus-visible/day:ring-ring/40 group-focus-visible/day:ring-inset"; + +const OUTSIDE_MONTH_CHIP = "bg-transparent text-muted-foreground/25"; +const EMPTY_CHIP = "bg-transparent text-foreground/75 group-hover/day:bg-muted/40"; +/** A picked day is a checked filter like a view or tag row: it takes the accent, not a ring. */ +const SELECTED_CHIP = "z-10 bg-primary font-medium text-primary-foreground"; + +/** Activity tints, ordered from the fraction of `maxCount` a day must exceed to earn them. */ +const INTENSITY_TINTS: ReadonlyArray = [ + [0.75, "bg-blue-400/60 text-foreground/85"], + [0.5, "bg-blue-400/45 text-foreground/80"], + [0.25, "bg-blue-400/30 text-foreground/80"], + [0, "bg-blue-400/18 text-foreground/75"], +]; + +const getFillClass = (day: CalendarDayCell, maxCount: number): string => { + if (!day.isCurrentMonth) return OUTSIDE_MONTH_CHIP; + // Selected owns the fill outright: layering the empty-cell hover tint on top would swap + // the accent out on hover and leave primary-foreground text on a muted chip. + if (day.isSelected) return SELECTED_CHIP; + if (day.count === 0) return EMPTY_CHIP; + + const ratio = day.count / maxCount; + const tint = INTENSITY_TINTS.find(([threshold]) => ratio > threshold); + return tint?.[1] ?? EMPTY_CHIP; +}; + +export const getChipClassName = (day: CalendarDayCell, maxCount: number, isInteractive: boolean): string => + cn(CHIP_BASE, getFillClass(day, maxCount), isInteractive && day.isCurrentMonth && INTERACTIVE_CHIP); diff --git a/web/src/components/ActivityCalendar/constants.ts b/web/src/components/ActivityCalendar/constants.ts deleted file mode 100644 index b55b2322..00000000 --- a/web/src/components/ActivityCalendar/constants.ts +++ /dev/null @@ -1,42 +0,0 @@ -export const DAYS_IN_WEEK = 7; -export const MONTHS_IN_YEAR = 12; -export const MIN_COUNT = 1; - -export const MIN_YEAR = 1970; -export const getMaxYear = () => new Date().getFullYear() + 1; - -export const INTENSITY_THRESHOLDS = { - HIGH: 0.75, - MEDIUM: 0.5, - LOW: 0.25, - MINIMAL: 0, -} as const; - -export const CELL_STYLES = { - HIGH: "bg-blue-400/60 text-foreground/85", - MEDIUM: "bg-blue-400/45 text-foreground/80", - LOW: "bg-blue-400/30 text-foreground/80", - MINIMAL: "bg-blue-400/18 text-foreground/75", - // Hover reads off the cell, not the chip, so the whole column responds to the pointer. - EMPTY: "bg-transparent text-foreground/75 group-hover/day:bg-muted/40", -} as const; - -/** - * `maxSize` caps the square chip that carries a day's fill, so a chip measures - * `min(column, maxSize)`. Narrow containers are untouched — the 224px rail's ~25px columns - * stay below the cap — while wider ones spend the surplus on hit area rather than height, - * which is what stops a widened sidebar from doubling the calendar's height. - */ -export const SMALL_CELL_SIZE = { - font: "text-[10px] font-normal leading-none tracking-[-0.01em] tabular-nums", - maxSize: "max-w-[24px]", - borderRadius: "rounded-md", - gap: "gap-1", -} as const; - -export const DEFAULT_CELL_SIZE = { - font: "text-xs font-normal leading-none tracking-[-0.01em] tabular-nums", - maxSize: "max-w-[30px]", - borderRadius: "rounded-md", - gap: "gap-1", -} as const; diff --git a/web/src/components/ActivityCalendar/hooks.ts b/web/src/components/ActivityCalendar/hooks.ts deleted file mode 100644 index 460cdad0..00000000 --- a/web/src/components/ActivityCalendar/hooks.ts +++ /dev/null @@ -1,23 +0,0 @@ -import dayjs from "dayjs"; -import { useMemo } from "react"; -import { useTranslate } from "@/utils/i18n"; - -export const useWeekdayLabels = () => { - const t = useTranslate(); - return useMemo( - () => [ - t("common.days.sun"), - t("common.days.mon"), - t("common.days.tue"), - t("common.days.wed"), - t("common.days.thu"), - t("common.days.fri"), - t("common.days.sat"), - ], - [t], - ); -}; - -export const useTodayDate = () => { - return dayjs().format("YYYY-MM-DD"); -}; diff --git a/web/src/components/ActivityCalendar/index.ts b/web/src/components/ActivityCalendar/index.ts index 2e9454d4..68c7607e 100644 --- a/web/src/components/ActivityCalendar/index.ts +++ b/web/src/components/ActivityCalendar/index.ts @@ -1,4 +1,2 @@ -export * from "./MonthCalendar"; -export * from "./types"; -export * from "./utils"; -export * from "./YearCalendar"; +export { MonthCalendar } from "./MonthCalendar"; +export type { CalendarData, CalendarDayCell, MonthCalendarProps } from "./types"; diff --git a/web/src/components/ActivityCalendar/monthDays.ts b/web/src/components/ActivityCalendar/monthDays.ts new file mode 100644 index 00000000..94548925 --- /dev/null +++ b/web/src/components/ActivityCalendar/monthDays.ts @@ -0,0 +1,59 @@ +import dayjs from "dayjs"; +import { useMemo } from "react"; +import type { CalendarData, CalendarDayCell } from "./types"; + +const DAYS_IN_WEEK = 7; + +export interface MonthDaysParams { + /** `YYYY-MM` */ + month: string; + data: CalendarData; + /** 0 = Sunday … 6 = Saturday; the instance's preferred first day of the week. */ + weekStartDayOffset: number; + /** ISO date, `YYYY-MM-DD`. */ + today: string; + selectedDate?: string; +} + +/** Rotate a Sunday-first list of weekday labels so it starts on `weekStartDayOffset`. */ +export const rotateWeekdays = (labels: T[], weekStartDayOffset: number): T[] => { + const offset = ((weekStartDayOffset % DAYS_IN_WEEK) + DAYS_IN_WEEK) % DAYS_IN_WEEK; + return labels.slice(offset).concat(labels.slice(0, offset)); +}; + +/** + * Every day of a month's grid in reading order, padded at both ends with out-of-month days so + * the list is a whole number of weeks aligned to `weekStartDayOffset`. + */ +export const buildMonthDays = ({ month, data, weekStartDayOffset, today, selectedDate }: MonthDaysParams): CalendarDayCell[] => { + const monthStart = dayjs(month).startOf("month"); + const monthEnd = monthStart.endOf("month"); + const monthKey = monthStart.format("YYYY-MM"); + + const leadingDays = (monthStart.day() - weekStartDayOffset + DAYS_IN_WEEK) % DAYS_IN_WEEK; + const trailingDays = (weekStartDayOffset + DAYS_IN_WEEK - 1 - monthEnd.day() + DAYS_IN_WEEK) % DAYS_IN_WEEK; + const gridStart = monthStart.subtract(leadingDays, "day"); + const gridEnd = monthEnd.add(trailingDays, "day"); + const dayCount = gridEnd.diff(gridStart, "day") + 1; + + return Array.from({ length: dayCount }, (_, index) => { + const current = gridStart.add(index, "day"); + const date = current.format("YYYY-MM-DD"); + return { + date, + label: current.date(), + count: data[date] ?? 0, + isCurrentMonth: current.format("YYYY-MM") === monthKey, + isToday: date === today, + isSelected: date === selectedDate, + }; + }); +}; + +export const useMonthDays = (params: MonthDaysParams): CalendarDayCell[] => { + const { month, data, weekStartDayOffset, today, selectedDate } = params; + return useMemo( + () => buildMonthDays({ month, data, weekStartDayOffset, today, selectedDate }), + [month, data, weekStartDayOffset, today, selectedDate], + ); +}; diff --git a/web/src/components/ActivityCalendar/types.ts b/web/src/components/ActivityCalendar/types.ts index b5e4c043..04731c3f 100644 --- a/web/src/components/ActivityCalendar/types.ts +++ b/web/src/components/ActivityCalendar/types.ts @@ -1,43 +1,26 @@ import type { MemoTimeBasis } from "@/contexts/ViewContext"; -export type CalendarSize = "default" | "small"; +/** Memo counts keyed by ISO date (`YYYY-MM-DD`). */ export type CalendarData = Record; export interface CalendarDayCell { + /** ISO date, `YYYY-MM-DD`. */ date: string; + /** Day of month, the numeral drawn in the cell. */ label: number; count: number; + /** False for the leading/trailing days that pad the grid out to whole weeks. */ isCurrentMonth: boolean; isToday: boolean; isSelected: boolean; } -export interface CalendarDayRow { - days: CalendarDayCell[]; -} - -export interface CalendarMatrixResult { - weeks: CalendarDayRow[]; - weekDays: string[]; -} - export interface MonthCalendarProps { + /** Month to render, `YYYY-MM`. */ month: string; data: CalendarData; - maxCount: number; - size?: CalendarSize; - onClick?: (date: string) => void; + /** ISO date of the day currently used as a filter, if any. */ selectedDate?: string; - className?: string; - disableTooltips?: boolean; - timeBasis?: MemoTimeBasis; -} - -export interface YearCalendarProps { - selectedYear: number; - data: CalendarData; - onYearChange: (year: number) => void; - onDateClick: (date: string) => void; - className?: string; + onClick?: (date: string) => void; timeBasis?: MemoTimeBasis; } diff --git a/web/src/components/ActivityCalendar/useCalendar.ts b/web/src/components/ActivityCalendar/useCalendar.ts deleted file mode 100644 index cf45dbee..00000000 --- a/web/src/components/ActivityCalendar/useCalendar.ts +++ /dev/null @@ -1,90 +0,0 @@ -import dayjs from "dayjs"; -import { useMemo } from "react"; -import { DAYS_IN_WEEK } from "./constants"; -import type { CalendarData, CalendarDayCell, CalendarMatrixResult } from "./types"; - -export interface UseCalendarMatrixParams { - month: string; - data: CalendarData; - weekDays: string[]; - weekStartDayOffset: number; - today: string; - selectedDate: string; -} - -const createCalendarDayCell = ( - current: dayjs.Dayjs, - monthKey: string, - data: CalendarData, - 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, - }; -}; - -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 }; -}; - -/** - * Generates a matrix of calendar days for a given month, handling week alignment and data mapping. - */ -export const useCalendarMatrix = ({ - month, - data, - weekDays, - weekStartDayOffset, - today, - 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"] = []; - - // 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); - - if (!weeks[weekIndex]) { - 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); - } - - return { - weeks, - weekDays: rotatedWeekDays, - }; - }, [month, data, weekDays, weekStartDayOffset, today, selectedDate]); -}; diff --git a/web/src/components/ActivityCalendar/utils.ts b/web/src/components/ActivityCalendar/utils.ts index 4bb8e698..3b362ebe 100644 --- a/web/src/components/ActivityCalendar/utils.ts +++ b/web/src/components/ActivityCalendar/utils.ts @@ -1,64 +1,11 @@ -import dayjs from "dayjs"; -import isSameOrAfter from "dayjs/plugin/isSameOrAfter"; -import isSameOrBefore from "dayjs/plugin/isSameOrBefore"; import type { MemoTimeBasis } from "@/contexts/ViewContext"; -import { useTranslate } from "@/utils/i18n"; -import { CELL_STYLES, INTENSITY_THRESHOLDS, MIN_COUNT, MONTHS_IN_YEAR } from "./constants"; -import type { CalendarData, CalendarDayCell } from "./types"; - -dayjs.extend(isSameOrAfter); -dayjs.extend(isSameOrBefore); +import type { useTranslate } from "@/utils/i18n"; +import type { CalendarData } from "./types"; export type TranslateFunction = ReturnType; -export const getCellIntensityClass = (day: CalendarDayCell, maxCount: number): string => { - if (!day.isCurrentMonth || day.count === 0) { - return CELL_STYLES.EMPTY; - } - - const ratio = day.count / maxCount; - 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 getCalendarCellStateClass = (day: Pick): string => - // A picked day is a checked filter like a view or tag row: it takes the accent, not a ring. - day.isSelected ? "z-10 bg-primary font-medium text-primary-foreground" : ""; - -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 calculateMaxCount = (data: CalendarData): 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"); -}; - -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; -}; +/** Largest daily count in the data, floored at 1 so intensity ratios never divide by zero. */ +export const calculateMaxCount = (data: CalendarData): number => Math.max(1, ...Object.values(data)); export const getTooltipText = (count: number, date: string, t: TranslateFunction, timeBasis: MemoTimeBasis = "create_time"): string => { if (count === 0) { diff --git a/web/src/components/StatisticsView/StatisticsView.tsx b/web/src/components/StatisticsView/StatisticsView.tsx index 103191a1..7d5dec73 100644 --- a/web/src/components/StatisticsView/StatisticsView.tsx +++ b/web/src/components/StatisticsView/StatisticsView.tsx @@ -1,6 +1,6 @@ import dayjs from "dayjs"; import { useState } from "react"; -import { calculateMaxCount, MonthCalendar } from "@/components/ActivityCalendar"; +import { MonthCalendar } from "@/components/ActivityCalendar"; import { useMemoFilterContext } from "@/contexts/MemoFilterContext"; import { useDateFilterNavigation } from "@/hooks"; import type { StatisticsData } from "@/types/statistics"; @@ -29,7 +29,6 @@ const StatisticsView = (props: Props) => { { navigateToDateFilter(date); diff --git a/web/src/lib/calendar-utils.ts b/web/src/lib/calendar-utils.ts index 96a3eb2f..e3322730 100644 --- a/web/src/lib/calendar-utils.ts +++ b/web/src/lib/calendar-utils.ts @@ -2,22 +2,6 @@ import dayjs from "dayjs"; export const MONTH_DATE_FORMAT = "YYYY-MM" as const; -export const formatMonth = (date: Date | string): string => { - return dayjs(date).format(MONTH_DATE_FORMAT); -}; - -export const getYearFromDate = (date: Date | string): number => { - return dayjs(date).year(); -}; - -export const getMonthFromDate = (date: Date | string): number => { - return dayjs(date).month(); -}; - export const addMonths = (date: Date | string, count: number): string => { return dayjs(date).add(count, "month").format(MONTH_DATE_FORMAT); }; - -export const setYearAndMonth = (year: number, month: number): string => { - return dayjs().year(year).month(month).format(MONTH_DATE_FORMAT); -}; diff --git a/web/tests/calendar-month-days.test.ts b/web/tests/calendar-month-days.test.ts new file mode 100644 index 00000000..53385722 --- /dev/null +++ b/web/tests/calendar-month-days.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { buildMonthDays, rotateWeekdays } from "@/components/ActivityCalendar/monthDays"; + +// May 2025 starts on a Thursday and ends on a Saturday. +const base = { month: "2025-05", data: {}, weekStartDayOffset: 0, today: "2025-05-16" }; + +describe("buildMonthDays", () => { + it("pads a Sunday-first grid out to whole weeks", () => { + const days = buildMonthDays(base); + + expect(days).toHaveLength(35); + expect(days[0].date).toBe("2025-04-27"); + expect(days.at(-1)?.date).toBe("2025-05-31"); + expect(days.filter((day) => day.isCurrentMonth)).toHaveLength(31); + expect(days.slice(0, 4).every((day) => !day.isCurrentMonth)).toBe(true); + }); + + it("realigns the padding when the week starts on Monday", () => { + const days = buildMonthDays({ ...base, weekStartDayOffset: 1 }); + + expect(days).toHaveLength(35); + expect(days[0].date).toBe("2025-04-28"); + expect(days.at(-1)?.date).toBe("2025-06-01"); + }); + + it("maps counts and flags onto the matching dates", () => { + const days = buildMonthDays({ ...base, data: { "2025-05-02": 3 }, selectedDate: "2025-05-20" }); + const byDate = Object.fromEntries(days.map((day) => [day.date, day])); + + expect(byDate["2025-05-02"]).toMatchObject({ label: 2, count: 3 }); + expect(byDate["2025-05-16"].isToday).toBe(true); + expect(byDate["2025-05-20"].isSelected).toBe(true); + expect(days.filter((day) => day.isToday || day.isSelected)).toHaveLength(2); + }); +}); + +describe("rotateWeekdays", () => { + const labels = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; + + it("keeps Sunday first at offset 0 and moves Monday first at offset 1", () => { + expect(rotateWeekdays(labels, 0)).toEqual(labels); + expect(rotateWeekdays(labels, 1)).toEqual(["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]); + }); +});