mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
feat: show tasks with due dates in the calendar month view #1107
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
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';
|
||||
|
||||
// #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
|
||||
// opened in the task editor.
|
||||
|
||||
const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
|
||||
|
||||
function makeTask(id: string, due: string, overrides: Partial<CalendarTask> = {}): CalendarTask {
|
||||
return {
|
||||
id,
|
||||
'@type': 'Task',
|
||||
uid: id,
|
||||
title: 'Task ' + id,
|
||||
description: '',
|
||||
due,
|
||||
start: null,
|
||||
duration: null,
|
||||
timeZone: null,
|
||||
showWithoutTime: true,
|
||||
progress: 'needs-action',
|
||||
priority: 0,
|
||||
privacy: 'public',
|
||||
keywords: null,
|
||||
categories: null,
|
||||
color: null,
|
||||
created: null,
|
||||
updated: '2026-09-01T00:00:00Z',
|
||||
recurrenceRules: null,
|
||||
alerts: null,
|
||||
relatedTo: null,
|
||||
calendarIds: { 'cal-1': true },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeAllDayEvent(id: string, start: string): CalendarEvent {
|
||||
return {
|
||||
id,
|
||||
'@type': 'Event',
|
||||
uid: id,
|
||||
title: 'Event ' + id,
|
||||
start,
|
||||
duration: 'P1D',
|
||||
showWithoutTime: true,
|
||||
calendarIds: { 'cal-1': true },
|
||||
} as unknown as CalendarEvent;
|
||||
}
|
||||
|
||||
type Props = React.ComponentProps<typeof CalendarMonthView>;
|
||||
|
||||
function renderView(overrides: Partial<Props> = {}) {
|
||||
const props: Props = {
|
||||
focus: { date: new Date(2026, 8, 9), nonce: 0 },
|
||||
windowKey: 'month:2026-09-09',
|
||||
// Monday 31 Aug to Sunday 4 Oct: the September grid.
|
||||
rangeStart: new Date(2026, 7, 31),
|
||||
rangeEnd: new Date(2026, 9, 4),
|
||||
selectedDate: new Date(2026, 8, 9),
|
||||
events: [],
|
||||
calendars,
|
||||
onSelectDate: vi.fn(),
|
||||
onSelectEvent: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
return { props, ...render(<CalendarMonthView {...props} />) };
|
||||
}
|
||||
|
||||
function chipSlot(taskId: string): HTMLElement {
|
||||
const chip = document.querySelector<HTMLElement>(`[data-calendar-task="${taskId}"]`);
|
||||
if (!chip?.parentElement) throw new Error(`no chip for ${taskId}`);
|
||||
return chip.parentElement;
|
||||
}
|
||||
|
||||
describe('CalendarMonthView tasks', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('shows a task on its due day, below that day\'s events', () => {
|
||||
renderView({
|
||||
events: [makeAllDayEvent('e1', '2026-09-10')],
|
||||
tasks: [
|
||||
makeTask('thu', '2026-09-10'),
|
||||
makeTask('fri', '2026-09-11T09:00:00', { showWithoutTime: false }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(screen.getByText('Task thu')).toBeInTheDocument();
|
||||
expect(screen.getByText('Task fri')).toBeInTheDocument();
|
||||
|
||||
// Thursday is the fourth column of the week starting Monday 7 Sep and
|
||||
// already has an event in row 0; Friday has no events.
|
||||
// (jsdom rounds the calc() percentage.)
|
||||
const thu = chipSlot('thu');
|
||||
expect(thu.style.left).toMatch(/^calc\(42\.857\d*% \+ 1px\)$/);
|
||||
expect(thu.style.top).toBe('22px');
|
||||
const fri = chipSlot('fri');
|
||||
expect(fri.style.left).toMatch(/^calc\(57\.142\d*% \+ 1px\)$/);
|
||||
expect(fri.style.top).toBe('0px');
|
||||
});
|
||||
|
||||
it('grows the week row to fit a crowded day', () => {
|
||||
renderView({
|
||||
events: [makeAllDayEvent('e1', '2026-09-10')],
|
||||
tasks: ['a', 'b', 'c'].map((id) => makeTask(id, '2026-09-10')),
|
||||
});
|
||||
const row = document.querySelector<HTMLElement>('[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`);
|
||||
});
|
||||
|
||||
it('toggles completion from the circle and opens the editor from the title', () => {
|
||||
const onToggleTaskComplete = vi.fn();
|
||||
const onSelectTask = vi.fn();
|
||||
const task = makeTask('t1', '2026-09-10');
|
||||
const { props } = renderView({ tasks: [task], onToggleTaskComplete, onSelectTask });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'tasks.mark_complete' }));
|
||||
expect(onToggleTaskComplete).toHaveBeenCalledWith(task);
|
||||
expect(onSelectTask).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Task t1' }));
|
||||
expect(onSelectTask).toHaveBeenCalledWith(task);
|
||||
expect(onToggleTaskComplete).toHaveBeenCalledTimes(1);
|
||||
expect(props.onSelectDate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks completed tasks and offers to reopen them', () => {
|
||||
renderView({ tasks: [makeTask('done', '2026-09-10', { progress: 'completed' })] });
|
||||
expect(screen.getByRole('button', { name: 'tasks.mark_incomplete' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Task done')).toHaveClass('line-through');
|
||||
});
|
||||
|
||||
it('shows no task chips when no tasks are passed', () => {
|
||||
renderView({ events: [makeAllDayEvent('e1', '2026-09-10')] });
|
||||
expect(document.querySelector('[data-calendar-task]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows tasks as rings next to the event dots on mobile', () => {
|
||||
renderView({
|
||||
isMobile: true,
|
||||
events: [makeAllDayEvent('e1', '2026-09-10')],
|
||||
tasks: [makeTask('t1', '2026-09-10')],
|
||||
});
|
||||
const ring = document.querySelector<HTMLElement>('[data-calendar-task="t1"]');
|
||||
expect(ring?.tagName).toBe('SPAN');
|
||||
expect(ring).toHaveClass('border');
|
||||
expect(screen.queryByText('Task t1')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -47,10 +47,11 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { useIsFocusedProTab } from "@/hooks/use-pane-context";
|
||||
import { useIsFocusedProTab, useIsPaneScoped } from "@/hooks/use-pane-context";
|
||||
import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
||||
import { filterTasksByCalendars } from "@/lib/calendar-tasks";
|
||||
import {
|
||||
baseEventStoreId,
|
||||
buildFallbackExcludePatch,
|
||||
@@ -66,7 +67,7 @@ import { displayNow } from "@/lib/timezone";
|
||||
import { useTaskStore } from "@/stores/task-store";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights } from "@/lib/jmap/types";
|
||||
import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights, CalendarTask } from "@/lib/jmap/types";
|
||||
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
@@ -138,6 +139,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor);
|
||||
const taskStore = useTaskStore();
|
||||
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
|
||||
const toggleTaskCompleteFn = useTaskStore(state => state.toggleTaskComplete);
|
||||
const { identities } = useIdentityStore();
|
||||
const contacts = useContactStore((s) => s.contacts);
|
||||
const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month";
|
||||
@@ -576,24 +578,45 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
setDefaultModalEndDate(endDate);
|
||||
setDefaultModalAllDay(allDay ?? false);
|
||||
setSelectedDate(d);
|
||||
setShowTaskModal(false);
|
||||
setEditTask(null);
|
||||
setShowEventModal(true);
|
||||
}, [selectedDate, setSelectedDate]);
|
||||
|
||||
const openEditModal = useCallback((event: CalendarEvent) => {
|
||||
setEditEvent(event);
|
||||
setDefaultModalDate(undefined);
|
||||
setShowTaskModal(false);
|
||||
setEditTask(null);
|
||||
setShowEventModal(true);
|
||||
}, []);
|
||||
|
||||
const openCreateTaskModal = useCallback(() => {
|
||||
setEditTask(null);
|
||||
setShowTaskModal(true);
|
||||
// The event and task panels share the space beside the grid; now that
|
||||
// tasks open from the month, week and day views (#1107), opening one
|
||||
// closes the other.
|
||||
const closeEventPanelForTask = useCallback(() => {
|
||||
setShowEventModal(false);
|
||||
setEditEvent(null);
|
||||
setPendingPreview(null);
|
||||
setDefaultCalendarIdForCreate(undefined);
|
||||
setDefaultModalAllDay(false);
|
||||
}, []);
|
||||
|
||||
const openEditTaskModal = useCallback((task: import("@/lib/jmap/types").CalendarTask) => {
|
||||
const openCreateTaskModal = useCallback(() => {
|
||||
closeEventPanelForTask();
|
||||
setEditTask(null);
|
||||
setShowTaskModal(true);
|
||||
}, [closeEventPanelForTask]);
|
||||
|
||||
const openEditTaskModal = useCallback((task: CalendarTask) => {
|
||||
closeEventPanelForTask();
|
||||
setEditTask(task);
|
||||
setShowTaskModal(true);
|
||||
}, []);
|
||||
}, [closeEventPanelForTask]);
|
||||
|
||||
const handleToggleTaskComplete = useCallback((task: CalendarTask) => {
|
||||
if (client) toggleTaskCompleteFn(client, task);
|
||||
}, [client, toggleTaskCompleteFn]);
|
||||
|
||||
const handleSaveTask = useCallback(async (data: Partial<import("@/lib/jmap/types").CalendarTask>) => {
|
||||
if (!client) return;
|
||||
@@ -746,6 +769,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
// that renders while Pro takes over a route stays silent.
|
||||
const proInterfaceActive = useProInterfaceActive();
|
||||
const isFocusedProTab = useIsFocusedProTab();
|
||||
const isPaneScoped = useIsPaneScoped();
|
||||
const eventLinkId = showEventModal && editEvent ? editEvent.id : null;
|
||||
const calendarLinkPath = appPath(buildCalendarPath({
|
||||
view: normalizedViewMode,
|
||||
@@ -1353,6 +1377,13 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
return filtered;
|
||||
}, [events, selectedCalendarIds, showBirthdayCalendar, birthdayEvents]);
|
||||
|
||||
// Tasks on the month, week and day grids follow the calendar filter like
|
||||
// the events do (#1107).
|
||||
const calendarTasks = useMemo(() => {
|
||||
if (!enableCalendarTasks || !showTasksOnCalendar) return undefined;
|
||||
return filterTasksByCalendars(taskStore.tasks, selectedCalendarIds);
|
||||
}, [enableCalendarTasks, showTasksOnCalendar, taskStore.tasks, selectedCalendarIds]);
|
||||
|
||||
useEffect(() => {
|
||||
const hiddenEvents = events.filter((event) => {
|
||||
if (!event.start || !event.calendarIds) {
|
||||
@@ -1460,6 +1491,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
isMobile={isMobile}
|
||||
pendingPreview={pendingPreview}
|
||||
tasks={calendarTasks}
|
||||
onToggleTaskComplete={handleToggleTaskComplete}
|
||||
onSelectTask={openEditTaskModal}
|
||||
/>
|
||||
);
|
||||
case "week":
|
||||
@@ -1480,8 +1514,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
timeFormat={timeFormat}
|
||||
isMobile={isMobile}
|
||||
pendingPreview={pendingPreview}
|
||||
tasks={enableCalendarTasks && showTasksOnCalendar ? taskStore.tasks : undefined}
|
||||
onToggleTaskComplete={(task) => { if (client) taskStore.toggleTaskComplete(client, task); }}
|
||||
tasks={calendarTasks}
|
||||
onToggleTaskComplete={handleToggleTaskComplete}
|
||||
onSelectTask={openEditTaskModal}
|
||||
/>
|
||||
);
|
||||
case "day":
|
||||
@@ -1500,8 +1535,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
timeFormat={timeFormat}
|
||||
isMobile={isMobile}
|
||||
pendingPreview={pendingPreview}
|
||||
tasks={enableCalendarTasks && showTasksOnCalendar ? taskStore.tasks : undefined}
|
||||
onToggleTaskComplete={(task) => { if (client) taskStore.toggleTaskComplete(client, task); }}
|
||||
tasks={calendarTasks}
|
||||
onToggleTaskComplete={handleToggleTaskComplete}
|
||||
onSelectTask={openEditTaskModal}
|
||||
/>
|
||||
);
|
||||
case "agenda":
|
||||
@@ -1534,7 +1570,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
filter={taskStore.filter}
|
||||
showCompleted={taskStore.showCompleted}
|
||||
onSelectTask={openEditTaskModal}
|
||||
onToggleComplete={(task) => { if (client) taskStore.toggleTaskComplete(client, task); }}
|
||||
onToggleComplete={handleToggleTaskComplete}
|
||||
selectedTaskId={taskStore.selectedTaskId}
|
||||
onQuickCreate={(title) => {
|
||||
if (client) {
|
||||
@@ -1851,10 +1887,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
d.setHours(0, 0, 0, 0);
|
||||
openCreateModal(d, undefined, true);
|
||||
}}
|
||||
onNewTask={enableCalendarTasks ? () => {
|
||||
setEditTask(null);
|
||||
setShowTaskModal(true);
|
||||
} : undefined}
|
||||
onNewTask={enableCalendarTasks ? openCreateTaskModal : undefined}
|
||||
onGoToToday={goToToday}
|
||||
/>
|
||||
);
|
||||
@@ -1900,6 +1933,26 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile task editor: full screen, like the event editor. */}
|
||||
{showTaskModal && isMobile && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={editTask ? t("tasks.edit") : t("tasks.create")}
|
||||
className={isPaneScoped ? "absolute inset-0 z-40 flex flex-col bg-background" : "fixed inset-0 z-50 flex flex-col bg-background"}
|
||||
>
|
||||
<TaskModal
|
||||
key={editTask?.id ?? 'new-task'}
|
||||
task={editTask}
|
||||
calendars={displayCalendars}
|
||||
onSave={handleSaveTask}
|
||||
onDelete={handleDeleteTask}
|
||||
onClose={() => { setShowTaskModal(false); setEditTask(null); }}
|
||||
isMobile={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showImportModal && client && (
|
||||
<ICalImportModal
|
||||
calendars={displayCalendars}
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import { useMemo, useEffect, useLayoutEffect, useRef, useState, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
|
||||
import { format, isSameDay, parseISO, eachDayOfInterval, differenceInCalendarDays } from "date-fns";
|
||||
import { format, isSameDay, eachDayOfInterval, differenceInCalendarDays } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check } from "@/components/icons";
|
||||
import { EventCard } from "./event-card";
|
||||
import { QuickEventInput } from "./quick-event-input";
|
||||
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
||||
import { displayNow, isDisplayToday } from "@/lib/timezone";
|
||||
import { groupTasksByDueDay } from "@/lib/calendar-tasks";
|
||||
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
|
||||
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||
import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "@/hooks/use-scroll-window";
|
||||
@@ -31,6 +32,7 @@ interface CalendarDayViewProps extends ScrollWindowViewProps {
|
||||
pendingPreview?: PendingEventPreview | null;
|
||||
tasks?: CalendarTask[];
|
||||
onToggleTaskComplete?: (task: CalendarTask) => void;
|
||||
onSelectTask?: (task: CalendarTask) => void;
|
||||
}
|
||||
|
||||
const HOUR_HEIGHT = 64;
|
||||
@@ -60,6 +62,7 @@ export function CalendarDayView({
|
||||
pendingPreview,
|
||||
tasks,
|
||||
onToggleTaskComplete,
|
||||
onSelectTask,
|
||||
}: CalendarDayViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
// Grid days / event dates are display dates (local fields = wall-clock in
|
||||
@@ -142,20 +145,7 @@ export function CalendarDayView({
|
||||
return map;
|
||||
}, [events]);
|
||||
|
||||
const tasksByDay = useMemo(() => {
|
||||
const map = new Map<string, CalendarTask[]>();
|
||||
if (!tasks?.length) return map;
|
||||
for (const task of tasks) {
|
||||
if (!task.due) continue;
|
||||
try {
|
||||
const key = format(parseISO(task.due), "yyyy-MM-dd");
|
||||
const existing = map.get(key) || [];
|
||||
existing.push(task);
|
||||
map.set(key, existing);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
return map;
|
||||
}, [tasks]);
|
||||
const tasksByDay = useMemo(() => groupTasksByDueDay(tasks), [tasks]);
|
||||
|
||||
// Column layouts are the costly part of a render; with months of columns
|
||||
// they must not be redone on every scroll-driven re-render.
|
||||
@@ -355,10 +345,13 @@ export function CalendarDayView({
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
data-calendar-task={task.id}
|
||||
onClick={() => onSelectTask?.(task)}
|
||||
className="flex items-center gap-1.5 px-1.5 py-0.5 rounded text-xs cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
style={{ borderLeft: `3px solid ${color}` }}
|
||||
style={{ borderInlineStart: `3px solid ${color}` }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onToggleTaskComplete?.(task); }}
|
||||
className={cn(
|
||||
"flex-shrink-0 w-3.5 h-3.5 rounded-full border flex items-center justify-center",
|
||||
@@ -366,6 +359,7 @@ export function CalendarDayView({
|
||||
? "bg-success border-success text-success-foreground"
|
||||
: "border-muted-foreground/40 hover:border-primary"
|
||||
)}
|
||||
aria-label={isCompleted ? t("tasks.mark_incomplete") : t("tasks.mark_complete")}
|
||||
>
|
||||
{isCompleted && <Check className="h-2.5 w-2.5" />}
|
||||
</button>
|
||||
|
||||
@@ -5,8 +5,10 @@ import { useTranslations } from "next-intl";
|
||||
import { format, parseISO, eachDayOfInterval, addDays } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EventCard } from "./event-card";
|
||||
import { CalendarTaskChip } from "./task-chip";
|
||||
import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import { groupTasksByDueDay } from "@/lib/calendar-tasks";
|
||||
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
@@ -30,6 +32,9 @@ interface CalendarMonthViewProps extends ScrollWindowViewProps {
|
||||
firstDayOfWeek?: number;
|
||||
isMobile?: boolean;
|
||||
pendingPreview?: PendingEventPreview | null;
|
||||
tasks?: CalendarTask[];
|
||||
onToggleTaskComplete?: (task: CalendarTask) => void;
|
||||
onSelectTask?: (task: CalendarTask) => void;
|
||||
}
|
||||
|
||||
/** Fraction of the viewport height at which the "current month" is sampled. */
|
||||
@@ -56,6 +61,9 @@ export function CalendarMonthView({
|
||||
onCreateAtTime,
|
||||
isMobile,
|
||||
pendingPreview,
|
||||
tasks,
|
||||
onToggleTaskComplete,
|
||||
onSelectTask,
|
||||
}: CalendarMonthViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
|
||||
@@ -115,13 +123,28 @@ export function CalendarMonthView({
|
||||
return map;
|
||||
}, [events]);
|
||||
|
||||
const tasksByDate = useMemo(() => groupTasksByDueDay(tasks), [tasks]);
|
||||
|
||||
const weekSegments = useMemo(() => {
|
||||
return weeks.map((week) => {
|
||||
const segments = buildWeekSegments(events, week);
|
||||
const rowCount = segments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
|
||||
return { week, segments, rowCount };
|
||||
// Tasks stack under the lowest event of their own day rather than of
|
||||
// the whole week, so a busy Monday does not push Friday's tasks down.
|
||||
const dayEventRows = week.map((_, dayIndex) => segments.reduce(
|
||||
(rows, segment) => dayIndex >= segment.startIndex && dayIndex < segment.startIndex + segment.span
|
||||
? Math.max(rows, segment.row + 1)
|
||||
: rows,
|
||||
0,
|
||||
));
|
||||
const dayTasks = week.map((day) => tasksByDate.get(format(day, "yyyy-MM-dd")) ?? []);
|
||||
const contentRows = dayTasks.reduce(
|
||||
(rows, dayTaskList, dayIndex) => Math.max(rows, dayEventRows[dayIndex] + dayTaskList.length),
|
||||
rowCount,
|
||||
);
|
||||
return { week, segments, rowCount, dayEventRows, dayTasks, contentRows };
|
||||
});
|
||||
}, [events, weeks]);
|
||||
}, [events, weeks, tasksByDate]);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const topSentinelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -258,10 +281,10 @@ export function CalendarMonthView({
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div ref={topSentinelRef} data-testid="month-top-sentinel" className="h-px flex-shrink-0" />
|
||||
{weekSegments.map(({ week, segments, rowCount }) => (
|
||||
{weekSegments.map(({ week, segments, rowCount, dayEventRows, dayTasks, contentRows }) => (
|
||||
<div key={dayKey(week[0])} data-week={dayKey(week[0])} className="relative flex-shrink-0 border-b border-border" role="row" style={{
|
||||
minHeight: showChips
|
||||
? Math.max(rowMinHeight, overlayTop + 4 + rowCount * rowHeight + 8)
|
||||
? Math.max(rowMinHeight, overlayTop + 4 + contentRows * rowHeight + 8)
|
||||
: rowMinHeight,
|
||||
}}>
|
||||
<div className="grid grid-cols-7 h-full">
|
||||
@@ -271,6 +294,7 @@ export function CalendarMonthView({
|
||||
const today = checkIsToday(day);
|
||||
const key = format(day, "yyyy-MM-dd");
|
||||
const dayEvents = eventsByDate.get(key) || [];
|
||||
const dayTaskList = dayTasks[dayIndex];
|
||||
const fullDateLabel = formatFullDate(day);
|
||||
const previous = dayIndex > 0 ? week[dayIndex - 1] : addDays(day, -1);
|
||||
const firstOfMonth = !checkIsSameMonth(day, previous);
|
||||
@@ -328,7 +352,19 @@ export function CalendarMonthView({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{dayEvents.length > 3 && (
|
||||
{/* Tasks are rings where events are filled dots. */}
|
||||
{dayTaskList.slice(0, Math.max(0, 3 - dayEvents.length)).map((task) => {
|
||||
const calId = Object.keys(task.calendarIds).find((id) => calendarMap.has(id));
|
||||
return (
|
||||
<span
|
||||
key={`task-${task.id}`}
|
||||
data-calendar-task={task.id}
|
||||
className={cn("w-1.5 h-1.5 rounded-full border", task.progress === "completed" && "opacity-40")}
|
||||
style={{ borderColor: (calId && calendarMap.get(calId)?.color) || "#3b82f6" }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{dayEvents.length + dayTaskList.length > 3 && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
|
||||
)}
|
||||
{pendingPreview && checkIsSameDay(pendingPreview.start, day) && (
|
||||
@@ -347,7 +383,7 @@ export function CalendarMonthView({
|
||||
{showChips && pendingPreview && (() => {
|
||||
const previewDayIdx = week.findIndex(d => checkIsSameDay(d, pendingPreview.start));
|
||||
if (previewDayIdx === -1) return null;
|
||||
const previewRow = rowCount;
|
||||
const previewRow = Math.max(rowCount, dayEventRows[previewDayIdx] + dayTasks[previewDayIdx].length);
|
||||
const cal = calendarMap.get(pendingPreview.calendarId);
|
||||
const color = cal?.color || "#3b82f6";
|
||||
return (
|
||||
@@ -408,6 +444,33 @@ export function CalendarMonthView({
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showChips && dayTasks.some((dayTaskList) => dayTaskList.length > 0) && (
|
||||
<div className="absolute inset-x-0 pointer-events-none" style={{ top: overlayTop }}>
|
||||
{dayTasks.map((dayTaskList, dayIndex) => dayTaskList.map((task, taskIndex) => {
|
||||
const calId = Object.keys(task.calendarIds).find((id) => calendarMap.has(id));
|
||||
return (
|
||||
<div
|
||||
key={`task-${task.id}`}
|
||||
className="absolute px-0.5 pointer-events-auto"
|
||||
style={{
|
||||
left: `calc(${(dayIndex / 7) * 100}% + 1px)`,
|
||||
width: `calc(${(1 / 7) * 100}% - 2px)`,
|
||||
top: (dayEventRows[dayIndex] + taskIndex) * rowHeight,
|
||||
height: chipHeight,
|
||||
}}
|
||||
>
|
||||
<CalendarTaskChip
|
||||
task={task}
|
||||
calendar={calId ? calendarMap.get(calId) : undefined}
|
||||
onToggleComplete={onToggleTaskComplete}
|
||||
onSelect={onSelectTask}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomSentinelRef} data-testid="month-bottom-sentinel" className="h-px flex-shrink-0" />
|
||||
|
||||
@@ -4,12 +4,13 @@ import { useMemo, useEffect, useLayoutEffect, useRef, useState, useCallback } fr
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
|
||||
import {
|
||||
startOfWeek, format, isSameDay, parseISO, eachDayOfInterval, differenceInCalendarDays,
|
||||
startOfWeek, format, isSameDay, eachDayOfInterval, differenceInCalendarDays,
|
||||
} from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check } from "@/components/icons";
|
||||
import { EventCard } from "./event-card";
|
||||
import { QuickEventInput } from "./quick-event-input";
|
||||
import { CalendarTaskChip } from "./task-chip";
|
||||
import { groupTasksByDueDay } from "@/lib/calendar-tasks";
|
||||
import { buildTimedFullDayWeekSegments, buildWeekSegmentsRaw, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents, packWeekSegments } from "@/lib/calendar-utils";
|
||||
import { displayNow, isDisplayToday } from "@/lib/timezone";
|
||||
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
|
||||
@@ -35,6 +36,7 @@ interface CalendarWeekViewProps extends ScrollWindowViewProps {
|
||||
pendingPreview?: PendingEventPreview | null;
|
||||
tasks?: CalendarTask[];
|
||||
onToggleTaskComplete?: (task: CalendarTask) => void;
|
||||
onSelectTask?: (task: CalendarTask) => void;
|
||||
}
|
||||
|
||||
const HOUR_HEIGHT = 60;
|
||||
@@ -67,6 +69,7 @@ export function CalendarWeekView({
|
||||
pendingPreview,
|
||||
tasks,
|
||||
onToggleTaskComplete,
|
||||
onSelectTask,
|
||||
}: CalendarWeekViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
// Grid days / event dates are display dates (local fields = wall-clock in
|
||||
@@ -175,21 +178,7 @@ export function CalendarWeekView({
|
||||
return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
|
||||
}, [allDaySegments]);
|
||||
|
||||
// Tasks grouped by day
|
||||
const tasksByDay = useMemo(() => {
|
||||
if (!tasks?.length) return new Map<string, CalendarTask[]>();
|
||||
const map = new Map<string, CalendarTask[]>();
|
||||
for (const task of tasks) {
|
||||
if (!task.due) continue;
|
||||
try {
|
||||
const key = format(parseISO(task.due), "yyyy-MM-dd");
|
||||
const existing = map.get(key) || [];
|
||||
existing.push(task);
|
||||
map.set(key, existing);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
return map;
|
||||
}, [tasks]);
|
||||
const tasksByDay = useMemo(() => groupTasksByDueDay(tasks), [tasks]);
|
||||
|
||||
// Max tasks on any single loaded day
|
||||
const taskRowCount = useMemo(() => {
|
||||
@@ -384,39 +373,25 @@ export function CalendarWeekView({
|
||||
{days.map((day, dayIndex) => {
|
||||
const key = format(day, "yyyy-MM-dd");
|
||||
const dayTasks = tasksByDay.get(key) || [];
|
||||
return dayTasks.map((task, taskIndex) => {
|
||||
const isCompleted = task.progress === "completed";
|
||||
const cal = calendars.find(c => task.calendarIds[c.id]);
|
||||
const color = cal?.color || "#3b82f6";
|
||||
return (
|
||||
<div
|
||||
key={`task-${task.id}`}
|
||||
className="absolute px-0.5 pointer-events-auto"
|
||||
style={{
|
||||
left: `calc(${(dayIndex / colCount) * 100}% + 1px)`,
|
||||
width: `calc(${(1 / colCount) * 100}% - 2px)`,
|
||||
top: taskIndex * 24,
|
||||
height: 20,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded text-[10px] leading-[20px] font-medium px-1.5 truncate flex items-center gap-1 cursor-pointer hover:opacity-80"
|
||||
style={{ backgroundColor: `${color}20`, borderLeft: `3px solid ${color}` }}
|
||||
onClick={() => onToggleTaskComplete?.(task)}
|
||||
>
|
||||
<span className={cn(
|
||||
"w-2.5 h-2.5 rounded-full border flex-shrink-0 flex items-center justify-center",
|
||||
isCompleted ? "bg-success border-success" : "border-current"
|
||||
)}>
|
||||
{isCompleted && <Check className="h-2 w-2 text-white" />}
|
||||
</span>
|
||||
<span className={cn("truncate", isCompleted && "line-through text-muted-foreground")}>
|
||||
{task.title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return dayTasks.map((task, taskIndex) => (
|
||||
<div
|
||||
key={`task-${task.id}`}
|
||||
className="absolute px-0.5 pointer-events-auto"
|
||||
style={{
|
||||
left: `calc(${(dayIndex / colCount) * 100}% + 1px)`,
|
||||
width: `calc(${(1 / colCount) * 100}% - 2px)`,
|
||||
top: taskIndex * 24,
|
||||
height: 20,
|
||||
}}
|
||||
>
|
||||
<CalendarTaskChip
|
||||
task={task}
|
||||
calendar={calendars.find(c => task.calendarIds[c.id])}
|
||||
onToggleComplete={onToggleTaskComplete}
|
||||
onSelect={onSelectTask}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check } from "@/components/icons";
|
||||
import type { Calendar, CalendarTask } from "@/lib/jmap/types";
|
||||
|
||||
interface CalendarTaskChipProps {
|
||||
task: CalendarTask;
|
||||
calendar?: Calendar;
|
||||
onToggleComplete?: (task: CalendarTask) => void;
|
||||
onSelect?: (task: CalendarTask) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const t = useTranslations("calendar");
|
||||
const isCompleted = task.progress === "completed";
|
||||
const color = calendar?.color || "#3b82f6";
|
||||
const title = task.title || t("tasks.no_title");
|
||||
|
||||
return (
|
||||
<div
|
||||
data-calendar-task={task.id}
|
||||
className={cn("h-full rounded text-[10px] font-medium flex items-center overflow-hidden", 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"
|
||||
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",
|
||||
isCompleted ? "bg-success border-success text-success-foreground" : "group-hover:bg-background",
|
||||
)}
|
||||
style={isCompleted ? undefined : { borderColor: color }}
|
||||
>
|
||||
{isCompleted && <Check className="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",
|
||||
isCompleted && "line-through text-muted-foreground",
|
||||
)}
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { filterTasksByCalendars, groupTasksByDueDay, taskDueDayKey } from '../calendar-tasks';
|
||||
import type { CalendarTask } from '@/lib/jmap/types';
|
||||
|
||||
// #1107: the month, week and day views place tasks on their due day and
|
||||
// hide tasks from calendars switched off in the sidebar.
|
||||
|
||||
function makeTask(id: string, overrides: Partial<CalendarTask> = {}): CalendarTask {
|
||||
return {
|
||||
id,
|
||||
'@type': 'Task',
|
||||
uid: id,
|
||||
title: 'Task ' + id,
|
||||
description: '',
|
||||
due: '2026-09-10',
|
||||
start: null,
|
||||
duration: null,
|
||||
timeZone: null,
|
||||
showWithoutTime: true,
|
||||
progress: 'needs-action',
|
||||
priority: 0,
|
||||
privacy: 'public',
|
||||
keywords: null,
|
||||
categories: null,
|
||||
color: null,
|
||||
created: null,
|
||||
updated: '2026-09-01T00:00:00Z',
|
||||
recurrenceRules: null,
|
||||
alerts: null,
|
||||
relatedTo: null,
|
||||
calendarIds: { 'cal-1': true },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('filterTasksByCalendars', () => {
|
||||
it('keeps tasks in at least one visible calendar', () => {
|
||||
const tasks = [
|
||||
makeTask('a', { calendarIds: { 'cal-1': true } }),
|
||||
makeTask('b', { calendarIds: { 'cal-2': true } }),
|
||||
makeTask('c', { calendarIds: { 'cal-2': true, 'cal-3': true } }),
|
||||
];
|
||||
expect(filterTasksByCalendars(tasks, ['cal-1', 'cal-3']).map((t) => t.id)).toEqual(['a', 'c']);
|
||||
expect(filterTasksByCalendars(tasks, [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('taskDueDayKey', () => {
|
||||
it('is the local day of the due date, with or without a time', () => {
|
||||
expect(taskDueDayKey(makeTask('a', { due: '2026-09-10' }))).toBe('2026-09-10');
|
||||
expect(taskDueDayKey(makeTask('a', { due: '2026-09-10T23:30:00', showWithoutTime: false }))).toBe('2026-09-10');
|
||||
});
|
||||
|
||||
it('is null for tasks without a usable due date', () => {
|
||||
expect(taskDueDayKey(makeTask('a', { due: null }))).toBeNull();
|
||||
expect(taskDueDayKey(makeTask('a', { due: 'not a date' }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupTasksByDueDay', () => {
|
||||
it('groups by due day and skips tasks without one', () => {
|
||||
const map = groupTasksByDueDay([
|
||||
makeTask('a', { due: '2026-09-10' }),
|
||||
makeTask('b', { due: '2026-09-11' }),
|
||||
makeTask('c', { due: null }),
|
||||
makeTask('d', { due: '2026-09-10T09:00:00', showWithoutTime: false }),
|
||||
]);
|
||||
expect([...map.keys()].sort()).toEqual(['2026-09-10', '2026-09-11']);
|
||||
expect(map.get('2026-09-10')?.map((t) => t.id).sort()).toEqual(['a', 'd']);
|
||||
});
|
||||
|
||||
it('orders a day by untimed first, then due time, then title', () => {
|
||||
const map = groupTasksByDueDay([
|
||||
makeTask('late', { due: '2026-09-10T17:00:00', showWithoutTime: false, title: 'A' }),
|
||||
makeTask('early', { due: '2026-09-10T08:00:00', showWithoutTime: false, title: 'Z' }),
|
||||
makeTask('allday-b', { due: '2026-09-10', title: 'B' }),
|
||||
makeTask('allday-a', { due: '2026-09-10', title: 'A' }),
|
||||
]);
|
||||
expect(map.get('2026-09-10')?.map((t) => t.id)).toEqual(['allday-a', 'allday-b', 'early', 'late']);
|
||||
});
|
||||
|
||||
it('is empty for no tasks', () => {
|
||||
expect(groupTasksByDueDay(undefined).size).toBe(0);
|
||||
expect(groupTasksByDueDay([]).size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { format, parseISO } from "date-fns";
|
||||
import type { CalendarTask } from "@/lib/jmap/types";
|
||||
|
||||
/**
|
||||
* Tasks shown on the calendar grid (#1107): only tasks with a due date, and
|
||||
* only those in a calendar the sidebar currently shows, the same rule the
|
||||
* events follow.
|
||||
*/
|
||||
export function filterTasksByCalendars(tasks: CalendarTask[], selectedCalendarIds: string[]): CalendarTask[] {
|
||||
const selected = new Set(selectedCalendarIds);
|
||||
return tasks.filter((task) => Object.keys(task.calendarIds ?? {}).some((id) => selected.has(id)));
|
||||
}
|
||||
|
||||
/** yyyy-MM-dd of the day a task is due, or null when it has no valid due date. */
|
||||
export function taskDueDayKey(task: CalendarTask): string | null {
|
||||
if (!task.due) return null;
|
||||
const due = parseISO(task.due);
|
||||
if (Number.isNaN(due.getTime())) return null;
|
||||
return format(due, "yyyy-MM-dd");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tasks keyed by the day they are due. Within a day, tasks without a time
|
||||
* come first, then by due time and title, so the order does not depend on
|
||||
* the order the server returned them in.
|
||||
*/
|
||||
export function groupTasksByDueDay(tasks: CalendarTask[] | undefined): Map<string, CalendarTask[]> {
|
||||
const map = new Map<string, CalendarTask[]>();
|
||||
if (!tasks?.length) return map;
|
||||
for (const task of tasks) {
|
||||
const key = taskDueDayKey(task);
|
||||
if (!key) continue;
|
||||
const existing = map.get(key);
|
||||
if (existing) existing.push(task);
|
||||
else map.set(key, [task]);
|
||||
}
|
||||
for (const dayTasks of map.values()) {
|
||||
dayTasks.sort((a, b) => {
|
||||
const aTimed = a.showWithoutTime ? 0 : 1;
|
||||
const bTimed = b.showWithoutTime ? 0 : 1;
|
||||
if (aTimed !== bTimed) return aTimed - bTimed;
|
||||
const byDue = (a.due ?? "").localeCompare(b.due ?? "");
|
||||
if (byDue !== 0) return byDue;
|
||||
return (a.title ?? "").localeCompare(b.title ?? "");
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -2990,7 +2990,7 @@
|
||||
"enable_tasks": "تفعيل المهام",
|
||||
"enable_tasks_desc": "إظهار عرض للمهام في التقويم لإدارة قوائم المهام",
|
||||
"show_tasks_on_calendar": "إظهار المهام في التقويم",
|
||||
"show_tasks_on_calendar_desc": "عرض شارات المهام في عروض التقويم اليومي والأسبوعي",
|
||||
"show_tasks_on_calendar_desc": "عرض المهام في عروض التقويم الشهري والأسبوعي واليومي",
|
||||
"hover_preview": "معاينة عند التمرير فوق الحدث",
|
||||
"hover_preview_desc": "إظهار نافذة تفاصيل منبثقة عند التمرير فوق أحداث التقويم",
|
||||
"hover_preview_instant": "فوري",
|
||||
|
||||
@@ -2990,7 +2990,7 @@
|
||||
"enable_tasks": "Activa les tasques",
|
||||
"enable_tasks_desc": "Mostra una vista de tasques al calendari per gestionar pendents",
|
||||
"show_tasks_on_calendar": "Mostra les tasques al calendari",
|
||||
"show_tasks_on_calendar_desc": "Mostra etiquetes de tasca a les vistes de dia i setmana del calendari",
|
||||
"show_tasks_on_calendar_desc": "Mostra les tasques a les vistes de mes, setmana i dia del calendari",
|
||||
"hover_preview": "Previsualització en passar el cursor",
|
||||
"hover_preview_desc": "Mostra una finestra emergent de detalls en passar el cursor per sobre dels esdeveniments",
|
||||
"hover_preview_instant": "Instantània",
|
||||
|
||||
@@ -2990,7 +2990,7 @@
|
||||
"enable_tasks": "Povolit úkoly",
|
||||
"enable_tasks_desc": "Zobrazovat zobrazení úkolů v kalendáři pro správu úkolů",
|
||||
"show_tasks_on_calendar": "Zobrazit úkoly v kalendáři",
|
||||
"show_tasks_on_calendar_desc": "Zobrazovat značky úkolů v denním a týdenním zobrazení kalendáře",
|
||||
"show_tasks_on_calendar_desc": "Zobrazovat úkoly v měsíčním, týdenním a denním zobrazení kalendáře",
|
||||
"hover_preview": "Náhled události po přejetí",
|
||||
"hover_preview_desc": "Zobrazovat vyskakovací okno s podrobnostmi při najetí myší na události v kalendáři",
|
||||
"hover_preview_instant": "Okamžitě",
|
||||
|
||||
@@ -2990,7 +2990,7 @@
|
||||
"enable_tasks": "Aktivér opgaver",
|
||||
"enable_tasks_desc": "Vis en opgavevisning i kalenderen til styring af gøremål",
|
||||
"show_tasks_on_calendar": "Vis opgaver på kalender",
|
||||
"show_tasks_on_calendar_desc": "Vis opgave-chips i dags- og ugekalendervisningerne",
|
||||
"show_tasks_on_calendar_desc": "Vis opgaver i måneds-, uge- og dagsvisningen",
|
||||
"hover_preview": "Hover-forhåndsvisning",
|
||||
"hover_preview_desc": "Vis en detalje-popover når du holder musen over kalenderbegivenheder",
|
||||
"hover_preview_instant": "Øjeblikkeligt",
|
||||
|
||||
@@ -2990,7 +2990,7 @@
|
||||
"enable_tasks": "Aufgaben aktivieren",
|
||||
"enable_tasks_desc": "Eine Aufgabenansicht im Kalender zum Verwalten von To-dos anzeigen",
|
||||
"show_tasks_on_calendar": "Aufgaben im Kalender anzeigen",
|
||||
"show_tasks_on_calendar_desc": "Aufgabenmarkierungen in der Tages- und Wochenansicht anzeigen",
|
||||
"show_tasks_on_calendar_desc": "Aufgaben in der Monats-, Wochen- und Tagesansicht anzeigen",
|
||||
"hover_preview": "Ereignis-Hover-Vorschau",
|
||||
"hover_preview_desc": "Ein Detail-Popover anzeigen, wenn über Kalender-Ereignisse gehovert wird",
|
||||
"hover_preview_instant": "Sofort",
|
||||
|
||||
@@ -2990,7 +2990,7 @@
|
||||
"enable_tasks": "Enable tasks",
|
||||
"enable_tasks_desc": "Show a tasks view in the calendar for managing to-dos",
|
||||
"show_tasks_on_calendar": "Show tasks on calendar",
|
||||
"show_tasks_on_calendar_desc": "Display task chips on the day and week calendar views",
|
||||
"show_tasks_on_calendar_desc": "Display tasks on the month, week and day calendar views",
|
||||
"hover_preview": "Event hover preview",
|
||||
"hover_preview_desc": "Show a detail popover when hovering over calendar events",
|
||||
"hover_preview_instant": "Instant",
|
||||
|
||||
@@ -2990,7 +2990,7 @@
|
||||
"enable_tasks": "Activar tareas",
|
||||
"enable_tasks_desc": "Mostrar una vista de tareas en el calendario para gestionar tareas pendientes",
|
||||
"show_tasks_on_calendar": "Mostrar tareas en el calendario",
|
||||
"show_tasks_on_calendar_desc": "Mostrar etiquetas de tareas en las vistas diarias y semanales del calendario",
|
||||
"show_tasks_on_calendar_desc": "Mostrar las tareas en las vistas mensual, semanal y diaria del calendario",
|
||||
"hover_preview": "Vista previa al pasar el ratón",
|
||||
"hover_preview_desc": "Mostrar un popover de detalles al pasar el ratón sobre eventos del calendario",
|
||||
"hover_preview_instant": "Instantáneo",
|
||||
|
||||
@@ -2990,7 +2990,7 @@
|
||||
"enable_tasks": "فعال کردن وظایف",
|
||||
"enable_tasks_desc": "نمایش نمای وظایف در تقویم برای مدیریت کارها",
|
||||
"show_tasks_on_calendar": "نمایش وظایف روی تقویم",
|
||||
"show_tasks_on_calendar_desc": "نمایش برچسب وظایف در نماهای روز و هفته",
|
||||
"show_tasks_on_calendar_desc": "نمایش وظایف در نماهای ماه، هفته و روز",
|
||||
"hover_preview": "پیشنمایش هاور رویداد",
|
||||
"hover_preview_desc": "نمایش popover جزئیات هنگام هاور روی رویدادهای تقویم",
|
||||
"hover_preview_instant": "بلافاصله",
|
||||
|
||||
@@ -2990,7 +2990,7 @@
|
||||
"enable_tasks": "Activer les tâches",
|
||||
"enable_tasks_desc": "Afficher une vue des tâches dans le calendrier pour gérer les choses à faire",
|
||||
"show_tasks_on_calendar": "Afficher les tâches dans le calendrier",
|
||||
"show_tasks_on_calendar_desc": "Afficher les pastilles des tâches dans les vues jour et semaine du calendrier",
|
||||
"show_tasks_on_calendar_desc": "Afficher les tâches dans les vues mois, semaine et jour du calendrier",
|
||||
"hover_preview": "Aperçu au survol",
|
||||
"hover_preview_desc": "Afficher un popover de détails au survol des événements du calendrier",
|
||||
"hover_preview_instant": "Instantané",
|
||||
|
||||
@@ -2914,7 +2914,7 @@
|
||||
"enable_tasks": "אפשר משימות",
|
||||
"enable_tasks_desc": "הצג תצוגת משימות בלוח השנה לניהול מטלות",
|
||||
"show_tasks_on_calendar": "הצג משימות בלוח השנה",
|
||||
"show_tasks_on_calendar_desc": "הצג שבבי משימות בתצוגות יומן היום והשבוע",
|
||||
"show_tasks_on_calendar_desc": "הצג משימות בתצוגות החודש, השבוע והיום של היומן",
|
||||
"hover_preview": "תצוגה מקדימה של ריחוף לאירוע",
|
||||
"hover_preview_desc": "הצג חלון קופץ לפרטי בעת ריחוף מעל אירועי לוח שנה",
|
||||
"hover_preview_instant": "מיידי",
|
||||
|
||||
@@ -2990,7 +2990,7 @@
|
||||
"enable_tasks": "Feladatok engedélyezése",
|
||||
"enable_tasks_desc": "Feladatnézet mutatása a naptárban a teendők kezeléséhez",
|
||||
"show_tasks_on_calendar": "Feladatok mutatása a naptárban",
|
||||
"show_tasks_on_calendar_desc": "Feladat chip-ek mutatása a nap és hét naptár nézetekben",
|
||||
"show_tasks_on_calendar_desc": "Feladatok mutatása a havi, heti és napi naptárnézetben",
|
||||
"hover_preview": "Esemény előnézet egérrel",
|
||||
"hover_preview_desc": "Részletes felugró ablak mutatása az események fölé húzva",
|
||||
"hover_preview_instant": "Azonnali",
|
||||
|
||||
@@ -2990,7 +2990,7 @@
|
||||
"enable_tasks": "Abilita attività",
|
||||
"enable_tasks_desc": "Mostra una vista attività nel calendario per gestire le cose da fare",
|
||||
"show_tasks_on_calendar": "Mostra attività nel calendario",
|
||||
"show_tasks_on_calendar_desc": "Mostra gli indicatori delle attività nelle viste giornaliera e settimanale del calendario",
|
||||
"show_tasks_on_calendar_desc": "Mostra le attività nelle viste mensile, settimanale e giornaliera del calendario",
|
||||
"hover_preview": "Anteprima al passaggio del mouse",
|
||||
"hover_preview_desc": "Mostra un popover dettagliato al passaggio del mouse sugli eventi del calendario",
|
||||
"hover_preview_instant": "Istantaneo",
|
||||
|
||||
@@ -2990,7 +2990,7 @@
|
||||
"enable_tasks": "タスクを有効化",
|
||||
"enable_tasks_desc": "カレンダーにタスク表示を追加して、ToDoを管理します",
|
||||
"show_tasks_on_calendar": "カレンダーにタスクを表示",
|
||||
"show_tasks_on_calendar_desc": "日表示と週表示にタスクチップを表示します",
|
||||
"show_tasks_on_calendar_desc": "月表示・週表示・日表示にタスクを表示します",
|
||||
"hover_preview": "イベントホバープレビュー",
|
||||
"hover_preview_desc": "カレンダーイベントにマウスを合わせたときに詳細ポップオーバーを表示",
|
||||
"hover_preview_instant": "即時",
|
||||
|
||||
@@ -2990,7 +2990,7 @@
|
||||
"enable_tasks": "할 일 사용",
|
||||
"enable_tasks_desc": "캘린더에 할 일을 관리할 수 있는 메뉴를 추가해요",
|
||||
"show_tasks_on_calendar": "캘린더에 할 일 표시",
|
||||
"show_tasks_on_calendar_desc": "일간 및 주간 캘린더에 할 일 배지를 보여줘요",
|
||||
"show_tasks_on_calendar_desc": "월간, 주간 및 일간 캘린더에 할 일을 보여줘요",
|
||||
"hover_preview": "마우스 호버 미리보기",
|
||||
"hover_preview_desc": "캘린더 일정 위에 마우스를 올리면 세부 정보 팝업을 띄워요",
|
||||
"hover_preview_instant": "즉시",
|
||||
|
||||
@@ -2989,7 +2989,7 @@
|
||||
"enable_tasks": "Iespējot uzdevumus",
|
||||
"enable_tasks_desc": "Rādīt uzdevumu sadaļu kalendārā",
|
||||
"show_tasks_on_calendar": "Rādīt uzdevumus kalendārā",
|
||||
"show_tasks_on_calendar_desc": "Rādīt uzdevumu ierakstus dienas un nedēļas skatā",
|
||||
"show_tasks_on_calendar_desc": "Rādīt uzdevumus mēneša, nedēļas un dienas skatā",
|
||||
"hover_preview": "Priekšskatījums uzvirzot",
|
||||
"hover_preview_desc": "Rādīt informāciju, uzvirzot kursoru uz pasākuma",
|
||||
"hover_preview_instant": "Uzreiz",
|
||||
|
||||
@@ -2990,7 +2990,7 @@
|
||||
"enable_tasks": "Даалгавруудыг идэвхжүүлэх",
|
||||
"enable_tasks_desc": "Хийх ажлуудыг удирдахын тулд календарьт ажлын харагдах байдлыг харуулах",
|
||||
"show_tasks_on_calendar": "Хуанли дээр даалгавруудыг харуулах",
|
||||
"show_tasks_on_calendar_desc": "Даалгаврын чипийг өдөр, долоо хоногийн хуанлийн харагдац дээр харуулах",
|
||||
"show_tasks_on_calendar_desc": "Даалгаврыг сар, долоо хоног, өдрийн хуанлийн харагдац дээр харуулах",
|
||||
"hover_preview": "Үйл явдлыг товшино уу",
|
||||
"hover_preview_desc": "Календарийн үйл явдлууд дээр хулганыг гүйлгэх үед дэлгэрэнгүй поповыг харуулах",
|
||||
"hover_preview_instant": "Шуурхай",
|
||||
|
||||
@@ -2990,7 +2990,7 @@
|
||||
"enable_tasks": "Aktiver oppgaver",
|
||||
"enable_tasks_desc": "Vis en oppgavevisning i kalenderen for å administrere gjøremål",
|
||||
"show_tasks_on_calendar": "Vis oppgaver i kalenderen",
|
||||
"show_tasks_on_calendar_desc": "Vis oppgavemarkører i dags- og ukesvisningen",
|
||||
"show_tasks_on_calendar_desc": "Vis oppgaver i måneds-, uke- og dagsvisningen",
|
||||
"hover_preview": "Forhåndsvis hendelser når du peker",
|
||||
"hover_preview_desc": "Vis et detaljvindu når du holder markøren over kalenderhendelser",
|
||||
"hover_preview_instant": "Øyeblikkelig",
|
||||
|
||||
@@ -2990,7 +2990,7 @@
|
||||
"enable_tasks": "Taken inschakelen",
|
||||
"enable_tasks_desc": "Een takenweergave in de agenda tonen om to-do's te beheren",
|
||||
"show_tasks_on_calendar": "Taken in agenda tonen",
|
||||
"show_tasks_on_calendar_desc": "Taaklabels tonen in dag- en weekweergave van de agenda",
|
||||
"show_tasks_on_calendar_desc": "Taken tonen in de maand-, week- en dagweergave van de agenda",
|
||||
"hover_preview": "Voorvertoning bij hover",
|
||||
"hover_preview_desc": "Toon een detailpopover bij het hoveren over agenda-evenementen",
|
||||
"hover_preview_instant": "Direct",
|
||||
|
||||
@@ -2990,7 +2990,7 @@
|
||||
"enable_tasks": "Włącz zadania",
|
||||
"enable_tasks_desc": "Pokaż widok zadań w kalendarzu do zarządzania zadaniami",
|
||||
"show_tasks_on_calendar": "Pokaż zadania w kalendarzu",
|
||||
"show_tasks_on_calendar_desc": "Wyświetlaj oznaczenia zadań w dziennym i tygodniowym widoku kalendarza",
|
||||
"show_tasks_on_calendar_desc": "Wyświetlaj zadania w miesięcznym, tygodniowym i dziennym widoku kalendarza",
|
||||
"hover_preview": "Podgląd wydarzenia po najechaniu",
|
||||
"hover_preview_desc": "Pokaż wyskakujące okno szczegółów po najechaniu na wydarzenia w kalendarzu",
|
||||
"hover_preview_instant": "Natychmiast",
|
||||
|
||||
@@ -2990,7 +2990,7 @@
|
||||
"enable_tasks": "Ativar tarefas",
|
||||
"enable_tasks_desc": "Mostrar uma visualização de tarefas no calendário para gerenciar pendências",
|
||||
"show_tasks_on_calendar": "Mostrar tarefas no calendário",
|
||||
"show_tasks_on_calendar_desc": "Exibir marcadores de tarefas nas visualizações diária e semanal do calendário",
|
||||
"show_tasks_on_calendar_desc": "Exibir tarefas nas visualizações mensal, semanal e diária do calendário",
|
||||
"hover_preview": "Pré-visualização ao passar o rato",
|
||||
"hover_preview_desc": "Mostrar um popover de detalhes ao passar o rato sobre eventos do calendário",
|
||||
"hover_preview_instant": "Instantâneo",
|
||||
|
||||
@@ -2990,7 +2990,7 @@
|
||||
"enable_tasks": "Activați sarcinile",
|
||||
"enable_tasks_desc": "Afișați o vizualizare a sarcinilor în calendar pentru gestionarea sarcinilor de îndeplinit",
|
||||
"show_tasks_on_calendar": "Afișați sarcinile în calendar",
|
||||
"show_tasks_on_calendar_desc": "Afișați cartonașe cu sarcini în vizualizările calendarului zilnic și săptămânal",
|
||||
"show_tasks_on_calendar_desc": "Afișați sarcinile în vizualizările lunare, săptămânale și zilnice ale calendarului",
|
||||
"hover_preview": "Previzualizare la trecerea cursorului peste elemente",
|
||||
"hover_preview_desc": "Afișați o fereastră pop-up cu detalii atunci când treceți cu mouse-ul peste evenimentele din calendar",
|
||||
"hover_preview_instant": "Instant",
|
||||
|
||||
@@ -2990,7 +2990,7 @@
|
||||
"enable_tasks": "Включить задачи",
|
||||
"enable_tasks_desc": "Показывать представление задач в календаре для управления делами",
|
||||
"show_tasks_on_calendar": "Показывать задачи в календаре",
|
||||
"show_tasks_on_calendar_desc": "Отображать чипы задач в видах «день» и «неделя»",
|
||||
"show_tasks_on_calendar_desc": "Отображать задачи в видах «месяц», «неделя» и «день»",
|
||||
"hover_preview": "Предпросмотр при наведении",
|
||||
"hover_preview_desc": "Показывать всплывающее окно с деталями при наведении на события календаря",
|
||||
"hover_preview_instant": "Мгновенно",
|
||||
|
||||
@@ -2990,7 +2990,7 @@
|
||||
"enable_tasks": "Povoliť úlohy",
|
||||
"enable_tasks_desc": "Zobrazovať zobrazenie úloh v kalendári",
|
||||
"show_tasks_on_calendar": "Zobraziť úlohy v kalendári",
|
||||
"show_tasks_on_calendar_desc": "Zobrazovať značky úloh v dennom a týždennom zobrazení kalendára",
|
||||
"show_tasks_on_calendar_desc": "Zobrazovať úlohy v mesačnom, týždennom a dennom zobrazení kalendára",
|
||||
"hover_preview": "Náhľad udalosti po prejdení",
|
||||
"hover_preview_desc": "Zobrazovať vyskakovacie okno s podrobnosťami pri najetí myšou na udalosti v kalendári",
|
||||
"hover_preview_instant": "Okamžite",
|
||||
|
||||
@@ -2990,7 +2990,7 @@
|
||||
"enable_tasks": "Görevleri etkinleştir",
|
||||
"enable_tasks_desc": "Yapılacaklar listesini yönetmek için takvimde görev görünümü göster",
|
||||
"show_tasks_on_calendar": "Görevleri takvimde göster",
|
||||
"show_tasks_on_calendar_desc": "Gün ve hafta takvim görünümlerinde görev parçacıklarını görüntüle",
|
||||
"show_tasks_on_calendar_desc": "Görevleri ay, hafta ve gün takvim görünümlerinde görüntüle",
|
||||
"hover_preview": "Etkinlik üzerine gelme önizlemesi",
|
||||
"hover_preview_desc": "Takvim etkinliklerinin üzerine gelindiğinde ayrıntı açılır penceresi göster",
|
||||
"hover_preview_instant": "Anında",
|
||||
|
||||
@@ -2990,7 +2990,7 @@
|
||||
"enable_tasks": "Увімкнути завдання",
|
||||
"enable_tasks_desc": "Показати перегляд завдань у календарі для керування справами",
|
||||
"show_tasks_on_calendar": "Показати завдання в календарі",
|
||||
"show_tasks_on_calendar_desc": "Відображення фішок завдань у режимі перегляду календаря дня та тижня",
|
||||
"show_tasks_on_calendar_desc": "Відображати завдання в режимах перегляду календаря місяця, тижня та дня",
|
||||
"hover_preview": "Попередній перегляд подій",
|
||||
"hover_preview_desc": "Показувати спливаюче вікно з деталями під час наведення курсора на події календаря",
|
||||
"hover_preview_instant": "Миттєво",
|
||||
|
||||
@@ -2990,7 +2990,7 @@
|
||||
"enable_tasks": "啟用任務",
|
||||
"enable_tasks_desc": "在行事曆中顯示任務檢視以管理待辦事項",
|
||||
"show_tasks_on_calendar": "在行事曆上顯示任務",
|
||||
"show_tasks_on_calendar_desc": "在行事曆的日/週檢視中顯示任務標籤",
|
||||
"show_tasks_on_calendar_desc": "在行事曆的月/週/日檢視中顯示任務",
|
||||
"hover_preview": "活動游標暫留預覽",
|
||||
"hover_preview_desc": "游標移至行事曆活動上方時顯示詳細資料浮動視窗",
|
||||
"hover_preview_instant": "立即",
|
||||
|
||||
@@ -2990,7 +2990,7 @@
|
||||
"enable_tasks": "启用任务",
|
||||
"enable_tasks_desc": "在日历中显示任务视图以管理待办事项",
|
||||
"show_tasks_on_calendar": "在日历上显示任务",
|
||||
"show_tasks_on_calendar_desc": "在日历的日/周视图中显示任务标签",
|
||||
"show_tasks_on_calendar_desc": "在日历的月/周/日视图中显示任务",
|
||||
"hover_preview": "事件悬停预览",
|
||||
"hover_preview_desc": "将鼠标悬停在日历事件上时显示详细信息弹出窗口",
|
||||
"hover_preview_instant": "立即",
|
||||
|
||||
Reference in New Issue
Block a user