feat: add task management features to calendar
This commit is contained in:
@@ -23,6 +23,9 @@ import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
|
|||||||
import { CalendarWeekView } from "@/components/calendar/calendar-week-view";
|
import { CalendarWeekView } from "@/components/calendar/calendar-week-view";
|
||||||
import { CalendarDayView } from "@/components/calendar/calendar-day-view";
|
import { CalendarDayView } from "@/components/calendar/calendar-day-view";
|
||||||
import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-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 { MiniCalendar } from "@/components/calendar/mini-calendar";
|
||||||
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
|
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
|
||||||
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
|
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 { InlineAppView } from "@/components/layout/inline-app-view";
|
||||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
|
import { useTaskStore } from "@/stores/task-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
import { getUserParticipantId } from "@/lib/calendar-participants";
|
import { getUserParticipantId } from "@/lib/calendar-participants";
|
||||||
@@ -63,7 +67,8 @@ export default function CalendarPage() {
|
|||||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
|
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
|
||||||
refreshAllSubscriptions,
|
refreshAllSubscriptions,
|
||||||
} = useCalendarStore();
|
} = useCalendarStore();
|
||||||
const { firstDayOfWeek, timeFormat, showWeekNumbers } = useSettingsStore();
|
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar } = useSettingsStore();
|
||||||
|
const taskStore = useTaskStore();
|
||||||
const { identities } = useIdentityStore();
|
const { identities } = useIdentityStore();
|
||||||
const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month";
|
const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month";
|
||||||
|
|
||||||
@@ -83,6 +88,8 @@ export default function CalendarPage() {
|
|||||||
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
|
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
|
||||||
const [detailAnchorRect, setDetailAnchorRect] = useState<DOMRect | null>(null);
|
const [detailAnchorRect, setDetailAnchorRect] = useState<DOMRect | null>(null);
|
||||||
const [pendingPreview, setPendingPreview] = useState<PendingEventPreview | null>(null);
|
const [pendingPreview, setPendingPreview] = useState<PendingEventPreview | null>(null);
|
||||||
|
const [showTaskModal, setShowTaskModal] = useState(false);
|
||||||
|
const [editTask, setEditTask] = useState<import("@/lib/jmap/types").CalendarTask | null>(null);
|
||||||
const hasFetched = useRef(false);
|
const hasFetched = useRef(false);
|
||||||
|
|
||||||
// Sidebar resize state
|
// Sidebar resize state
|
||||||
@@ -166,9 +173,18 @@ export default function CalendarPage() {
|
|||||||
end: format(addDays(agendaStart, 30), "yyyy-MM-dd'T'23:59:59"),
|
end: format(addDays(agendaStart, 30), "yyyy-MM-dd'T'23:59:59"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case "tasks":
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}, [selectedDate, normalizedViewMode, firstDayOfWeek]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (client && calendars.length > 0 && dateRange) {
|
if (client && calendars.length > 0 && dateRange) {
|
||||||
fetchEvents(client, dateRange.start, dateRange.end);
|
fetchEvents(client, dateRange.start, dateRange.end);
|
||||||
@@ -182,6 +198,7 @@ export default function CalendarPage() {
|
|||||||
case "week": next = subWeeks(selectedDate, 1); break;
|
case "week": next = subWeeks(selectedDate, 1); break;
|
||||||
case "day": next = subDays(selectedDate, 1); break;
|
case "day": next = subDays(selectedDate, 1); break;
|
||||||
case "agenda": next = subMonths(selectedDate, 1); break;
|
case "agenda": next = subMonths(selectedDate, 1); break;
|
||||||
|
case "tasks": return;
|
||||||
}
|
}
|
||||||
setSelectedDate(next);
|
setSelectedDate(next);
|
||||||
setMiniMonth(next);
|
setMiniMonth(next);
|
||||||
@@ -194,6 +211,7 @@ export default function CalendarPage() {
|
|||||||
case "week": next = addWeeks(selectedDate, 1); break;
|
case "week": next = addWeeks(selectedDate, 1); break;
|
||||||
case "day": next = addDays(selectedDate, 1); break;
|
case "day": next = addDays(selectedDate, 1); break;
|
||||||
case "agenda": next = addMonths(selectedDate, 1); break;
|
case "agenda": next = addMonths(selectedDate, 1); break;
|
||||||
|
case "tasks": return;
|
||||||
}
|
}
|
||||||
setSelectedDate(next);
|
setSelectedDate(next);
|
||||||
setMiniMonth(next);
|
setMiniMonth(next);
|
||||||
@@ -254,6 +272,34 @@ export default function CalendarPage() {
|
|||||||
setShowEventModal(true);
|
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<import("@/lib/jmap/types").CalendarTask>) => {
|
||||||
|
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<ReturnType<typeof setTimeout> | null>(null);
|
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const closeDetail = useCallback(() => {
|
const closeDetail = useCallback(() => {
|
||||||
@@ -608,6 +654,7 @@ export default function CalendarPage() {
|
|||||||
case "w": setViewMode("week"); break;
|
case "w": setViewMode("week"); break;
|
||||||
case "d": setViewMode("day"); break;
|
case "d": setViewMode("day"); break;
|
||||||
case "a": setViewMode("agenda"); break;
|
case "a": setViewMode("agenda"); break;
|
||||||
|
case "k": if (enableCalendarTasks) setViewMode("tasks"); break;
|
||||||
case "n": openCreateModal(); break;
|
case "n": openCreateModal(); break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -668,6 +715,8 @@ export default function CalendarPage() {
|
|||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
pendingPreview={pendingPreview}
|
pendingPreview={pendingPreview}
|
||||||
|
tasks={enableCalendarTasks && showTasksOnCalendar ? taskStore.tasks : undefined}
|
||||||
|
onToggleTaskComplete={(task) => { if (client) taskStore.toggleTaskComplete(client, task); }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "day":
|
case "day":
|
||||||
@@ -683,6 +732,8 @@ export default function CalendarPage() {
|
|||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
pendingPreview={pendingPreview}
|
pendingPreview={pendingPreview}
|
||||||
|
tasks={enableCalendarTasks && showTasksOnCalendar ? taskStore.tasks : undefined}
|
||||||
|
onToggleTaskComplete={(task) => { if (client) taskStore.toggleTaskComplete(client, task); }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "agenda":
|
case "agenda":
|
||||||
@@ -697,6 +748,33 @@ export default function CalendarPage() {
|
|||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case "tasks":
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<TaskToolbar
|
||||||
|
filter={taskStore.filter}
|
||||||
|
showCompleted={taskStore.showCompleted}
|
||||||
|
onFilterChange={taskStore.setFilter}
|
||||||
|
onShowCompletedChange={taskStore.setShowCompleted}
|
||||||
|
onCreateTask={openCreateTaskModal}
|
||||||
|
/>
|
||||||
|
<TaskListView
|
||||||
|
tasks={taskStore.tasks}
|
||||||
|
calendars={calendars}
|
||||||
|
selectedCalendarIds={selectedCalendarIds}
|
||||||
|
filter={taskStore.filter}
|
||||||
|
showCompleted={taskStore.showCompleted}
|
||||||
|
onSelectTask={openEditTaskModal}
|
||||||
|
onToggleComplete={(task) => { 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 } });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -792,6 +870,7 @@ export default function CalendarPage() {
|
|||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
selectedCalendarIds={selectedCalendarIds}
|
selectedCalendarIds={selectedCalendarIds}
|
||||||
onToggleVisibility={toggleCalendarVisibility}
|
onToggleVisibility={toggleCalendarVisibility}
|
||||||
|
enableCalendarTasks={enableCalendarTasks}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -823,6 +902,21 @@ export default function CalendarPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Desktop task panel */}
|
||||||
|
{!isMobile && showTaskModal && (
|
||||||
|
<div className="w-[400px] border-l border-border flex-shrink-0 overflow-hidden">
|
||||||
|
<TaskModal
|
||||||
|
key={editTask?.id ?? 'new-task'}
|
||||||
|
task={editTask}
|
||||||
|
calendars={calendars}
|
||||||
|
onSave={handleSaveTask}
|
||||||
|
onDelete={handleDeleteTask}
|
||||||
|
onClose={() => { setShowTaskModal(false); setEditTask(null); }}
|
||||||
|
isMobile={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Floating Create Event Button (mobile) */}
|
{/* Floating Create Event Button (mobile) */}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import { useMemo, useEffect, useRef, useState } from "react";
|
|||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
import { format, isSameDay, isToday, parseISO } from "date-fns";
|
import { format, isSameDay, isToday, parseISO } from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
import { EventCard, parseDuration } from "./event-card";
|
import { EventCard, parseDuration } from "./event-card";
|
||||||
import { QuickEventInput } from "./quick-event-input";
|
import { QuickEventInput } from "./quick-event-input";
|
||||||
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
import { 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 { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||||
import type { PendingEventPreview } from "./event-modal";
|
import type { PendingEventPreview } from "./event-modal";
|
||||||
|
|
||||||
@@ -22,6 +23,8 @@ interface CalendarDayViewProps {
|
|||||||
timeFormat?: "12h" | "24h";
|
timeFormat?: "12h" | "24h";
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
pendingPreview?: PendingEventPreview | null;
|
pendingPreview?: PendingEventPreview | null;
|
||||||
|
tasks?: CalendarTask[];
|
||||||
|
onToggleTaskComplete?: (task: CalendarTask) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOUR_HEIGHT = 64;
|
const HOUR_HEIGHT = 64;
|
||||||
@@ -38,6 +41,8 @@ export function CalendarDayView({
|
|||||||
timeFormat = "24h",
|
timeFormat = "24h",
|
||||||
isMobile,
|
isMobile,
|
||||||
pendingPreview,
|
pendingPreview,
|
||||||
|
tasks,
|
||||||
|
onToggleTaskComplete,
|
||||||
}: CalendarDayViewProps) {
|
}: CalendarDayViewProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const intlFormatter = useFormatter();
|
const intlFormatter = useFormatter();
|
||||||
@@ -68,6 +73,16 @@ export function CalendarDayView({
|
|||||||
return { timedEvents: timed, allDayEvents: allDay };
|
return { timedEvents: timed, allDayEvents: allDay };
|
||||||
}, [events, selectedDate]);
|
}, [events, selectedDate]);
|
||||||
|
|
||||||
|
const dayTasks = useMemo(() => {
|
||||||
|
if (!tasks?.length) return [];
|
||||||
|
return tasks.filter(task => {
|
||||||
|
if (!task.due) return false;
|
||||||
|
try {
|
||||||
|
return isSameDay(parseISO(task.due), selectedDate);
|
||||||
|
} catch { return false; }
|
||||||
|
});
|
||||||
|
}, [tasks, selectedDate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollRef.current) {
|
if (scrollRef.current) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -125,25 +140,63 @@ export function CalendarDayView({
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{allDayEvents.length > 0 && (
|
{(allDayEvents.length > 0 || dayTasks.length > 0) && (
|
||||||
<div className="px-4 py-2 border-b border-border">
|
<div className="px-4 py-2 border-b border-border">
|
||||||
<div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div>
|
{allDayEvents.length > 0 && (
|
||||||
<div className="space-y-1">
|
<>
|
||||||
{allDayEvents.map((ev) => {
|
<div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div>
|
||||||
const calId = getPrimaryCalendarId(ev);
|
<div className="space-y-1">
|
||||||
return (
|
{allDayEvents.map((ev) => {
|
||||||
<EventCard
|
const calId = getPrimaryCalendarId(ev);
|
||||||
key={ev.id}
|
return (
|
||||||
event={ev}
|
<EventCard
|
||||||
calendar={calId ? calendarMap.get(calId) : undefined}
|
key={ev.id}
|
||||||
variant="chip"
|
event={ev}
|
||||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
calendar={calId ? calendarMap.get(calId) : undefined}
|
||||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
variant="chip"
|
||||||
onMouseLeave={onHoverLeave}
|
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||||
/>
|
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||||
);
|
onMouseLeave={onHoverLeave}
|
||||||
})}
|
/>
|
||||||
</div>
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{dayTasks.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className={cn("text-[10px] text-muted-foreground mb-1", allDayEvents.length > 0 && "mt-2")}>{t("tasks.label")}</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{dayTasks.map((task) => {
|
||||||
|
const isCompleted = task.progress === "completed";
|
||||||
|
const cal = calendars.find(c => task.calendarIds[c.id]);
|
||||||
|
const color = cal?.color || "#3b82f6";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
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}` }}
|
||||||
|
>
|
||||||
|
<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",
|
||||||
|
isCompleted
|
||||||
|
? "bg-green-500 border-green-500 text-white"
|
||||||
|
: "border-muted-foreground/40 hover:border-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isCompleted && <Check className="h-2.5 w-2.5" />}
|
||||||
|
</button>
|
||||||
|
<span className={cn("truncate", isCompleted && "line-through text-muted-foreground")}>
|
||||||
|
{task.title || t("tasks.no_title")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
import { useState, useRef, useEffect, useMemo } from "react";
|
import { useState, useRef, useEffect, useMemo } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
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 { cn, formatDateTime } from "@/lib/utils";
|
||||||
import type { Calendar } from "@/lib/jmap/types";
|
import type { Calendar } from "@/lib/jmap/types";
|
||||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { useTaskStore } from "@/stores/task-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
|
||||||
@@ -35,6 +36,15 @@ export function CalendarSidebarPanel({
|
|||||||
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
||||||
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
|
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
|
||||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
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<string | null>(null);
|
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
||||||
const [contextMenuCalId, setContextMenuCalId] = useState<string | null>(null);
|
const [contextMenuCalId, setContextMenuCalId] = useState<string | null>(null);
|
||||||
@@ -209,6 +219,21 @@ export function CalendarSidebarPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
|
{enableCalendarTasks && (
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('tasks')}
|
||||||
|
className="flex items-center gap-2 w-full px-1.5 py-1.5 mb-3 rounded-md text-sm hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<ListTodo className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span>{t('tasks.label')}</span>
|
||||||
|
{pendingTaskCount > 0 && (
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">{pendingTaskCount}</span>
|
||||||
|
)}
|
||||||
|
{overdueTaskCount > 0 && (
|
||||||
|
<span className="text-xs text-destructive font-medium">{overdueTaskCount} {t('tasks.filter_overdue').toLowerCase()}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
||||||
{t("my_calendars")}
|
{t("my_calendars")}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { addDays, startOfWeek } from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarViewMode } from "@/stores/calendar-store";
|
import type { CalendarViewMode } from "@/stores/calendar-store";
|
||||||
@@ -25,6 +25,7 @@ interface CalendarToolbarProps {
|
|||||||
calendars?: Calendar[];
|
calendars?: Calendar[];
|
||||||
selectedCalendarIds?: string[];
|
selectedCalendarIds?: string[];
|
||||||
onToggleVisibility?: (id: string) => void;
|
onToggleVisibility?: (id: string) => void;
|
||||||
|
enableCalendarTasks?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CalendarToolbar({
|
export function CalendarToolbar({
|
||||||
@@ -42,10 +43,13 @@ export function CalendarToolbar({
|
|||||||
calendars,
|
calendars,
|
||||||
selectedCalendarIds,
|
selectedCalendarIds,
|
||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
|
enableCalendarTasks,
|
||||||
}: CalendarToolbarProps) {
|
}: CalendarToolbarProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const formatter = useFormatter();
|
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 [showCalendarDropdown, setShowCalendarDropdown] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -86,6 +90,8 @@ export function CalendarToolbar({
|
|||||||
return isMobile
|
return isMobile
|
||||||
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
|
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
|
||||||
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
|
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
|
||||||
|
case "tasks":
|
||||||
|
return t("views.tasks");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import {
|
|||||||
startOfWeek, addDays, format, isSameDay, isToday, parseISO,
|
startOfWeek, addDays, format, isSameDay, isToday, parseISO,
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
import { EventCard, parseDuration } from "./event-card";
|
import { EventCard, parseDuration } from "./event-card";
|
||||||
import { QuickEventInput } from "./quick-event-input";
|
import { QuickEventInput } from "./quick-event-input";
|
||||||
import { buildWeekSegments, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
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 { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||||
import type { PendingEventPreview } from "./event-modal";
|
import type { PendingEventPreview } from "./event-modal";
|
||||||
|
|
||||||
@@ -26,6 +27,8 @@ interface CalendarWeekViewProps {
|
|||||||
timeFormat?: "12h" | "24h";
|
timeFormat?: "12h" | "24h";
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
pendingPreview?: PendingEventPreview | null;
|
pendingPreview?: PendingEventPreview | null;
|
||||||
|
tasks?: CalendarTask[];
|
||||||
|
onToggleTaskComplete?: (task: CalendarTask) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOUR_HEIGHT = 60;
|
const HOUR_HEIGHT = 60;
|
||||||
@@ -44,6 +47,8 @@ export function CalendarWeekView({
|
|||||||
timeFormat = "24h",
|
timeFormat = "24h",
|
||||||
isMobile,
|
isMobile,
|
||||||
pendingPreview,
|
pendingPreview,
|
||||||
|
tasks,
|
||||||
|
onToggleTaskComplete,
|
||||||
}: CalendarWeekViewProps) {
|
}: CalendarWeekViewProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const intlFormatter = useFormatter();
|
const intlFormatter = useFormatter();
|
||||||
@@ -96,9 +101,36 @@ export function CalendarWeekView({
|
|||||||
return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
|
return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
|
||||||
}, [allDaySegments]);
|
}, [allDaySegments]);
|
||||||
|
|
||||||
|
// Tasks grouped by day for the week
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// 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(() => {
|
const hasAllDay = useMemo(() => {
|
||||||
return allDaySegments.length > 0;
|
return allDaySegments.length > 0 || taskRowCount > 0;
|
||||||
}, [allDaySegments]);
|
}, [allDaySegments, taskRowCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollRef.current) {
|
if (scrollRef.current) {
|
||||||
@@ -151,13 +183,13 @@ export function CalendarWeekView({
|
|||||||
<div className="flex border-b border-border">
|
<div className="flex border-b border-border">
|
||||||
<div
|
<div
|
||||||
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10" : "w-14")}
|
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10" : "w-14")}
|
||||||
style={{ minHeight: Math.max(28, allDayRowCount * 24 + 4) }}
|
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
|
||||||
>
|
>
|
||||||
{t("events.all_day")}
|
{t("events.all_day")}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn("flex-1 relative grid gap-px bg-border", isMobile ? "grid-cols-3" : "grid-cols-7")}
|
className={cn("flex-1 relative grid gap-px bg-border", isMobile ? "grid-cols-3" : "grid-cols-7")}
|
||||||
style={{ minHeight: Math.max(28, allDayRowCount * 24 + 4) }}
|
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
|
||||||
>
|
>
|
||||||
{weekDays.map((day) => (
|
{weekDays.map((day) => (
|
||||||
<div key={format(day, "yyyy-MM-dd")} className="bg-background min-h-[28px]" />
|
<div key={format(day, "yyyy-MM-dd")} className="bg-background min-h-[28px]" />
|
||||||
@@ -191,6 +223,49 @@ export function CalendarWeekView({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Task chips in all-day area */}
|
||||||
|
{taskRowCount > 0 && (
|
||||||
|
<div className="absolute inset-x-0 pointer-events-none" style={{ top: allDayRowCount * 24 + 2 }}>
|
||||||
|
{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 (
|
||||||
|
<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-green-500 border-green-500" : "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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useCallback } from "react";
|
import { useMemo, useCallback, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { format, parseISO, isPast, isToday, isTomorrow } from "date-fns";
|
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 { cn } from "@/lib/utils";
|
||||||
import type { CalendarTask, Calendar } from "@/lib/jmap/types";
|
import type { CalendarTask, Calendar } from "@/lib/jmap/types";
|
||||||
import type { TaskViewFilter } from "@/stores/task-store";
|
import type { TaskViewFilter } from "@/stores/task-store";
|
||||||
@@ -18,6 +18,7 @@ interface TaskListViewProps {
|
|||||||
onSelectTask: (task: CalendarTask) => void;
|
onSelectTask: (task: CalendarTask) => void;
|
||||||
onToggleComplete: (task: CalendarTask) => void;
|
onToggleComplete: (task: CalendarTask) => void;
|
||||||
selectedTaskId?: string | null;
|
selectedTaskId?: string | null;
|
||||||
|
onQuickCreate?: (title: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTaskPriorityIcon(priority: number) {
|
function getTaskPriorityIcon(priority: number) {
|
||||||
@@ -69,9 +70,11 @@ export function TaskListView({
|
|||||||
onSelectTask,
|
onSelectTask,
|
||||||
onToggleComplete,
|
onToggleComplete,
|
||||||
selectedTaskId,
|
selectedTaskId,
|
||||||
|
onQuickCreate,
|
||||||
}: TaskListViewProps) {
|
}: TaskListViewProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||||
|
const [quickAddTitle, setQuickAddTitle] = useState("");
|
||||||
|
|
||||||
const filteredTasks = useMemo(() => {
|
const filteredTasks = useMemo(() => {
|
||||||
let result = tasks.filter(task => {
|
let result = tasks.filter(task => {
|
||||||
@@ -128,15 +131,57 @@ export function TaskListView({
|
|||||||
|
|
||||||
if (filteredTasks.length === 0) {
|
if (filteredTasks.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center flex-1 text-muted-foreground py-12">
|
<div className="flex flex-col flex-1">
|
||||||
<ListTodo className="h-12 w-12 mb-3 opacity-30" />
|
{onQuickCreate && (
|
||||||
<p className="text-sm">{t("tasks.no_tasks")}</p>
|
<div className="px-4 py-2 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Plus className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={quickAddTitle}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col items-center justify-center flex-1 text-muted-foreground py-12">
|
||||||
|
<ListTodo className="h-12 w-12 mb-3 opacity-30" />
|
||||||
|
<p className="text-sm">{t("tasks.no_tasks")}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{onQuickCreate && (
|
||||||
|
<div className="px-4 py-2 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Plus className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={quickAddTitle}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
{filteredTasks.map(task => {
|
{filteredTasks.map(task => {
|
||||||
const cal = calendars.find(c => task.calendarIds[c.id]);
|
const cal = calendars.find(c => task.calendarIds[c.id]);
|
||||||
|
|||||||
@@ -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<CalendarTask>) => void | Promise<void>;
|
||||||
|
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<HTMLInputElement>(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<PriorityLevel>(priorityToLevel(task?.priority ?? 0));
|
||||||
|
const [progress, setProgress] = useState<CalendarTask["progress"]>(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<AlertOption>(() => {
|
||||||
|
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<string, CalendarEventAlert> | 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<CalendarTask> = {
|
||||||
|
"@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 (
|
||||||
|
<div className="flex flex-col h-full bg-background" onKeyDown={handleKeyDown}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
|
<h2 className="text-sm font-semibold">
|
||||||
|
{isEdit ? t("tasks.edit") : t("tasks.create")}
|
||||||
|
</h2>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onClose}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
{/* Title */}
|
||||||
|
<Input
|
||||||
|
ref={titleRef}
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder={t("tasks.title_placeholder")}
|
||||||
|
className="text-base font-medium"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder={t("tasks.description_placeholder")}
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Due Date */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<CalendarDays className="h-3.5 w-3.5" />
|
||||||
|
{t("tasks.due_date")}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dueDate}
|
||||||
|
onChange={(e) => setDueDate(e.target.value)}
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
{dueDate && (
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showTime}
|
||||||
|
onChange={(e) => setShowTime(e.target.checked)}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
{t("tasks.include_time")}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{showTime && (
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={dueTime}
|
||||||
|
onChange={(e) => setDueTime(e.target.value)}
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<Flag className="h-3.5 w-3.5" />
|
||||||
|
{t("tasks.priority")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={priority}
|
||||||
|
onChange={(e) => setPriority(e.target.value as PriorityLevel)}
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
|
||||||
|
>
|
||||||
|
<option value="none">{t("tasks.priority_none")}</option>
|
||||||
|
<option value="high">{t("tasks.priority_high")}</option>
|
||||||
|
<option value="medium">{t("tasks.priority_medium")}</option>
|
||||||
|
<option value="low">{t("tasks.priority_low")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
{t("tasks.progress")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={progress}
|
||||||
|
onChange={(e) => setProgress(e.target.value as CalendarTask["progress"])}
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
|
||||||
|
>
|
||||||
|
<option value="needs-action">{t("tasks.progress_needs_action")}</option>
|
||||||
|
<option value="in-process">{t("tasks.progress_in_process")}</option>
|
||||||
|
<option value="completed">{t("tasks.progress_completed")}</option>
|
||||||
|
<option value="cancelled">{t("tasks.progress_cancelled")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar */}
|
||||||
|
{writableCalendars.length > 1 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
{t("tasks.calendar")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={calendarId}
|
||||||
|
onChange={(e) => setCalendarId(e.target.value)}
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
|
||||||
|
>
|
||||||
|
{writableCalendars.map((cal) => (
|
||||||
|
<option key={cal.id} value={cal.id}>{cal.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Alert */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<Bell className="h-3.5 w-3.5" />
|
||||||
|
{t("tasks.alert")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={alertOption}
|
||||||
|
onChange={(e) => setAlertOption(e.target.value as AlertOption)}
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
|
||||||
|
>
|
||||||
|
<option value="none">{t("tasks.alert_none")}</option>
|
||||||
|
<option value="at_time">{t("tasks.alert_at_time")}</option>
|
||||||
|
<option value="5">{t("tasks.alert_5min")}</option>
|
||||||
|
<option value="15">{t("tasks.alert_15min")}</option>
|
||||||
|
<option value="30">{t("tasks.alert_30min")}</option>
|
||||||
|
<option value="60">{t("tasks.alert_1hr")}</option>
|
||||||
|
<option value="1440">{t("tasks.alert_1day")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
|
||||||
|
<div>
|
||||||
|
{isEdit && onDelete && task && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={() => onDelete(task.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
|
{t("tasks.delete")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={onClose}>
|
||||||
|
{t("tasks.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={!title.trim() || saving}>
|
||||||
|
{t("tasks.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { TaskViewFilter } from "@/stores/task-store";
|
||||||
|
|
||||||
|
interface TaskToolbarProps {
|
||||||
|
filter: TaskViewFilter;
|
||||||
|
showCompleted: boolean;
|
||||||
|
onFilterChange: (filter: TaskViewFilter) => void;
|
||||||
|
onShowCompletedChange: (show: boolean) => void;
|
||||||
|
onCreateTask: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTERS: TaskViewFilter[] = ["all", "pending", "completed", "overdue"];
|
||||||
|
|
||||||
|
export function TaskToolbar({
|
||||||
|
filter,
|
||||||
|
showCompleted,
|
||||||
|
onFilterChange,
|
||||||
|
onShowCompletedChange,
|
||||||
|
onCreateTask,
|
||||||
|
}: TaskToolbarProps) {
|
||||||
|
const t = useTranslations("calendar");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-border flex-wrap">
|
||||||
|
<div className="flex border border-border rounded-md overflow-hidden">
|
||||||
|
{FILTERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => onFilterChange(f)}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 text-xs font-medium transition-colors",
|
||||||
|
f === filter
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "hover:bg-muted text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t(`tasks.filter_${f}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none ml-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showCompleted}
|
||||||
|
onChange={(e) => onShowCompletedChange(e.target.checked)}
|
||||||
|
className="rounded border-border"
|
||||||
|
/>
|
||||||
|
{t("tasks.show_completed")}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<Button size="sm" onClick={onCreateTask}>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
{t("tasks.create")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ export function CalendarSettings() {
|
|||||||
calendarNotificationsEnabled,
|
calendarNotificationsEnabled,
|
||||||
calendarNotificationSound,
|
calendarNotificationSound,
|
||||||
calendarInvitationParsingEnabled,
|
calendarInvitationParsingEnabled,
|
||||||
|
enableCalendarTasks,
|
||||||
|
showTasksOnCalendar,
|
||||||
updateSetting,
|
updateSetting,
|
||||||
} = useSettingsStore();
|
} = useSettingsStore();
|
||||||
|
|
||||||
@@ -79,6 +81,28 @@ export function CalendarSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
<SettingItem
|
||||||
|
label={t('enable_tasks')}
|
||||||
|
description={t('enable_tasks_desc')}
|
||||||
|
>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={enableCalendarTasks}
|
||||||
|
onChange={(checked) => updateSetting('enableCalendarTasks', checked)}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
|
{enableCalendarTasks && (
|
||||||
|
<SettingItem
|
||||||
|
label={t('show_tasks_on_calendar')}
|
||||||
|
description={t('show_tasks_on_calendar_desc')}
|
||||||
|
>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={showTasksOnCalendar}
|
||||||
|
onChange={(checked) => updateSetting('showTasksOnCalendar', checked)}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
)}
|
||||||
|
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={t('notifications_enabled')}
|
label={t('notifications_enabled')}
|
||||||
description={t('notifications_enabled_desc')}
|
description={t('notifications_enabled_desc')}
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { useTranslations, useLocale } from 'next-intl';
|
|||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { useCalendarStore } from '@/stores/calendar-store';
|
import { useCalendarStore } from '@/stores/calendar-store';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
import { useTaskStore } from '@/stores/task-store';
|
||||||
import { useCalendarNotificationStore } from '@/stores/calendar-notification-store';
|
import { useCalendarNotificationStore } from '@/stores/calendar-notification-store';
|
||||||
import { useToastStore } from '@/stores/toast-store';
|
import { useToastStore } from '@/stores/toast-store';
|
||||||
import { getPendingAlerts, buildAlertKey } from '@/lib/calendar-alerts';
|
import { getPendingAlerts, getPendingTaskAlerts, buildAlertKey } from '@/lib/calendar-alerts';
|
||||||
import { playNotificationSound } from '@/lib/notification-sound';
|
import { playNotificationSound } from '@/lib/notification-sound';
|
||||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||||
|
|
||||||
@@ -18,7 +19,8 @@ const PROACTIVE_THROTTLE_MS = CHECK_INTERVAL_MS * 5;
|
|||||||
export function useCalendarAlerts() {
|
export function useCalendarAlerts() {
|
||||||
const { isAuthenticated, client } = useAuthStore();
|
const { isAuthenticated, client } = useAuthStore();
|
||||||
const { events, calendars, supportsCalendar } = useCalendarStore();
|
const { events, calendars, supportsCalendar } = useCalendarStore();
|
||||||
const { calendarNotificationsEnabled, calendarNotificationSound } = useSettingsStore();
|
const { calendarNotificationsEnabled, calendarNotificationSound, enableCalendarTasks } = useSettingsStore();
|
||||||
|
const { tasks: storeTasks } = useTaskStore();
|
||||||
const { acknowledgedAlerts, acknowledgeAlert, cleanupStaleAlerts } = useCalendarNotificationStore();
|
const { acknowledgedAlerts, acknowledgeAlert, cleanupStaleAlerts } = useCalendarNotificationStore();
|
||||||
const addToast = useToastStore((s) => s.addToast);
|
const addToast = useToastStore((s) => s.addToast);
|
||||||
const t = useTranslations('calendar.notifications');
|
const t = useTranslations('calendar.notifications');
|
||||||
@@ -69,6 +71,36 @@ export function useCalendarAlerts() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Task alerts
|
||||||
|
if (enableCalendarTasks && storeTasks.length > 0) {
|
||||||
|
const pendingTaskAlerts = getPendingTaskAlerts(storeTasks, calendars, acknowledgedKeys, now);
|
||||||
|
for (const taskAlert of pendingTaskAlerts) {
|
||||||
|
const key = buildAlertKey(taskAlert.taskId, taskAlert.alertId, taskAlert.fireTimeMs);
|
||||||
|
if (shownKeysRef.current.has(key)) continue;
|
||||||
|
|
||||||
|
shownKeysRef.current.add(key);
|
||||||
|
acknowledgeAlert(key, taskAlert.fireTimeMs);
|
||||||
|
|
||||||
|
if (calendarNotificationSound) {
|
||||||
|
playNotificationSound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskMsg = taskAlert.calendarName
|
||||||
|
? `${t('task_due')} · ${taskAlert.calendarName}`
|
||||||
|
: t('task_due');
|
||||||
|
|
||||||
|
addToast({
|
||||||
|
type: 'info',
|
||||||
|
title: taskAlert.task.title || t('alert_title'),
|
||||||
|
message: taskMsg,
|
||||||
|
duration: 15000,
|
||||||
|
onClick: () => {
|
||||||
|
window.location.href = `/${locale}/calendar`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Silently ignore alert evaluation errors
|
// Silently ignore alert evaluation errors
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
CalendarOffsetTrigger,
|
CalendarOffsetTrigger,
|
||||||
CalendarAbsoluteTrigger,
|
CalendarAbsoluteTrigger,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
CalendarTask,
|
||||||
} from '@/lib/jmap/types';
|
} from '@/lib/jmap/types';
|
||||||
|
|
||||||
export interface PendingAlert {
|
export interface PendingAlert {
|
||||||
@@ -121,3 +122,68 @@ export function getPendingAlerts(
|
|||||||
|
|
||||||
return pending;
|
return pending;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PendingTaskAlert {
|
||||||
|
taskId: string;
|
||||||
|
alertId: string;
|
||||||
|
fireTimeMs: number;
|
||||||
|
task: CalendarTask;
|
||||||
|
calendarName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeTaskFireTime(
|
||||||
|
task: CalendarTask,
|
||||||
|
trigger: CalendarOffsetTrigger | CalendarAbsoluteTrigger
|
||||||
|
): number | null {
|
||||||
|
if (trigger['@type'] === 'AbsoluteTrigger') {
|
||||||
|
const t = new Date(trigger.when).getTime();
|
||||||
|
return Number.isNaN(t) ? null : t;
|
||||||
|
}
|
||||||
|
|
||||||
|
const offsetMs = parseAlertOffset(trigger.offset);
|
||||||
|
if (offsetMs === null) return null;
|
||||||
|
|
||||||
|
if (!task.due) return null;
|
||||||
|
const baseTime = new Date(task.due).getTime();
|
||||||
|
if (Number.isNaN(baseTime)) return null;
|
||||||
|
return baseTime + offsetMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPendingTaskAlerts(
|
||||||
|
tasks: CalendarTask[],
|
||||||
|
calendars: Calendar[],
|
||||||
|
acknowledgedKeys: Set<string>,
|
||||||
|
now: number
|
||||||
|
): PendingTaskAlert[] {
|
||||||
|
const pending: PendingTaskAlert[] = [];
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
if (!task.alerts) continue;
|
||||||
|
if (task.progress === 'completed' || task.progress === 'cancelled') continue;
|
||||||
|
|
||||||
|
const calendar = calendars.find(c => c.id === Object.keys(task.calendarIds)[0]) ?? null;
|
||||||
|
|
||||||
|
for (const [alertId, alert] of Object.entries(task.alerts)) {
|
||||||
|
if (alert.action !== 'display') continue;
|
||||||
|
if (alert.acknowledged) continue;
|
||||||
|
|
||||||
|
const fireTimeMs = computeTaskFireTime(task, alert.trigger);
|
||||||
|
if (fireTimeMs === null) continue;
|
||||||
|
if (fireTimeMs > now) continue;
|
||||||
|
if (fireTimeMs <= now - STALE_THRESHOLD_MS) continue;
|
||||||
|
|
||||||
|
const key = buildAlertKey(task.id, alertId, fireTimeMs);
|
||||||
|
if (acknowledgedKeys.has(key)) continue;
|
||||||
|
|
||||||
|
pending.push({
|
||||||
|
taskId: task.id,
|
||||||
|
alertId,
|
||||||
|
fireTimeMs,
|
||||||
|
task,
|
||||||
|
calendarName: calendar?.name ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|||||||
+50
-1
@@ -1,5 +1,5 @@
|
|||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, FileNode } from '@/lib/jmap/types';
|
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode } from '@/lib/jmap/types';
|
||||||
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
|
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
|
||||||
import { getDemoData, type DemoData } from './demo-data';
|
import { getDemoData, type DemoData } from './demo-data';
|
||||||
import { generateDemoId } from './demo-utils';
|
import { generateDemoId } from './demo-utils';
|
||||||
@@ -621,6 +621,55 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
return []; // no-op in demo
|
return []; // no-op in demo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Calendar Tasks ────────────────────────────────────────────
|
||||||
|
|
||||||
|
async getCalendarTasks(calendarIds?: string[]): Promise<CalendarTask[]> {
|
||||||
|
let tasks = this.data.calendarTasks || [];
|
||||||
|
if (calendarIds) {
|
||||||
|
tasks = tasks.filter(t => Object.keys(t.calendarIds).some(id => calendarIds.includes(id)));
|
||||||
|
}
|
||||||
|
return [...tasks];
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCalendarTask(task: Partial<CalendarTask>): Promise<CalendarTask> {
|
||||||
|
const full: CalendarTask = {
|
||||||
|
id: generateDemoId('task'),
|
||||||
|
uid: generateDemoId('task-uid'),
|
||||||
|
'@type': 'Task',
|
||||||
|
calendarIds: task.calendarIds || { [this.data.calendars[0]?.id || 'cal-1']: true },
|
||||||
|
title: task.title || '',
|
||||||
|
description: task.description || '',
|
||||||
|
due: task.due || null,
|
||||||
|
start: task.start || null,
|
||||||
|
duration: task.duration || null,
|
||||||
|
timeZone: task.timeZone || null,
|
||||||
|
showWithoutTime: task.showWithoutTime ?? true,
|
||||||
|
progress: task.progress || 'needs-action',
|
||||||
|
progressUpdated: null,
|
||||||
|
priority: task.priority || 0,
|
||||||
|
privacy: task.privacy || 'public',
|
||||||
|
keywords: task.keywords || null,
|
||||||
|
categories: task.categories || null,
|
||||||
|
color: task.color || null,
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
updated: new Date().toISOString(),
|
||||||
|
recurrenceRules: task.recurrenceRules || null,
|
||||||
|
alerts: task.alerts || null,
|
||||||
|
relatedTo: task.relatedTo || null,
|
||||||
|
};
|
||||||
|
this.data.calendarTasks.push(full);
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCalendarTask(taskId: string, updates: Partial<CalendarTask>): Promise<void> {
|
||||||
|
const task = this.data.calendarTasks.find(t => t.id === taskId);
|
||||||
|
if (task) Object.assign(task, updates, { updated: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCalendarTask(taskId: string): Promise<void> {
|
||||||
|
this.data.calendarTasks = this.data.calendarTasks.filter(t => t.id !== taskId);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Sieve / Filters ──────────────────────────────────────────
|
// ── Sieve / Filters ──────────────────────────────────────────
|
||||||
|
|
||||||
getSieveAccountId(): string { return 'demo-account'; }
|
getSieveAccountId(): string { return 'demo-account'; }
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { createDemoMailboxes } from './fixtures/mailboxes';
|
|||||||
import { createDemoEmails } from './fixtures/emails';
|
import { createDemoEmails } from './fixtures/emails';
|
||||||
import { createDemoContacts, createDemoAddressBooks } from './fixtures/contacts';
|
import { createDemoContacts, createDemoAddressBooks } from './fixtures/contacts';
|
||||||
import { createDemoCalendars, createDemoCalendarEvents } from './fixtures/calendars';
|
import { createDemoCalendars, createDemoCalendarEvents } from './fixtures/calendars';
|
||||||
|
import { createDemoCalendarTasks } from './fixtures/tasks';
|
||||||
import { createDemoIdentities } from './fixtures/identities';
|
import { createDemoIdentities } from './fixtures/identities';
|
||||||
import { createDemoSieveScripts, createDemoSieveCapabilities, createDemoSieveContent } from './fixtures/filters';
|
import { createDemoSieveScripts, createDemoSieveCapabilities, createDemoSieveContent } from './fixtures/filters';
|
||||||
import { createDemoFileNodes } from './fixtures/files';
|
import { createDemoFileNodes } from './fixtures/files';
|
||||||
import { createDemoVacationResponse } from './fixtures/vacation';
|
import { createDemoVacationResponse } from './fixtures/vacation';
|
||||||
|
|
||||||
import type { Email, Mailbox, ContactCard, AddressBook, Calendar, CalendarEvent, Identity, VacationResponse, FileNode } from '@/lib/jmap/types';
|
import type { Email, Mailbox, ContactCard, AddressBook, Calendar, CalendarEvent, CalendarTask, Identity, VacationResponse, FileNode } from '@/lib/jmap/types';
|
||||||
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
|
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
|
||||||
|
|
||||||
export interface DemoData {
|
export interface DemoData {
|
||||||
@@ -18,6 +19,7 @@ export interface DemoData {
|
|||||||
addressBooks: AddressBook[];
|
addressBooks: AddressBook[];
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
calendarEvents: CalendarEvent[];
|
calendarEvents: CalendarEvent[];
|
||||||
|
calendarTasks: CalendarTask[];
|
||||||
identities: Identity[];
|
identities: Identity[];
|
||||||
sieveScripts: SieveScript[];
|
sieveScripts: SieveScript[];
|
||||||
sieveCapabilities: SieveCapabilities;
|
sieveCapabilities: SieveCapabilities;
|
||||||
@@ -35,6 +37,7 @@ export function getDemoData(): DemoData {
|
|||||||
addressBooks: createDemoAddressBooks(),
|
addressBooks: createDemoAddressBooks(),
|
||||||
calendars: createDemoCalendars(),
|
calendars: createDemoCalendars(),
|
||||||
calendarEvents: createDemoCalendarEvents(),
|
calendarEvents: createDemoCalendarEvents(),
|
||||||
|
calendarTasks: createDemoCalendarTasks(),
|
||||||
identities: createDemoIdentities(),
|
identities: createDemoIdentities(),
|
||||||
sieveScripts: createDemoSieveScripts(),
|
sieveScripts: createDemoSieveScripts(),
|
||||||
sieveCapabilities: createDemoSieveCapabilities(),
|
sieveCapabilities: createDemoSieveCapabilities(),
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import type { CalendarTask } from '@/lib/jmap/types';
|
||||||
|
import { demoDate } from '../demo-utils';
|
||||||
|
|
||||||
|
export function createDemoCalendarTasks(): CalendarTask[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'demo-task-1',
|
||||||
|
calendarIds: { 'demo-calendar-personal': true },
|
||||||
|
'@type': 'Task',
|
||||||
|
uid: 'demo-task-uid-1',
|
||||||
|
title: 'Buy groceries',
|
||||||
|
description: 'Milk, bread, eggs, and vegetables',
|
||||||
|
due: demoDate(0, 2),
|
||||||
|
start: null,
|
||||||
|
duration: null,
|
||||||
|
timeZone: null,
|
||||||
|
showWithoutTime: false,
|
||||||
|
progress: 'needs-action',
|
||||||
|
progressUpdated: null,
|
||||||
|
priority: 0,
|
||||||
|
privacy: 'public',
|
||||||
|
keywords: null,
|
||||||
|
categories: null,
|
||||||
|
color: null,
|
||||||
|
created: demoDate(-3),
|
||||||
|
updated: demoDate(-1),
|
||||||
|
recurrenceRules: null,
|
||||||
|
alerts: null,
|
||||||
|
relatedTo: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-task-2',
|
||||||
|
calendarIds: { 'demo-calendar-work': true },
|
||||||
|
'@type': 'Task',
|
||||||
|
uid: 'demo-task-uid-2',
|
||||||
|
title: 'Prepare quarterly report',
|
||||||
|
description: 'Compile Q4 metrics and send to team',
|
||||||
|
due: demoDate(1, 4),
|
||||||
|
start: null,
|
||||||
|
duration: null,
|
||||||
|
timeZone: null,
|
||||||
|
showWithoutTime: false,
|
||||||
|
progress: 'in-process',
|
||||||
|
progressUpdated: demoDate(-1),
|
||||||
|
priority: 1,
|
||||||
|
privacy: 'public',
|
||||||
|
keywords: null,
|
||||||
|
categories: null,
|
||||||
|
color: null,
|
||||||
|
created: demoDate(-5),
|
||||||
|
updated: demoDate(0),
|
||||||
|
recurrenceRules: null,
|
||||||
|
alerts: {
|
||||||
|
'demo-alert-1': {
|
||||||
|
'@type': 'Alert',
|
||||||
|
trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' },
|
||||||
|
action: 'display',
|
||||||
|
acknowledged: null,
|
||||||
|
relatedTo: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
relatedTo: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-task-3',
|
||||||
|
calendarIds: { 'demo-calendar-personal': true },
|
||||||
|
'@type': 'Task',
|
||||||
|
uid: 'demo-task-uid-3',
|
||||||
|
title: 'Schedule dentist appointment',
|
||||||
|
description: '',
|
||||||
|
due: demoDate(3),
|
||||||
|
start: null,
|
||||||
|
duration: null,
|
||||||
|
timeZone: null,
|
||||||
|
showWithoutTime: true,
|
||||||
|
progress: 'needs-action',
|
||||||
|
progressUpdated: null,
|
||||||
|
priority: 5,
|
||||||
|
privacy: 'public',
|
||||||
|
keywords: null,
|
||||||
|
categories: null,
|
||||||
|
color: null,
|
||||||
|
created: demoDate(-2),
|
||||||
|
updated: demoDate(-2),
|
||||||
|
recurrenceRules: null,
|
||||||
|
alerts: null,
|
||||||
|
relatedTo: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-task-4',
|
||||||
|
calendarIds: { 'demo-calendar-work': true },
|
||||||
|
'@type': 'Task',
|
||||||
|
uid: 'demo-task-uid-4',
|
||||||
|
title: 'Review pull requests',
|
||||||
|
description: 'Review open PRs from the team',
|
||||||
|
due: demoDate(-1),
|
||||||
|
start: null,
|
||||||
|
duration: null,
|
||||||
|
timeZone: null,
|
||||||
|
showWithoutTime: true,
|
||||||
|
progress: 'completed',
|
||||||
|
progressUpdated: demoDate(0),
|
||||||
|
priority: 0,
|
||||||
|
privacy: 'public',
|
||||||
|
keywords: null,
|
||||||
|
categories: null,
|
||||||
|
color: null,
|
||||||
|
created: demoDate(-4),
|
||||||
|
updated: demoDate(0),
|
||||||
|
recurrenceRules: null,
|
||||||
|
alerts: null,
|
||||||
|
relatedTo: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-task-5',
|
||||||
|
calendarIds: { 'demo-calendar-personal': true },
|
||||||
|
'@type': 'Task',
|
||||||
|
uid: 'demo-task-uid-5',
|
||||||
|
title: 'Pay electricity bill',
|
||||||
|
description: '',
|
||||||
|
due: demoDate(-2),
|
||||||
|
start: null,
|
||||||
|
duration: null,
|
||||||
|
timeZone: null,
|
||||||
|
showWithoutTime: true,
|
||||||
|
progress: 'needs-action',
|
||||||
|
progressUpdated: null,
|
||||||
|
priority: 1,
|
||||||
|
privacy: 'public',
|
||||||
|
keywords: null,
|
||||||
|
categories: null,
|
||||||
|
color: null,
|
||||||
|
created: demoDate(-7),
|
||||||
|
updated: demoDate(-7),
|
||||||
|
recurrenceRules: null,
|
||||||
|
alerts: null,
|
||||||
|
relatedTo: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, FileNode } from "./types";
|
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode } from "./types";
|
||||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -201,6 +201,12 @@ export interface IJMAPClient {
|
|||||||
queryAllCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number): Promise<CalendarEvent[]>;
|
queryAllCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number): Promise<CalendarEvent[]>;
|
||||||
parseCalendarEvents(accountId: string, blobId: string): Promise<Partial<CalendarEvent>[]>;
|
parseCalendarEvents(accountId: string, blobId: string): Promise<Partial<CalendarEvent>[]>;
|
||||||
|
|
||||||
|
// ── Calendar Tasks ────────────────────────────────────────────
|
||||||
|
getCalendarTasks(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]>;
|
||||||
|
createCalendarTask(task: Partial<CalendarTask>, targetAccountId?: string): Promise<CalendarTask>;
|
||||||
|
updateCalendarTask(taskId: string, updates: Partial<CalendarTask>, targetAccountId?: string): Promise<void>;
|
||||||
|
deleteCalendarTask(taskId: string, targetAccountId?: string): Promise<void>;
|
||||||
|
|
||||||
// ── Sieve / Filters ──────────────────────────────────────────
|
// ── Sieve / Filters ──────────────────────────────────────────
|
||||||
getSieveAccountId(): string;
|
getSieveAccountId(): string;
|
||||||
getSieveCapabilities(): SieveCapabilities | null;
|
getSieveCapabilities(): SieveCapabilities | null;
|
||||||
|
|||||||
+29
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, FileNode, FileNodeFilter } from "./types";
|
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter } from "./types";
|
||||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||||
import type { IJMAPClient } from "./client-interface";
|
import type { IJMAPClient } from "./client-interface";
|
||||||
import { toWildcardQuery } from "./search-utils";
|
import { toWildcardQuery } from "./search-utils";
|
||||||
@@ -3173,6 +3173,34 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
return { destroyed, notDestroyed };
|
return { destroyed, notDestroyed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Calendar Tasks (JSCalendar Task objects via CalendarEvent endpoints) ───
|
||||||
|
|
||||||
|
async getCalendarTasks(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]> {
|
||||||
|
try {
|
||||||
|
const events = await this.getCalendarEvents(calendarIds, targetAccountId);
|
||||||
|
return events.filter((e): e is CalendarTask & CalendarEvent =>
|
||||||
|
(e as unknown as CalendarTask)['@type'] === 'Task'
|
||||||
|
) as unknown as CalendarTask[];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get calendar tasks:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCalendarTask(task: Partial<CalendarTask>, targetAccountId?: string): Promise<CalendarTask> {
|
||||||
|
const event = { ...task, '@type': 'Task' } as unknown as Partial<CalendarEvent>;
|
||||||
|
const created = await this.createCalendarEvent(event, false, targetAccountId);
|
||||||
|
return created as unknown as CalendarTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCalendarTask(taskId: string, updates: Partial<CalendarTask>, targetAccountId?: string): Promise<void> {
|
||||||
|
await this.updateCalendarEvent(taskId, updates as unknown as Partial<CalendarEvent>, false, targetAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCalendarTask(taskId: string, targetAccountId?: string): Promise<void> {
|
||||||
|
await this.deleteCalendarEvent(taskId, false, targetAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── JMAP FileNode methods (draft-ietf-jmap-filenode) ───
|
// ─── JMAP FileNode methods (draft-ietf-jmap-filenode) ───
|
||||||
|
|
||||||
supportsFiles(): boolean {
|
supportsFiles(): boolean {
|
||||||
|
|||||||
+49
-4
@@ -1753,7 +1753,9 @@
|
|||||||
"month_hint": "Month (m)",
|
"month_hint": "Month (m)",
|
||||||
"week_hint": "Week (w)",
|
"week_hint": "Week (w)",
|
||||||
"day_hint": "Day (d)",
|
"day_hint": "Day (d)",
|
||||||
"agenda_hint": "Agenda (a)"
|
"agenda_hint": "Agenda (a)",
|
||||||
|
"tasks": "Tasks",
|
||||||
|
"tasks_hint": "Tasks (k)"
|
||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"create": "Create event",
|
"create": "Create event",
|
||||||
@@ -1866,7 +1868,11 @@
|
|||||||
"show_time_in_month_view": "Show time in month view",
|
"show_time_in_month_view": "Show time in month view",
|
||||||
"show_time_in_month_view_desc": "Display event times in the month calendar view",
|
"show_time_in_month_view_desc": "Display event times in the month calendar view",
|
||||||
"show_week_numbers": "Show week numbers",
|
"show_week_numbers": "Show week numbers",
|
||||||
"show_week_numbers_desc": "Display week numbers in the mini-calendar"
|
"show_week_numbers_desc": "Display week numbers in the mini-calendar",
|
||||||
|
"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"
|
||||||
},
|
},
|
||||||
"days": {
|
"days": {
|
||||||
"monday": "Monday",
|
"monday": "Monday",
|
||||||
@@ -1899,7 +1905,8 @@
|
|||||||
"rsvp_updated": "Response updated",
|
"rsvp_updated": "Response updated",
|
||||||
"rsvp_error": "Failed to update response",
|
"rsvp_error": "Failed to update response",
|
||||||
"event_duplicated": "Event duplicated",
|
"event_duplicated": "Event duplicated",
|
||||||
"event_error": "Failed to save event"
|
"event_error": "Failed to save event",
|
||||||
|
"task_due": "Task due"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading_calendars": "Loading calendars...",
|
"loading_calendars": "Loading calendars...",
|
||||||
@@ -1997,10 +2004,48 @@
|
|||||||
"last_refreshed": "Last updated: {time}"
|
"last_refreshed": "Last updated: {time}"
|
||||||
},
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
|
"label": "Tasks",
|
||||||
"no_tasks": "No tasks",
|
"no_tasks": "No tasks",
|
||||||
"no_title": "(No title)",
|
"no_title": "(No title)",
|
||||||
"mark_complete": "Mark as complete",
|
"mark_complete": "Mark as complete",
|
||||||
"mark_incomplete": "Mark as incomplete"
|
"mark_incomplete": "Mark as incomplete",
|
||||||
|
"filter_all": "All",
|
||||||
|
"filter_pending": "Pending",
|
||||||
|
"filter_completed": "Completed",
|
||||||
|
"filter_overdue": "Overdue",
|
||||||
|
"show_completed": "Show completed",
|
||||||
|
"create": "New Task",
|
||||||
|
"edit": "Edit Task",
|
||||||
|
"title_placeholder": "Task title",
|
||||||
|
"description_placeholder": "Add a description...",
|
||||||
|
"due_date": "Due date",
|
||||||
|
"include_time": "Include time",
|
||||||
|
"priority": "Priority",
|
||||||
|
"priority_none": "None",
|
||||||
|
"priority_high": "High",
|
||||||
|
"priority_medium": "Medium",
|
||||||
|
"priority_low": "Low",
|
||||||
|
"progress": "Status",
|
||||||
|
"progress_needs_action": "Needs action",
|
||||||
|
"progress_in_process": "In process",
|
||||||
|
"progress_completed": "Completed",
|
||||||
|
"progress_cancelled": "Cancelled",
|
||||||
|
"calendar": "Calendar",
|
||||||
|
"alert": "Reminder",
|
||||||
|
"alert_none": "None",
|
||||||
|
"alert_at_time": "At time of due date",
|
||||||
|
"alert_5min": "5 minutes before",
|
||||||
|
"alert_15min": "15 minutes before",
|
||||||
|
"alert_30min": "30 minutes before",
|
||||||
|
"alert_1hr": "1 hour before",
|
||||||
|
"alert_1day": "1 day before",
|
||||||
|
"delete": "Delete",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save",
|
||||||
|
"quick_add_placeholder": "Add a task...",
|
||||||
|
"due_today": "Today",
|
||||||
|
"due_tomorrow": "Tomorrow",
|
||||||
|
"overdue": "Overdue"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"advanced_search": {
|
"advanced_search": {
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/ty
|
|||||||
import { debug } from '@/lib/debug';
|
import { debug } from '@/lib/debug';
|
||||||
import { normalizeAllDayDuration } from '@/lib/calendar-utils';
|
import { normalizeAllDayDuration } from '@/lib/calendar-utils';
|
||||||
|
|
||||||
export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda';
|
export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda' | 'tasks';
|
||||||
|
|
||||||
const CALENDAR_VIEW_MODES: CalendarViewMode[] = ['month', 'week', 'day', 'agenda'];
|
const CALENDAR_VIEW_MODES: CalendarViewMode[] = ['month', 'week', 'day', 'agenda', 'tasks'];
|
||||||
|
|
||||||
export function isCalendarViewMode(value: unknown): value is CalendarViewMode {
|
export function isCalendarViewMode(value: unknown): value is CalendarViewMode {
|
||||||
return typeof value === 'string' && CALENDAR_VIEW_MODES.includes(value as CalendarViewMode);
|
return typeof value === 'string' && CALENDAR_VIEW_MODES.includes(value as CalendarViewMode);
|
||||||
|
|||||||
@@ -126,6 +126,10 @@ interface SettingsState {
|
|||||||
showTimeInMonthView: boolean;
|
showTimeInMonthView: boolean;
|
||||||
showWeekNumbers: boolean;
|
showWeekNumbers: boolean;
|
||||||
|
|
||||||
|
// Calendar Tasks
|
||||||
|
enableCalendarTasks: boolean;
|
||||||
|
showTasksOnCalendar: boolean;
|
||||||
|
|
||||||
// Calendar Notifications
|
// Calendar Notifications
|
||||||
calendarNotificationsEnabled: boolean;
|
calendarNotificationsEnabled: boolean;
|
||||||
calendarNotificationSound: boolean;
|
calendarNotificationSound: boolean;
|
||||||
@@ -230,6 +234,10 @@ const DEFAULT_SETTINGS = {
|
|||||||
showTimeInMonthView: false,
|
showTimeInMonthView: false,
|
||||||
showWeekNumbers: false,
|
showWeekNumbers: false,
|
||||||
|
|
||||||
|
// Calendar Tasks
|
||||||
|
enableCalendarTasks: false,
|
||||||
|
showTasksOnCalendar: true,
|
||||||
|
|
||||||
// Calendar Notifications
|
// Calendar Notifications
|
||||||
calendarNotificationsEnabled: true,
|
calendarNotificationsEnabled: true,
|
||||||
calendarNotificationSound: true,
|
calendarNotificationSound: true,
|
||||||
@@ -314,6 +322,8 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
calendarNotificationsEnabled: state.calendarNotificationsEnabled,
|
calendarNotificationsEnabled: state.calendarNotificationsEnabled,
|
||||||
calendarNotificationSound: state.calendarNotificationSound,
|
calendarNotificationSound: state.calendarNotificationSound,
|
||||||
calendarInvitationParsingEnabled: state.calendarInvitationParsingEnabled,
|
calendarInvitationParsingEnabled: state.calendarInvitationParsingEnabled,
|
||||||
|
enableCalendarTasks: state.enableCalendarTasks,
|
||||||
|
showTasksOnCalendar: state.showTasksOnCalendar,
|
||||||
expandedFilterView: state.expandedFilterView,
|
expandedFilterView: state.expandedFilterView,
|
||||||
showTimeInMonthView: state.showTimeInMonthView,
|
showTimeInMonthView: state.showTimeInMonthView,
|
||||||
showWeekNumbers: state.showWeekNumbers,
|
showWeekNumbers: state.showWeekNumbers,
|
||||||
|
|||||||
+58
-1
@@ -1,5 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { CalendarTask } from '@/lib/jmap/types';
|
import type { CalendarTask } from '@/lib/jmap/types';
|
||||||
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
|
||||||
export type TaskViewFilter = 'all' | 'pending' | 'completed' | 'overdue';
|
export type TaskViewFilter = 'all' | 'pending' | 'completed' | 'overdue';
|
||||||
|
|
||||||
@@ -8,19 +9,75 @@ interface TaskStore {
|
|||||||
selectedTaskId: string | null;
|
selectedTaskId: string | null;
|
||||||
filter: TaskViewFilter;
|
filter: TaskViewFilter;
|
||||||
showCompleted: boolean;
|
showCompleted: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
setTasks: (tasks: CalendarTask[]) => void;
|
setTasks: (tasks: CalendarTask[]) => void;
|
||||||
setSelectedTaskId: (id: string | null) => void;
|
setSelectedTaskId: (id: string | null) => void;
|
||||||
setFilter: (filter: TaskViewFilter) => void;
|
setFilter: (filter: TaskViewFilter) => void;
|
||||||
setShowCompleted: (show: boolean) => void;
|
setShowCompleted: (show: boolean) => void;
|
||||||
|
fetchTasks: (client: IJMAPClient, calendarIds?: string[]) => Promise<void>;
|
||||||
|
createTask: (client: IJMAPClient, task: Partial<CalendarTask>) => Promise<CalendarTask>;
|
||||||
|
updateTask: (client: IJMAPClient, id: string, updates: Partial<CalendarTask>) => Promise<void>;
|
||||||
|
deleteTask: (client: IJMAPClient, id: string) => Promise<void>;
|
||||||
|
toggleTaskComplete: (client: IJMAPClient, task: CalendarTask) => Promise<void>;
|
||||||
|
clearTasks: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTaskStore = create<TaskStore>((set) => ({
|
export const useTaskStore = create<TaskStore>((set, get) => ({
|
||||||
tasks: [],
|
tasks: [],
|
||||||
selectedTaskId: null,
|
selectedTaskId: null,
|
||||||
filter: 'all',
|
filter: 'all',
|
||||||
showCompleted: false,
|
showCompleted: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
setTasks: (tasks) => set({ tasks }),
|
setTasks: (tasks) => set({ tasks }),
|
||||||
setSelectedTaskId: (id) => set({ selectedTaskId: id }),
|
setSelectedTaskId: (id) => set({ selectedTaskId: id }),
|
||||||
setFilter: (filter) => set({ filter }),
|
setFilter: (filter) => set({ filter }),
|
||||||
setShowCompleted: (show) => set({ showCompleted: show }),
|
setShowCompleted: (show) => set({ showCompleted: show }),
|
||||||
|
|
||||||
|
fetchTasks: async (client, calendarIds) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const tasks = await client.getCalendarTasks(calendarIds);
|
||||||
|
set({ tasks, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch tasks:', error);
|
||||||
|
set({ isLoading: false, error: 'Failed to fetch tasks' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createTask: async (client, task) => {
|
||||||
|
const created = await client.createCalendarTask(task);
|
||||||
|
set({ tasks: [...get().tasks, created] });
|
||||||
|
return created;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTask: async (client, id, updates) => {
|
||||||
|
await client.updateCalendarTask(id, updates);
|
||||||
|
set({
|
||||||
|
tasks: get().tasks.map(t => t.id === id ? { ...t, ...updates, updated: new Date().toISOString() } : t),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteTask: async (client, id) => {
|
||||||
|
await client.deleteCalendarTask(id);
|
||||||
|
set({
|
||||||
|
tasks: get().tasks.filter(t => t.id !== id),
|
||||||
|
selectedTaskId: get().selectedTaskId === id ? null : get().selectedTaskId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleTaskComplete: async (client, task) => {
|
||||||
|
const newProgress = task.progress === 'completed' ? 'needs-action' : 'completed';
|
||||||
|
const updates: Partial<CalendarTask> = {
|
||||||
|
progress: newProgress,
|
||||||
|
progressUpdated: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await client.updateCalendarTask(task.id, updates);
|
||||||
|
set({
|
||||||
|
tasks: get().tasks.map(t => t.id === task.id ? { ...t, ...updates, updated: new Date().toISOString() } : t),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearTasks: () => set({ tasks: [], selectedTaskId: null, error: null }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user