diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index 56276b30..ef46445e 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -23,6 +23,9 @@ import { CalendarMonthView } from "@/components/calendar/calendar-month-view"; import { CalendarWeekView } from "@/components/calendar/calendar-week-view"; import { CalendarDayView } from "@/components/calendar/calendar-day-view"; import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view"; +import { TaskListView } from "@/components/calendar/task-list-view"; +import { TaskToolbar } from "@/components/calendar/task-toolbar"; +import { TaskModal } from "@/components/calendar/task-modal"; import { MiniCalendar } from "@/components/calendar/mini-calendar"; import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel"; import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal"; @@ -35,6 +38,7 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { InlineAppView } from "@/components/layout/inline-app-view"; import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { ResizeHandle } from "@/components/layout/resize-handle"; +import { useTaskStore } from "@/stores/task-store"; import { cn } from "@/lib/utils"; import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types"; import { getUserParticipantId } from "@/lib/calendar-participants"; @@ -63,7 +67,8 @@ export default function CalendarPage() { setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, refreshAllSubscriptions, } = useCalendarStore(); - const { firstDayOfWeek, timeFormat, showWeekNumbers } = useSettingsStore(); + const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar } = useSettingsStore(); + const taskStore = useTaskStore(); const { identities } = useIdentityStore(); const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month"; @@ -83,6 +88,8 @@ export default function CalendarPage() { const [detailEvent, setDetailEvent] = useState(null); const [detailAnchorRect, setDetailAnchorRect] = useState(null); const [pendingPreview, setPendingPreview] = useState(null); + const [showTaskModal, setShowTaskModal] = useState(false); + const [editTask, setEditTask] = useState(null); const hasFetched = useRef(false); // Sidebar resize state @@ -166,9 +173,18 @@ export default function CalendarPage() { end: format(addDays(agendaStart, 30), "yyyy-MM-dd'T'23:59:59"), }; } + case "tasks": + return null; } }, [selectedDate, normalizedViewMode, firstDayOfWeek]); + // Fetch tasks when tasks view is active or when tasks are shown on calendar grid + useEffect(() => { + if (client && enableCalendarTasks && (normalizedViewMode === "tasks" || showTasksOnCalendar)) { + taskStore.fetchTasks(client); + } + }, [client, enableCalendarTasks, normalizedViewMode, showTasksOnCalendar]); + useEffect(() => { if (client && calendars.length > 0 && dateRange) { fetchEvents(client, dateRange.start, dateRange.end); @@ -182,6 +198,7 @@ export default function CalendarPage() { case "week": next = subWeeks(selectedDate, 1); break; case "day": next = subDays(selectedDate, 1); break; case "agenda": next = subMonths(selectedDate, 1); break; + case "tasks": return; } setSelectedDate(next); setMiniMonth(next); @@ -194,6 +211,7 @@ export default function CalendarPage() { case "week": next = addWeeks(selectedDate, 1); break; case "day": next = addDays(selectedDate, 1); break; case "agenda": next = addMonths(selectedDate, 1); break; + case "tasks": return; } setSelectedDate(next); setMiniMonth(next); @@ -254,6 +272,34 @@ export default function CalendarPage() { setShowEventModal(true); }, []); + const openCreateTaskModal = useCallback(() => { + setEditTask(null); + setShowTaskModal(true); + }, []); + + const openEditTaskModal = useCallback((task: import("@/lib/jmap/types").CalendarTask) => { + setEditTask(task); + setShowTaskModal(true); + }, []); + + const handleSaveTask = useCallback(async (data: Partial) => { + if (!client) return; + if (editTask) { + await taskStore.updateTask(client, editTask.id, data); + } else { + await taskStore.createTask(client, data); + } + setShowTaskModal(false); + setEditTask(null); + }, [client, editTask, taskStore]); + + const handleDeleteTask = useCallback(async (id: string) => { + if (!client) return; + await taskStore.deleteTask(client, id); + setShowTaskModal(false); + setEditTask(null); + }, [client, taskStore]); + const hoverTimerRef = useRef | null>(null); const closeDetail = useCallback(() => { @@ -608,6 +654,7 @@ export default function CalendarPage() { case "w": setViewMode("week"); break; case "d": setViewMode("day"); break; case "a": setViewMode("agenda"); break; + case "k": if (enableCalendarTasks) setViewMode("tasks"); break; case "n": openCreateModal(); break; } }; @@ -668,6 +715,8 @@ export default function CalendarPage() { timeFormat={timeFormat} isMobile={isMobile} pendingPreview={pendingPreview} + tasks={enableCalendarTasks && showTasksOnCalendar ? taskStore.tasks : undefined} + onToggleTaskComplete={(task) => { if (client) taskStore.toggleTaskComplete(client, task); }} /> ); case "day": @@ -683,6 +732,8 @@ export default function CalendarPage() { timeFormat={timeFormat} isMobile={isMobile} pendingPreview={pendingPreview} + tasks={enableCalendarTasks && showTasksOnCalendar ? taskStore.tasks : undefined} + onToggleTaskComplete={(task) => { if (client) taskStore.toggleTaskComplete(client, task); }} /> ); case "agenda": @@ -697,6 +748,33 @@ export default function CalendarPage() { timeFormat={timeFormat} /> ); + case "tasks": + return ( +
+ + { if (client) taskStore.toggleTaskComplete(client, task); }} + selectedTaskId={taskStore.selectedTaskId} + onQuickCreate={(title) => { + if (client) { + taskStore.createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [calendars[0]?.id ?? ""]: true } }); + } + }} + /> +
+ ); } })(); @@ -792,6 +870,7 @@ export default function CalendarPage() { calendars={calendars} selectedCalendarIds={selectedCalendarIds} onToggleVisibility={toggleCalendarVisibility} + enableCalendarTasks={enableCalendarTasks} />
)} + {/* Desktop task panel */} + {!isMobile && showTaskModal && ( +
+ { setShowTaskModal(false); setEditTask(null); }} + isMobile={false} + /> +
+ )} + {/* Floating Create Event Button (mobile) */} {isMobile && (
- {allDayEvents.length > 0 && ( + {(allDayEvents.length > 0 || dayTasks.length > 0) && (
-
{t("events.all_day")}
-
- {allDayEvents.map((ev) => { - const calId = getPrimaryCalendarId(ev); - return ( - onSelectEvent(ev, rect)} - onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} - onMouseLeave={onHoverLeave} - /> - ); - })} -
+ {allDayEvents.length > 0 && ( + <> +
{t("events.all_day")}
+
+ {allDayEvents.map((ev) => { + const calId = getPrimaryCalendarId(ev); + return ( + onSelectEvent(ev, rect)} + onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} + onMouseLeave={onHoverLeave} + /> + ); + })} +
+ + )} + {dayTasks.length > 0 && ( + <> +
0 && "mt-2")}>{t("tasks.label")}
+
+ {dayTasks.map((task) => { + const isCompleted = task.progress === "completed"; + const cal = calendars.find(c => task.calendarIds[c.id]); + const color = cal?.color || "#3b82f6"; + return ( +
+ + + {task.title || t("tasks.no_title")} + +
+ ); + })} +
+ + )}
)} diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index d8aba289..4b46dbc2 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -2,12 +2,13 @@ import { useState, useRef, useEffect, useMemo } from "react"; import { useTranslations } from "next-intl"; -import { Globe, Plus, RefreshCw, Share2, Trash2 } from "lucide-react"; +import { Globe, ListTodo, Plus, RefreshCw, Share2, Trash2 } from "lucide-react"; import { cn, formatDateTime } from "@/lib/utils"; import type { Calendar } from "@/lib/jmap/types"; import { CalendarColorPicker } from "@/components/settings/calendar-management-settings"; import { useCalendarStore } from "@/stores/calendar-store"; import { useSettingsStore } from "@/stores/settings-store"; +import { useTaskStore } from "@/stores/task-store"; import { toast } from "@/stores/toast-store"; import type { IJMAPClient } from '@/lib/jmap/client-interface'; @@ -35,6 +36,15 @@ export function CalendarSidebarPanel({ const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription); const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription); const timeFormat = useSettingsStore((s) => s.timeFormat); + const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks); + const tasks = useTaskStore((s) => s.tasks); + const setViewMode = useCalendarStore((s) => s.setViewMode); + + const pendingTaskCount = useMemo(() => tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled').length, [tasks]); + const overdueTaskCount = useMemo(() => { + const now = new Date(); + return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length; + }, [tasks]); const [colorPickerId, setColorPickerId] = useState(null); const [contextMenuCalId, setContextMenuCalId] = useState(null); @@ -209,6 +219,21 @@ export function CalendarSidebarPanel({ return (
+ {enableCalendarTasks && ( + + )}

{t("my_calendars")}

diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index c9db0990..396959f8 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -3,7 +3,7 @@ import { useState, useRef, useEffect } from "react"; import { useTranslations, useFormatter } from "next-intl"; import { Button } from "@/components/ui/button"; -import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown } from "lucide-react"; +import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ListTodo } from "lucide-react"; import { addDays, startOfWeek } from "date-fns"; import { cn } from "@/lib/utils"; import type { CalendarViewMode } from "@/stores/calendar-store"; @@ -25,6 +25,7 @@ interface CalendarToolbarProps { calendars?: Calendar[]; selectedCalendarIds?: string[]; onToggleVisibility?: (id: string) => void; + enableCalendarTasks?: boolean; } export function CalendarToolbar({ @@ -42,10 +43,13 @@ export function CalendarToolbar({ calendars, selectedCalendarIds, onToggleVisibility, + enableCalendarTasks, }: CalendarToolbarProps) { const t = useTranslations("calendar"); const formatter = useFormatter(); - const views: CalendarViewMode[] = ["month", "week", "day", "agenda"]; + const views: CalendarViewMode[] = enableCalendarTasks + ? ["month", "week", "day", "agenda", "tasks"] + : ["month", "week", "day", "agenda"]; const [showCalendarDropdown, setShowCalendarDropdown] = useState(false); const dropdownRef = useRef(null); @@ -86,6 +90,8 @@ export function CalendarToolbar({ return isMobile ? formatter.dateTime(selectedDate, { month: "short", year: "numeric" }) : formatter.dateTime(selectedDate, { month: "long", year: "numeric" }); + case "tasks": + return t("views.tasks"); } }; diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index 33327f6e..d181b8ab 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -6,10 +6,11 @@ import { startOfWeek, addDays, format, isSameDay, isToday, parseISO, } from "date-fns"; import { cn } from "@/lib/utils"; +import { Check } from "lucide-react"; import { EventCard, parseDuration } from "./event-card"; import { QuickEventInput } from "./quick-event-input"; import { buildWeekSegments, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils"; -import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; +import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types"; import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions"; import type { PendingEventPreview } from "./event-modal"; @@ -26,6 +27,8 @@ interface CalendarWeekViewProps { timeFormat?: "12h" | "24h"; isMobile?: boolean; pendingPreview?: PendingEventPreview | null; + tasks?: CalendarTask[]; + onToggleTaskComplete?: (task: CalendarTask) => void; } const HOUR_HEIGHT = 60; @@ -44,6 +47,8 @@ export function CalendarWeekView({ timeFormat = "24h", isMobile, pendingPreview, + tasks, + onToggleTaskComplete, }: CalendarWeekViewProps) { const t = useTranslations("calendar"); const intlFormatter = useFormatter(); @@ -96,9 +101,36 @@ export function CalendarWeekView({ return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0); }, [allDaySegments]); + // Tasks grouped by day for the week + const tasksByDay = useMemo(() => { + if (!tasks?.length) return new Map(); + const map = new 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]); + + // Max tasks on any single day in this week + const taskRowCount = useMemo(() => { + let max = 0; + for (const day of weekDays) { + const key = format(day, "yyyy-MM-dd"); + const count = tasksByDay.get(key)?.length ?? 0; + if (count > max) max = count; + } + return max; + }, [tasksByDay, weekDays]); + const hasAllDay = useMemo(() => { - return allDaySegments.length > 0; - }, [allDaySegments]); + return allDaySegments.length > 0 || taskRowCount > 0; + }, [allDaySegments, taskRowCount]); useEffect(() => { if (scrollRef.current) { @@ -151,13 +183,13 @@ export function CalendarWeekView({
{t("events.all_day")}
{weekDays.map((day) => (
@@ -191,6 +223,49 @@ export function CalendarWeekView({ ); })}
+ + {/* Task chips in all-day area */} + {taskRowCount > 0 && ( +
+ {weekDays.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 ( +
+
onToggleTaskComplete?.(task)} + > + + {isCompleted && } + + + {task.title} + +
+
+ ); + }); + })} +
+ )}
)} diff --git a/components/calendar/task-list-view.tsx b/components/calendar/task-list-view.tsx index 4e459408..c1a79e00 100644 --- a/components/calendar/task-list-view.tsx +++ b/components/calendar/task-list-view.tsx @@ -1,9 +1,9 @@ "use client"; -import { useMemo, useCallback } from "react"; +import { useMemo, useCallback, useState } from "react"; import { useTranslations } from "next-intl"; import { format, parseISO, isPast, isToday, isTomorrow } from "date-fns"; -import { Check, Circle, Flag, CalendarDays, ListTodo } from "lucide-react"; +import { Check, Circle, Flag, CalendarDays, ListTodo, Plus } from "lucide-react"; import { cn } from "@/lib/utils"; import type { CalendarTask, Calendar } from "@/lib/jmap/types"; import type { TaskViewFilter } from "@/stores/task-store"; @@ -18,6 +18,7 @@ interface TaskListViewProps { onSelectTask: (task: CalendarTask) => void; onToggleComplete: (task: CalendarTask) => void; selectedTaskId?: string | null; + onQuickCreate?: (title: string) => void; } function getTaskPriorityIcon(priority: number) { @@ -69,9 +70,11 @@ export function TaskListView({ onSelectTask, onToggleComplete, selectedTaskId, + onQuickCreate, }: TaskListViewProps) { const t = useTranslations("calendar"); const timeFormat = useSettingsStore((s) => s.timeFormat); + const [quickAddTitle, setQuickAddTitle] = useState(""); const filteredTasks = useMemo(() => { let result = tasks.filter(task => { @@ -128,15 +131,57 @@ export function TaskListView({ if (filteredTasks.length === 0) { return ( -
- -

{t("tasks.no_tasks")}

+
+ {onQuickCreate && ( +
+
+ + setQuickAddTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && quickAddTitle.trim()) { + onQuickCreate(quickAddTitle.trim()); + setQuickAddTitle(""); + } + }} + placeholder={t("tasks.quick_add_placeholder")} + className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> +
+
+ )} +
+ +

{t("tasks.no_tasks")}

+
); } return (
+ {onQuickCreate && ( +
+
+ + setQuickAddTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && quickAddTitle.trim()) { + onQuickCreate(quickAddTitle.trim()); + setQuickAddTitle(""); + } + }} + placeholder={t("tasks.quick_add_placeholder")} + className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> +
+
+ )}
{filteredTasks.map(task => { const cal = calendars.find(c => task.calendarIds[c.id]); diff --git a/components/calendar/task-modal.tsx b/components/calendar/task-modal.tsx new file mode 100644 index 00000000..683db859 --- /dev/null +++ b/components/calendar/task-modal.tsx @@ -0,0 +1,321 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { X, Trash2, CalendarDays, Bell, Flag } from "lucide-react"; +import { format, parseISO } from "date-fns"; +import { cn } from "@/lib/utils"; +import type { CalendarTask, Calendar, CalendarEventAlert } from "@/lib/jmap/types"; + +interface TaskModalProps { + task?: CalendarTask | null; + calendars: Calendar[]; + onSave: (data: Partial) => void | Promise; + onDelete?: (id: string) => void; + onClose: () => void; + isMobile?: boolean; +} + +type PriorityLevel = "none" | "high" | "medium" | "low"; +type AlertOption = "none" | "at_time" | "5" | "15" | "30" | "60" | "1440"; + +function priorityToLevel(p: number): PriorityLevel { + if (p >= 1 && p <= 4) return "high"; + if (p === 5) return "medium"; + if (p >= 6 && p <= 9) return "low"; + return "none"; +} + +function levelToPriority(l: PriorityLevel): number { + switch (l) { + case "high": return 1; + case "medium": return 5; + case "low": return 9; + default: return 0; + } +} + +export function TaskModal({ + task, + calendars, + onSave, + onDelete, + onClose, + isMobile, +}: TaskModalProps) { + const t = useTranslations("calendar"); + const isEdit = !!task; + const titleRef = useRef(null); + + const writableCalendars = calendars.filter(c => !c.isShared || c.myRights?.mayWriteAll || c.myRights?.mayWriteOwn); + const defaultCalendarId = writableCalendars[0]?.id ?? calendars[0]?.id ?? ""; + + const [title, setTitle] = useState(task?.title ?? ""); + const [description, setDescription] = useState(task?.description ?? ""); + const [dueDate, setDueDate] = useState(task?.due ? format(parseISO(task.due), "yyyy-MM-dd") : ""); + const [dueTime, setDueTime] = useState(task?.due && !task.showWithoutTime ? format(parseISO(task.due), "HH:mm") : ""); + const [showTime, setShowTime] = useState(task?.due ? !task.showWithoutTime : false); + const [priority, setPriority] = useState(priorityToLevel(task?.priority ?? 0)); + const [progress, setProgress] = useState(task?.progress ?? "needs-action"); + const [calendarId, setCalendarId] = useState(() => { + if (task) { + const ids = Object.keys(task.calendarIds); + return ids[0] ?? defaultCalendarId; + } + return defaultCalendarId; + }); + const [alertOption, setAlertOption] = useState(() => { + if (!task?.alerts) return "none"; + const first = Object.values(task.alerts)[0]; + if (!first || first.trigger["@type"] !== "OffsetTrigger") return "none"; + const offset = first.trigger.offset; + if (offset === "PT0S") return "at_time"; + const m = offset.match(/-?PT?(\d+)M$/); + if (m) return m[1] as AlertOption; + const h = offset.match(/-?PT?(\d+)H$/); + if (h) return String(parseInt(h[1]) * 60) as AlertOption; + const d = offset.match(/-?P(\d+)D/); + if (d) return String(parseInt(d[1]) * 1440) as AlertOption; + return "none"; + }); + const [saving, setSaving] = useState(false); + + useEffect(() => { + titleRef.current?.focus(); + }, []); + + const handleSave = useCallback(async () => { + if (!title.trim()) return; + setSaving(true); + try { + let due: string | null = null; + let showWithoutTime = true; + if (dueDate) { + if (showTime && dueTime) { + due = `${dueDate}T${dueTime}:00`; + showWithoutTime = false; + } else { + due = `${dueDate}T00:00:00`; + showWithoutTime = true; + } + } + + let alerts: Record | null = null; + if (alertOption !== "none") { + const offset = alertOption === "at_time" ? "PT0S" : `-PT${alertOption}M`; + alerts = { + "default-alert": { + "@type": "Alert", + trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" }, + action: "display", + acknowledged: null, + relatedTo: null, + }, + }; + } + + const data: Partial = { + "@type": "Task", + title: title.trim(), + description: description.trim() || "", + due, + showWithoutTime, + priority: levelToPriority(priority), + progress, + calendarIds: { [calendarId]: true }, + alerts, + }; + + if (isEdit && task) { + data.id = task.id; + } + + await onSave(data); + onClose(); + } finally { + setSaving(false); + } + }, [title, description, dueDate, dueTime, showTime, priority, progress, calendarId, alertOption, isEdit, task, onSave, onClose]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + } + if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { + e.preventDefault(); + handleSave(); + } + }, [onClose, handleSave]); + + return ( +
+ {/* Header */} +
+

+ {isEdit ? t("tasks.edit") : t("tasks.create")} +

+ +
+ + {/* Body */} +
+ {/* Title */} + setTitle(e.target.value)} + placeholder={t("tasks.title_placeholder")} + className="text-base font-medium" + /> + + {/* Description */} +