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 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-03-11 09:15:11 +01:00
parent 7b02e648ab
commit af2de09f6b
7 changed files with 457 additions and 8 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "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", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -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;

View File

@ -99,6 +99,7 @@ model User {
startDayOffset Int @default(-1) startDayOffset Int @default(-1)
quoteSourceUrls String[] @default([]) quoteSourceUrls String[] @default([])
quoteLanguages String[] @default(["en", "de"]) quoteLanguages String[] @default(["en", "de"])
kanbanStages String?
accounts Account[] accounts Account[]
cachedCalendarEvents CachedCalendarEvent[] cachedCalendarEvents CachedCalendarEvent[]
calendarConnections CalendarConnection[] calendarConnections CalendarConnection[]
@ -175,6 +176,7 @@ model Task {
parentTaskId String? parentTaskId String?
somedaySlotIndex Int? somedaySlotIndex Int?
projectId String? projectId String?
kanbanStage String?
parent Task? @relation("SubTasks", fields: [parentTaskId], references: [id], onDelete: Cascade) parent Task? @relation("SubTasks", fields: [parentTaskId], references: [id], onDelete: Cascade)
subTasks Task[] @relation("SubTasks") subTasks Task[] @relation("SubTasks")
project Project? @relation(fields: [projectId], references: [id]) project Project? @relation(fields: [projectId], references: [id])

View File

@ -220,7 +220,7 @@ export async function POST(request: NextRequest) {
const body = await request.json(); 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; let { isRolling } = body;
const { isRecurring } = body; const { isRecurring } = body;
@ -331,6 +331,7 @@ export async function POST(request: NextRequest) {
somedaySlotIndex: somedaySlotIndex !== undefined ? parseInt(somedaySlotIndex) : null, somedaySlotIndex: somedaySlotIndex !== undefined ? parseInt(somedaySlotIndex) : null,
parentTaskId: parentTaskId || null, parentTaskId: parentTaskId || null,
...(projectId !== undefined && { projectId: projectId || null }), ...(projectId !== undefined && { projectId: projectId || null }),
...(kanbanStage !== undefined && { kanbanStage: kanbanStage || null }),
...(externalId && { externalId, externalProvider, externalListId }), ...(externalId && { externalId, externalProvider, externalListId }),
}, },
}); });
@ -374,7 +375,7 @@ export async function PATCH(request: NextRequest) {
const body = await request.json(); const body = await request.json();
const { id } = body; 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) { if (!id) {
return NextResponse.json( return NextResponse.json(
@ -463,6 +464,7 @@ export async function PATCH(request: NextRequest) {
...(somedaySlotIndex !== undefined && { somedaySlotIndex: somedaySlotIndex !== null ? parseInt(somedaySlotIndex) : null }), ...(somedaySlotIndex !== undefined && { somedaySlotIndex: somedaySlotIndex !== null ? parseInt(somedaySlotIndex) : null }),
...(parentTaskId !== undefined && { parentTaskId: parentTaskId || null }), ...(parentTaskId !== undefined && { parentTaskId: parentTaskId || null }),
...(projectId !== undefined && { projectId: projectId || null }), ...(projectId !== undefined && { projectId: projectId || null }),
...(kanbanStage !== undefined && { kanbanStage: kanbanStage || null }),
...(externalProvider !== undefined && { externalProvider: externalProvider || null }), ...(externalProvider !== undefined && { externalProvider: externalProvider || null }),
}, },
}); });

View File

@ -89,6 +89,7 @@ export async function GET(request: NextRequest) {
customWeekdayNames: true, customWeekdayNames: true,
quoteSourceUrls: true, quoteSourceUrls: true,
quoteLanguages: true, quoteLanguages: true,
kanbanStages: true,
accountNumber: true, accountNumber: true,
createdAt: true createdAt: true
} }
@ -131,7 +132,8 @@ export async function PATCH(request: NextRequest) {
cwFontFamily, cwFontSize, cwFontWeight, cwColor, cwFontFamily, cwFontSize, cwFontWeight, cwColor,
yearFontFamily, yearFontSize, yearFontWeight, yearColor, yearFontFamily, yearFontSize, yearFontWeight, yearColor,
showTaskCheckboxes, dayHeaderGap, showTaskCheckboxes, dayHeaderGap,
showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, quoteSourceUrls, quoteLanguages showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, quoteSourceUrls, quoteLanguages,
kanbanStages
} = body; } = body;
const updateData: any = { const updateData: any = {
@ -210,6 +212,7 @@ export async function PATCH(request: NextRequest) {
...(customWeekdayNames !== undefined && { customWeekdayNames }), ...(customWeekdayNames !== undefined && { customWeekdayNames }),
...(quoteSourceUrls !== undefined && { quoteSourceUrls }), ...(quoteSourceUrls !== undefined && { quoteSourceUrls }),
...(quoteLanguages !== undefined && { quoteLanguages }), ...(quoteLanguages !== undefined && { quoteLanguages }),
...(kanbanStages !== undefined && { kanbanStages }),
}; };
if (password && password.trim() !== "") { if (password && password.trim() !== "") {
updateData.passwordHash = await bcrypt.hash(password, 10); updateData.passwordHash = await bcrypt.hash(password, 10);
@ -296,6 +299,7 @@ export async function PATCH(request: NextRequest) {
customWeekdayNames: true, customWeekdayNames: true,
quoteSourceUrls: true, quoteSourceUrls: true,
quoteLanguages: true, quoteLanguages: true,
kanbanStages: true,
accountNumber: true, accountNumber: true,
} }
}); });

View File

@ -1655,6 +1655,150 @@ h3 {
padding: 2rem 1.5rem; 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 { .preferences-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;

View File

@ -88,7 +88,13 @@ function setCookie(name: string, value: string, days: number = 365) {
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`; 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 { export interface Task {
id: string; id: string;
@ -129,6 +135,7 @@ export interface Task {
externalListId?: string | null; externalListId?: string | null;
projectId?: string | null; projectId?: string | null;
project?: { id: string; name: string; icon?: string | null; color?: string | null } | null; project?: { id: string; name: string; icon?: string | null; color?: string | null } | null;
kanbanStage?: string | null;
} }
interface CalendarEvent { interface CalendarEvent {
@ -237,6 +244,12 @@ const translations: Record<string, any> = {
simpleView: "Simple", simpleView: "Simple",
calendarView: "Calendar", calendarView: "Calendar",
listView: "List", 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", language: "Language",
dateFormat: "Date Format", dateFormat: "Date Format",
timeFormat: "Time Format", timeFormat: "Time Format",
@ -430,6 +443,12 @@ const translations: Record<string, any> = {
viewStyle: "Ansichtsstil", viewStyle: "Ansichtsstil",
simpleView: "Einfach", simpleView: "Einfach",
calendarView: "Kalender", 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", listView: "Liste",
notes: "Notizen", notes: "Notizen",
notesSidebar: "Notizen-Seitenleiste", notesSidebar: "Notizen-Seitenleiste",
@ -626,6 +645,12 @@ const translations: Record<string, any> = {
simpleView: "Simple", simpleView: "Simple",
calendarView: "Calendrier", calendarView: "Calendrier",
listView: "Liste", 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", language: "Langue",
dateFormat: "Format de date", dateFormat: "Format de date",
timeFormat: "Format d'heure", timeFormat: "Format d'heure",
@ -820,6 +845,12 @@ const translations: Record<string, any> = {
simpleView: "Simple", simpleView: "Simple",
calendarView: "Calendario", calendarView: "Calendario",
listView: "Lista", 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", language: "Idioma",
dateFormat: "Formato de fecha", dateFormat: "Formato de fecha",
timeFormat: "Formato de hora", timeFormat: "Formato de hora",
@ -1014,6 +1045,12 @@ const translations: Record<string, any> = {
simpleView: "Semplice", simpleView: "Semplice",
calendarView: "Calendario", calendarView: "Calendario",
listView: "Lista", 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", language: "Lingua",
dateFormat: "Formato data", dateFormat: "Formato data",
timeFormat: "Formato ora", timeFormat: "Formato ora",
@ -1793,6 +1830,24 @@ export default function WeeklyView() {
slotIdx?: number; slotIdx?: number;
} | null>(null); } | null>(null);
const [viewStyle, setViewStyle] = useState<ViewStyle>("simple"); const [viewStyle, setViewStyle] = useState<ViewStyle>("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<KanbanStage[]>(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 [protectEventTimes, setProtectEventTimes] = useState(false);
const [unlockedEvents, setUnlockedEvents] = useState<Set<string>>(new Set()); const [unlockedEvents, setUnlockedEvents] = useState<Set<string>>(new Set());
@ -2553,6 +2608,12 @@ export default function WeeklyView() {
setViewStyle(data.user.viewStyle as ViewStyle); setViewStyle(data.user.viewStyle as ViewStyle);
setShowTimeGrid(data.user.showTimeGrid ?? true); 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) { if (data.user.viewDays !== undefined) {
savedViewDaysRef.current = data.user.viewDays; savedViewDaysRef.current = data.user.viewDays;
const width = window.innerWidth; const width = window.innerWidth;
@ -5772,8 +5833,158 @@ export default function WeeklyView() {
{/* All-Day Events Section (above position) */} {/* All-Day Events Section (above position) */}
{allDayPosition === "above" && allDaySection} {allDayPosition === "above" && allDaySection}
{/* Kanban Board View */}
{viewStyle === "kanban" && (
<div className="kanban-board">
{kanbanStages.map((stage) => {
const stageTasks = tasks.filter(t => (t.kanbanStage || null) === stage.id && !t.somedayListId);
return (
<div
key={stage.id}
className="kanban-column"
onDragOver={(e) => {
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 });
}
}}
>
<div className="kanban-column-header" style={{ borderBottomColor: stage.color }}>
<span className="kanban-column-dot" style={{ background: stage.color }} />
<span className="kanban-column-title">{stage.name}</span>
<span className="kanban-column-count">{stageTasks.length}</span>
</div>
<div className="kanban-column-body">
{stageTasks.map(task => (
<div
key={task.id}
className={`kanban-card ${task.completed ? "kanban-card-done" : ""}`}
draggable
onDragStart={(e) => {
e.dataTransfer.setData("text/kanban-task", task.id);
e.dataTransfer.effectAllowed = "move";
}}
>
<div className="kanban-card-header">
<input
type="checkbox"
checked={task.completed}
onChange={() => toggleTask(task.id)}
className="kanban-card-checkbox"
/>
<span
className="kanban-card-title"
contentEditable
suppressContentEditableWarning
onBlur={(e) => {
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}
</span>
</div>
{task.scheduledDate && (
<div className="kanban-card-date">
{new Date(task.scheduledDate).toLocaleDateString(language, { month: "short", day: "numeric" })}
</div>
)}
{task.project && (
<div className="kanban-card-project" style={{ color: task.project.color || "#888" }}>
{task.project.icon || "📁"} {task.project.name}
</div>
)}
</div>
))}
</div>
</div>
);
})}
{/* Unassigned column */}
{(() => {
const unassigned = tasks.filter(t => !t.kanbanStage && !t.somedayListId && !t.completed);
if (unassigned.length === 0) return null;
return (
<div
className="kanban-column kanban-column-unassigned"
onDragOver={(e) => {
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 });
}
}}
>
<div className="kanban-column-header" style={{ borderBottomColor: "#d1d5db" }}>
<span className="kanban-column-dot" style={{ background: "#d1d5db" }} />
<span className="kanban-column-title">{t.noStage}</span>
<span className="kanban-column-count">{unassigned.length}</span>
</div>
<div className="kanban-column-body">
{unassigned.map(task => (
<div
key={task.id}
className="kanban-card"
draggable
onDragStart={(e) => {
e.dataTransfer.setData("text/kanban-task", task.id);
e.dataTransfer.effectAllowed = "move";
}}
>
<div className="kanban-card-header">
<input
type="checkbox"
checked={task.completed}
onChange={() => toggleTask(task.id)}
className="kanban-card-checkbox"
/>
<span className="kanban-card-title">{task.title}</span>
</div>
{task.scheduledDate && (
<div className="kanban-card-date">
{new Date(task.scheduledDate).toLocaleDateString(language, { month: "short", day: "numeric" })}
</div>
)}
</div>
))}
</div>
</div>
);
})()}
</div>
)}
{/* Main Grid with Time Column */} {/* Main Grid with Time Column */}
<div className="time-grid-wrapper"> {viewStyle !== "kanban" && <div className="time-grid-wrapper">
{/* Side Navigation Arrows (hover overlays) */} {/* Side Navigation Arrows (hover overlays) */}
<div className="side-nav side-nav-left"> <div className="side-nav side-nav-left">
<button onClick={goToPrevDay} title="Previous Day" className="side-nav-btn"> <button onClick={goToPrevDay} title="Previous Day" className="side-nav-btn">
@ -6365,6 +6576,7 @@ export default function WeeklyView() {
showTaskCheckboxes={profile.showTaskCheckboxes} showTaskCheckboxes={profile.showTaskCheckboxes}
projects={projects} projects={projects}
onProjectAssign={assignProject} onProjectAssign={assignProject}
kanbanStages={kanbanStages}
/> />
))} ))}
</ol> </ol>
@ -6384,7 +6596,7 @@ export default function WeeklyView() {
<ChevronsLeft size={16} className="rotate-180" /> <ChevronsLeft size={16} className="rotate-180" />
</button> </button>
</div> </div>
</div> </div>}
{/* All-Day Events Section (below position) */} {/* All-Day Events Section (below position) */}
{allDayPosition === "below" && allDaySection} {allDayPosition === "below" && allDaySection}
@ -7038,6 +7250,7 @@ export default function WeeklyView() {
showTaskCheckboxes={profile.showTaskCheckboxes} showTaskCheckboxes={profile.showTaskCheckboxes}
projects={projects} projects={projects}
onProjectAssign={assignProject} onProjectAssign={assignProject}
kanbanStages={kanbanStages}
/> />
) : ( ) : (
activeAddSlot?.listId === list.id && activeAddSlot?.slotIdx === slot.index && ( activeAddSlot?.listId === list.id && activeAddSlot?.slotIdx === slot.index && (
@ -7108,6 +7321,7 @@ export default function WeeklyView() {
showTaskCheckboxes={profile.showTaskCheckboxes} showTaskCheckboxes={profile.showTaskCheckboxes}
projects={projects} projects={projects}
onProjectAssign={assignProject} onProjectAssign={assignProject}
kanbanStages={kanbanStages}
/> />
</div> </div>
))} ))}
@ -7569,6 +7783,8 @@ export default function WeeklyView() {
setCurrentWeekStart={setCurrentWeekStart} setCurrentWeekStart={setCurrentWeekStart}
projects={projects} projects={projects}
onProjectsChanged={fetchProjects} onProjectsChanged={fetchProjects}
kanbanStages={kanbanStages}
saveKanbanStages={saveKanbanStages}
/> />
) )
} }
@ -7849,6 +8065,7 @@ interface TaskItemProps {
showTaskCheckboxes?: boolean; showTaskCheckboxes?: boolean;
projects?: { id: string; name: string; icon?: string | null; color?: string | null }[]; projects?: { id: string; name: string; icon?: string | null; color?: string | null }[];
onProjectAssign?: (taskId: string, projectId: string | null) => void; onProjectAssign?: (taskId: string, projectId: string | null) => void;
kanbanStages?: KanbanStage[];
} }
function TaskItem({ function TaskItem({
@ -7875,6 +8092,7 @@ function TaskItem({
showTaskCheckboxes = false, showTaskCheckboxes = false,
projects = [], projects = [],
onProjectAssign, onProjectAssign,
kanbanStages = [],
}: TaskItemProps) { }: TaskItemProps) {
const [editValue, setEditValue] = useState(task.title); const [editValue, setEditValue] = useState(task.title);
const [isNotesOpen, setIsNotesOpen] = useState(false); const [isNotesOpen, setIsNotesOpen] = useState(false);
@ -7994,7 +8212,12 @@ function TaskItem({
<li <li
ref={taskItemRef} ref={taskItemRef}
className={`weekly-task-item ${variant} ${task.completed ? "completed" : ""} ${task.completed && showTaskCheckboxes ? "completed-with-checkbox" : ""} ${isSomeday ? "relative mx-2 w-full" : ""} ${touchActive ? "touch-active" : ""} ${swipeX !== 0 ? "task-swipe-container" : ""}`} className={`weekly-task-item ${variant} ${task.completed ? "completed" : ""} ${task.completed && showTaskCheckboxes ? "completed-with-checkbox" : ""} ${isSomeday ? "relative mx-2 w-full" : ""} ${touchActive ? "touch-active" : ""} ${swipeX !== 0 ? "task-swipe-container" : ""}`}
style={task.project?.color ? { borderLeft: `3px solid ${task.project.color}`, paddingLeft: "6px" } : undefined} style={(() => {
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} draggable={!isEditing && !isNotesOpen && swipeX === 0}
onDragStart={(e) => { onDragStart={(e) => {
// If dragging a subtask, don't drag the parent // If dragging a subtask, don't drag the parent
@ -8862,6 +9085,8 @@ interface SettingsSidebarProps {
initialTab?: "calendar" | "general" | "account" | "styling" | "motivation" | "about"; initialTab?: "calendar" | "general" | "account" | "styling" | "motivation" | "about";
projects: { id: string; name: string; icon?: string | null; color?: string | null }[]; projects: { id: string; name: string; icon?: string | null; color?: string | null }[];
onProjectsChanged: () => void; onProjectsChanged: () => void;
kanbanStages: KanbanStage[];
saveKanbanStages: (stages: KanbanStage[]) => Promise<void>;
} }
// Notes Sidebar Component // Notes Sidebar Component
interface NotesSidebarProps { interface NotesSidebarProps {
@ -9080,6 +9305,8 @@ function SettingsSidebar({
setCurrentWeekStart, setCurrentWeekStart,
projects, projects,
onProjectsChanged, onProjectsChanged,
kanbanStages,
saveKanbanStages,
}: SettingsSidebarProps) { }: SettingsSidebarProps) {
const [activeTab, setActiveTab] = useState< const [activeTab, setActiveTab] = useState<
"calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation" "calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation"
@ -10324,9 +10551,74 @@ function SettingsSidebar({
> >
{t.listView} {t.listView}
</button> </button>
<button
onClick={() => {
setViewStyle("kanban");
saveSetting("viewStyle", "kanban");
}}
className={`px-4 py-2 text-sm rounded transition-colors ${viewStyle === "kanban" ? "bg-white shadow-sm font-bold text-black" : "text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700"}`}
>
{t.kanbanView}
</button>
</div> </div>
</div> </div>
{/* Kanban Stages Settings */}
<div style={{ marginTop: "24px", borderTop: "1px solid var(--border-color, #e5e7eb)", paddingTop: "16px" }}>
<h4 style={{ fontSize: "0.95rem", fontWeight: 700, marginBottom: "8px", display: "flex", alignItems: "center", gap: "6px" }}>
<LayoutGrid size={16} /> {t.kanbanStages}
</h4>
<p style={{ fontSize: "0.8rem", color: "#888", marginBottom: "12px" }}>{t.kanbanStagesDesc}</p>
<div style={{ display: "flex", flexDirection: "column", gap: "6px", marginBottom: "12px" }}>
{kanbanStages.map((stage, idx) => (
<div key={stage.id} style={{ display: "flex", alignItems: "center", gap: "8px", padding: "4px 8px", borderRadius: "6px", background: "var(--bg-secondary, #f9fafb)" }}>
<input
type="color"
value={stage.color}
onChange={(e) => {
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 }}
/>
<input
type="text"
defaultValue={stage.name}
onBlur={(e) => {
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" }}
/>
<button
onClick={() => {
const updated = kanbanStages.filter((_, i) => i !== idx);
saveKanbanStages(updated);
}}
style={{ padding: "2px", opacity: 0.5, cursor: "pointer", color: "#ef4444", background: "none", border: "none" }}
title="Delete"
>
<X size={14} />
</button>
</div>
))}
</div>
<button
onClick={() => {
const id = `stage-${Date.now()}`;
saveKanbanStages([...kanbanStages, { id, name: t.stageName, color: "#6b7280" }]);
}}
style={{
display: "flex", alignItems: "center", gap: "4px",
background: "none", border: "1px dashed #ccc", borderRadius: "6px",
padding: "6px 12px", cursor: "pointer", color: "#888", fontSize: "0.85rem",
}}
>
<Plus size={14} /> {t.addStage}
</button>
</div>
{/* Projects Section */} {/* Projects Section */}
<div style={{ marginTop: "24px", borderTop: "1px solid var(--border-color, #e5e7eb)", paddingTop: "16px" }}> <div style={{ marginTop: "24px", borderTop: "1px solid var(--border-color, #e5e7eb)", paddingTop: "16px" }}>