fix(hooks): definitively resolve React Hook violations in GridTaskBlock

This commit is contained in:
mARTin 2026-02-26 07:49:45 +01:00
parent 9652bd374e
commit b1ad9ec546

View File

@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from "react"; import React, { useState, useRef, useEffect } from "react";
import { Repeat, ChevronDown, ChevronRight, FileText, CheckCircle, Circle, Copy } from "lucide-react"; import { Repeat } from "lucide-react";
import { Task } from "./WeeklyView"; import { Task } from "./WeeklyView";
interface GridTaskBlockProps { interface GridTaskBlockProps {
@ -107,7 +107,6 @@ export function GridTaskBlock({
}, 0); }, 0);
}; };
// Calculate dimensions (needed by resize useEffect, must be before early return)
const pixelsPerMinute = getSlotHeight(cellDuration) / cellDuration; const pixelsPerMinute = getSlotHeight(cellDuration) / cellDuration;
useEffect(() => { useEffect(() => {
@ -144,20 +143,16 @@ export function GridTaskBlock({
}; };
}, [isResizing, resizeHeight, pixelsPerMinute, task.id, updateTaskDuration, task.startTime]); }, [isResizing, resizeHeight, pixelsPerMinute, task.id, updateTaskDuration, task.startTime]);
// Conditional rendering should only happen after hooks
if (!task.startTime) return null; if (!task.startTime) return null;
const [startHour, startMinute] = task.startTime.split(":").map(Number); const [startHour, startMinute] = task.startTime.split(":").map(Number);
const startMinutes = (startHour - workingHoursStart) * 60 + startMinute; const startMinutes = (startHour - workingHoursStart) * 60 + startMinute;
// Calculate top offset
const topOffset = startMinutes * pixelsPerMinute; const topOffset = startMinutes * pixelsPerMinute;
const duration = task.duration || 15;
// Calculate height
const duration = task.duration || 15; // default 15m if not set
const baseHeight = duration * pixelsPerMinute; const baseHeight = duration * pixelsPerMinute;
const currentHeight = isResizing && resizeHeight !== null ? resizeHeight : baseHeight; const currentHeight = isResizing && resizeHeight !== null ? resizeHeight : baseHeight;
// Handlers for resizing
const onResizeStart = (e: React.MouseEvent) => { const onResizeStart = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
@ -167,7 +162,6 @@ export function GridTaskBlock({
document.body.style.cursor = "ns-resize"; document.body.style.cursor = "ns-resize";
}; };
return ( return (
<div <div
className={`time-slot-task ${task.completed && !showTaskCheckboxes ? "completed" : ""} ${draggedTask?.id === task.id ? "dragging" : ""}`} className={`time-slot-task ${task.completed && !showTaskCheckboxes ? "completed" : ""} ${draggedTask?.id === task.id ? "dragging" : ""}`}
@ -202,7 +196,7 @@ export function GridTaskBlock({
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
const input = e.currentTarget.elements.namedItem("title") as HTMLInputElement; const input = e.currentTarget.elements.namedItem("title") as HTMLInputElement;
updateTask(task.id, input.value); updateTask(task.id, input.value || \"\");
}} }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ width: "100%", paddingRight: "20px" }} style={{ width: "100%", paddingRight: "20px" }}
@ -246,48 +240,11 @@ export function GridTaskBlock({
style={{ width: "12px", height: "12px", margin: 0, cursor: "pointer", position: "relative", top: "3px", left: "-2px", accentColor: "#FFF" }} style={{ width: "12px", height: "12px", margin: 0, cursor: "pointer", position: "relative", top: "3px", left: "-2px", accentColor: "#FFF" }}
/> />
)} )}
{task.externalProvider && (
!task.externalId ||
!task.lastSyncedAt ||
new Date(task.updatedAt) > new Date(task.lastSyncedAt)
) && (
<span title="Needs to be synced" className="text-yellow-500 flex-shrink-0 mt-[2px]">
<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38" />
</svg>
</span>
)}
{task.subTasks && task.subTasks.length > 0 && (
<span
className="task-subtask-icon cursor-pointer flex-shrink-0 mt-[2px]"
onClick={(e) => { e.stopPropagation(); setIsSubTasksOpen(!isSubTasksOpen); }}
>
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ transform: isSubTasksOpen ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.2s" }}>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
</span>
)}
<span style={task.completed && showTaskCheckboxes ? { opacity: 0.5, flex: 1 } : { flex: 1 }}>{task.title}</span> <span style={task.completed && showTaskCheckboxes ? { opacity: 0.5, flex: 1 } : { flex: 1 }}>{task.title}</span>
{task.markdownContent && (
<span
className="task-note-icon cursor-pointer flex-shrink-0 mt-[2px]"
onClick={(e) => { e.stopPropagation(); setIsNotesOpen(!isNotesOpen); }}
style={{ marginLeft: "auto" }}
>
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
</span> </span>
)} )}
</span>
)}
</div> </div>
{/* Task Actions - shown below text on hover */}
<div <div
className="task-actions" className="task-actions"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@ -311,74 +268,78 @@ export function GridTaskBlock({
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /> <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg> </svg>
</button> </button>
<button className={`task-action-btn ${isSubTaskInputOpen ? "active" : ""}`} onClick={(e) => { e.stopPropagation(); setIsSubTaskInputOpen(!isSubTaskInputOpen); if (!isSubTaskInputOpen) { setIsSubTasksOpen(true); setTimeout(() => subTaskInputRef.current?.focus(), 50); } }} title="Add sub-task"> <button className={`task-action-btn ${isSubTaskInputOpen ? \"active\" : \"\"}`} onClick={(e) => { e.stopPropagation(); setIsSubTaskInputOpen(!isSubTaskInputOpen); if (!isSubTaskInputOpen) { setIsSubTasksOpen(true); setTimeout(() => subTaskInputRef.current?.focus(), 50); } }} title="Add sub-task">
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round"> <svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19" /> <line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" /> <line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
</button> </button>
<button className={`task-action-btn ${isNotesOpen ? "active" : ""}`} onClick={(e) => { e.stopPropagation(); setIsNotesOpen(!isNotesOpen); }} title="Notes"> <button className={`task-action-btn ${isNotesOpen ? \"active\" : \"\"}`} onClick={(e) => { e.stopPropagation(); setIsNotesOpen(!isNotesOpen); }} title="Notes">
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round"> < svg viewBox = "0 0 24 24" width = "12" height = "12" stroke = "currentColor" strokeWidth = "2.5" fill = "none" strokeLinecap = "round" strokeLinejoin = "round" >
<line x1="3" y1="12" x2="21" y2="12" /> <line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="6" x2="21" y2="6" /> <line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="18" x2="21" y2="18" /> <line x1="3" y1="18" x2="21" y2="18" />
</svg> </svg >
</button> </button >
{!task.completed && ( {!task.completed && (
<button <button
className={`task-action-btn ${task.isRolling ? "active" : ""}`} className={`task-action-btn ${task.isRolling ?\"active\" : \"\"}`}
onClick={(e) => { e.stopPropagation(); toggleTaskRolling(task.id); }} onClick = {(e) => { e.stopPropagation(); toggleTaskRolling(task.id); }
title={task.isRolling ? "Disable rolling" : "Enable rolling"} }
title = {
task.isRolling ?\"Disable rolling\" : \"Enable rolling\"}
> >
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round"> <svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
<polyline points="23 4 23 10 17 10" /> <polyline points="23 4 23 10 17 10" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" /> <path d=\"M20.49 15a9 9 0 1 1-2.12-9.36L23 10\" />
</svg> </svg>
</button> </button>
)} )}
{!task.completed && ( {
!task.completed && (
<button <button
className={`task-action-btn ${task.isRecurring ? "active" : ""}`} className={`task-action-btn ${task.isRecurring ?\"active\" : \"\"}`}
onClick={(e) => { e.stopPropagation(); setSelectedTaskForRecurrence(task); }} onClick = {(e) => { e.stopPropagation(); setSelectedTaskForRecurrence(task); }
title={task.isRecurring ? "Edit recurrence" : "Make recurring"} }
title = {
task.isRecurring ?\"Edit recurrence\" : \"Make recurring\"}
> >
<Repeat size={12} /> <Repeat size={12} />
</button> </button>
)} )}
<button className="task-action-btn delete text-red-500 hover:text-red-700 hover:bg-red-100/50 dark:hover:bg-red-900/30 rounded" onClick={(e) => { e.stopPropagation(); deleteTask(task.id); }} title="Delete"> <button className="task-action-btn delete" onClick={(e) => { e.stopPropagation(); deleteTask(task.id); }} title="Delete">
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round"> <svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"></polyline> <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> <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> </svg>
</button> </button>
</div> </div >
{/* Inline Expanders Container */}
<div style={{ paddingLeft: "4px", paddingRight: "4px", paddingBottom: "10px", marginTop: "4px" }}> <div style={{ paddingLeft: "4px", paddingRight: "4px", paddingBottom: "10px", marginTop: "4px" }}>
{isNotesOpen && ( {isNotesOpen && (
<div <div
className={`weekly-notes-popup ${topOffset > 180 ? "on-top" : ""}`} className={`weekly-notes-popup ${topOffset > 180 ? \"on-top\" : \"\"}`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ style={{
top: topOffset > 180 ? "auto" : "100%", top: topOffset > 180 ? \"auto\" : \"100%\",
bottom: topOffset > 180 ? "100%" : "auto", bottom: topOffset > 180 ? \"100%\" : \"auto\",
marginTop: topOffset > 180 ? "0" : "10px", marginTop: topOffset > 180 ? \"0\" : \"10px\",
marginBottom: topOffset > 180 ? "10px" : "0", marginBottom: topOffset > 180 ? \"10px\" : \"0\",
left: "-10px", left: \"-10px\",
right: "-10px", right: \"-10px\",
width: "auto", width: \"auto\",
}} }}
> >
<div className="notes-toolbar"> <div className="notes-toolbar">
<button className="notes-toolbar-btn" onClick={() => insertMarkdown("**", "**")} title="Bold">B</button> <button className="notes-toolbar-btn" onClick={() => insertMarkdown(\"**\", \"**\")} title=\"Bold\">B</button>
<button className="notes-toolbar-btn" onClick={() => insertMarkdown("*", "*")} title="Italic">i</button> <button className="notes-toolbar-btn" onClick={() => insertMarkdown(\"*\", \"*\")} title=\"Italic\">i</button>
<button className="notes-toolbar-btn" onClick={() => insertMarkdown("- ")} title="List"></button> <button className="notes-toolbar-btn" onClick={() => insertMarkdown(\"- \")} title=\"List\"></button>
<span style={{ marginLeft: "auto", fontSize: "0.75rem", color: "#999" }}>Markdown</span> <span style={{ marginLeft: \"auto\", fontSize: \"0.75rem\", color: \"#999\" }}>Markdown</span>
<button <button
className="notes-toolbar-btn" className="notes-toolbar-btn"
onClick={() => setIsNotesOpen(false)} onClick={() => setIsNotesOpen(false)}
title="Close" title=\"Close\"
style={{ marginLeft: "8px", fontSize: "1rem", lineHeight: 1 }} style={{ marginLeft: \"8px\", fontSize: \"1rem\", lineHeight: 1 }}
>×</button> >×</button>
</div> </div>
<textarea <textarea
@ -388,42 +349,45 @@ export function GridTaskBlock({
onChange={(e) => setNotesValue(e.target.value)} onChange={(e) => setNotesValue(e.target.value)}
onBlur={handleNotesBlur} onBlur={handleNotesBlur}
placeholder="Add notes..." placeholder="Add notes..."
style={{ minHeight: "120px", padding: "4px" }} style={{ minHeight: \"120px\", padding: \"4px\" }}
/> />
</div> </div>
)} )}
{isSubTasksOpen && task.subTasks && task.subTasks.length > 0 && ( {isSubTasksOpen && task.subTasks && task.subTasks.length > 0 && (
<ul className="subtask-list mt-1" onClick={(e) => e.stopPropagation()}> <ul className="subtask-list mt-1" onClick={(e) => e.stopPropagation()}>
{task.subTasks.map((subTask: Task) => ( {task.subTasks.map((subTask: Task) => (
<li key={subTask.id} className={`subtask-item ${subTask.completed ? "completed" : ""}`}> <li key={subTask.id} className={`subtask-item ${subTask.completed ? \"completed\" : \"\"}`}>
<button className="subtask-checkbox" onClick={() => toggleSubTask(subTask.id)}> <button className="subtask-checkbox" onClick={() => toggleSubTask(subTask.id)}>
{subTask.completed ? ( {subTask.completed ? (
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><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.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\"><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"><circle cx="12" cy="12" r="10" /></svg> <svg viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"10\" /></svg>
)} )}
</button> </button>
{onSetEditingTaskId && editingTaskId === subTask.id ? ( {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 }}> <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); }} /> <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> </form>
) : ( ) : (
<span className={`subtask-title ${subTask.completed ? "completed" : ""}`} onClick={() => onSetEditingTaskId && onSetEditingTaskId(subTask.id)}>{subTask.title}</span> <span className={`subtask-title ${subTask.completed ?\"completed\" : \"\"}`} onClick={() => onSetEditingTaskId && onSetEditingTaskId(subTask.id)}>{subTask.title}</span>
)} )}
<button className="subtask-delete-btn" onClick={() => deleteSubTask(subTask.id)} title="Remove"><svg viewBox="0 0 24 24" width="10" height="10" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg></button> <button className=\"subtask-delete-btn\" onClick={() => deleteSubTask(subTask.id)} title=\"Remove\"><svg viewBox=\"0 0 24 24\" width=\"10\" height=\"10\" stroke=\"currentColor\" strokeWidth=\"2.5\" fill=\"none\" strokeLinecap=\"round\" strokeLinejoin=\"round\"><line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\" /><line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\" /></svg></button>
</li> </li >
))} ))}
</ul> </ul >
)} )}
{isSubTaskInputOpen && ( {
isSubTaskInputOpen && (
<div className="subtask-add-row mt-1" onClick={(e) => e.stopPropagation()}> <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 }}> <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 }}><circle cx="12" cy="12" r="10" /></svg> <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 }}><circle cx=\"12\" cy=\"12\" r=\"10\" /></svg>
<input ref={subTaskInputRef} type="text" 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 /> <input ref={subTaskInputRef} type=\"text\" 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> </form >
</div> </div >
)} )
</div> }
</div> </div >
<div className="resize-handle" onMouseDown={onResizeStart} />
</div >
); );
} }