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"]); + }); +});