From af2de09f6b9c4bcb6ec5c2539ad183ee0d497c39 Mon Sep 17 00:00:00 2001 From: mARTin Date: Wed, 11 Mar 2026 09:15:11 +0100 Subject: [PATCH] feat: add Kanban board view with customizable stages - New "Kanban" view style alongside Simple, Calendar, and List - Drag tasks between columns to change their stage - Customizable stages with colors in Settings > View Style - Stage colors appear as left border indicators on tasks in weekly view - Default stages: Backlog, To Do, In Progress, Review, Done - Stages persist in database (User.kanbanStages as JSON) - Task stage persists in database (Task.kanbanStage) - Full i18n support (EN, DE, FR, ES, IT) - Unassigned tasks shown in separate column v1.28.0 Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- .../20260311_add_kanban_stage/migration.sql | 5 + prisma/schema.prisma | 2 + src/app/api/tasks/route.ts | 6 +- src/app/api/user/profile/route.ts | 6 +- src/app/globals.css | 144 +++++++++ src/components/WeeklyView.tsx | 300 +++++++++++++++++- 7 files changed, 457 insertions(+), 8 deletions(-) create mode 100644 prisma/migrations/20260311_add_kanban_stage/migration.sql diff --git a/package.json b/package.json index 6039097..fc770c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.27.3", + "version": "1.28.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/prisma/migrations/20260311_add_kanban_stage/migration.sql b/prisma/migrations/20260311_add_kanban_stage/migration.sql new file mode 100644 index 0000000..fe74819 --- /dev/null +++ b/prisma/migrations/20260311_add_kanban_stage/migration.sql @@ -0,0 +1,5 @@ +-- Add kanbanStage to Task +ALTER TABLE "Task" ADD COLUMN IF NOT EXISTS "kanbanStage" TEXT; + +-- Add kanbanStages (JSON string) to User for stage definitions +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "kanbanStages" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dd71248..271a28e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -99,6 +99,7 @@ model User { startDayOffset Int @default(-1) quoteSourceUrls String[] @default([]) quoteLanguages String[] @default(["en", "de"]) + kanbanStages String? accounts Account[] cachedCalendarEvents CachedCalendarEvent[] calendarConnections CalendarConnection[] @@ -175,6 +176,7 @@ model Task { parentTaskId String? somedaySlotIndex Int? projectId String? + kanbanStage String? parent Task? @relation("SubTasks", fields: [parentTaskId], references: [id], onDelete: Cascade) subTasks Task[] @relation("SubTasks") project Project? @relation(fields: [projectId], references: [id]) diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index 383cfe4..6432c2c 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -220,7 +220,7 @@ export async function POST(request: NextRequest) { const body = await request.json(); - const { title, description, dayOfWeek, order, markdownContent, somedayListId, somedaySlotIndex, startTime, scheduledDate, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, recurrenceDays, parentTaskId, projectId } = body; + const { title, description, dayOfWeek, order, markdownContent, somedayListId, somedaySlotIndex, startTime, scheduledDate, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, recurrenceDays, parentTaskId, projectId, kanbanStage } = body; let { isRolling } = body; const { isRecurring } = body; @@ -331,6 +331,7 @@ export async function POST(request: NextRequest) { somedaySlotIndex: somedaySlotIndex !== undefined ? parseInt(somedaySlotIndex) : null, parentTaskId: parentTaskId || null, ...(projectId !== undefined && { projectId: projectId || null }), + ...(kanbanStage !== undefined && { kanbanStage: kanbanStage || null }), ...(externalId && { externalId, externalProvider, externalListId }), }, }); @@ -374,7 +375,7 @@ export async function PATCH(request: NextRequest) { const body = await request.json(); const { id } = body; - const { title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime, somedayListId, somedaySlotIndex, isRecurring, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, recurrenceDays, restore, parentTaskId, projectId, externalProvider } = body; + const { title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime, somedayListId, somedaySlotIndex, isRecurring, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, recurrenceDays, restore, parentTaskId, projectId, kanbanStage, externalProvider } = body; if (!id) { return NextResponse.json( @@ -463,6 +464,7 @@ export async function PATCH(request: NextRequest) { ...(somedaySlotIndex !== undefined && { somedaySlotIndex: somedaySlotIndex !== null ? parseInt(somedaySlotIndex) : null }), ...(parentTaskId !== undefined && { parentTaskId: parentTaskId || null }), ...(projectId !== undefined && { projectId: projectId || null }), + ...(kanbanStage !== undefined && { kanbanStage: kanbanStage || null }), ...(externalProvider !== undefined && { externalProvider: externalProvider || null }), }, }); diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index cf9e593..5decba2 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -89,6 +89,7 @@ export async function GET(request: NextRequest) { customWeekdayNames: true, quoteSourceUrls: true, quoteLanguages: true, + kanbanStages: true, accountNumber: true, createdAt: true } @@ -131,7 +132,8 @@ export async function PATCH(request: NextRequest) { cwFontFamily, cwFontSize, cwFontWeight, cwColor, yearFontFamily, yearFontSize, yearFontWeight, yearColor, showTaskCheckboxes, dayHeaderGap, - showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, quoteSourceUrls, quoteLanguages + showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, quoteSourceUrls, quoteLanguages, + kanbanStages } = body; const updateData: any = { @@ -210,6 +212,7 @@ export async function PATCH(request: NextRequest) { ...(customWeekdayNames !== undefined && { customWeekdayNames }), ...(quoteSourceUrls !== undefined && { quoteSourceUrls }), ...(quoteLanguages !== undefined && { quoteLanguages }), + ...(kanbanStages !== undefined && { kanbanStages }), }; if (password && password.trim() !== "") { updateData.passwordHash = await bcrypt.hash(password, 10); @@ -296,6 +299,7 @@ export async function PATCH(request: NextRequest) { customWeekdayNames: true, quoteSourceUrls: true, quoteLanguages: true, + kanbanStages: true, accountNumber: true, } }); diff --git a/src/app/globals.css b/src/app/globals.css index 5ec2702..6bc3e76 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1655,6 +1655,150 @@ h3 { padding: 2rem 1.5rem; } +/* Kanban Board */ +.kanban-board { + display: flex; + gap: 12px; + padding: 12px; + overflow-x: auto; + min-height: 300px; + flex: 1; +} + +.kanban-column { + min-width: 250px; + max-width: 320px; + flex: 1; + background: var(--weekly-bg-alt, #f8f9fa); + border-radius: 8px; + display: flex; + flex-direction: column; + transition: box-shadow 0.15s; +} + +.kanban-column-drag-over { + box-shadow: inset 0 0 0 2px var(--weekly-accent, #6366f1); +} + +.kanban-column-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-bottom: 3px solid; + font-weight: 600; + font-size: 0.85rem; +} + +.kanban-column-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.kanban-column-title { + flex: 1; +} + +.kanban-column-count { + font-size: 0.7rem; + font-weight: 500; + color: var(--weekly-text-light, #888); + background: rgba(0,0,0,0.06); + border-radius: 10px; + padding: 1px 7px; +} + +.kanban-column-body { + flex: 1; + padding: 8px; + display: flex; + flex-direction: column; + gap: 6px; + overflow-y: auto; + min-height: 60px; +} + +.kanban-card { + background: var(--weekly-bg, #fff); + border: 1px solid var(--weekly-border, #e5e7eb); + border-radius: 6px; + padding: 8px 10px; + cursor: grab; + transition: box-shadow 0.15s, transform 0.1s; + font-size: 0.85rem; +} + +.kanban-card:hover { + box-shadow: 0 2px 8px rgba(0,0,0,0.08); +} + +.kanban-card:active { + cursor: grabbing; + transform: rotate(2deg); +} + +.kanban-card-done { + opacity: 0.5; +} + +.kanban-card-done .kanban-card-title { + text-decoration: line-through; +} + +.kanban-card-header { + display: flex; + align-items: flex-start; + gap: 6px; +} + +.kanban-card-checkbox { + margin-top: 2px; + flex-shrink: 0; + cursor: pointer; +} + +.kanban-card-title { + flex: 1; + outline: none; + line-height: 1.3; +} + +.kanban-card-date { + font-size: 0.7rem; + color: var(--weekly-text-light, #888); + margin-top: 4px; + padding-left: 20px; +} + +.kanban-card-project { + font-size: 0.7rem; + margin-top: 2px; + padding-left: 20px; +} + +/* Kanban stage color indicator on weekly tasks */ +.kanban-stage-indicator { + width: 4px; + border-radius: 2px; + flex-shrink: 0; + align-self: stretch; + margin-right: 4px; +} + +@media (max-width: 768px) { + .kanban-board { + gap: 8px; + padding: 8px; + } + + .kanban-column { + min-width: 200px; + max-width: none; + } +} + .preferences-overlay { position: fixed; inset: 0; diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index 79f905d..c11495f 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -88,7 +88,13 @@ function setCookie(name: string, value: string, days: number = 365) { document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`; } -export type ViewStyle = "simple" | "calendar" | "list" | "grid"; +export type ViewStyle = "simple" | "calendar" | "list" | "grid" | "kanban"; + +export interface KanbanStage { + id: string; + name: string; + color: string; +} export interface Task { id: string; @@ -129,6 +135,7 @@ export interface Task { externalListId?: string | null; projectId?: string | null; project?: { id: string; name: string; icon?: string | null; color?: string | null } | null; + kanbanStage?: string | null; } interface CalendarEvent { @@ -237,6 +244,12 @@ const translations: Record = { simpleView: "Simple", calendarView: "Calendar", listView: "List", + kanbanView: "Kanban", + kanbanStages: "Kanban Stages", + kanbanStagesDesc: "Define the stages for your Kanban board. Drag tasks between columns to change their stage.", + addStage: "Add stage", + stageName: "Stage name", + noStage: "No stage", language: "Language", dateFormat: "Date Format", timeFormat: "Time Format", @@ -430,6 +443,12 @@ const translations: Record = { viewStyle: "Ansichtsstil", simpleView: "Einfach", calendarView: "Kalender", + kanbanView: "Kanban", + 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", + stageName: "Phasenname", + noStage: "Keine Phase", listView: "Liste", notes: "Notizen", notesSidebar: "Notizen-Seitenleiste", @@ -626,6 +645,12 @@ const translations: Record = { simpleView: "Simple", calendarView: "Calendrier", listView: "Liste", + kanbanView: "Kanban", + 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", + stageName: "Nom de l'étape", + noStage: "Aucune étape", language: "Langue", dateFormat: "Format de date", timeFormat: "Format d'heure", @@ -820,6 +845,12 @@ const translations: Record = { simpleView: "Simple", calendarView: "Calendario", listView: "Lista", + kanbanView: "Kanban", + kanbanStages: "Etapas Kanban", + kanbanStagesDesc: "Define las etapas de tu tablero Kanban. Arrastra tareas entre columnas para cambiar su etapa.", + addStage: "Añadir etapa", + stageName: "Nombre de etapa", + noStage: "Sin etapa", language: "Idioma", dateFormat: "Formato de fecha", timeFormat: "Formato de hora", @@ -1014,6 +1045,12 @@ const translations: Record = { simpleView: "Semplice", calendarView: "Calendario", listView: "Lista", + kanbanView: "Kanban", + 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", + stageName: "Nome fase", + noStage: "Nessuna fase", language: "Lingua", dateFormat: "Formato data", timeFormat: "Formato ora", @@ -1793,6 +1830,24 @@ export default function WeeklyView() { slotIdx?: number; } | null>(null); const [viewStyle, setViewStyle] = useState("simple"); + const defaultKanbanStages: KanbanStage[] = [ + { id: "backlog", name: "Backlog", color: "#94a3b8" }, + { id: "todo", name: "To Do", color: "#3b82f6" }, + { id: "in-progress", name: "In Progress", color: "#f59e0b" }, + { id: "review", name: "Review", color: "#8b5cf6" }, + { id: "done", name: "Done", color: "#22c55e" }, + ]; + const [kanbanStages, setKanbanStages] = useState(defaultKanbanStages); + const saveKanbanStages = async (stages: KanbanStage[]) => { + setKanbanStages(stages); + try { + await fetch("/api/user/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kanbanStages: JSON.stringify(stages) }), + }); + } catch (e) { console.error("Failed to save kanban stages:", e); } + }; const [protectEventTimes, setProtectEventTimes] = useState(false); const [unlockedEvents, setUnlockedEvents] = useState>(new Set()); @@ -2553,6 +2608,12 @@ export default function WeeklyView() { setViewStyle(data.user.viewStyle as ViewStyle); setShowTimeGrid(data.user.showTimeGrid ?? true); } + if (data.user.kanbanStages) { + try { + const parsed = JSON.parse(data.user.kanbanStages); + if (Array.isArray(parsed) && parsed.length > 0) setKanbanStages(parsed); + } catch { /* use defaults */ } + } if (data.user.viewDays !== undefined) { savedViewDaysRef.current = data.user.viewDays; const width = window.innerWidth; @@ -5772,8 +5833,158 @@ export default function WeeklyView() { {/* All-Day Events Section (above position) */} {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"); + } + }} + 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 }); + } + }} + > +
+ + {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} +
+ )} +
+ ))} +
+
+ ); + })} + {/* 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 }); + } + }} + > +
+ + {t.noStage} + {unassigned.length} +
+
+ {unassigned.map(task => ( +
{ + e.dataTransfer.setData("text/kanban-task", task.id); + e.dataTransfer.effectAllowed = "move"; + }} + > +
+ toggleTask(task.id)} + className="kanban-card-checkbox" + /> + {task.title} +
+ {task.scheduledDate && ( +
+ {new Date(task.scheduledDate).toLocaleDateString(language, { month: "short", day: "numeric" })} +
+ )} +
+ ))} +
+
+ ); + })()} +
+ )} + {/* Main Grid with Time Column */} -
+ {viewStyle !== "kanban" &&
{/* Side Navigation Arrows (hover overlays) */}
-
+
} {/* All-Day Events Section (below position) */} {allDayPosition === "below" && allDaySection} @@ -7038,6 +7250,7 @@ export default function WeeklyView() { showTaskCheckboxes={profile.showTaskCheckboxes} projects={projects} onProjectAssign={assignProject} + kanbanStages={kanbanStages} /> ) : ( activeAddSlot?.listId === list.id && activeAddSlot?.slotIdx === slot.index && ( @@ -7108,6 +7321,7 @@ export default function WeeklyView() { showTaskCheckboxes={profile.showTaskCheckboxes} projects={projects} onProjectAssign={assignProject} + kanbanStages={kanbanStages} /> ))} @@ -7569,6 +7783,8 @@ export default function WeeklyView() { setCurrentWeekStart={setCurrentWeekStart} projects={projects} onProjectsChanged={fetchProjects} + kanbanStages={kanbanStages} + saveKanbanStages={saveKanbanStages} /> ) } @@ -7849,6 +8065,7 @@ interface TaskItemProps { showTaskCheckboxes?: boolean; projects?: { id: string; name: string; icon?: string | null; color?: string | null }[]; onProjectAssign?: (taskId: string, projectId: string | null) => void; + kanbanStages?: KanbanStage[]; } function TaskItem({ @@ -7875,6 +8092,7 @@ function TaskItem({ showTaskCheckboxes = false, projects = [], onProjectAssign, + kanbanStages = [], }: TaskItemProps) { const [editValue, setEditValue] = useState(task.title); const [isNotesOpen, setIsNotesOpen] = useState(false); @@ -7994,7 +8212,12 @@ function TaskItem({
  • { + const stageColor = task.kanbanStage ? kanbanStages.find(s => s.id === task.kanbanStage)?.color : null; + if (stageColor) return { borderLeft: `4px solid ${stageColor}`, paddingLeft: "6px" }; + if (task.project?.color) return { borderLeft: `3px solid ${task.project.color}`, paddingLeft: "6px" }; + return undefined; + })()} draggable={!isEditing && !isNotesOpen && swipeX === 0} onDragStart={(e) => { // If dragging a subtask, don't drag the parent @@ -8862,6 +9085,8 @@ interface SettingsSidebarProps { initialTab?: "calendar" | "general" | "account" | "styling" | "motivation" | "about"; projects: { id: string; name: string; icon?: string | null; color?: string | null }[]; onProjectsChanged: () => void; + kanbanStages: KanbanStage[]; + saveKanbanStages: (stages: KanbanStage[]) => Promise; } // Notes Sidebar Component interface NotesSidebarProps { @@ -9080,6 +9305,8 @@ function SettingsSidebar({ setCurrentWeekStart, projects, onProjectsChanged, + kanbanStages, + saveKanbanStages, }: SettingsSidebarProps) { const [activeTab, setActiveTab] = useState< "calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation" @@ -10324,9 +10551,74 @@ function SettingsSidebar({ > {t.listView} + + {/* Kanban Stages Settings */} +
    +

    + {t.kanbanStages} +

    +

    {t.kanbanStagesDesc}

    +
    + {kanbanStages.map((stage, idx) => ( +
    + { + const updated = kanbanStages.map((s, i) => i === idx ? { ...s, color: e.target.value } : s); + saveKanbanStages(updated); + }} + style={{ width: "24px", height: "24px", border: "none", cursor: "pointer", padding: 0 }} + /> + { + const updated = kanbanStages.map((s, i) => i === idx ? { ...s, name: e.target.value } : s); + saveKanbanStages(updated); + }} + onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} + className="weekly-input" + style={{ flex: 1, padding: "4px 8px", fontSize: "0.85rem" }} + /> + +
    + ))} +
    + +
    {/* Projects Section */}