feat: add setting to show task checkboxes in weekly view

- Add showTaskCheckboxes field to User model in Prisma schema
- Create database migration for the new field
- Update user profile API to handle the new setting
- Implement checkbox rendering in GridTaskBlock controlled by user preference
- Add CSS styles for task checkboxes
This commit is contained in:
mARTin 2026-02-25 12:59:55 +01:00
parent f0672e5c68
commit dddde43935
7 changed files with 161 additions and 46 deletions

View File

@ -0,0 +1,2 @@
-- AlterTable: Add showTaskCheckboxes setting
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "showTaskCheckboxes" BOOLEAN NOT NULL DEFAULT false;

View File

@ -37,6 +37,7 @@ model User {
showSomeday Boolean @default(true)
showAllDayEvents Boolean @default(true)
showSchedule Boolean @default(true)
showTaskCheckboxes Boolean @default(false)
cellDuration Int @default(30)
viewStyle String @default("grid")
viewDays Int @default(7)

View File

@ -30,6 +30,7 @@ export async function GET(request: NextRequest) {
showSomeday: true,
showAllDayEvents: true,
showSchedule: true,
showTaskCheckboxes: true,
cellDuration: true,
viewStyle: true,
viewDays: true,
@ -115,7 +116,8 @@ export async function PATCH(request: NextRequest) {
dateLayout,
hourLabelFormat, showSubHourSlots, allDayPosition,
cwFontFamily, cwFontSize, cwFontWeight, cwColor,
yearFontFamily, yearFontSize, yearFontWeight, yearColor
yearFontFamily, yearFontSize, yearFontWeight, yearColor,
showTaskCheckboxes
} = body;
const updateData: any = {
@ -136,6 +138,7 @@ export async function PATCH(request: NextRequest) {
...(showSomeday !== undefined && { showSomeday }),
...(showAllDayEvents !== undefined && { showAllDayEvents }),
...(showSchedule !== undefined && { showSchedule }),
...(showTaskCheckboxes !== undefined && { showTaskCheckboxes }),
...(cellDuration !== undefined && !isNaN(cellDuration) && { cellDuration }),
...(viewStyle !== undefined && { viewStyle }),
...(viewDays !== undefined && !isNaN(viewDays) && { viewDays }),
@ -210,6 +213,7 @@ export async function PATCH(request: NextRequest) {
showSomeday: true,
showAllDayEvents: true,
showSchedule: true,
showTaskCheckboxes: true,
cellDuration: true,
viewStyle: true,
viewDays: true,

View File

@ -1183,8 +1183,8 @@ h3 {
}
.weekly-someday.expanded {
max-height: 400px;
overflow: auto;
max-height: none;
overflow: hidden;
}
.weekly-someday-bar {
@ -1308,6 +1308,7 @@ h3 {
min-height: 200px;
flex: 0 0 280px; /* Fixed width for horizontal scrolling */
width: 280px;
transition: transform 0.2s ease, opacity 0.2s ease;
max-width: 100%;
display: flex;
flex-direction: column;

View File

@ -29,6 +29,7 @@ interface GridTaskBlockProps {
deleteSubTask: (id: string) => void;
onSetEditingTaskId?: (id: string | null) => void;
workingHoursStart: number;
showTaskCheckboxes?: boolean;
}
export function GridTaskBlock({
@ -57,7 +58,8 @@ export function GridTaskBlock({
updateSubTask,
deleteSubTask,
onSetEditingTaskId,
workingHoursStart
workingHoursStart,
showTaskCheckboxes
}: GridTaskBlockProps) {
const [isNotesOpen, setIsNotesOpen] = useState(false);
const [notesValue, setNotesValue] = useState(task.markdownContent || "");
@ -166,7 +168,7 @@ export function GridTaskBlock({
return (
<div
className={`time-slot-task ${task.completed ? "completed" : ""} ${draggedTask?.id === task.id ? "dragging" : ""}`}
className={`time-slot-task ${task.completed && !showTaskCheckboxes ? "completed" : ""} ${draggedTask?.id === task.id ? "dragging" : ""}`}
style={{
position: "absolute",
top: `${topOffset}px`,
@ -226,13 +228,22 @@ export function GridTaskBlock({
whiteSpace: "normal",
wordBreak: "break-word",
flex: 1,
fontSize: "0.8rem",
}}
onDoubleClick={(e) => {
e.stopPropagation();
setEditingTaskId(task.id);
}}
>
{showTaskCheckboxes && (
<input
type="checkbox"
checked={task.completed}
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" }}
/>
)}
{task.externalProvider && (
!task.externalId ||
!task.lastSyncedAt ||
@ -254,10 +265,12 @@ export function GridTaskBlock({
</svg>
</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" />
@ -267,7 +280,6 @@ export function GridTaskBlock({
</svg>
</span>
)}
{task.title}
</span>
)}
@ -360,6 +372,12 @@ export function GridTaskBlock({
<button className="notes-toolbar-btn" onClick={() => insertMarkdown("*", "*")} title="Italic">i</button>
<button className="notes-toolbar-btn" onClick={() => insertMarkdown("- ")} title="List"></button>
<span style={{ marginLeft: "auto", fontSize: "0.75rem", color: "#999" }}>Markdown</span>
<button
className="notes-toolbar-btn"
onClick={() => setIsNotesOpen(false)}
title="Close"
style={{ marginLeft: "8px", fontSize: "1rem", lineHeight: 1 }}
>×</button>
</div>
<textarea
ref={notesRef}

View File

@ -214,6 +214,7 @@ const translations: Record<string, any> = {
goalScopeDay: "Per Day",
goalFallback: "Goal Fallback Type",
defaultGoal: "Custom Default Goal",
showTaskCheckboxes: "Show Checkboxes on Tasks",
showSomeday: "Show Someday Section",
showAllDay: "Show All-Day Section",
allDayPosition: "All-Day Events Position",
@ -278,6 +279,7 @@ const translations: Record<string, any> = {
goalScopeDay: "Pro Tag",
goalFallback: "Ziel-Fallback-Typ",
defaultGoal: "Benutzerdefiniertes Standardziel",
showTaskCheckboxes: "Checkboxen bei Aufgaben anzeigen",
showSomeday: "Irgendwann-Bereich anzeigen",
showAllDay: "Ganztägige Ereignisse anzeigen",
allDayPosition: "Position ganztägiger Ereignisse",
@ -504,6 +506,8 @@ export default function WeeklyView() {
const [somedayLists, setSomedayLists] = useState<SomedayList[]>([]);
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
const [draggingListId, setDraggingListId] = useState<string | null>(null);
const [dropTargetListIndex, setDropTargetListIndex] = useState<number | null>(null);
const isDragFromHandle = useRef(false);
// Undo/Redo state
const undoStackRef = useRef<{ tasks: Task[]; somedayLists: SomedayList[] }[]>([]);
@ -608,6 +612,7 @@ export default function WeeklyView() {
yearFontWeight?: string;
yearColor?: string;
dayHeaderGap?: string;
showTaskCheckboxes?: boolean;
quoteSourceUrls?: string[];
}>({
name: session?.user?.name || "",
@ -807,6 +812,7 @@ export default function WeeklyView() {
const dayColumnsRef = useRef<HTMLDivElement[]>([]);
const isScrollSyncing = useRef(false);
const dayHeaderRef = useRef<HTMLElement>(null);
const somedayGridRef = useRef<HTMLDivElement>(null);
// Scroll sync handler
const handleTimeColumnScroll = (e: React.UIEvent<HTMLDivElement>) => {
@ -1243,11 +1249,18 @@ export default function WeeklyView() {
const handleSomedayWheel = (e: React.WheelEvent) => {
if (e.currentTarget) {
e.currentTarget.scrollLeft += e.deltaY;
useEffect(() => {
const el = somedayGridRef.current;
if (!el) return;
const handler = (e: WheelEvent) => {
if (e.deltaY !== 0) {
e.preventDefault();
el.scrollLeft += e.deltaY;
}
};
el.addEventListener("wheel", handler, { passive: false });
return () => el.removeEventListener("wheel", handler);
}, [showSomeday, somedayExpanded]);
const saveSetting = async (key: string, value: any) => {
try {
await fetch("/api/user/profile", {
@ -1432,6 +1445,8 @@ export default function WeeklyView() {
id: l.id,
title: l.title,
tasks: l.tasks || [], // Tasks will be overwritten/populated by fetchTasks
externalId: l.externalId || null,
externalProvider: l.externalProvider || null,
})),
);
return data.lists;
@ -1457,6 +1472,7 @@ export default function WeeklyView() {
id: l.id,
title: l.title,
tasks: [],
externalId: l.externalId || null,
externalProvider: l.externalProvider || null,
}));
}
@ -4110,6 +4126,7 @@ export default function WeeklyView() {
deleteSubTask={deleteSubTask}
onSetEditingTaskId={setEditingTaskId}
workingHoursStart={workingHoursStart}
showTaskCheckboxes={profile.showTaskCheckboxes}
/>
))}
{visibleSlots.map((slot) => {
@ -4346,6 +4363,7 @@ export default function WeeklyView() {
onUpdateSubTask={updateSubTask}
editingTaskId={editingTaskId}
onSetEditingTaskId={setEditingTaskId}
showTaskCheckboxes={profile.showTaskCheckboxes}
/>
))}
</div>
@ -4430,6 +4448,7 @@ export default function WeeklyView() {
onUpdateSubTask={updateSubTask}
editingTaskId={editingTaskId}
onSetEditingTaskId={setEditingTaskId}
showTaskCheckboxes={profile.showTaskCheckboxes}
/>
))}
</ol>
@ -4591,17 +4610,29 @@ export default function WeeklyView() {
</button>
</div>
)}
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
<div style={{ flex: 1, minWidth: 0 }}>
{somedayExpanded && (
<div
ref={somedayGridRef}
className={`weekly-someday-lists-grid cols-${Math.min(7, Math.max(1, viewDays))}`}
onWheel={handleSomedayWheel}
>
{(somedayLists.length > 0
{(() => {
const baseLists = somedayLists.length > 0
? somedayLists
: [{ id: "default", title: "LISTE", tasks: [] }]
)
.slice(0, Math.max(somedayLists.length, viewDays))
: [{ id: "default", title: "LISTE", tasks: [] as Task[] }];
const sliced = baseLists.slice(0, Math.max(somedayLists.length, viewDays));
// Compute visual order during drag
if (draggingListId && dropTargetListIndex !== null) {
const dragIdx = sliced.findIndex(l => l.id === draggingListId);
if (dragIdx !== -1 && dragIdx !== dropTargetListIndex) {
const reordered = [...sliced];
const [moved] = reordered.splice(dragIdx, 1);
reordered.splice(dropTargetListIndex, 0, moved);
return reordered;
}
}
return sliced;
})()
.map((list) => (
<div
key={list.id}
@ -4638,29 +4669,46 @@ export default function WeeklyView() {
if (target.closest(".weekly-task-item")) {
return; // Let the TaskItem handle its own drag
}
// Only allow list drag if clicking the handle
if (!target.closest(".someday-drag-handle")) {
// Only allow list drag if started from the handle
if (!isDragFromHandle.current) {
e.preventDefault();
return;
}
isDragFromHandle.current = false;
setDraggingListId(list.id);
e.dataTransfer.setData("text/list-id", list.id);
e.dataTransfer.effectAllowed = "move";
}}
onDragEnd={() => setDraggingListId(null)}
onDragEnd={() => { setDraggingListId(null); setDropTargetListIndex(null); }}
onDragOver={(e) => {
e.preventDefault(); // Allow drop
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (!draggingListId) return;
const container = somedayGridRef.current;
if (!container) return;
const children = Array.from(container.children) as HTMLElement[];
let targetIdx = children.length - 1;
for (let i = 0; i < children.length; i++) {
const rect = children[i].getBoundingClientRect();
if (e.clientX < rect.left + rect.width / 2) {
targetIdx = i;
break;
}
}
setDropTargetListIndex(targetIdx);
}}
onDrop={async (e) => {
e.preventDefault();
setDraggingListId(null);
const draggedListId =
const droppedListId =
e.dataTransfer.getData("text/list-id");
const draggedTaskId =
e.dataTransfer.getData("text/plain");
if (draggedListId === list.id) return;
if (droppedListId === list.id && !draggedTaskId) {
setDraggingListId(null);
setDropTargetListIndex(null);
return;
}
// Check if a calendar task is being dropped into this someday list
if (
@ -4781,25 +4829,28 @@ export default function WeeklyView() {
return;
}
// Reorder logic (list drag)
if (!draggedListId) return;
// Reorder logic (list drag) - apply the visual order
if (!droppedListId || dropTargetListIndex === null) {
setDraggingListId(null);
setDropTargetListIndex(null);
return;
}
const draggedIndex = somedayLists.findIndex(
(l) => l.id === draggedListId,
(l) => l.id === droppedListId,
);
const targetIndex = somedayLists.findIndex(
(l) => l.id === list.id,
);
if (draggedIndex === -1 || targetIndex === -1) return;
if (draggedIndex === -1) {
setDraggingListId(null);
setDropTargetListIndex(null);
return;
}
const newLists = [...somedayLists];
const [draggedItem] = newLists.splice(
draggedIndex,
1,
);
newLists.splice(targetIndex, 0, draggedItem);
const [draggedItem] = newLists.splice(draggedIndex, 1);
newLists.splice(dropTargetListIndex, 0, draggedItem);
setSomedayLists(newLists);
setDraggingListId(null);
setDropTargetListIndex(null);
// Persist order
const orderUpdates = newLists.map((l, index) => ({
@ -4828,6 +4879,8 @@ export default function WeeklyView() {
<div
className="someday-drag-handle"
title="Drag to reorder"
onMouseDown={() => { isDragFromHandle.current = true; }}
onMouseUp={() => { isDragFromHandle.current = false; }}
>
<GripVertical size={14} />
</div>
@ -4949,6 +5002,7 @@ export default function WeeklyView() {
onUpdateSubTask={updateSubTask}
editingTaskId={editingTaskId}
onSetEditingTaskId={setEditingTaskId}
showTaskCheckboxes={profile.showTaskCheckboxes}
/>
))}
<SomedayAddTask
@ -5628,6 +5682,7 @@ interface TaskItemProps {
editingTaskId?: string | null;
onSetEditingTaskId?: (id: string | null) => void;
isSubTask?: boolean;
showTaskCheckboxes?: boolean;
}
function TaskItem({
@ -5651,6 +5706,7 @@ function TaskItem({
editingTaskId,
onSetEditingTaskId,
isSubTask = false,
showTaskCheckboxes = false,
}: TaskItemProps) {
const [editValue, setEditValue] = useState(task.title);
const [isNotesOpen, setIsNotesOpen] = useState(false);
@ -5726,7 +5782,7 @@ function TaskItem({
return (
<li
className={`weekly-task-item ${variant} ${task.completed ? "completed" : ""} ${isSomeday ? "relative mx-2" : ""}`}
className={`weekly-task-item ${variant} ${task.completed && !showTaskCheckboxes ? "completed" : ""} ${isSomeday ? "relative mx-2" : ""}`}
draggable={!isEditing && !isNotesOpen} // Disable drag when editing
onDragStart={(e) => onDragStart(e as unknown as DragEvent, task)}
onDragEnd={onDragEnd}
@ -5807,18 +5863,28 @@ function TaskItem({
) : (
<>
<span
className={`weekly-task-text flex-1 ${task.completed ? "completed" : ""}`}
className={`weekly-task-text flex-1 ${task.completed && !showTaskCheckboxes ? "completed" : ""}`}
onClick={(e) => {
if (variant === "default") onToggle();
if (variant === "default" && !showTaskCheckboxes) onToggle();
// For minimal/someday, parent onClick handles edit
}}
onDoubleClick={variant === "default" ? onEdit : undefined}
style={
variant === "minimal" || isSomeday
? { fontSize: "0.9375rem", display: "flex", alignItems: "center", gap: "4px" }
: { display: "flex", alignItems: "center", gap: "6px" }
? { fontSize: "0.9375rem", display: "flex", alignItems: "center", gap: "4px", ...(task.completed && showTaskCheckboxes ? { opacity: 0.5 } : {}) }
: { display: "flex", alignItems: "center", gap: "6px", ...(task.completed && showTaskCheckboxes ? { opacity: 0.5 } : {}) }
}
>
{showTaskCheckboxes && (
<input
type="checkbox"
checked={task.completed}
onChange={(e) => { e.stopPropagation(); onToggle(); }}
onClick={(e) => e.stopPropagation()}
className="task-checkbox flex-shrink-0"
style={{ width: "14px", height: "14px", margin: 0, cursor: "pointer", position: "relative", top: "4px", left: "-2px", accentColor: "#333" }}
/>
)}
{needsSync && (
<span title="Needs to be synced" className="text-yellow-500 flex-shrink-0">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
@ -6281,6 +6347,7 @@ interface SettingsSidebarProps {
yearFontWeight?: string;
yearColor?: string;
dayHeaderGap?: string;
showTaskCheckboxes?: boolean;
quoteSourceUrls: string[];
}) => void;
quoteSourceUrls?: string[];
@ -6636,6 +6703,7 @@ function SettingsSidebar({
yearFontWeight?: string;
yearColor?: string;
dayHeaderGap?: string;
showTaskCheckboxes?: boolean;
quoteSourceUrls?: string[];
}>({
name: "",
@ -6772,6 +6840,7 @@ function SettingsSidebar({
yearFontSize: profile.yearFontSize,
yearFontWeight: profile.yearFontWeight,
dayHeaderGap: profile.dayHeaderGap,
showTaskCheckboxes: profile.showTaskCheckboxes,
} as any);
}, [profile, showTimeGrid, cellDuration, viewStyle, fontSize, showNextTask, showSomeday, showAllDay, showSchedule]);
@ -6846,6 +6915,7 @@ function SettingsSidebar({
hourLabelFormat: data.user.hourLabelFormat || "short",
showSubHourSlots: data.user.showSubHourSlots !== undefined ? data.user.showSubHourSlots : true,
allDayPosition: data.user.allDayPosition || "below",
showTaskCheckboxes: data.user.showTaskCheckboxes || false,
});
if (data.user.showTimeGrid !== undefined)
@ -7297,6 +7367,25 @@ function SettingsSidebar({
{t.runningList}
</label>
</div>
<div
style={{ display: "flex", alignItems: "center", gap: "8px" }}
>
<input
type="checkbox"
id="showTaskCheckboxes"
checked={profile.showTaskCheckboxes || false}
onChange={(e) =>
setProfile({ ...profile, showTaskCheckboxes: e.target.checked })
}
style={{ width: "16px", height: "16px" }}
/>
<label
htmlFor="showTaskCheckboxes"
style={{ fontSize: "0.9rem", fontWeight: 600 }}
>
{t.showTaskCheckboxes}
</label>
</div>
<div
style={{ display: "flex", alignItems: "center", gap: "8px" }}
>

File diff suppressed because one or more lines are too long