From 7eebc45f4fb4f6ea41186f32a4768a35026aab51 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:07:30 +0200 Subject: [PATCH] feat(calendar): show "N more" in busy month cells and open days from the date On wide screens a month row keeps its height instead of growing for the busiest day. A day with more entries than fit shows the first ones and a "+N more" link that opens the day view, and clicking a date number opens that day too. Today keeps the filled circle and the selected day is tinted instead of looking the same. Phones still grow the row to fit and open a day by tapping the cell. --- .../calendar-month-view-tasks.test.tsx | 34 +++++++- components/calendar/calendar-app.tsx | 1 + components/calendar/calendar-month-view.tsx | 83 ++++++++++++++++--- 3 files changed, 104 insertions(+), 14 deletions(-) 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 && ( +
+ +
+ ))} +
+ )}
- ))} + ); + })}