fix: give tasks in the phone day view a full row with a tappable circle and drop the empty all-day block

This commit is contained in:
Linus Rath
2026-10-28 10:01:34 +01:00
parent 6eaaca5cfa
commit 86b56d490a
3 changed files with 101 additions and 17 deletions
@@ -0,0 +1,66 @@
import { render, screen, within } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { CalendarDayView } from '../calendar-day-view';
import type { Calendar, CalendarEvent, CalendarTask } from '@/lib/jmap/types';
// CAL-27 follow-up: the phone day view drew tasks as 10px chips with a
// circle too small to tap, and a busy neighbouring day left an empty
// all-day block of ~150px above the hours of a day with none.
const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
const holiday = {
id: 'ev-1', calendarIds: { 'cal-1': true }, title: 'Holiday', start: '2026-09-10T00:00:00',
duration: 'P1D', showWithoutTime: true,
} as unknown as CalendarEvent;
const task = {
id: 't1', '@type': 'Task', title: 'Buy milk', calendarIds: { 'cal-1': true },
progress: 'needs-action', priority: 0, due: '2026-09-09', showWithoutTime: true,
} as unknown as CalendarTask;
function renderDay(isMobile: boolean, tasks: CalendarTask[] = [task]) {
return render(
<CalendarDayView
focus={{ date: new Date(2026, 8, 9), nonce: 0 }}
windowKey="day:2026-09-08"
rangeStart={new Date(2026, 8, 8)}
rangeEnd={new Date(2026, 8, 10)}
selectedDate={new Date(2026, 8, 9)}
events={[holiday]}
calendars={calendars}
tasks={tasks}
onSelectEvent={vi.fn()}
onCreateAtTime={vi.fn()}
isMobile={isMobile}
/>,
);
}
describe('day view on a phone', () => {
it('gives each task a full row with a 44px target for the circle', () => {
renderDay(true);
const chip = document.querySelector<HTMLElement>('[data-calendar-task="t1"]')!;
expect(chip).toHaveClass('min-h-11', 'text-xs');
expect(within(chip).getByRole('button', { name: /mark_complete|Mark as complete/i })).toHaveClass('w-11', 'self-stretch');
});
it('keeps the small shared chip on a wide screen', () => {
renderDay(false);
const chip = document.querySelector<HTMLElement>('[data-calendar-task="t1"]')!;
expect(chip).toHaveClass('h-full', 'text-[10px]');
});
it('sizes the all-day row by the day in view, not by a busy neighbour', () => {
renderDay(true, []);
const today = document.querySelector<HTMLElement>('[data-day="2026-09-09"]')!;
const next = document.querySelector<HTMLElement>('[data-day="2026-09-10"]')!;
// The day in view has nothing all-day: no empty block under its date.
expect(within(today).queryByText(/all_day|All day/i)).toBeNull();
expect(today).not.toHaveClass('h-0');
// The neighbour's all-day event takes no height of its own.
expect(within(next).getByRole('button', { name: /Holiday/ })).toBeInTheDocument();
expect(next).toHaveClass('h-0', 'min-h-full', 'overflow-hidden');
expect(screen.getAllByRole('columnheader')).toHaveLength(3);
});
});
+18 -11
View File
@@ -197,14 +197,6 @@ export function CalendarDayView({
return map;
}, [days, eventsByDay, hours]);
const hasAllDayArea = useMemo(
() => days.some((day) => {
const key = format(day, "yyyy-MM-dd");
return (eventsByDay.get(key)?.allDay.length ?? 0) > 0 || (tasksByDay.get(key)?.length ?? 0) > 0;
}),
[days, eventsByDay, tasksByDay],
);
const scrollToFocus = useCallback(() => {
const root = rootRef.current;
if (!root) return;
@@ -232,6 +224,10 @@ export function CalendarDayView({
// not used: browsers re-snap on their own when columns are prepended,
// which would double the scroll correction.)
const visibleKeyRef = useRef<string | null>(null);
// The day in view: its all-day events and tasks size the row under the
// dates, so a busy neighbour doesn't leave an empty block above the hours.
const [visibleKey, setVisibleKey] = useState(() => dayKey(focus.date));
useEffect(() => { setVisibleKey(dayKey(focus.date)); }, [focus.date, focus.nonce]);
const scrollFrameRef = useRef<number | null>(null);
const snapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleStripScroll = useCallback(() => {
@@ -255,6 +251,7 @@ export function CalendarDayView({
const key = dayKey(day);
if (key === visibleKeyRef.current) return;
visibleKeyRef.current = key;
setVisibleKey(key);
onVisibleDateChange?.(day);
});
}, [colWidth, colCount, days, onVisibleDateChange]);
@@ -352,7 +349,16 @@ export function CalendarDayView({
const allDayEvents = eventsByDay.get(key)?.allDay ?? [];
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
key={key}
data-day={key}
className={cn(
"min-w-0 border-e border-border last:border-e-0",
// Other days take no height of their own: they fill the
// row the day in view sets, clipped while swiping past.
key !== visibleKey && "h-0 min-h-full overflow-hidden",
)}
>
<div
className={cn("flex flex-col items-start gap-0.5 px-3 pt-2 pb-1.5", isMobile && "px-2")}
role="columnheader"
@@ -376,7 +382,7 @@ export function CalendarDayView({
<PluginDayLabel surface="calendar-day-header" date={key} className="w-full" />
</div>
{hasAllDayArea && (
{(allDayEvents.length > 0 || dayTasks.length > 0) && (
<div
className="px-4 py-2 border-t border-border"
onContextMenu={onContextMenuEmpty ? (e) => {
@@ -414,12 +420,13 @@ export function CalendarDayView({
<div className="space-y-0.5">
{/* The same chip as the month and week grids. */}
{dayTasks.map((task) => (
<div key={task.id} className="h-5">
<div key={task.id} className={isMobile ? undefined : "h-5"}>
<CalendarTaskChip
task={task}
calendar={calendars.find((c) => task.calendarIds[c.id])}
onToggleComplete={onToggleTaskComplete}
onSelect={onSelectTask}
touch={isMobile}
/>
</div>
))}
+17 -6
View File
@@ -10,6 +10,8 @@ interface CalendarTaskChipProps {
calendar?: Calendar;
onToggleComplete?: (task: CalendarTask) => void;
onSelect?: (task: CalendarTask) => void;
/** A full row for a finger: 44px tall, a bigger circle with a 44px hit area (phone day view). */
touch?: boolean;
className?: string;
}
@@ -17,7 +19,7 @@ interface CalendarTaskChipProps {
* A task on its due day in the month and week grids (#1107). The circle
* toggles completion; the title opens the task in the editor.
*/
export function CalendarTaskChip({ task, calendar, onToggleComplete, onSelect, className }: CalendarTaskChipProps) {
export function CalendarTaskChip({ task, calendar, onToggleComplete, onSelect, touch = false, className }: CalendarTaskChipProps) {
const t = useTranslations("calendar");
const isCompleted = task.progress === "completed";
const color = calendar?.color || "#3b82f6";
@@ -26,30 +28,39 @@ export function CalendarTaskChip({ task, calendar, onToggleComplete, onSelect, c
return (
<div
data-calendar-task={task.id}
className={cn("h-full rounded text-[10px] font-medium flex items-center overflow-hidden", className)}
className={cn(
"rounded font-medium flex items-center overflow-hidden",
touch ? "min-h-11 text-xs" : "h-full text-[10px]",
className,
)}
style={{ backgroundColor: `${color}20`, borderInlineStart: `3px solid ${color}` }}
>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onToggleComplete?.(task); }}
className="group h-full ps-1 pe-0.5 flex items-center flex-shrink-0"
className={cn(
"group flex items-center flex-shrink-0",
touch ? "self-stretch w-11 justify-center" : "h-full ps-1 pe-0.5",
)}
aria-label={isCompleted ? t("tasks.mark_incomplete") : t("tasks.mark_complete")}
>
<span
className={cn(
"w-2.5 h-2.5 rounded-full border flex items-center justify-center",
"rounded-full border flex items-center justify-center",
touch ? "w-4 h-4" : "w-2.5 h-2.5",
isCompleted ? "bg-success border-success text-success-foreground" : "group-hover:bg-background",
)}
style={isCompleted ? undefined : { borderColor: color }}
>
{isCompleted && <Check className="h-2 w-2" />}
{isCompleted && <Check className={touch ? "h-3 w-3" : "h-2 w-2"} />}
</span>
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onSelect?.(task); }}
className={cn(
"h-full flex-1 min-w-0 truncate text-start pe-1 hover:opacity-80",
"flex-1 min-w-0 truncate text-start pe-1 hover:opacity-80",
touch ? "self-stretch" : "h-full",
isCompleted && "line-through text-muted-foreground",
)}
title={title}