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