From 8703239f0f2020548a2c870c0cef707eb4a956b0 Mon Sep 17 00:00:00 2001 From: mARTin Date: Wed, 11 Mar 2026 13:12:55 +0100 Subject: [PATCH] feat: fix kanban drag persistence, add view switcher and kanban filters Fix stale closure in updateTaskFields causing tasks to disappear after dragging between kanban columns. Add view switcher icons (Week/List/Kanban) in the header on hover. Add search bar and filter dropdowns (project, someday list, week) to the kanban board. Include someday list tasks in kanban view. v1.29.0 Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- src/app/globals.css | 91 ++++++++ src/components/WeeklyView.tsx | 405 ++++++++++++++++++++++------------ 3 files changed, 353 insertions(+), 145 deletions(-) diff --git a/package.json b/package.json index fc770c3..658aa1d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.28.0", + "version": "1.29.0", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "main": "index.js", "scripts": { diff --git a/src/app/globals.css b/src/app/globals.css index 6bc3e76..c123966 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1656,6 +1656,89 @@ h3 { } /* Kanban Board */ +.kanban-wrapper { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; +} + +.kanban-filter-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--weekly-border, #e5e7eb); + background: var(--weekly-bg, #fff); + flex-shrink: 0; + flex-wrap: wrap; +} + +.kanban-filter-search { + display: flex; + align-items: center; + gap: 4px; + background: var(--weekly-bg-alt, #f3f4f6); + border: 1px solid var(--weekly-border, #e5e7eb); + border-radius: 6px; + padding: 4px 8px; + min-width: 160px; +} + +.kanban-filter-icon { + color: #9ca3af; + flex-shrink: 0; +} + +.kanban-filter-input { + border: none; + background: transparent; + outline: none; + font-size: 0.8rem; + width: 100%; + color: inherit; +} + +.kanban-filter-clear { + background: none; + border: none; + cursor: pointer; + color: #9ca3af; + padding: 2px; + display: flex; + border-radius: 50%; +} +.kanban-filter-clear:hover { + color: #374151; + background: #e5e7eb; +} + +.kanban-filter-select { + font-size: 0.8rem; + padding: 4px 8px; + border: 1px solid var(--weekly-border, #e5e7eb); + border-radius: 6px; + background: var(--weekly-bg-alt, #f3f4f6); + color: inherit; + cursor: pointer; + max-width: 180px; +} + +.kanban-filter-reset { + background: none; + border: 1px solid var(--weekly-border, #e5e7eb); + border-radius: 6px; + cursor: pointer; + color: #9ca3af; + padding: 4px 6px; + display: flex; + align-items: center; +} +.kanban-filter-reset:hover { + color: #ef4444; + border-color: #ef4444; +} + .kanban-board { display: flex; gap: 12px; @@ -1778,6 +1861,14 @@ h3 { padding-left: 20px; } +.kanban-card-list { + font-size: 0.65rem; + margin-top: 2px; + padding-left: 20px; + color: #6b7280; + font-style: italic; +} + /* Kanban stage color indicator on weekly tasks */ .kanban-stage-indicator { width: 4px; diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index c11495f..dfafde5 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -62,6 +62,10 @@ import { Link, Globe, Tag, + Kanban, + CalendarDays, + ListTodo, + Filter, } from "lucide-react"; // Types @@ -244,7 +248,11 @@ const translations: Record = { simpleView: "Simple", calendarView: "Calendar", listView: "List", + weekView: "Week", kanbanView: "Kanban", + filterByProject: "All Projects", + filterByList: "All Lists", + filterByWeek: "All Weeks", kanbanStages: "Kanban Stages", kanbanStagesDesc: "Define the stages for your Kanban board. Drag tasks between columns to change their stage.", addStage: "Add stage", @@ -444,6 +452,10 @@ const translations: Record = { simpleView: "Einfach", calendarView: "Kalender", kanbanView: "Kanban", + weekView: "Woche", + filterByProject: "Alle Projekte", + filterByList: "Alle Listen", + filterByWeek: "Alle Wochen", kanbanStages: "Kanban-Phasen", kanbanStagesDesc: "Definiere die Phasen für dein Kanban-Board. Ziehe Aufgaben zwischen Spalten, um ihre Phase zu ändern.", addStage: "Phase hinzufügen", @@ -645,7 +657,11 @@ const translations: Record = { simpleView: "Simple", calendarView: "Calendrier", listView: "Liste", + weekView: "Semaine", kanbanView: "Kanban", + filterByProject: "Tous les projets", + filterByList: "Toutes les listes", + filterByWeek: "Toutes les semaines", kanbanStages: "Étapes Kanban", kanbanStagesDesc: "Définissez les étapes de votre tableau Kanban. Glissez les tâches entre les colonnes pour changer leur étape.", addStage: "Ajouter une étape", @@ -845,7 +861,11 @@ const translations: Record = { simpleView: "Simple", calendarView: "Calendario", listView: "Lista", + weekView: "Semana", kanbanView: "Kanban", + filterByProject: "Todos los proyectos", + filterByList: "Todas las listas", + filterByWeek: "Todas las semanas", kanbanStages: "Etapas Kanban", kanbanStagesDesc: "Define las etapas de tu tablero Kanban. Arrastra tareas entre columnas para cambiar su etapa.", addStage: "Añadir etapa", @@ -1045,7 +1065,11 @@ const translations: Record = { simpleView: "Semplice", calendarView: "Calendario", listView: "Lista", + weekView: "Settimana", kanbanView: "Kanban", + filterByProject: "Tutti i progetti", + filterByList: "Tutte le liste", + filterByWeek: "Tutte le settimane", kanbanStages: "Fasi Kanban", kanbanStagesDesc: "Definisci le fasi della tua board Kanban. Trascina le attività tra le colonne per cambiare la loro fase.", addStage: "Aggiungi fase", @@ -1848,6 +1872,12 @@ export default function WeeklyView() { }); } catch (e) { console.error("Failed to save kanban stages:", e); } }; + // Kanban filters + const [kanbanFilterProject, setKanbanFilterProject] = useState(""); + const [kanbanFilterList, setKanbanFilterList] = useState(""); + const [kanbanFilterWeek, setKanbanFilterWeek] = useState(""); + const [kanbanSearch, setKanbanSearch] = useState(""); + const [protectEventTimes, setProtectEventTimes] = useState(false); const [unlockedEvents, setUnlockedEvents] = useState>(new Set()); @@ -3785,8 +3815,8 @@ export default function WeeklyView() { }; const updateTaskFields = async (taskId: string, fields: Partial) => { - setTasks( - tasks.map((t) => + setTasks((prev) => + prev.map((t) => t.id === taskId ? { ...t, ...fields, updatedAt: new Date() } : t, ), ); @@ -5512,6 +5542,30 @@ export default function WeeklyView() { )} + {/* View Switcher */} +
+ + + +
@@ -5834,154 +5888,217 @@ export default function WeeklyView() { {allDayPosition === "above" && allDaySection} {/* Kanban Board View */} - {viewStyle === "kanban" && ( -
- {kanbanStages.map((stage) => { - const stageTasks = tasks.filter(t => (t.kanbanStage || null) === stage.id && !t.somedayListId); - return ( -
{ - if (e.dataTransfer.types.includes("text/kanban-task")) { - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - e.currentTarget.classList.add("kanban-column-drag-over"); - } + {viewStyle === "kanban" && (() => { + // Combine all tasks (weekly + someday) for kanban + const allSomedayTasks = somedayLists.flatMap(l => l.tasks); + const allKanbanTasks = [...tasks, ...allSomedayTasks]; + + // Build week options for filter + const weekOptions = (() => { + const weeks = new Map(); + allKanbanTasks.forEach(t => { + if (t.scheduledDate) { + const d = new Date(t.scheduledDate); + const weekStart = getStartOfWeek(d); + const key = weekStart.toISOString().split("T")[0]; + if (!weeks.has(key)) { + const weekEnd = new Date(weekStart); + weekEnd.setDate(weekEnd.getDate() + 6); + weeks.set(key, `${weekStart.toLocaleDateString(language, { month: "short", day: "numeric" })} - ${weekEnd.toLocaleDateString(language, { month: "short", day: "numeric" })}`); + } + } + }); + return Array.from(weeks.entries()).sort((a, b) => a[0].localeCompare(b[0])); + })(); + + // Apply filters + const filteredKanbanTasks = allKanbanTasks.filter(t => { + if (kanbanSearch && !t.title.toLowerCase().includes(kanbanSearch.toLowerCase())) return false; + if (kanbanFilterProject && t.projectId !== kanbanFilterProject) return false; + if (kanbanFilterList) { + if (kanbanFilterList === "__none__") { + if (t.somedayListId) return false; + } else { + if (t.somedayListId !== kanbanFilterList) return false; + } + } + if (kanbanFilterWeek && t.scheduledDate) { + const d = new Date(t.scheduledDate); + const weekStart = getStartOfWeek(d); + if (weekStart.toISOString().split("T")[0] !== kanbanFilterWeek) return false; + } + return true; + }); + + const renderKanbanCard = (task: Task) => ( +
{ + e.dataTransfer.setData("text/kanban-task", task.id); + e.dataTransfer.effectAllowed = "move"; + }} + > +
+ toggleTask(task.id)} + className="kanban-card-checkbox" + /> + { + const text = (e.target as HTMLElement).textContent || ""; + if (text !== task.title) updateTask(task.id, text); }} - onDragLeave={(e) => { - if (!e.currentTarget.contains(e.relatedTarget as Node)) { - e.currentTarget.classList.remove("kanban-column-drag-over"); - } - }} - onDrop={async (e) => { - e.currentTarget.classList.remove("kanban-column-drag-over"); - const taskId = e.dataTransfer.getData("text/kanban-task"); - if (taskId) { - e.preventDefault(); - await updateTaskFields(taskId, { kanbanStage: stage.id }); - } + onKeyDown={(e) => { + if (e.key === "Enter") { e.preventDefault(); (e.target as HTMLElement).blur(); } }} > -
- - {stage.name} - {stageTasks.length} -
-
- {stageTasks.map(task => ( -
{ - e.dataTransfer.setData("text/kanban-task", task.id); - e.dataTransfer.effectAllowed = "move"; - }} - > -
- toggleTask(task.id)} - className="kanban-card-checkbox" - /> - { - const text = (e.target as HTMLElement).textContent || ""; - if (text !== task.title) updateTask(task.id, text); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { e.preventDefault(); (e.target as HTMLElement).blur(); } - }} - > - {task.title} - -
- {task.scheduledDate && ( -
- {new Date(task.scheduledDate).toLocaleDateString(language, { month: "short", day: "numeric" })} -
- )} - {task.project && ( -
- {task.project.icon || "📁"} {task.project.name} -
- )} -
- ))} -
+ {task.title} +
+
+ {task.scheduledDate && ( +
+ {new Date(task.scheduledDate).toLocaleDateString(language, { month: "short", day: "numeric" })}
- ); - })} - {/* Unassigned column */} - {(() => { - const unassigned = tasks.filter(t => !t.kanbanStage && !t.somedayListId && !t.completed); - if (unassigned.length === 0) return null; - return ( -
{ - if (e.dataTransfer.types.includes("text/kanban-task")) { - e.preventDefault(); - e.currentTarget.classList.add("kanban-column-drag-over"); - } - }} - onDragLeave={(e) => { - if (!e.currentTarget.contains(e.relatedTarget as Node)) { - e.currentTarget.classList.remove("kanban-column-drag-over"); - } - }} - onDrop={async (e) => { - e.currentTarget.classList.remove("kanban-column-drag-over"); - const taskId = e.dataTransfer.getData("text/kanban-task"); - if (taskId) { - e.preventDefault(); - await updateTaskFields(taskId, { kanbanStage: null }); - } - }} + )} + {task.project && ( +
+ {task.project.icon || "📁"} {task.project.name} +
+ )} + {task.somedayListId && (() => { + const list = somedayLists.find(l => l.id === task.somedayListId); + return list ?
{list.title}
: null; + })()} +
+ ); + + const kanbanColumnDrop = (stageId: string | null) => ({ + onDragOver: (e: React.DragEvent) => { + if (e.dataTransfer.types.includes("text/kanban-task")) { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + e.currentTarget.classList.add("kanban-column-drag-over"); + } + }, + onDragLeave: (e: React.DragEvent) => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + e.currentTarget.classList.remove("kanban-column-drag-over"); + } + }, + onDrop: async (e: React.DragEvent) => { + e.currentTarget.classList.remove("kanban-column-drag-over"); + const taskId = e.dataTransfer.getData("text/kanban-task"); + if (taskId) { + e.preventDefault(); + await updateTaskFields(taskId, { kanbanStage: stageId }); + } + }, + }); + + const hasActiveFilters = kanbanSearch || kanbanFilterProject || kanbanFilterList || kanbanFilterWeek; + + return ( +
+ {/* Filter Bar */} +
+
+ + setKanbanSearch(e.target.value)} + placeholder="Search..." + className="kanban-filter-input" + /> + {kanbanSearch && ( + + )} +
+ toggleTask(task.id)} - className="kanban-card-checkbox" - /> - {task.title} -
- {task.scheduledDate && ( -
- {new Date(task.scheduledDate).toLocaleDateString(language, { month: "short", day: "numeric" })} -
- )} + + {projects.map(p => ( + + ))} + + + + {hasActiveFilters && ( + + )} +
+
+ {kanbanStages.map((stage) => { + const stageTasks = filteredKanbanTasks.filter(t => (t.kanbanStage || null) === stage.id); + return ( +
+
+ + {stage.name} + {stageTasks.length}
- ))} -
-
- ); - })()} -
- )} +
+ {stageTasks.map(renderKanbanCard)} +
+
+ ); + })} + {/* Unassigned column */} + {(() => { + const unassigned = filteredKanbanTasks.filter(t => !t.kanbanStage && !t.completed); + if (unassigned.length === 0 && !hasActiveFilters) return null; + return ( +
+
+ + {t.noStage} + {unassigned.length} +
+
+ {unassigned.map(renderKanbanCard)} +
+
+ ); + })()} +
+ + ); + })()} {/* Main Grid with Time Column */} {viewStyle !== "kanban" &&