feat(calendar): open a day from the week header and show a clearer now line

The week and day headers now show a small weekday above a large date,
with today's date in a filled circle and its weekday in the primary
colour. Clicking a date in the week header opens that day in the day
view. The all-day row moved below the dates, where it lines up with
the columns it belongs to.

The current time line is thicker with a larger dot at the start of
today's column, and events shorter than 45 minutes show their start
time next to the title instead of hiding it.
This commit is contained in:
Linus Rath
2026-10-10 22:38:59 +02:00
parent 90b1db0432
commit 95d21c49a9
4 changed files with 102 additions and 59 deletions
+12
View File
@@ -566,6 +566,17 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
setNarrowSidebarOpen(false);
}, [setSelectedDate, jumpTo, isMobile, normalizedViewMode, setViewMode]);
// Clicking a date number in the week or month header opens that day in
// the day view, as in Google Calendar.
const handleOpenDay = useCallback((date: Date) => {
setMobileReturnToMonth(isMobile && normalizedViewMode === "month");
jumpTo(date);
setSelectedDate(date);
setMiniMonth(date);
setViewMode("day");
setNarrowSidebarOpen(false);
}, [jumpTo, setSelectedDate, setViewMode, isMobile, normalizedViewMode]);
// The mini calendar is a navigation control: unlike a click in the week or
// month grid, which marks a day that is already on screen, picking a day
// here has to bring that day into view.
@@ -1485,6 +1496,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
events={visibleEvents}
calendars={allCalendars}
onSelectDate={handleSelectDate}
onOpenDay={handleOpenDay}
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
+23 -10
View File
@@ -313,13 +313,26 @@ export function CalendarDayView({
const dayTasks = tasksByDay.get(key) ?? [];
return (
<div key={key} data-day={key} className="min-w-0 border-e border-border last:border-e-0">
<div className={cn("px-4 py-3", isMobile && "px-3 py-2")}>
<h3 className={cn("font-semibold truncate", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
{isMobile
? intlFormatter.dateTime(day, { weekday: "short", month: "short", day: "numeric" })
: intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" })
}
</h3>
<div
className={cn("flex flex-col items-start gap-0.5 px-3 pt-2 pb-1.5", isMobile && "px-2")}
role="columnheader"
aria-label={intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}
>
<div className={cn(
"w-10 text-center text-[11px] font-medium uppercase tracking-wide",
today ? "text-primary" : "text-muted-foreground",
)}>
{intlFormatter.dateTime(day, { weekday: "short" })}
</div>
<div
aria-current={today ? "date" : undefined}
className={cn(
"inline-flex items-center justify-center w-10 h-10 rounded-full text-xl",
today ? "bg-primary text-primary-foreground font-semibold" : "text-foreground",
)}
>
{format(day, "d")}
</div>
</div>
{hasAllDayArea && (
@@ -539,9 +552,9 @@ export function CalendarDayView({
className="absolute left-0 right-0 z-20 pointer-events-none"
style={{ top: ((nowMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT }}
>
<div className="flex items-center">
<div className="w-2.5 h-2.5 rounded-full bg-destructive -ms-1" />
<div className="flex-1 h-px bg-destructive" />
<div className="flex items-center -translate-y-1/2">
<div className="w-3 h-3 rounded-full bg-destructive -ms-1.5 flex-shrink-0" />
<div className="flex-1 h-0.5 bg-destructive" />
</div>
</div>
)}
+59 -47
View File
@@ -28,6 +28,8 @@ interface CalendarWeekViewProps extends ScrollWindowViewProps {
events: CalendarEvent[];
calendars: Calendar[];
onSelectDate: (date: Date) => void;
/** Open a day in the day view (clicking its date in the header). */
onOpenDay?: (date: Date) => void;
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void;
@@ -63,6 +65,7 @@ export function CalendarWeekView({
isLoading = false,
onVisibleDateChange,
onSelectDate,
onOpenDay,
onSelectEvent,
onHoverEvent,
onHoverLeave,
@@ -384,6 +387,59 @@ export function CalendarWeekView({
<div ref={startSentinelRef} data-testid="week-start-sentinel" className="absolute inset-y-0 start-0 w-px pointer-events-none" />
<div ref={endSentinelRef} data-testid="week-end-sentinel" className="absolute inset-y-0 end-0 w-px pointer-events-none" />
<div className="sticky top-0 z-50 bg-background">
<div className="flex border-b border-border" role="row">
<div className={cn(gutterClass, "flex items-end justify-center pb-1")}>
{canToggleHours && (
<AllHoursToggle
showAllHours={showAllHours}
configured={configuredHours}
timeFormat={timeFormat}
onToggle={toggleAllHours}
/>
)}
</div>
<div className="border-s border-border grid" style={columnsStyle}>
{days.map((day) => {
const todayCol = isDisplayToday(day);
const selected = isSameDay(day, selectedDate);
const fullLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
return (
<div
key={day.toISOString()}
role="columnheader"
aria-label={fullLabel}
data-day={dayKey(day)}
className="flex flex-col items-center gap-0.5 pt-2 pb-1.5 border-e border-border last:border-e-0"
>
<div className={cn(
"text-[11px] font-medium uppercase tracking-wide",
todayCol ? "text-primary" : "text-muted-foreground",
)}>
{intlFormatter.dateTime(day, { weekday: "short" })}
</div>
{/* The date opens that day in the day view. */}
<button
type="button"
onClick={() => (onOpenDay ?? onSelectDate)(day)}
aria-label={fullLabel}
aria-current={todayCol ? "date" : undefined}
className={cn(
"inline-flex items-center justify-center rounded-full transition-colors touch-manipulation",
isMobile ? "w-8 h-8 text-base" : "w-10 h-10 text-xl",
todayCol
? "bg-primary text-primary-foreground font-semibold hover:bg-primary/90"
: selected
? "bg-accent text-accent-foreground font-medium"
: "text-foreground hover:bg-muted",
)}
>
{format(day, "d")}
</button>
</div>
);
})}
</div>
</div>
{hasAllDay && (
<div className="flex border-b border-border">
<div
@@ -486,50 +542,6 @@ export function CalendarWeekView({
</div>
)}
<div className="flex border-b border-border" role="row">
<div className={cn(gutterClass, "flex items-end justify-center pb-1")}>
{canToggleHours && (
<AllHoursToggle
showAllHours={showAllHours}
configured={configuredHours}
timeFormat={timeFormat}
onToggle={toggleAllHours}
/>
)}
</div>
<div className="border-s border-border grid" style={columnsStyle}>
{days.map((day) => {
const todayCol = isDisplayToday(day);
const selected = isSameDay(day, selectedDate);
const fullLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
return (
<button
key={day.toISOString()}
onClick={() => onSelectDate(day)}
role="columnheader"
aria-label={fullLabel}
data-day={dayKey(day)}
className={cn(
"text-center py-2 text-sm border-e border-border last:border-e-0 transition-colors touch-manipulation",
"hover:bg-muted/50",
todayCol && "font-bold",
)}
>
<div className="text-[10px] text-muted-foreground uppercase">
{intlFormatter.dateTime(day, { weekday: "short" })}
</div>
<div className={cn(
"inline-flex items-center justify-center w-7 h-7 rounded-full text-sm",
todayCol && "bg-primary text-primary-foreground",
selected && !todayCol && "bg-accent text-accent-foreground"
)}>
{format(day, "d")}
</div>
</button>
);
})}
</div>
</div>
</div>
@@ -673,9 +685,9 @@ export function CalendarWeekView({
className="absolute left-0 right-0 z-20 pointer-events-none"
style={{ top: ((nowMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT }}
>
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-destructive -ms-1" />
<div className="flex-1 h-px bg-destructive" />
<div className="flex items-center -translate-y-1/2">
<div className="w-3 h-3 rounded-full bg-destructive -ms-1.5 flex-shrink-0" />
<div className="flex-1 h-0.5 bg-destructive" />
</div>
</div>
)}
+8 -2
View File
@@ -218,8 +218,14 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
)}
style={{ ...eventFillStyle(color, { inactive: isInactive, separated: true, selected: isSelected }), ...style }}
>
<div className={cn("font-medium truncate", isInactive && "line-through")}>{event.title || t("events.no_title")}</div>
{/* Shorter blocks have no room for a second line at the 60px hour. */}
{/* Shorter blocks have no room for a second line at the 60px hour, so
their start time follows the title on the same line. */}
<div className="flex items-baseline gap-1 min-w-0">
<span className={cn("font-medium truncate", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
{!event.showWithoutTime && durationMinutes < 45 && (
<span className="flex-shrink-0 opacity-85 text-[10.5px]">{safeFormat(startDate, timeFmt)}</span>
)}
</div>
{!event.showWithoutTime && durationMinutes >= 45 && (
<div className="opacity-85 text-[10.5px]">
{timeString}