diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index 5e067970..3595ba2a 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -1,8 +1,8 @@ "use client"; -import { useState, useRef, useEffect } from "react"; +import { useState, useRef, useEffect, useMemo } from "react"; import { useTranslations } from "next-intl"; -import { Globe, Plus, RefreshCw, Trash2 } from "lucide-react"; +import { Globe, 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"; @@ -42,6 +42,20 @@ export function CalendarSidebarPanel({ const colorPickerRef = useRef(null); const contextMenuRef = useRef(null); + const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]); + const sharedAccountGroups = useMemo(() => { + const shared = calendars.filter(c => c.isShared); + const groups = new Map(); + for (const cal of shared) { + const key = cal.accountId!; + if (!groups.has(key)) { + groups.set(key, { accountName: cal.accountName || key, calendars: [] }); + } + groups.get(key)!.calendars.push(cal); + } + return Array.from(groups.values()); + }, [calendars]); + useEffect(() => { if (!colorPickerId && !contextMenuCalId) return; const handleClick = (e: MouseEvent) => { @@ -97,108 +111,122 @@ export function CalendarSidebarPanel({ if (calendars.length === 0 && !onSubscribe) return null; + const renderCalendarItem = (cal: Calendar) => { + const isVisible = selectedCalendarIds.includes(cal.id); + const color = cal.color || "#3b82f6"; + + return ( +
+ + + {/* Subscription context menu on right-click */} + {contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => { + const sub = getSubscriptionForCalendar(cal.id); + if (!sub) return null; + return ( +
+ + + {sub.lastRefreshed && ( +
+ {tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })} +
+ )} +
+ ); + })()} + + {/* Color picker popover on right-click */} + {colorPickerId === cal.id && onColorChange && ( +
+

{t("management.change_color")}

+ { + onColorChange(cal.id, c); + setColorPickerId(null); + }} + allowCustom + /> +
+ )} +
+ ); + }; + return (

{t("my_calendars")}

- {calendars.map((cal) => { - const isVisible = selectedCalendarIds.includes(cal.id); - const color = cal.color || "#3b82f6"; - - return ( -
- - - {/* Subscription context menu on right-click */} - {contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => { - const sub = getSubscriptionForCalendar(cal.id); - if (!sub) return null; - return ( -
- - - {sub.lastRefreshed && ( -
- {tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })} -
- )} -
- ); - })()} - - {/* Color picker popover on right-click */} - {colorPickerId === cal.id && onColorChange && ( -
-

{t("management.change_color")}

- { - onColorChange(cal.id, c); - setColorPickerId(null); - }} - allowCustom - /> -
- )} -
- ); - })} + {personalCalendars.map(renderCalendarItem)}
+ + {sharedAccountGroups.map((group) => ( +
+

+ + {group.accountName} +

+
+ {group.calendars.map(renderCalendarItem)} +
+
+ ))}
); } diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index 2395a1e0..5780eb41 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -153,7 +153,7 @@ export function CalendarToolbar({ {t("my_calendars")}
- {calendars.map((cal) => { + {calendars.filter(c => !c.isShared).map((cal) => { const isVisible = selectedCalendarIds.includes(cal.id); const color = cal.color || "#3b82f6"; return ( @@ -179,6 +179,49 @@ export function CalendarToolbar({ ); })}
+ {(() => { + const shared = calendars.filter(c => c.isShared); + const groups = new Map(); + for (const c of shared) { + const key = c.accountId!; + if (!groups.has(key)) groups.set(key, { accountName: c.accountName || key, cals: [] }); + groups.get(key)!.cals.push(c); + } + return Array.from(groups.values()).map((group) => ( +
+

+ {group.accountName} +

+
+ {group.cals.map((cal) => { + const isVisible = selectedCalendarIds.includes(cal.id); + const color = cal.color || "#3b82f6"; + return ( + + ); + })} +
+
+ )); + })()} )} diff --git a/components/calendar/task-list-view.tsx b/components/calendar/task-list-view.tsx new file mode 100644 index 00000000..4e459408 --- /dev/null +++ b/components/calendar/task-list-view.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { useMemo, useCallback } 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 { cn } from "@/lib/utils"; +import type { CalendarTask, Calendar } from "@/lib/jmap/types"; +import type { TaskViewFilter } from "@/stores/task-store"; +import { useSettingsStore } from "@/stores/settings-store"; + +interface TaskListViewProps { + tasks: CalendarTask[]; + calendars: Calendar[]; + selectedCalendarIds: string[]; + filter: TaskViewFilter; + showCompleted: boolean; + onSelectTask: (task: CalendarTask) => void; + onToggleComplete: (task: CalendarTask) => void; + selectedTaskId?: string | null; +} + +function getTaskPriorityIcon(priority: number) { + if (priority >= 1 && priority <= 4) return ; + if (priority === 5) return ; + if (priority >= 6 && priority <= 9) return ; + return null; +} + +function getDueDateLabel(due: string, showWithoutTime: boolean, t: ReturnType, timeFormat: string): { label: string; className: string } { + const dueDate = parseISO(due); + const overdue = isPast(dueDate) && !isToday(dueDate); + + if (isToday(dueDate)) { + return { + label: t("tasks.due_today"), + className: "text-blue-600 dark:text-blue-400", + }; + } + if (isTomorrow(dueDate)) { + return { + label: t("tasks.due_tomorrow"), + className: "text-muted-foreground", + }; + } + if (overdue) { + return { + label: t("tasks.overdue"), + className: "text-red-600 dark:text-red-400", + }; + } + + const formatted = showWithoutTime + ? format(dueDate, "MMM d") + : format(dueDate, timeFormat === "12h" ? "MMM d, h:mm a" : "MMM d, HH:mm"); + + return { + label: formatted, + className: "text-muted-foreground", + }; +} + +export function TaskListView({ + tasks, + calendars, + selectedCalendarIds, + filter, + showCompleted, + onSelectTask, + onToggleComplete, + selectedTaskId, +}: TaskListViewProps) { + const t = useTranslations("calendar"); + const timeFormat = useSettingsStore((s) => s.timeFormat); + + const filteredTasks = useMemo(() => { + let result = tasks.filter(task => { + const calIds = Object.keys(task.calendarIds); + return calIds.some(id => selectedCalendarIds.includes(id)); + }); + + if (!showCompleted) { + result = result.filter(task => task.progress !== "completed" && task.progress !== "cancelled"); + } + + switch (filter) { + case "pending": + result = result.filter(task => task.progress === "needs-action" || task.progress === "in-process"); + break; + case "completed": + result = result.filter(task => task.progress === "completed"); + break; + case "overdue": + result = result.filter(task => { + if (!task.due || task.progress === "completed" || task.progress === "cancelled") return false; + return isPast(parseISO(task.due)) && !isToday(parseISO(task.due)); + }); + break; + } + + // Sort: overdue first, then by due date (no due date last), then by priority + result.sort((a, b) => { + // Completed tasks at the bottom + if (a.progress === "completed" && b.progress !== "completed") return 1; + if (a.progress !== "completed" && b.progress === "completed") return -1; + + // Tasks with due dates before those without + if (a.due && !b.due) return -1; + if (!a.due && b.due) return 1; + if (a.due && b.due) { + const dateCompare = new Date(a.due).getTime() - new Date(b.due).getTime(); + if (dateCompare !== 0) return dateCompare; + } + + // Higher priority first (lower number = higher priority, but 0 = no priority goes last) + const aPri = a.priority || 10; + const bPri = b.priority || 10; + return aPri - bPri; + }); + + return result; + }, [tasks, selectedCalendarIds, filter, showCompleted]); + + const handleToggle = useCallback((e: React.MouseEvent, task: CalendarTask) => { + e.stopPropagation(); + onToggleComplete(task); + }, [onToggleComplete]); + + if (filteredTasks.length === 0) { + return ( +
+ +

{t("tasks.no_tasks")}

+
+ ); + } + + return ( +
+
+ {filteredTasks.map(task => { + const cal = calendars.find(c => task.calendarIds[c.id]); + const isCompleted = task.progress === "completed"; + const priorityIcon = getTaskPriorityIcon(task.priority); + const dueDateInfo = task.due ? getDueDateLabel(task.due, task.showWithoutTime, t, timeFormat) : null; + + return ( +
onSelectTask(task)} + className={cn( + "flex items-start gap-3 px-4 py-3 cursor-pointer hover:bg-muted/50 transition-colors", + selectedTaskId === task.id && "bg-muted", + )} + > + {/* Checkbox */} + + + {/* Content */} +
+
+ + {task.title || t("tasks.no_title")} + + {priorityIcon} +
+ +
+ {dueDateInfo && ( + + + {dueDateInfo.label} + + )} + {cal && ( + + + {cal.name} + + )} +
+
+
+ ); + })} +
+
+ ); +} diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 954206f4..9a9ed309 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -92,7 +92,7 @@ export const useCalendarStore = create()( fetchCalendars: async (client) => { set({ isLoading: true, error: null }); try { - const calendars = await client.getCalendars(); + const calendars = await client.getAllCalendars(); const { selectedCalendarIds } = get(); const validIds = calendars.map(c => c.id); const stillValid = selectedCalendarIds.filter(id => validIds.includes(id)); @@ -110,7 +110,7 @@ export const useCalendarStore = create()( fetchEvents: async (client, start, end) => { set({ isLoadingEvents: true, error: null }); try { - const events = await client.queryCalendarEvents({ + const events = await client.queryAllCalendarEvents({ after: start, before: end, }); @@ -124,7 +124,23 @@ export const useCalendarStore = create()( createEvent: async (client, event, sendSchedulingMessages) => { set({ error: null }); try { - const created = await client.createCalendarEvent(event, sendSchedulingMessages); + // Resolve shared calendar context from calendarIds + let targetAccountId = event.accountId; + const cleanEvent = { ...event }; + if (event.calendarIds) { + const calId = Object.keys(event.calendarIds)[0]; + if (calId) { + const cal = get().calendars.find(c => c.id === calId); + if (cal?.isShared && cal.originalId) { + targetAccountId = cal.accountId; + cleanEvent.calendarIds = { [cal.originalId]: true }; + } + } + } + if (event.originalCalendarIds) { + cleanEvent.calendarIds = event.originalCalendarIds; + } + const created = await client.createCalendarEvent(cleanEvent, sendSchedulingMessages, targetAccountId); set((state) => ({ events: [...state.events, created] })); if (sendSchedulingMessages && created.participants) { try { @@ -144,13 +160,27 @@ export const useCalendarStore = create()( updateEvent: async (client, id, updates, sendSchedulingMessages) => { set({ error: null }); try { - await client.updateCalendarEvent(id, updates, sendSchedulingMessages); + // Resolve shared event IDs + const storeEvent = get().events.find(e => e.id === id); + const realId = storeEvent?.originalId || id; + const targetAccountId = storeEvent?.accountId; + // Remap namespaced calendarIds back to original IDs + const cleanUpdates = { ...updates }; + if (cleanUpdates.calendarIds) { + const remapped: Record = {}; + for (const [calId, v] of Object.entries(cleanUpdates.calendarIds)) { + const cal = get().calendars.find(c => c.id === calId); + remapped[cal?.originalId || calId] = v; + } + cleanUpdates.calendarIds = remapped; + } + await client.updateCalendarEvent(realId, cleanUpdates, sendSchedulingMessages, targetAccountId); set((state) => ({ events: state.events.map(e => e.id === id ? { ...e, ...updates } : e), })); if (sendSchedulingMessages) { try { - const updatedEvent = await client.getCalendarEvent(id); + const updatedEvent = await client.getCalendarEvent(realId, targetAccountId); if (updatedEvent?.participants) { await client.sendImipInvitation(updatedEvent); } @@ -174,6 +204,10 @@ export const useCalendarStore = create()( throw new Error('Invalid participant ID'); } try { + // Resolve shared event IDs + const storeEvent = get().events.find(e => e.id === eventId); + const realId = storeEvent?.originalId || eventId; + const targetAccountId = storeEvent?.accountId; // Escape per RFC 6901 (JSON Pointer): ~ → ~0, / → ~1 const escapedId = participantId.replace(/~/g, '~0').replace(/\//g, '~1'); const patchKey = `participants/${escapedId}/participationStatus`; @@ -184,9 +218,10 @@ export const useCalendarStore = create()( patch.replyTo = replyTo; } await client.updateCalendarEvent( - eventId, + realId, patch as unknown as Partial, - true + true, + targetAccountId ); set((state) => ({ events: state.events.map(e => { @@ -209,6 +244,10 @@ export const useCalendarStore = create()( importEvents: async (client, events, calendarId) => { let imported = 0; + // Resolve shared calendar IDs + const cal = get().calendars.find(c => c.id === calendarId); + const realCalendarId = cal?.originalId || calendarId; + const targetAccountId = cal?.accountId; for (const event of events) { const src = event as Partial; try { @@ -246,7 +285,7 @@ export const useCalendarStore = create()( } const data: Partial = { - calendarIds: { [calendarId]: true }, + calendarIds: { [realCalendarId]: true }, uid: src.uid, title: src.title, description: src.description, @@ -276,7 +315,7 @@ export const useCalendarStore = create()( const v = (data as Record)[k]; if (v === undefined || v === null) delete (data as Record)[k]; }); - const created = await client.createCalendarEvent(data); + const created = await client.createCalendarEvent(data, undefined, targetAccountId); set((state) => ({ events: [...state.events, created] })); imported++; } catch (error) { @@ -289,7 +328,7 @@ export const useCalendarStore = create()( continue; } try { - const all = await client.queryCalendarEvents({}); + const all = await client.queryCalendarEvents({}, undefined, undefined, targetAccountId); const matching = all.filter((e) => e.uid === src.uid); if (matching.length > 0) { const existingIds = new Set(storeEvents.map((e) => e.id)); @@ -313,9 +352,13 @@ export const useCalendarStore = create()( deleteEvent: async (client, id, sendSchedulingMessages) => { set({ error: null }); try { + // Resolve shared event IDs + const storeEvent = get().events.find(e => e.id === id); + const realId = storeEvent?.originalId || id; + const targetAccountId = storeEvent?.accountId; if (sendSchedulingMessages) { try { - const event = await client.getCalendarEvent(id); + const event = await client.getCalendarEvent(realId, targetAccountId); if (event?.participants) { await client.sendImipCancellation(event); } @@ -323,7 +366,7 @@ export const useCalendarStore = create()( debug.error('Failed to send cancellation emails:', e); } } - await client.deleteCalendarEvent(id, sendSchedulingMessages); + await client.deleteCalendarEvent(realId, sendSchedulingMessages, targetAccountId); set((state) => ({ events: state.events.filter(e => e.id !== id), selectedEventId: state.selectedEventId === id ? null : state.selectedEventId, @@ -341,7 +384,10 @@ export const useCalendarStore = create()( updateCalendar: async (client, calendarId, updates) => { set({ error: null }); try { - await client.updateCalendar(calendarId, updates); + const cal = get().calendars.find(c => c.id === calendarId); + const realId = cal?.originalId || calendarId; + const targetAccountId = cal?.accountId; + await client.updateCalendar(realId, updates, targetAccountId); set((state) => ({ calendars: state.calendars.map(c => c.id === calendarId ? { ...c, ...updates } : c @@ -373,7 +419,10 @@ export const useCalendarStore = create()( removeCalendar: async (client, calendarId) => { set({ error: null }); try { - await client.deleteCalendar(calendarId); + const cal = get().calendars.find(c => c.id === calendarId); + const realId = cal?.originalId || calendarId; + const targetAccountId = cal?.accountId; + await client.deleteCalendar(realId, targetAccountId); set((state) => ({ calendars: state.calendars.filter(c => c.id !== calendarId), selectedCalendarIds: state.selectedCalendarIds.filter(id => id !== calendarId), @@ -389,18 +438,21 @@ export const useCalendarStore = create()( clearCalendarEvents: async (client, calendarId) => { set({ error: null }); try { + const cal = get().calendars.find(c => c.id === calendarId); + const realCalId = cal?.originalId || calendarId; + const targetAccountId = cal?.accountId; let totalDeleted = 0; // Loop to handle pagination (getCalendarEvents has a 1000 limit) let hasMore = true; while (hasMore) { // Query all events and filter client-side by calendarId // to avoid relying on server-side inCalendars filter support - const allEvents = await client.getCalendarEvents(); - const calendarEvents = allEvents.filter(e => e.calendarIds?.[calendarId]); + const allEvents = await client.getCalendarEvents(undefined, targetAccountId); + const calendarEvents = allEvents.filter(e => e.calendarIds?.[realCalId]); if (calendarEvents.length === 0) break; const ids = calendarEvents.map(e => e.id); - const { destroyed } = await client.batchDeleteCalendarEvents(ids); + const { destroyed } = await client.batchDeleteCalendarEvents(ids, targetAccountId); totalDeleted += destroyed.length; // If we couldn't destroy any events, stop to avoid infinite loop