My-Weekly-ToDo-List/src/components/GridTaskBlock.tsx
mARTin-B78 2042c54b2d
Some checks are pending
Playwright Tests / test (push) Waiting to run
Test and SonarQube / quality (push) Waiting to run
feat: Aufgaben per Touch zwischen Zeitslots verschieben (Mobile-Drag-and-Drop)
M3 aus TODO.md, nach v1.126.0 als gemeldete Luecke aufgenommen und jetzt geschlossen:
Aufgaben liessen sich bislang nur am Desktop per Maus verschieben, weil WeeklyView.tsx
ausschliesslich natives HTML5-Drag nutzte (draggable/onDragStart/onDrop), das auf
Touch-Geraeten grundsaetzlich nicht feuert.

Neuer Long-Press-Mechanismus, keine neue Dependency:
- GridTaskBlock bekommt onTouchStart/onTouchMove (handleTaskTouchStart/
  handleTaskTouchMovePending) -- 200ms Halten ohne wesentliche Fingerbewegung
  aktiviert den Drag (Vibration als Feedback, wie beim bestehenden Muster)
- Zwei-Phasen-Architektur identisch zum bereits vorhandenen Touch-Long-Press fuer
  "Termin per Ziehen anlegen" (upgradeToDragListeners/handleTouchStart weiter oben in
  derselben Datei): passiver Timer zum Aktivieren, danach ein document-weiter,
  nicht-passiver touchmove-Listener nur waehrend des aktiven Drags (blockiert Scrollen
  nur dann, nicht davor)
- handleDrop akzeptiert jetzt e: DragEvent | null -- der Touch-Pfad ruft dieselbe
  Funktion direkt auf (kein natives DragEvent vorhanden), decideTaskDrop (v1.126.0)
  unveraendert wiederverwendet, keine eigene Entscheidungslogik, keine geaenderte
  API-Payload

Echt verifiziert, nicht nur angenommen: lokale Instanz mit Wegwerf-Postgres (Docker)
aufgesetzt, Testnutzer + Task geseedet, per Playwright ein echter Touch-Drag simuliert
(dispatchte TouchEvent-Objekte, 200ms Halten, schrittweise Bewegung, Loslassen) --
Task landete nachweislich per DB-Abfrage auf dem neuen Zeitslot. Derselbe Aufbau mit
dragTo() bestaetigt: der Desktop-Pfad (Maus) funktioniert unveraendert weiter.

Ausserdem: Typecheck, Lint (0 Fehler), 100 Unit-Tests, Produktions-Build -- alle gruen.

v1.127.0

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 10:22:32 +02:00

969 lines
56 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, 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, getPriorityBadge } 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;
// Touch-Gegenstueck zu handleDragStart/handleDragEnd -- natives HTML5-Drag feuert auf
// Touch-Geraeten nicht, siehe WeeklyView.tsx.
handleTaskTouchStart: (e: React.TouchEvent, task: Task) => void;
handleTaskTouchMovePending: (e: React.TouchEvent) => 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,
handleTaskTouchStart,
handleTaskTouchMovePending,
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 `4px solid transparent`;
})(),
borderRadius: (isNotesOpen || isSubTasksOpen || isResizing) ? "4px" : "0",
padding: weatherEnabled ? "2px 30px 2px 8px" : "2px 4px 2px 8px",
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}
onTouchStart={(e) => handleTaskTouchStart(e, task)}
onTouchMove={handleTaskTouchMovePending}
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: "center", 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: "center",
gap: "3px",
overflow: "visible",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
flex: 1,
}}
onDoubleClick={(e) => {
e.stopPropagation();
setEditingTaskId(task.id);
}}
>
{(showPriorityIcons || showProjectIcons) && (
<span style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", width: "14px", minWidth: "14px", flexShrink: 0, gap: "2px", opacity: task.completed && showTaskCheckboxes ? 0.5 : 1 }}>
{showPriorityIcons && (() => {
const pb = getPriorityBadge(task, priorityStyle, 11);
return pb ? (
<span title={pb.label} style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: "12px", height: "12px" }}>
{pb.node}
</span>
) : null;
})()}
{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);
return (
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: "12px", height: "12px" }}>
{found
? (found.type === "fa"
? <FontAwesomeIcon icon={found.icon as IconDefinition} style={{ fontSize: "11px", color: task.project!.color || "#888" }} />
: <MdiIcon path={found.icon as string} size={0.5} color={task.project!.color || "#888"} style={{ display: "inline-block" }} />)
: (task.project!.icon
? <span style={{ fontSize: "11px" }}>{task.project!.icon}</span>
: <FontAwesomeIcon icon={faFolder} style={{ fontSize: "11px", color: task.project!.color || "#888" }} />)
}
</span>
);
})()}
</span>
)}
{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", flexShrink: 0, 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" }}>{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>
)}
</span>
</span>
)}
{editingTaskId !== task.id && (() => {
const rolling = task.isRolling && !task.completed;
const provider = task.externalProvider || (task.externalId?.startsWith("synology::") ? "synology" : null);
if (!rolling && !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" },
};
return (
<span style={{ display: "flex", alignItems: "center", gap: "3px", flexShrink: 0 }}>
{rolling && (
<span title="Auto-rolling task" style={{ display: "inline-flex", alignItems: "center", color: "var(--weekly-teal, #009a9a)" }}>
<svg viewBox="0 0 24 24" width="10" height="10" 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>
</span>
)}
{provider && (() => {
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 }}>
<FontAwesomeIcon icon={info.icon} aria-hidden="true" style={{ width: 10, height: 10, color: info.color }} />
</span>
);
})()}
</span>
);
})()}
</div>
<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"
>
{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);
if (found) {
return found.type === "fa"
? <FontAwesomeIcon icon={found.icon as IconDefinition} aria-hidden="true" style={{ fontSize: "11px", color: task.project.color || "#888" }} />
: <MdiIcon path={found.icon as string} size={0.5} color={task.project.color || "#888"} style={{ display: "inline-block" }} />;
}
return task.project.icon
? <span style={{ fontSize: "11px", color: task.project.color || "#888" }}>{task.project.icon}</span>
: <FontAwesomeIcon icon={faFolder} aria-hidden="true" style={{ fontSize: "11px", color: task.project.color || "#888" }} />;
})() : <FontAwesomeIcon icon={faFolder} aria-hidden="true" style={{ fontSize: "11px" }} />}
</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);
}}
>
{(() => {
const rawIcon = p.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);
if (found) {
return found.type === "fa"
? <FontAwesomeIcon icon={found.icon as IconDefinition} style={{ fontSize: "10px", color: p.color || "#888" }} />
: <MdiIcon path={found.icon as string} size={0.45} color={p.color || "#888"} style={{ display: "inline-block" }} />;
}
return p.icon
? <span style={{ fontSize: "10px", color: p.color || "#888" }}>{p.icon}</span>
: <FontAwesomeIcon icon={faFolder} style={{ fontSize: "10px", color: p.color || "#888" }} />;
})()}
{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>
);
}