My-Weekly-ToDo-List/src/components/GridTaskBlock.tsx
mARTin-B78 52d160ccea feat: projects tab, priority icons, list/tab visuals
Wave-1 user feedback (points 7–10):
- Settings: new Projects tab with full CRUD (name, color, icon picker)
- Priority Icons render in simple/calendar/list views with style-specific
  visuals (Eisenhower icons, ABCDE letters, Ivy Lee 1–6, Pareto star).
  Toggle + style selector in Settings; Ivy Lee ranks now persist to
  task.priority so cross-view badges stay consistent.
- Someday Lists gain optional icon (left of title) and color (left
  border tint), edited via a pencil-popup with IconPicker + color picker.
- Tabs gain optional icon and color, stored in user.viewSettings JSON
  and edited via the same popup pattern.

Schema: SomedayList.color/icon, User.showPriorityIcons/priorityStyle.

v1.99.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 14:23:56 +02:00

934 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useRef, useEffect, Suspense } from "react";
import dynamic from "next/dynamic";
const RichTextEditor = dynamic(() => import("./RichTextEditor"), { ssr: false });
import { Repeat, Circle, X, ChevronDown, Link } from "lucide-react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGoogle, faMicrosoft, faApple } from "@fortawesome/free-brands-svg-icons";
import { faServer, faFolder, IconDefinition } from "@fortawesome/free-solid-svg-icons";
import MdiIcon from "@mdi/react";
import { allIcons } from "./iconRegistry";
import { Task, KanbanStage } from "./WeeklyView";
interface GridTaskBlockProps {
task: Task;
date: Date;
activeDate: Date;
cellDuration: number;
darkMode: boolean;
isProtected: boolean;
editingTaskId: string | null;
setEditingTaskId: (id: string | null) => void;
updateTask: (id: string, title: string) => void;
updateTaskNotes: (id: string, notes: string) => void;
updateTaskUrl: (id: string, url: string) => void;
updateTaskDuration: (id: string, duration: number) => void;
toggleTask: (id: string) => void;
deleteTask: (id: string) => void;
toggleTaskRolling: (id: string) => void;
setSelectedTaskForNotes: (task: Task) => void;
setSelectedTaskForRecurrence: (task: Task) => void;
handleDragStart: (e: React.DragEvent, task: Task) => void;
handleDragEnd: (e: React.DragEvent) => void;
getSlotHeight: (duration: number) => number;
draggedTask: Task | null;
addSubTask: (parentId: string, title: string) => void;
toggleSubTask: (id: string) => void;
updateSubTask: (id: string, title: string) => void;
deleteSubTask: (id: string) => void;
onSetEditingTaskId?: (id: string | null) => void;
workingHoursStart: number;
showTaskCheckboxes?: boolean;
showProjectIcons?: boolean;
showPriorityIcons?: boolean;
priorityStyle?: string;
projects?: any[];
onProjectAssign?: (taskId: string, projectId: string | null) => void;
kanbanStages?: KanbanStage[];
weatherEnabled?: boolean;
onMoveTask?: (taskId: string, targetDate: Date, targetTime: string) => void;
}
export function GridTaskBlock({
task,
date,
activeDate,
cellDuration,
darkMode,
isProtected,
editingTaskId,
setEditingTaskId,
updateTask,
updateTaskNotes,
updateTaskUrl,
updateTaskDuration,
toggleTask,
deleteTask,
toggleTaskRolling,
setSelectedTaskForNotes,
setSelectedTaskForRecurrence,
handleDragStart,
handleDragEnd,
getSlotHeight,
draggedTask,
addSubTask,
toggleSubTask,
updateSubTask,
deleteSubTask,
onSetEditingTaskId,
workingHoursStart,
showTaskCheckboxes,
showProjectIcons,
showPriorityIcons = true,
priorityStyle = "eisenhower",
projects,
onProjectAssign,
kanbanStages = [],
weatherEnabled = false,
onMoveTask,
}: GridTaskBlockProps) {
const [isNotesOpen, setIsNotesOpen] = useState(false);
const [notesValue, setNotesValue] = useState(task.markdownContent || "");
const [isUrlOpen, setIsUrlOpen] = useState(false);
const [urlValue, setUrlValue] = useState(task.url || "");
const urlInputRef = useRef<HTMLInputElement>(null);
const [isSubTasksOpen, setIsSubTasksOpen] = useState(false);
const [isSubTaskInputOpen, setIsSubTaskInputOpen] = useState(false);
const [newSubTaskTitle, setNewSubTaskTitle] = useState("");
const [showProjectPicker, setShowProjectPicker] = useState(false);
const [showMoveDialog, setShowMoveDialog] = useState(false);
const [moveDate, setMoveDate] = useState('');
const [moveTime, setMoveTime] = useState('');
const notesRef = useRef<HTMLTextAreaElement>(null);
const subTaskInputRef = useRef<HTMLInputElement>(null);
const projectPickerRef = useRef<HTMLDivElement>(null);
const moveDialogRef = useRef<HTMLDivElement>(null);
// Touch: tap-to-reveal actions
const [touchActive, setTouchActive] = useState(false);
const blockRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!touchActive) return;
const handler = (e: Event) => {
if (blockRef.current && !blockRef.current.contains(e.target as Node)) setTouchActive(false);
};
document.addEventListener("touchstart", handler);
document.addEventListener("mousedown", handler);
return () => { document.removeEventListener("touchstart", handler); document.removeEventListener("mousedown", handler); };
}, [touchActive]);
// Close project picker on outside click
useEffect(() => {
if (!showProjectPicker) return;
const handler = (e: Event) => {
if (projectPickerRef.current && !projectPickerRef.current.contains(e.target as Node)) {
setShowProjectPicker(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [showProjectPicker]);
// Resize State
const [isResizing, setIsResizing] = useState(false);
const [resizeHeight, setResizeHeight] = useState<number | null>(null);
const resizeStartY = useRef<number>(0);
const resizeStartHeight = useRef<number>(0);
// Focus notes when opened
useEffect(() => {
if (isNotesOpen && notesRef.current) {
notesRef.current.focus();
}
}, [isNotesOpen]);
// Focus URL input when opened
useEffect(() => {
if (isUrlOpen) setTimeout(() => urlInputRef.current?.focus(), 50);
}, [isUrlOpen]);
// Sync urlValue if task.url changes externally
useEffect(() => { setUrlValue(task.url || ""); }, [task.url]);
const handleNotesBlur = () => {
if (notesValue !== task.markdownContent) {
updateTaskNotes(task.id, notesValue);
}
};
const handleUrlSave = (val: string) => {
setIsUrlOpen(false);
if (val.trim() !== (task.url || "")) {
updateTaskUrl(task.id, val.trim());
}
};
const insertMarkdown = (prefix: string, suffix: string = "") => {
if (!notesRef.current) return;
const start = notesRef.current.selectionStart;
const end = notesRef.current.selectionEnd;
const text = notesValue;
const before = text.substring(0, start);
const selection = text.substring(start, end);
const after = text.substring(end);
const newText = `${before}${prefix}${selection}${suffix}${after}`;
setNotesValue(newText);
setTimeout(() => {
if (notesRef.current) {
notesRef.current.focus();
const newCursorPos = start + prefix.length + selection.length + suffix.length;
notesRef.current.setSelectionRange(newCursorPos, newCursorPos);
}
}, 0);
};
const pixelsPerMinute = getSlotHeight(cellDuration) / cellDuration;
useEffect(() => {
if (!task.startTime) return;
const handleResizeMove = (e: MouseEvent) => {
if (!isResizing) return;
const deltaY = e.clientY - resizeStartY.current;
let newHeight = resizeStartHeight.current + deltaY;
const minHeight = 15 * pixelsPerMinute;
if (newHeight < minHeight) newHeight = minHeight;
setResizeHeight(newHeight);
};
const handleResizeEnd = () => {
if (!isResizing) return;
setIsResizing(false);
document.body.style.cursor = "";
if (resizeHeight !== null) {
const newDurationMins = Math.round(resizeHeight / pixelsPerMinute / 15) * 15;
updateTaskDuration(task.id, newDurationMins);
}
setResizeHeight(null);
};
if (isResizing) {
window.addEventListener("mousemove", handleResizeMove);
window.addEventListener("mouseup", handleResizeEnd);
}
return () => {
window.removeEventListener("mousemove", handleResizeMove);
window.removeEventListener("mouseup", handleResizeEnd);
};
}, [isResizing, resizeHeight, pixelsPerMinute, task.id, updateTaskDuration, task.startTime]);
// Conditional rendering should only happen after hooks
if (!task.startTime) return null;
const [startHour, startMinute] = task.startTime.split(":").map(Number);
const startMinutes = startHour * 60 + startMinute;
const topOffset = startMinutes * pixelsPerMinute;
const duration = task.duration || 15;
const baseHeight = duration * pixelsPerMinute;
const currentHeight = isResizing && resizeHeight !== null ? resizeHeight : baseHeight;
const onResizeStart = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
setIsResizing(true);
resizeStartY.current = e.clientY;
resizeStartHeight.current = baseHeight;
document.body.style.cursor = "ns-resize";
};
const onResizeKeyDown = (e: React.KeyboardEvent) => {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
e.preventDefault();
e.stopPropagation();
const step = 15;
const newDuration = e.key === 'ArrowUp'
? Math.min(duration + step, 480)
: Math.max(duration - step, 15);
if (newDuration !== duration) {
updateTaskDuration(task.id, newDuration);
}
};
return (
<div
ref={blockRef}
className={`time-slot-task ${task.completed && !showTaskCheckboxes ? "completed" : ""} ${draggedTask?.id === task.id ? "dragging" : ""} ${touchActive ? "touch-active" : ""} ${showProjectPicker ? "picker-open" : ""}`}
style={{
position: "absolute",
top: `${topOffset}px`,
left: 0,
right: 0,
minHeight: `${Math.max(currentHeight, 20)}px`,
height: isSubTasksOpen ? "auto" : `${currentHeight}px`,
zIndex: isResizing || isNotesOpen || isSubTasksOpen ? 10 : 5,
background: (isNotesOpen || isSubTasksOpen || isResizing) ? (darkMode ? "#2a2a2a" : "#ffffff") : "transparent",
border: (isNotesOpen || isSubTasksOpen || isResizing) ? `1px solid ${darkMode ? "#404040" : "#e0e0e0"}` : "none",
borderLeft: (() => {
const stageColor = task.kanbanStage ? kanbanStages.find(s => s.id === task.kanbanStage)?.color : null;
if (stageColor) return `4px solid ${stageColor}`;
if (task.project?.color) return `4px solid ${task.project.color}`;
return (isNotesOpen || isSubTasksOpen || isResizing) ? `1px solid ${darkMode ? "#404040" : "#e0e0e0"}` : "none";
})(),
borderRadius: (isNotesOpen || isSubTasksOpen || isResizing) ? "4px" : "0",
padding: (task.kanbanStage && kanbanStages.some(s => s.id === task.kanbanStage)) || task.project?.color ? "2px 4px 2px 8px" : "2px 4px",
boxShadow: (isNotesOpen || isSubTasksOpen || isResizing) ? "0 1px 3px rgba(0,0,0,0.05)" : "none",
display: "flex",
flexDirection: "column",
overflow: "visible !important",
}}
draggable={!editingTaskId && !isResizing}
onDragStart={(e) => handleDragStart(e, task)}
onDragEnd={handleDragEnd}
onMouseLeave={(e) => {
if (showProjectPicker) {
// Don't close if mouse moved into the project picker dropdown
const related = e.relatedTarget as HTMLElement | null;
if (related && projectPickerRef.current?.contains(related)) return;
setShowProjectPicker(false);
}
}}
onClick={(e) => {
e.stopPropagation();
// Touch: toggle action toolbar on tap instead of toggling completion
if (window.matchMedia("(pointer: coarse)").matches && editingTaskId !== task.id) {
const target = e.target as HTMLElement;
if (target.closest(".task-actions") || target.closest("button")) return;
setTouchActive(!touchActive);
return;
}
if (editingTaskId !== task.id) toggleTask(task.id);
}}
>
<div style={{ display: "flex", alignItems: "flex-start", gap: "0.25rem", width: "100%", justifyContent: "space-between" }}>
{editingTaskId === task.id ? (
<form
onSubmit={(e) => {
e.preventDefault();
const input = e.currentTarget.elements.namedItem("title") as HTMLTextAreaElement;
updateTask(task.id, input.value || "");
}}
onClick={(e) => e.stopPropagation()}
style={{ width: "100%", paddingRight: "20px" }}
>
<textarea
name="title"
aria-label={`Edit task title: ${task.title}`}
autoFocus
defaultValue={task.title}
onBlur={(e) => updateTask(task.id, e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") setEditingTaskId(null);
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); e.currentTarget.blur(); }
}}
rows={(task.title?.split("\n").length) || 1}
className="weekly-task-text"
style={{ width: "100%", background: "transparent", border: "none", borderBottom: "1px solid var(--weekly-border)", outline: "none", resize: "none", overflow: "hidden", fontFamily: "inherit", lineHeight: "inherit" }}
/>
</form>
) : (
<span
style={{
display: "flex",
alignItems: "flex-start",
gap: "3px",
overflow: "visible",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
flex: 1,
}}
onDoubleClick={(e) => {
e.stopPropagation();
setEditingTaskId(task.id);
}}
>
{showTaskCheckboxes && (
<input
type="checkbox"
checked={task.completed}
aria-label={task.completed ? `Mark "${task.title}" incomplete` : `Mark "${task.title}" complete`}
onChange={(e) => { e.stopPropagation(); toggleTask(task.id); }}
onClick={(e) => e.stopPropagation()}
className="task-checkbox flex-shrink-0"
style={{ width: "12px", height: "12px", margin: 0, cursor: "pointer", position: "relative", top: "3px", left: "-2px", accentColor: "#FFF" }}
/>
)}
<span style={task.completed && showTaskCheckboxes ? { opacity: 0.5, flex: 1, display: "flex", alignItems: "center", flexWrap: "wrap", rowGap: "2px" } : { flex: 1, display: "flex", alignItems: "center", flexWrap: "wrap", rowGap: "2px" }}>
<span style={{ marginRight: "4px" }}>{showProjectIcons && task.project && (() => {
const rawIcon = task.project!.icon || "";
const normalised = rawIcon.startsWith("fa") && rawIcon.length > 2 && rawIcon[2] === rawIcon[2].toUpperCase()
? rawIcon.slice(2, 3).toLowerCase() + rawIcon.slice(3)
: rawIcon;
const found = allIcons.find(i => i.name === normalised || i.name === rawIcon);
const iconStyle = { marginRight: "4px", verticalAlign: "middle" } as const;
if (found) {
return found.type === "fa"
? <FontAwesomeIcon icon={found.icon as IconDefinition} style={{ fontSize: "11px", ...iconStyle, color: task.project!.color || "#888" }} />
: <MdiIcon path={found.icon as string} size={0.5} color={task.project!.color || "#888"} style={{ ...iconStyle, display: "inline-block" }} />;
}
return task.project!.icon
? <span style={{ fontSize: "11px", ...iconStyle }}>{task.project!.icon}</span>
: <FontAwesomeIcon icon={faFolder} style={{ fontSize: "11px", ...iconStyle, color: task.project!.color || "#888" }} />;
})()}{task.title}</span>
{/* Subtask indicator */}
{task.subTasks && task.subTasks.length > 0 && (() => {
const completed = task.subTasks.filter(s => s.completed).length;
const total = task.subTasks.length;
const allDone = completed === total;
const expanded = isSubTasksOpen || isSubTaskInputOpen;
const pct = total > 0 ? (completed / total) * 100 : 0;
return (
<button
onClick={(e) => {
e.stopPropagation();
setIsSubTasksOpen(!isSubTasksOpen);
}}
className="subtask-indicator-badge"
aria-label={expanded ? "Collapse subtasks" : `${completed} of ${total} subtasks done`}
aria-expanded={expanded}
style={{
display: "inline-flex",
alignItems: "center",
gap: "4px",
padding: "2px 8px 2px 4px",
borderRadius: "12px",
background: expanded ? "rgba(99,102,241,0.08)" : "transparent",
color: allDone ? "var(--weekly-teal, #0d9488)" : "var(--weekly-text-light, #9ca3af)",
border: "none",
cursor: "pointer",
fontSize: "0.7rem",
fontWeight: 600,
lineHeight: 1,
flexShrink: 0,
whiteSpace: "nowrap",
transition: "all 0.15s",
}}
>
<ChevronDown size={12} aria-hidden="true" style={{ transform: expanded ? "rotate(0deg)" : "rotate(-90deg)", transition: "transform 0.2s", flexShrink: 0 }} />
<span style={{
display: "inline-block",
width: "36px",
height: "5px",
borderRadius: "3px",
background: "var(--weekly-border, #e5e7eb)",
position: "relative",
overflow: "hidden",
flexShrink: 0,
}}>
<span style={{
position: "absolute",
left: 0,
top: 0,
height: "100%",
width: `${pct}%`,
borderRadius: "3px",
background: allDone ? "var(--weekly-teal, #0d9488)" : "var(--weekly-accent, #6366f1)",
transition: "width 0.3s ease",
}} />
</span>
<span style={{ opacity: 0.8 }}>{completed}/{total}</span>
</button>
);
})()}
{/* URL indicator */}
{task.url && (
<a
href={task.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
aria-label={`Open link: ${task.url}`}
style={{
display: "inline-flex",
alignItems: "center",
padding: "1px 3px",
borderRadius: "3px",
background: "rgba(37,99,235,0.10)",
color: "#2563eb",
cursor: "pointer",
marginLeft: "3px",
textDecoration: "none",
}}
>
<Link size={10} aria-hidden="true" />
</a>
)}
{/* Note indicator */}
{task.markdownContent && task.markdownContent.trim().length > 0 && (
<button
onClick={(e) => {
e.stopPropagation();
setIsNotesOpen(!isNotesOpen);
}}
aria-label={isNotesOpen ? "Collapse note" : "Expand note"}
aria-expanded={isNotesOpen}
style={{
display: "inline-flex",
alignItems: "center",
padding: "1px 3px",
borderRadius: "3px",
background: isNotesOpen ? "rgba(245, 158, 11, 0.12)" : "rgba(0,0,0,0.04)",
color: isNotesOpen ? "#f59e0b" : (darkMode ? "#888" : "#999"),
cursor: "pointer",
marginLeft: "3px",
border: "none",
}}
>
<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
</button>
)}
{weatherEnabled && (() => {
const provider = task.externalProvider
|| (task.externalId?.startsWith("synology::") ? "synology" : null);
if (!provider) return null;
const iconMap: Record<string, { icon: any; color: string; label: string }> = {
google: { icon: faGoogle, color: "#4285F4", label: "Google" },
outlook: { icon: faMicrosoft, color: "#0078D4", label: "Microsoft" },
apple: { icon: faApple, color: "#555", label: "Apple" },
synology: { icon: faServer, color: "#007AFF", label: "Synology" },
};
const info = iconMap[provider];
if (!info) return null;
return (
<span
className="flex-shrink-0"
aria-label={`Synced with ${info.label}`}
style={{ display: "inline-flex", alignItems: "center", opacity: 0.55, marginLeft: "3px" }}
>
<FontAwesomeIcon icon={info.icon} aria-hidden="true" style={{ width: 10, height: 10, color: info.color }} />
</span>
);
})()}
</span>
</span>
)}
</div>
{!weatherEnabled && (() => {
const provider = task.externalProvider
|| (task.externalId?.startsWith("synology::") ? "synology" : null);
if (!provider) return null;
const iconMap: Record<string, { icon: any; color: string; label: string }> = {
google: { icon: faGoogle, color: "#4285F4", label: "Google" },
outlook: { icon: faMicrosoft, color: "#0078D4", label: "Microsoft" },
apple: { icon: faApple, color: "#555", label: "Apple" },
synology: { icon: faServer, color: "#007AFF", label: "Synology" },
};
const info = iconMap[provider];
if (!info) return null;
return (
<span
aria-label={`Synced with ${info.label}`}
style={{ position: "absolute", top: "3px", right: "4px", display: "inline-flex", alignItems: "center", opacity: 0.45, zIndex: 2 }}
>
<FontAwesomeIcon icon={info.icon} aria-hidden="true" style={{ width: 10, height: 10, color: info.color }} />
</span>
);
})()}
<div
className="task-actions"
onClick={(e) => e.stopPropagation()}
>
<button
className={`task-action-btn ${task.completed ? "active text-green-600 dark:text-green-500" : ""}`}
onClick={(e) => { e.stopPropagation(); toggleTask(task.id); }}
aria-label={task.completed ? `Mark "${task.title}" incomplete` : `Mark "${task.title}" complete`}
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="3" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
<button
className="task-action-btn"
onClick={(e) => { e.stopPropagation(); setEditingTaskId(task.id); }}
aria-label={`Edit "${task.title}"`}
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
className={`task-action-btn ${isSubTaskInputOpen ? "active" : ""}`}
onClick={(e) => { e.stopPropagation(); setIsSubTaskInputOpen(!isSubTaskInputOpen); if (!isSubTaskInputOpen) { setIsSubTasksOpen(true); setTimeout(() => subTaskInputRef.current?.focus(), 50); } }}
aria-label="Add sub-task"
aria-expanded={isSubTaskInputOpen}
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
<button
className={`task-action-btn ${isNotesOpen ? "active" : ""}`}
onClick={(e) => { e.stopPropagation(); setIsNotesOpen(!isNotesOpen); }}
aria-label={isNotesOpen ? "Close notes" : "Open notes"}
aria-expanded={isNotesOpen}
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
{!task.completed && (
<button
className={`task-action-btn ${task.isRolling ? "active" : ""}`}
onClick={(e) => { e.stopPropagation(); toggleTaskRolling(task.id); }}
aria-label={task.isRolling ? "Disable rolling (task auto-rolls to next day)" : "Enable rolling (task auto-rolls to next day)"}
aria-pressed={task.isRolling}
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="23 4 23 10 17 10" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
</button>
)}
{!task.completed && (
<button
className={`task-action-btn ${task.isRecurring ? "active" : ""}`}
onClick={(e) => { e.stopPropagation(); setSelectedTaskForRecurrence(task); }}
aria-label={task.isRecurring ? "Edit recurrence" : "Make task recurring"}
>
<Repeat size={12} aria-hidden="true" />
</button>
)}
{/* Project Assignment */}
{projects && projects.length > 0 && onProjectAssign && (
<div className="relative" ref={projectPickerRef}>
<button
className={`task-action-btn ${task.project ? "active" : ""}`}
onClick={(e) => {
e.stopPropagation();
setShowProjectPicker(!showProjectPicker);
}}
aria-label={task.project ? `Project: ${task.project.name} — change project` : "Assign to project"}
aria-expanded={showProjectPicker}
aria-haspopup="listbox"
>
<Circle
size={12}
aria-hidden="true"
fill={task.project?.color || "none"}
stroke={task.project?.color || "currentColor"}
strokeWidth={2}
/>
</button>
{showProjectPicker && (
<div className="absolute z-[100] top-full left-1/2 -translate-x-1/2 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 min-w-[140px]" style={{ whiteSpace: "nowrap" }}>
{task.projectId && (
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-500"
onClick={(e) => {
e.stopPropagation();
onProjectAssign(task.id, null);
setShowProjectPicker(false);
}}
>
<X size={10} /> Remove
</button>
)}
{projects.map((p) => (
<button
key={p.id}
className={`flex items-center gap-2 w-full px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-gray-700 ${task.projectId === p.id ? "font-bold" : ""}`}
onClick={(e) => {
e.stopPropagation();
onProjectAssign(task.id, task.projectId === p.id ? null : p.id);
setShowProjectPicker(false);
}}
>
<Circle size={10} fill={p.color || "#999"} stroke={p.color || "#999"} strokeWidth={0} />
{p.name}
</button>
))}
</div>
)}
</div>
)}
<button
className={`task-action-btn ${task.url || isUrlOpen ? "active" : ""}`}
onClick={(e) => { e.stopPropagation(); setIsUrlOpen(!isUrlOpen); }}
aria-label={task.url ? `Edit link: ${task.url}` : "Add link"}
aria-expanded={isUrlOpen}
style={task.url ? { color: "#2563eb" } : {}}
>
<Link size={12} aria-hidden="true" />
</button>
<button
className="task-action-btn delete"
onClick={(e) => { e.stopPropagation(); deleteTask(task.id); }}
aria-label={`Delete "${task.title}"`}
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
{onMoveTask && (
<button
className={`task-action-btn ${showMoveDialog ? "active" : ""}`}
onClick={(e) => {
e.stopPropagation();
if (!showMoveDialog) {
// Pre-fill with current date/time
const d = task.scheduledDate ? new Date(task.scheduledDate) : date;
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
setMoveDate(`${yyyy}-${mm}-${dd}`);
setMoveTime(task.startTime || '09:00');
}
setShowMoveDialog(!showMoveDialog);
}}
aria-label="Move task to different date/time"
aria-expanded={showMoveDialog}
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="5 9 2 12 5 15" />
<polyline points="9 5 12 2 15 5" />
<line x1="2" y1="12" x2="22" y2="12" />
<line x1="12" y1="2" x2="12" y2="22" />
</svg>
</button>
)}
</div>
<div style={{ paddingLeft: "4px", paddingRight: "4px", paddingBottom: "10px", marginTop: "4px" }}>
{isUrlOpen && (
<div
className={`weekly-notes-popup ${topOffset > 180 ? "on-top" : ""}`}
onClick={(e) => e.stopPropagation()}
style={{
top: topOffset > 180 ? "auto" : "100%",
bottom: topOffset > 180 ? "100%" : "auto",
marginTop: topOffset > 180 ? "0" : "10px",
marginBottom: topOffset > 180 ? "10px" : "0",
left: "-10px",
right: "-10px",
width: "auto",
padding: "8px 10px",
display: "flex",
flexDirection: "column",
gap: "6px",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<Link size={13} aria-hidden="true" style={{ color: "#2563eb", flexShrink: 0 }} />
<input
ref={urlInputRef}
type="url"
aria-label="Task link URL"
value={urlValue}
onChange={(e) => setUrlValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); handleUrlSave(urlValue); }
if (e.key === "Escape") { setIsUrlOpen(false); setUrlValue(task.url || ""); }
}}
onBlur={() => handleUrlSave(urlValue)}
placeholder="https://..."
style={{
flex: 1,
fontSize: "0.78rem",
border: "1px solid var(--weekly-border, #ddd)",
borderRadius: "4px",
padding: "3px 6px",
background: "var(--weekly-bg, white)",
color: "var(--weekly-text, #333)",
outline: "none",
}}
/>
{urlValue && (
<button
onMouseDown={(e) => { e.preventDefault(); setUrlValue(""); updateTaskUrl(task.id, ""); setIsUrlOpen(false); }}
aria-label="Remove link"
style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: "#999" }}
>
<X size={12} aria-hidden="true" />
</button>
)}
{urlValue && (
<a
href={urlValue.startsWith("http") ? urlValue : `https://${urlValue}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
aria-label="Open link in new tab"
style={{ color: "#2563eb", flexShrink: 0, display: "inline-flex" }}
>
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
</a>
)}
</div>
</div>
)}
{isNotesOpen && (
<div
className={`weekly-notes-popup ${topOffset > 180 ? "on-top" : ""}`}
onClick={(e) => e.stopPropagation()}
style={{
top: topOffset > 180 ? "auto" : "100%",
bottom: topOffset > 180 ? "100%" : "auto",
marginTop: topOffset > 180 ? "0" : "10px",
marginBottom: topOffset > 180 ? "10px" : "0",
left: "-10px",
right: "-10px",
width: "auto",
padding: "0",
}}
>
<div style={{ display: "flex", justifyContent: "flex-end", padding: "4px 6px 0", gap: "4px" }}>
<button
onMouseDown={(e) => { e.preventDefault(); setIsNotesOpen(false); }}
style={{ background: "none", border: "none", cursor: "pointer", fontSize: "1rem", lineHeight: 1, color: "#999", padding: "2px 4px" }}
aria-label="Close notes"
>×</button>
</div>
<div style={{ padding: "0 6px 8px" }}>
<RichTextEditor
value={notesValue}
onChange={(html) => {
setNotesValue(html);
updateTaskNotes(task.id, html);
}}
placeholder="Add notes..."
minHeight="100px"
/>
</div>
</div>
)}
{isSubTasksOpen && task.subTasks && task.subTasks.length > 0 && (
<ul className="subtask-list mt-1" onClick={(e) => e.stopPropagation()}>
{task.subTasks.map((subTask: Task) => (
<li key={subTask.id} className={`subtask-item ${subTask.completed ? "completed" : ""}`}>
<button
className="subtask-checkbox"
onClick={() => toggleSubTask(subTask.id)}
aria-label={subTask.completed ? `Mark subtask "${subTask.title}" incomplete` : `Mark subtask "${subTask.title}" complete`}
>
{subTask.completed ? (
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12" /></svg>
) : (
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /></svg>
)}
</button>
{onSetEditingTaskId && editingTaskId === subTask.id ? (
<form onSubmit={(e) => { e.preventDefault(); const input = e.currentTarget.querySelector("input"); if (input) { updateSubTask(subTask.id, input.value); onSetEditingTaskId(null); } }} style={{ flex: 1 }}>
<input type="text" defaultValue={subTask.title} autoFocus className="subtask-edit-input" onBlur={(e) => { updateSubTask(subTask.id, e.target.value); onSetEditingTaskId(null); }} onKeyDown={(e) => { if (e.key === "Escape") onSetEditingTaskId(null); }} />
</form>
) : (
<span className={`subtask-title ${subTask.completed ? "completed" : ""}`} onClick={() => onSetEditingTaskId && onSetEditingTaskId(subTask.id)}>{subTask.title}</span>
)}
<button className="subtask-delete-btn" onClick={() => deleteSubTask(subTask.id)} aria-label={`Remove subtask "${subTask.title}"`}><svg viewBox="0 0 24 24" width="10" height="10" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg></button>
</li>
))}
</ul>
)}
{isSubTaskInputOpen && (
<div className="subtask-add-row mt-1" onClick={(e) => e.stopPropagation()}>
<form onSubmit={(e) => { e.preventDefault(); if (newSubTaskTitle.trim()) { addSubTask(task.id, newSubTaskTitle.trim()); setNewSubTaskTitle(""); } }} style={{ display: "flex", alignItems: "center", gap: "0.25rem", flex: 1 }}>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="var(--weekly-text-muted, #999)" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }} aria-hidden="true"><circle cx="12" cy="12" r="10" /></svg>
<input ref={subTaskInputRef} type="text" aria-label="New sub-task title" value={newSubTaskTitle} onChange={(e) => setNewSubTaskTitle(e.target.value)} onBlur={() => { if (!newSubTaskTitle.trim()) setIsSubTaskInputOpen(false); }} onKeyDown={(e) => { if (e.key === "Escape") { setNewSubTaskTitle(""); setIsSubTaskInputOpen(false); } }} placeholder="Add sub-task..." className="subtask-add-input" autoFocus />
</form>
</div>
)}
</div>
{showMoveDialog && onMoveTask && (
<div
ref={moveDialogRef}
role="dialog"
aria-modal="true"
aria-label={`Move task "${task.title}" to a new date and time`}
className="weekly-notes-popup"
onClick={(e) => e.stopPropagation()}
style={{
top: topOffset > 180 ? "auto" : "100%",
bottom: topOffset > 180 ? "100%" : "auto",
marginTop: topOffset > 180 ? "0" : "10px",
marginBottom: topOffset > 180 ? "10px" : "0",
left: "-10px",
right: "-10px",
padding: "10px 12px",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
<p style={{ margin: 0, fontSize: "0.78rem", fontWeight: 600, color: "var(--weekly-text, #333)" }}>
Move: <em>{task.title}</em>
</p>
<div style={{ display: "flex", gap: "6px", alignItems: "center" }}>
<label htmlFor={`move-date-${task.id}`} style={{ fontSize: "0.75rem", whiteSpace: "nowrap" }}>Date</label>
<input
id={`move-date-${task.id}`}
type="date"
value={moveDate}
onChange={(e) => setMoveDate(e.target.value)}
autoFocus
style={{ flex: 1, fontSize: "0.78rem", border: "1px solid var(--weekly-border, #ddd)", borderRadius: "4px", padding: "3px 6px", background: "var(--weekly-bg, white)", color: "var(--weekly-text, #333)", outline: "none" }}
/>
</div>
<div style={{ display: "flex", gap: "6px", alignItems: "center" }}>
<label htmlFor={`move-time-${task.id}`} style={{ fontSize: "0.75rem", whiteSpace: "nowrap" }}>Time</label>
<input
id={`move-time-${task.id}`}
type="time"
value={moveTime}
onChange={(e) => setMoveTime(e.target.value)}
step="900"
style={{ flex: 1, fontSize: "0.78rem", border: "1px solid var(--weekly-border, #ddd)", borderRadius: "4px", padding: "3px 6px", background: "var(--weekly-bg, white)", color: "var(--weekly-text, #333)", outline: "none" }}
/>
</div>
<div style={{ display: "flex", gap: "6px", justifyContent: "flex-end" }}>
<button
onClick={() => setShowMoveDialog(false)}
style={{ fontSize: "0.75rem", padding: "3px 8px", borderRadius: "4px", border: "1px solid var(--weekly-border, #ddd)", background: "transparent", cursor: "pointer", color: "var(--weekly-text, #333)" }}
>
Cancel
</button>
<button
onClick={() => {
if (moveDate && moveTime) {
const [y, m, d] = moveDate.split('-').map(Number);
const targetDate = new Date(y, m - 1, d);
// Round time to nearest 15 min
const [h, min] = moveTime.split(':').map(Number);
const rounded = Math.round(min / 15) * 15;
const roundedMin = rounded === 60 ? 0 : rounded;
const roundedH = rounded === 60 ? h + 1 : h;
const roundedTime = `${String(roundedH).padStart(2, '0')}:${String(roundedMin).padStart(2, '0')}`;
onMoveTask(task.id, targetDate, roundedTime);
setShowMoveDialog(false);
}
}}
style={{ fontSize: "0.75rem", padding: "3px 8px", borderRadius: "4px", border: "none", background: "var(--weekly-accent, #6366f1)", color: "white", cursor: "pointer" }}
>
Move
</button>
</div>
</div>
)}
<div
className="resize-handle"
onMouseDown={onResizeStart}
onKeyDown={onResizeKeyDown}
tabIndex={0}
role="slider"
aria-label={`Resize task duration, currently ${duration} minutes`}
aria-valuemin={15}
aria-valuemax={480}
aria-valuenow={duration}
aria-valuetext={`${duration} minutes`}
/>
</div>
);
}