feat: add calendar task list view and shared calendar grouping

- Add TaskListView component for displaying calendar tasks
- Group shared calendars by account in sidebar panel
- Add task view toggle to calendar toolbar
- Extend calendar store with task-related state
This commit is contained in:
Linus Rath
2026-03-19 01:21:43 +01:00
parent 96c2ee9e13
commit 2793d4b4af
4 changed files with 441 additions and 115 deletions
+125 -97
View File
@@ -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<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]);
const sharedAccountGroups = useMemo(() => {
const shared = calendars.filter(c => c.isShared);
const groups = new Map<string, { accountName: string; calendars: Calendar[] }>();
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 (
<div key={cal.id} className="relative">
<button
onClick={() => onToggleVisibility(cal.id)}
onContextMenu={(e) => {
e.preventDefault();
if (isSubscriptionCalendar(cal.id) && client) {
setContextMenuCalId(contextMenuCalId === cal.id ? null : cal.id);
setColorPickerId(null);
} else if (onColorChange) {
setColorPickerId(colorPickerId === cal.id ? null : cal.id);
setContextMenuCalId(null);
}
}}
className={cn(
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
"hover:bg-muted"
)}
>
<span
className={cn(
"w-3 h-3 rounded-sm border-2 flex-shrink-0 transition-colors",
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
{cal.name}
</span>
{isSubscriptionCalendar(cal.id) && (
<>
<Globe className="w-3 h-3 text-muted-foreground flex-shrink-0" />
{refreshingSubId === getSubscriptionForCalendar(cal.id)?.id && (
<RefreshCw className="w-3 h-3 text-muted-foreground flex-shrink-0 animate-spin" />
)}
</>
)}
</button>
{/* Subscription context menu on right-click */}
{contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => {
const sub = getSubscriptionForCalendar(cal.id);
if (!sub) return null;
return (
<div
ref={contextMenuRef}
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
>
<button
onClick={() => handleRefreshSubscription(sub.id)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" />
{tSub('refresh')}
</button>
<button
onClick={() => handleUnsubscribe(sub.id)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
{tSub('unsubscribe')}
</button>
{sub.lastRefreshed && (
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
</div>
)}
</div>
);
})()}
{/* Color picker popover on right-click */}
{colorPickerId === cal.id && onColorChange && (
<div
ref={colorPickerRef}
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-3 w-56"
>
<p className="text-xs font-medium text-muted-foreground mb-2">{t("management.change_color")}</p>
<CalendarColorPicker
value={color}
onChange={(c) => {
onColorChange(cal.id, c);
setColorPickerId(null);
}}
allowCustom
/>
</div>
)}
</div>
);
};
return (
<div className="mt-4">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
{t("my_calendars")}
</h3>
<div className="space-y-0.5">
{calendars.map((cal) => {
const isVisible = selectedCalendarIds.includes(cal.id);
const color = cal.color || "#3b82f6";
return (
<div key={cal.id} className="relative">
<button
onClick={() => onToggleVisibility(cal.id)}
onContextMenu={(e) => {
e.preventDefault();
if (isSubscriptionCalendar(cal.id) && client) {
setContextMenuCalId(contextMenuCalId === cal.id ? null : cal.id);
setColorPickerId(null);
} else if (onColorChange) {
setColorPickerId(colorPickerId === cal.id ? null : cal.id);
setContextMenuCalId(null);
}
}}
className={cn(
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
"hover:bg-muted"
)}
>
<span
className={cn(
"w-3 h-3 rounded-sm border-2 flex-shrink-0 transition-colors",
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
{cal.name}
</span>
{isSubscriptionCalendar(cal.id) && (
<>
<Globe className="w-3 h-3 text-muted-foreground flex-shrink-0" />
{refreshingSubId === getSubscriptionForCalendar(cal.id)?.id && (
<RefreshCw className="w-3 h-3 text-muted-foreground flex-shrink-0 animate-spin" />
)}
</>
)}
</button>
{/* Subscription context menu on right-click */}
{contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => {
const sub = getSubscriptionForCalendar(cal.id);
if (!sub) return null;
return (
<div
ref={contextMenuRef}
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
>
<button
onClick={() => handleRefreshSubscription(sub.id)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" />
{tSub('refresh')}
</button>
<button
onClick={() => handleUnsubscribe(sub.id)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
{tSub('unsubscribe')}
</button>
{sub.lastRefreshed && (
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
</div>
)}
</div>
);
})()}
{/* Color picker popover on right-click */}
{colorPickerId === cal.id && onColorChange && (
<div
ref={colorPickerRef}
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-3 w-56"
>
<p className="text-xs font-medium text-muted-foreground mb-2">{t("management.change_color")}</p>
<CalendarColorPicker
value={color}
onChange={(c) => {
onColorChange(cal.id, c);
setColorPickerId(null);
}}
allowCustom
/>
</div>
)}
</div>
);
})}
{personalCalendars.map(renderCalendarItem)}
</div>
{sharedAccountGroups.map((group) => (
<div key={group.accountName} className="mt-4">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1 flex items-center gap-1.5">
<Share2 className="w-3 h-3" />
{group.accountName}
</h3>
<div className="space-y-0.5">
{group.calendars.map(renderCalendarItem)}
</div>
</div>
))}
</div>
);
}
+44 -1
View File
@@ -153,7 +153,7 @@ export function CalendarToolbar({
{t("my_calendars")}
</h3>
<div className="space-y-0.5">
{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({
);
})}
</div>
{(() => {
const shared = calendars.filter(c => c.isShared);
const groups = new Map<string, { accountName: string; cals: typeof shared }>();
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) => (
<div key={group.accountName} className="mt-2">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1 px-1">
{group.accountName}
</h3>
<div className="space-y-0.5">
{group.cals.map((cal) => {
const isVisible = selectedCalendarIds.includes(cal.id);
const color = cal.color || "#3b82f6";
return (
<button
key={cal.id}
onClick={() => onToggleVisibility(cal.id)}
className={cn(
"flex items-center gap-2 w-full px-2 py-2 rounded-md text-sm transition-colors duration-150 touch-manipulation",
"hover:bg-muted"
)}
>
<span
className={cn(
"w-3.5 h-3.5 rounded-sm border-2 flex-shrink-0 transition-colors",
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
{cal.name}
</span>
</button>
);
})}
</div>
</div>
));
})()}
</div>
)}
</div>
+203
View File
@@ -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 <Flag className="h-3.5 w-3.5 text-red-500" />;
if (priority === 5) return <Flag className="h-3.5 w-3.5 text-orange-500" />;
if (priority >= 6 && priority <= 9) return <Flag className="h-3.5 w-3.5 text-gray-400" />;
return null;
}
function getDueDateLabel(due: string, showWithoutTime: boolean, t: ReturnType<typeof useTranslations>, 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 (
<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>
);
}
return (
<div className="flex-1 overflow-y-auto">
<div className="divide-y divide-border">
{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 (
<div
key={task.id}
onClick={() => 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 */}
<button
onClick={(e) => handleToggle(e, task)}
className={cn(
"mt-0.5 flex-shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors",
isCompleted
? "bg-green-500 border-green-500 text-white"
: "border-muted-foreground/40 hover:border-primary"
)}
aria-label={isCompleted ? t("tasks.mark_incomplete") : t("tasks.mark_complete")}
>
{isCompleted && <Check className="h-3 w-3" />}
</button>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className={cn(
"text-sm font-medium truncate",
isCompleted && "line-through text-muted-foreground"
)}>
{task.title || t("tasks.no_title")}
</span>
{priorityIcon}
</div>
<div className="flex items-center gap-2 mt-0.5">
{dueDateInfo && (
<span className={cn("text-xs flex items-center gap-1", dueDateInfo.className)}>
<CalendarDays className="h-3 w-3" />
{dueDateInfo.label}
</span>
)}
{cal && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: cal.color || "#3b82f6" }} />
{cal.name}
</span>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
);
}