diff --git a/components/calendar/__tests__/calendar-month-view-tasks.test.tsx b/components/calendar/__tests__/calendar-month-view-tasks.test.tsx index 7575c638f..d5447eb9d 100644 --- a/components/calendar/__tests__/calendar-month-view-tasks.test.tsx +++ b/components/calendar/__tests__/calendar-month-view-tasks.test.tsx @@ -2,6 +2,7 @@ import { render, screen, fireEvent, cleanup } from '@testing-library/react'; import { describe, it, expect, vi, afterEach } from 'vitest'; import { CalendarMonthView } from '../calendar-month-view'; import type { CalendarEvent, Calendar, CalendarTask } from '@/lib/jmap/types'; +import { useSettingsStore } from '@/stores/settings-store'; // #1107: with "Show tasks on calendar" on, the month view shows each task // on its due day, below that day's events, and lets it be completed or @@ -103,15 +104,40 @@ describe('CalendarMonthView tasks', () => { expect(fri.style.top).toBe('0px'); }); - it('grows the week row to fit a crowded day', () => { + it('keeps a crowded day to its row height and links to the rest', () => { + const onOpenDay = vi.fn(); renderView({ events: [makeAllDayEvent('e1', '2026-09-10')], tasks: ['a', 'b', 'c'].map((id) => makeTask(id, '2026-09-10')), + onOpenDay, }); const row = document.querySelector('[data-week="2026-09-07"]'); - // overlay offset 30 + 4, one event row and three task rows of 22px, 8 below. - expect(row?.style.minHeight).toBe(`${30 + 4 + 4 * 22 + 8}px`); - expect(chipSlot('c').style.top).toBe(`${3 * 22}px`); + // jsdom measures no viewport, so rows keep the 100px minimum: room for + // two 22px rows under the 30 + 4 offset, the second taken by the link. + expect(row?.style.minHeight).toBe('100px'); + expect(screen.getByText('Event e1')).toBeInTheDocument(); + expect(document.querySelector('[data-calendar-task="a"]')).toBeNull(); + const more = screen.getByRole('button', { name: 'events.more' }); + expect(more.parentElement?.style.top).toBe('22px'); + fireEvent.click(more); + expect(onOpenDay).toHaveBeenCalledWith(new Date(2026, 8, 10)); + }); + + it('grows the week row to fit a crowded day on phones', () => { + useSettingsStore.setState({ showTimeInMonthView: true }); + try { + renderView({ + events: [makeAllDayEvent('e1', '2026-09-10')], + tasks: ['a', 'b', 'c'].map((id) => makeTask(id, '2026-09-10')), + isMobile: true, + }); + const row = document.querySelector('[data-week="2026-09-07"]'); + // overlay offset 34 + 4, one event row and three task rows of 18px, 8 below. + expect(row?.style.minHeight).toBe(`${34 + 4 + 4 * 18 + 8}px`); + expect(chipSlot('c').style.top).toBe(`${3 * 18}px`); + } finally { + useSettingsStore.setState({ showTimeInMonthView: false }); + } }); it('toggles completion from the circle and opens the editor from the title', () => { diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index d2e4f57ad..e58f36c2b 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -1580,6 +1580,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = events={visibleEvents} calendars={allCalendars} onSelectDate={handleSelectDate} + onOpenDay={handleOpenDay} onSelectEvent={handleSelectEvent} onHoverEvent={handleHoverEvent} onHoverLeave={handleHoverLeave} diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index 5189dbeb8..31ba69a59 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -30,6 +30,8 @@ interface CalendarMonthViewProps extends ScrollWindowViewProps { onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void; onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void; onCreateAtTime?: (date: Date) => void; + /** Open a day in the day view (its date number, or its "N more" link). */ + onOpenDay?: (date: Date) => void; firstDayOfWeek?: number; isMobile?: boolean; pendingPreview?: PendingEventPreview | null; @@ -62,6 +64,7 @@ export function CalendarMonthView({ onContextMenuEvent, onContextMenuEmpty, onCreateAtTime, + onOpenDay, isMobile, pendingPreview, tasks, @@ -166,6 +169,11 @@ export function CalendarMonthView({ return () => observer.disconnect(); }, []); const rowMinHeight = Math.max(baseRowMinHeight, Math.floor(viewportHeight / 6)); + // On wide screens a week keeps its height: a day with more entries than + // fit shows the first ones and a "N more" link (as in Google Calendar). + // Phones keep growing the row to fit. + const rowCapacity = Math.max(1, Math.floor((rowMinHeight - overlayTop - 4 - 8) / rowHeight)); + const capRows = showChips && !isMobile; // The month the user is looking at: sampled a little above the middle of // the viewport. It dims the other months' days and drives the title. @@ -287,9 +295,24 @@ export function CalendarMonthView({ onScroll={handleScroll} >
- {weekSegments.map(({ week, segments, rowCount, dayEventRows, dayTasks, contentRows }) => ( + {weekSegments.map(({ week, segments, rowCount, dayEventRows, dayTasks, contentRows }) => { + const overflowing = capRows && contentRows > rowCapacity; + // Rows drawn per day; the last one is taken by the "N more" link. + const rowLimit = overflowing ? rowCapacity - 1 : Infinity; + const hiddenPerDay = overflowing + ? week.map((_, dayIndex) => { + const hiddenEvents = segments.filter((segment) => + segment.row >= rowLimit + && dayIndex >= segment.startIndex + && dayIndex < segment.startIndex + segment.span, + ).length; + const hiddenTasks = dayTasks[dayIndex].filter((_, taskIndex) => dayEventRows[dayIndex] + taskIndex >= rowLimit).length; + return hiddenEvents + hiddenTasks; + }) + : null; + return (
@@ -331,18 +354,31 @@ export function CalendarMonthView({ {t(`months.${monthLabelKeys[getMonth(day)]}`)} )} - { + if (isMobile || !onOpenDay) return; + e.stopPropagation(); + onOpenDay(day); + }} + onDoubleClick={(e) => e.stopPropagation()} + aria-label={fullDateLabel} + aria-current={today ? "date" : undefined} className={cn( - "inline-flex items-center justify-center rounded-full", + "inline-flex items-center justify-center rounded-full transition-colors", isMobile ? "w-7 h-7 text-xs" : "w-6 h-6 text-xs", - today && !selected && "bg-primary text-primary-foreground font-bold", - selected && "bg-primary text-primary-foreground font-bold", + today && "bg-primary text-primary-foreground font-bold", + selected && !today && "bg-primary/15 text-primary font-bold", !inMonth && !selected && !today && "text-muted-foreground/50", - inMonth && !selected && !today && "font-medium" + inMonth && !selected && !today && "font-medium", + !today && !isMobile && "hover:bg-muted" )} > {formatDayNumber(day)} - +
{isMobile && !showChips ? (
@@ -420,7 +456,7 @@ export function CalendarMonthView({ {showChips && segments.length > 0 && (
- {segments.map((segment) => { + {segments.filter((segment) => segment.row < rowLimit).map((segment) => { const calId = getPrimaryCalendarId(segment.event); return (
dayTaskList.length > 0) && (
{dayTasks.map((dayTaskList, dayIndex) => dayTaskList.map((task, taskIndex) => { + if (dayEventRows[dayIndex] + taskIndex >= rowLimit) return null; const calId = Object.keys(task.calendarIds).find((id) => calendarMap.has(id)); return (
)} + + {hiddenPerDay && ( +
+ {hiddenPerDay.map((hidden, dayIndex) => hidden > 0 && ( +
+ +
+ ))} +
+ )}
- ))} + ); + })}