refactor: restliche fuenf Fremdkomponenten aus WeeklyView.tsx herausloesen
Some checks are pending
Playwright Tests / test (push) Waiting to run
Test and SonarQube / quality (push) Waiting to run

TaskInput, SomedayAddTask, TaskItem (ueber 900 Zeilen), ListVisualsPopover
und TabVisualsPopover liegen jetzt unter src/components/weekly/. Dazu die
Prioritaets-Abzeichen: GridTaskBlock importierte getPriorityBadge aus
WeeklyView.tsx und zog sich damit die gesamte Wochenansicht in seinen
Abhaengigkeitsgraphen.

WeeklyView.tsx: 11.148 -> 9.711 Zeilen, erstmals unter 10.000. Gegenueber
dem Stand vor dem Audit (11.906) sind das 2.195 Zeilen oder 18 Prozent.

Der Typecheck hat den Schnitt in mehreren 19-Sekunden-Runden gefuehrt und
dabei zwei Dinge gefunden, die sonst erst im Browser aufgeschlagen waeren:
ein doppeltes export-Schluesselwort und -- feiner -- dass DragEvent in
WeeklyView aus React stammt, in den neuen Dateien ohne Import aber das
globale DOM-DragEvent gewesen waere.

Verifiziert: Typecheck 0 Fehler, npm run build Exit 0, 63 Unit-Tests gruen.

v1.119.2

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-07 18:55:54 +02:00
parent 7db4dfbf47
commit e8198190fd
10 changed files with 1548 additions and 1455 deletions

View File

@ -2,6 +2,21 @@
All notable changes to this project are documented in this file. All notable changes to this project are documented in this file.
## [1.119.2] - 2026-09-07
### Changed
- Die restlichen fünf Fremdkomponenten aus `WeeklyView.tsx` liegen jetzt unter
`src/components/weekly/`: `TaskInput`, `SomedayAddTask`, `TaskItem` (mit über 900 Zeilen
die grösste), `ListVisualsPopover` und `TabVisualsPopover`.
- Die Prioritäts-Abzeichen sind nach `weekly/priority-badge.tsx` gewandert. `GridTaskBlock`
importierte `getPriorityBadge` aus `WeeklyView.tsx` und zog sich damit die gesamte
Wochenansicht in seinen Abhängigkeitsgraphen; `WeeklyView` re-exportiert die Funktion,
bestehende Importe bleiben gültig.
- **`WeeklyView.tsx`: 11.148 → 9.711 Zeilen** — erstmals unter 10.000. Gegenüber dem Stand
vor dem Audit (11.906) sind das 2.195 Zeilen oder 18 Prozent.
Verifiziert: Typecheck 0 Fehler, `npm run build` Exit 0, 63 Unit-Tests grün.
## [1.119.1] - 2026-09-07 ## [1.119.1] - 2026-09-07
### Changed ### Changed

18
TODO.md
View File

@ -136,7 +136,7 @@ Betroffen sind genau zwei Dateien:
| Datei | Zeilen | | Datei | Zeilen |
|---|---:| |---|---:|
| `src/components/WeeklyView.tsx` | 11.148 *(war 11.906)* | | `src/components/WeeklyView.tsx` | **9.711** *(war 11.906)* |
| `src/components/SettingsSidebar.tsx` | 5.445 | | `src/components/SettingsSidebar.tsx` | 5.445 |
**Voraussetzung:** C1 muss laufen — ohne Typecheck ist ein Refactoring dieser Größe **Voraussetzung:** C1 muss laufen — ohne Typecheck ist ein Refactoring dieser Größe
@ -148,8 +148,15 @@ Blindflug. Ebenso hilfreich: A2, damit es überhaupt Tests gibt.
- [x] **Entdeckung:** In `WeeklyView.tsx` steckten **acht eigenständige Komponenten**, die - [x] **Entdeckung:** In `WeeklyView.tsx` steckten **acht eigenständige Komponenten**, die
gar nichts mit der Wochenansicht zu tun haben — zusammen rund 1.870 Zeilen. Drei davon gar nichts mit der Wochenansicht zu tun haben — zusammen rund 1.870 Zeilen. Drei davon
sind heraus: `ProjectIcon`, `ProjectsSidebar`, `NotesSidebar`. sind heraus: `ProjectIcon`, `ProjectsSidebar`, `NotesSidebar`.
- [ ] Restliche fünf herauslösen: `TaskInput`, `SomedayAddTask`, `TaskItem`, - [x] Restliche fünf herausgelöst: `TaskInput`, `SomedayAddTask`, `TaskItem`,
`ListVisualsPopover`, `TabVisualsPopover` `ListVisualsPopover`, `TabVisualsPopover` — dazu die Prioritäts-Abzeichen, die
`GridTaskBlock` von `WeeklyView` importierte
- [ ] Someday-Listen aus dem Haupt-JSX herauslösen (jetzt echtes Props-Design nötig)
- [ ] All-Day-Leiste herauslösen
- [ ] Zeitraster und Tagesspalten herauslösen
- [ ] Einstellungs-Flyouts herauslösen
- [ ] Drag & Drop in einen Hook (~15 Abhängigkeiten — erst sinnvoll, wenn der State
geordneter ist; ein Hook mit 15 Parametern wäre keine Verbesserung)
- [x] Datums-/Zeitraster-Helfer nach `src/lib/weekly/date-utils.ts` (13 Funktionen, 202 Zeilen) - [x] Datums-/Zeitraster-Helfer nach `src/lib/weekly/date-utils.ts` (13 Funktionen, 202 Zeilen)
- [x] Cookie-/Geräteeinstellungen nach `src/lib/weekly/device-settings.ts` - [x] Cookie-/Geräteeinstellungen nach `src/lib/weekly/device-settings.ts`
- [x] Formatierungs-Helfer nach `src/lib/weekly/format.ts` - [x] Formatierungs-Helfer nach `src/lib/weekly/format.ts`
@ -158,11 +165,6 @@ Blindflug. Ebenso hilfreich: A2, damit es überhaupt Tests gibt.
vorher die drei Implementierungen auf Gleichheit prüfen vorher die drei Implementierungen auf Gleichheit prüfen
- [ ] `calendar-cache.ts` hat mit `getWeekStart` eine **vierte** eigene Wochenlogik — - [ ] `calendar-cache.ts` hat mit `getWeekStart` eine **vierte** eigene Wochenlogik —
ebenfalls gegen `date-utils.ts` ersetzen ebenfalls gegen `date-utils.ts` ersetzen
- [ ] Drag & Drop in einen Hook `useTaskDragDrop.ts`
- [ ] Someday-Listen in `SomedayLists.tsx`
- [ ] All-Day-Leiste in `AllDayBar.tsx`
- [ ] Zeitraster + Tagesspalten in `TimeGrid.tsx` / `DayColumn.tsx`
- [ ] Einstellungs-Flyouts in `QuickSettingsPanel.tsx`
- [ ] Daten-Laden in `useWeeklyData.ts` - [ ] Daten-Laden in `useWeeklyData.ts`
- [ ] `WeeklyView.tsx` bleibt als Orchestrator übrig - [ ] `WeeklyView.tsx` bleibt als Orchestrator übrig

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.119.1", "version": "1.119.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.119.1", "version": "1.119.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.11.1", "@auth/prisma-adapter": "^2.11.1",

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.119.1", "version": "1.119.2",
"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": {

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,134 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
/**
* Eingabezeile für die "Irgendwann"-Listen. Lag bis v1.119.2 in WeeklyView.tsx.
*/
export function SomedayAddTask({
listId,
onAdd,
onCancel,
slotIdx,
darkMode,
showProjectIcons,
showTaskCheckboxes,
}: {
listId: string;
onAdd: (title: string) => void;
onCancel?: () => void;
slotIdx?: number;
darkMode?: boolean;
showProjectIcons?: boolean;
showTaskCheckboxes?: boolean;
}) {
const [title, setTitle] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = () => {
if (title.trim()) {
onAdd(title.trim());
setTitle("");
} else if (onCancel) {
onCancel();
}
};
useEffect(() => {
if (slotIdx !== undefined && inputRef.current) {
inputRef.current.focus();
}
}, [slotIdx]);
const isBottomAddInput = slotIdx === undefined;
if (isBottomAddInput) {
// Render as a flex row that mirrors the task item layout so the input
// aligns exactly with the task text column (icon spacer + checkbox spacer + input).
return (
<li
className="weekly-task-item minimal"
style={{ margin: "0", listStyle: "none", width: "100%", padding: "4px 24px 4px 8px" }}
>
<form
onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}
style={{ display: "flex", alignItems: "center", gap: "4px", width: "100%" }}
>
{/* Icon column spacer — matches task item icon column */}
{showProjectIcons && <span style={{ width: "18px", minWidth: "18px", flexShrink: 0 }} />}
{/* Checkbox spacer — matches task item checkbox column */}
{showTaskCheckboxes && <span style={{ width: "16px", minWidth: "16px", flexShrink: 0 }} />}
<input
ref={inputRef}
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={handleSubmit}
onKeyDown={(e) => {
if (e.key === "Escape") {
setTitle("");
if (onCancel) onCancel();
else e.currentTarget.blur();
}
}}
className="weekly-task-text"
style={{
flex: 1,
border: `1px solid ${darkMode ? "#374151" : "#e5e7eb"}`,
background: darkMode ? "#1f2937" : "#ffffff",
color: darkMode ? "#f9fafb" : "#111827",
padding: "4px 8px",
borderRadius: "4px",
fontSize: "0.9375rem",
outline: "none",
height: "26px",
boxSizing: "border-box",
}}
placeholder="Add task..."
data-someday-add-input={listId}
/>
</form>
</li>
);
}
// Inline slot editor: transparent, no border
return (
<li
className="weekly-task-item minimal"
style={{ margin: "0", listStyle: "none", width: "100%" }}
>
<form
onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}
style={{ width: "100%" }}
>
<input
ref={inputRef}
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={handleSubmit}
onKeyDown={(e) => {
if (e.key === "Escape") {
setTitle("");
if (onCancel) onCancel();
else e.currentTarget.blur();
}
}}
className="weekly-task-text"
style={{
width: "100%",
border: "none",
background: "transparent",
padding: "0",
fontSize: "0.9375rem",
outline: "none",
height: "auto",
display: "block",
}}
/>
</form>
</li>
);
}

View File

@ -0,0 +1,58 @@
"use client";
import React, { useState, DragEvent } from "react";
/**
* Eingabezeile zum Anlegen einer Aufgabe. Lag bis v1.119.2 in WeeklyView.tsx.
*/
// Task Input Component
interface TaskInputProps {
onAddTask: (title: string) => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => void;
}
export function TaskInput({ onAddTask, onDragOver, onDrop }: TaskInputProps) {
const [newTaskTitle, setNewTaskTitle] = useState("");
const handleAddTask = () => {
if (newTaskTitle.trim()) {
onAddTask(newTaskTitle);
setNewTaskTitle("");
}
};
return (
<div
className="weekly-task-input"
onDragOver={onDragOver}
onDrop={onDrop}
>
<textarea
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleAddTask();
}
}}
placeholder="Type a to-do..."
rows={newTaskTitle.split("\n").length || 1}
style={{
resize: "none",
overflow: "hidden",
fontFamily: "inherit",
lineHeight: "inherit",
width: "100%",
border: "none",
background: "transparent",
outline: "none",
padding: "inherit",
fontSize: "inherit",
}}
/>
</div>
);
}

View File

@ -0,0 +1,973 @@
"use client";
import React, { useState, useEffect, useRef, DragEvent } from "react";
import dynamic from "next/dynamic";
import { Bot, ChevronDown, Circle, User, X } from "lucide-react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faApple, faGoogle, faMicrosoft, faNotion } from "@fortawesome/free-brands-svg-icons";
import { faServer } from "@fortawesome/free-solid-svg-icons";
import type { Task, SomedayList, KanbanStage } from "@/lib/weekly/types";
import { getPriorityBadge } from "./priority-badge";
import { ProjectIcon } from "./ProjectIcon";
const RichTextEditor = dynamic(() => import("../RichTextEditor"), { ssr: false });
/**
* Eine Aufgabe in der Listendarstellung. Mit über 900 Zeilen die grösste der acht
* Komponenten, die in WeeklyView.tsx steckten.
*/
// Task Item Component
interface TaskItemProps {
task: Task;
isEditing: boolean;
onToggle: () => void;
onEdit: () => void;
onUpdate: (title: string) => void;
onDelete: () => void;
onNotes: (notes: string) => void;
onUrl?: (url: string) => void;
onRollToggle: () => void;
onRecurrence: () => void;
onDragStart: (e: DragEvent, task: Task) => void;
onDragEnd: () => void;
variant?: "default" | "minimal";
isSomeday?: boolean;
onAddSubTask?: (parentId: string, title: string) => void;
onToggleSubTask?: (subTaskId: string) => void;
onDeleteSubTask?: (subTaskId: string) => void;
onUpdateSubTask?: (subTaskId: string, title: string) => void;
editingTaskId?: string | null;
onSetEditingTaskId?: (id: string | null) => void;
isSubTask?: boolean;
showTaskCheckboxes?: boolean;
showProjectIcons?: boolean;
showPriorityIcons?: boolean;
priorityStyle?: string;
projects?: { id: string; name: string; icon?: string | null; color?: string | null }[];
onProjectAssign?: (taskId: string, projectId: string | null) => void;
kanbanStages?: KanbanStage[];
}
export function TaskItem({
task,
isEditing,
onToggle,
onEdit,
onUpdate,
onDelete,
onNotes,
onUrl,
onRollToggle,
onRecurrence,
onDragStart,
onDragEnd,
variant = "default",
isSomeday = false,
onAddSubTask,
onToggleSubTask,
onDeleteSubTask,
onUpdateSubTask,
editingTaskId,
onSetEditingTaskId,
isSubTask = false,
showTaskCheckboxes = false,
showProjectIcons = false,
showPriorityIcons = true,
priorityStyle = "eisenhower",
projects = [],
onProjectAssign,
kanbanStages = [],
}: TaskItemProps) {
const [editValue, setEditValue] = useState(task.title);
const [isNotesOpen, setIsNotesOpen] = useState(false);
const [notesValue, setNotesValue] = useState(task.markdownContent || "");
const [isUrlOpen, setIsUrlOpen] = useState(false);
const [urlValue, setUrlValue] = useState(task.url || "");
const urlInputRef = useRef<HTMLInputElement>(null);
const [isSubTaskInputOpen, setIsSubTaskInputOpen] = useState(false);
const [isSubTasksOpen, setIsSubTasksOpen] = useState(false);
const [newSubTaskTitle, setNewSubTaskTitle] = useState("");
const [showProjectPicker, setShowProjectPicker] = useState(false);
const projectPickerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const subTaskInputRef = useRef<HTMLInputElement>(null);
// Touch: tap-to-reveal actions
const [touchActive, setTouchActive] = useState(false);
const taskItemRef = useRef<HTMLLIElement>(null);
// Touch: swipe gesture state
const [swipeX, setSwipeX] = useState(0);
const swipeTouchStart = useRef({ x: 0, y: 0, swiping: false });
// Close touch-active on outside click
useEffect(() => {
if (!touchActive) return;
const handler = (e: Event) => {
if (taskItemRef.current && !taskItemRef.current.contains(e.target as Node)) {
setTouchActive(false);
}
};
document.addEventListener("touchstart", handler);
document.addEventListener("mousedown", handler);
return () => {
document.removeEventListener("touchstart", handler);
document.removeEventListener("mousedown", handler);
};
}, [touchActive]);
// Close project picker on outside click
useEffect(() => {
if (!showProjectPicker) return;
const handler = (e: Event) => {
if (projectPickerRef.current && !projectPickerRef.current.contains(e.target as Node)) {
setShowProjectPicker(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [showProjectPicker]);
const needsSync = task.externalProvider && (
!task.externalId ||
!task.lastSyncedAt ||
new Date(task.updatedAt) > new Date(task.lastSyncedAt)
);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
// Focus URL input when opened; sync when task.url changes
useEffect(() => { if (isUrlOpen) setTimeout(() => urlInputRef.current?.focus(), 50); }, [isUrlOpen]);
useEffect(() => { setUrlValue(task.url || ""); }, [task.url]);
const handleUrlSave = (val: string) => {
setIsUrlOpen(false);
if (val.trim() !== (task.url || "") && onUrl) {
onUrl(val.trim());
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onUpdate(editValue);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
(e.target as HTMLElement).blur();
}
if (e.key === "Escape") {
setEditValue(task.title);
onUpdate(task.title);
}
};
return (
<li
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" : ""} ${showProjectPicker ? "picker-open" : ""} ${swipeX !== 0 ? "task-swipe-container" : ""}`}
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}
onDragStart={(e) => {
// If dragging a subtask, don't drag the parent
const target = e.target as HTMLElement;
if (target.closest('.subtask-list')) {
e.stopPropagation();
return;
}
onDragStart(e as unknown as DragEvent, task);
}}
onDragEnd={onDragEnd}
onClick={(e) => {
// Touch: toggle action toolbar on tap
if (window.matchMedia("(pointer: coarse)").matches && !isEditing) {
const target = e.target as HTMLElement;
if (target.closest(".task-actions") || target.closest("button")) return;
setTouchActive(!touchActive);
return;
}
if ((variant === "minimal" || isSomeday) && !isEditing) {
const target = e.target as HTMLElement;
if (
target.tagName === "BUTTON" ||
target.tagName === "INPUT" ||
target.closest("button")
)
return;
onEdit();
}
}}
onTouchStart={() => {
// No task-level swipe — container handles day navigation
}}
>
{/* Swipe indicators */}
{swipeX > 20 && (
<div className="task-swipe-indicator complete" style={{ width: Math.abs(swipeX) }}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12" /></svg>
</div>
)}
{swipeX < -20 && (
<div className="task-swipe-indicator delete" style={{ width: Math.abs(swipeX) }}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
</div>
)}
<div style={{ width: "100%", position: "relative", transform: swipeX !== 0 ? `translateX(${swipeX}px)` : undefined, transition: swipeX === 0 ? "transform 0.2s ease" : "none", background: "inherit" }}>
{/* Visual Indicator for Rolling Tasks */}
{task.isRolling && !task.completed && !isSomeday && (
<div className="rolling-icon-indicator" title="Auto-rolling task">
<svg
viewBox="0 0 24 24"
width="10"
height="10"
stroke="currentColor"
strokeWidth="3"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="23 4 23 10 17 10"></polyline>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
</svg>
</div>
)}
<div
style={{
display: "flex",
alignItems: isSomeday ? "center" : "flex-start",
gap: isSomeday ? "4px" : "0.5rem",
width: "100%",
}}
>
{isEditing ? (
<>
{/* Someday: match icon + checkbox column widths so textarea aligns with task text */}
{isSomeday && showProjectIcons && <span style={{ width: "18px", minWidth: "18px", flexShrink: 0 }} />}
{isSomeday && showTaskCheckboxes && <span style={{ width: "16px", minWidth: "16px", flexShrink: 0 }} />}
<form onSubmit={handleSubmit} style={{ flex: 1, display: "flex" }}>
{variant === "minimal" ? (
<textarea
ref={inputRef}
className="weekly-task-text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => onUpdate(editValue)}
rows={editValue.split("\n").length || 1}
style={{
border: "none",
background: "transparent",
outline: "none",
width: "100%",
padding: "0",
resize: "none",
overflow: "hidden",
fontFamily: "inherit",
fontSize: "inherit",
fontWeight: "inherit",
lineHeight: "inherit",
}}
/>
) : (
<textarea
ref={inputRef}
className="weekly-task-text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => onUpdate(editValue)}
rows={editValue.split("\n").length || 1}
style={{
resize: "none",
overflow: "hidden",
fontFamily: "inherit",
lineHeight: "inherit",
}}
/>
)}
</form>
</>
) : (
<>
{/* Someday: fixed-width icon column — project icon, priority badge, delegation, or empty placeholder */}
{isSomeday && (
<span style={{ width: "18px", minWidth: "18px", flexShrink: 0, display: "inline-flex", alignItems: "center", justifyContent: "center" }}>
{showProjectIcons && task.project
? <ProjectIcon icon={task.project.icon} size={13} color={task.project.color || "#888"} />
: (() => {
if (showPriorityIcons) { const pb = getPriorityBadge(task, priorityStyle, 11); if (pb) return pb.node; }
if (task.delegatedTo) return task.delegatedTo === "AI" ? <Bot size={11} color="#8b5cf6" /> : <User size={11} color="#8b5cf6" />;
return null;
})()
}
</span>
)}
{/* Someday: checkbox outside text span so it aligns in its own column */}
{isSomeday && showTaskCheckboxes && (
<input
type="checkbox"
checked={task.completed}
onChange={(e) => { e.stopPropagation(); onToggle(); }}
onClick={(e) => e.stopPropagation()}
className="task-checkbox flex-shrink-0"
style={{ width: "16px", height: "16px", margin: 0, cursor: "pointer", flexShrink: 0, accentColor: "var(--weekly-teal, #009a9a)", WebkitAppearance: "checkbox" }}
/>
)}
<span
className={`weekly-task-text flex-1 ${task.completed ? "completed" : ""}`}
onClick={(e) => {
if (variant === "default" && !showTaskCheckboxes) onToggle();
// For minimal/someday, parent onClick handles edit
}}
onDoubleClick={variant === "default" ? onEdit : undefined}
style={
variant === "minimal" || isSomeday
? { display: "flex", alignItems: "center", gap: "4px", ...(task.completed && showTaskCheckboxes ? { opacity: 0.5 } : {}) }
: { display: "flex", alignItems: "center", gap: "6px", ...(task.completed && showTaskCheckboxes ? { opacity: 0.5 } : {}) }
}
>
{/* Non-someday: checkbox stays inside text span */}
{!isSomeday && showTaskCheckboxes && (
<input
type="checkbox"
checked={task.completed}
onChange={(e) => { e.stopPropagation(); onToggle(); }}
onClick={(e) => e.stopPropagation()}
className="task-checkbox flex-shrink-0"
style={{
width: "16px",
height: "16px",
margin: 0,
cursor: "pointer",
position: "relative",
top: "3px",
left: "-2px",
accentColor: "var(--weekly-teal, #009a9a)",
WebkitAppearance: "checkbox"
}}
/>
)}
<span
style={{
whiteSpace: "pre-wrap",
wordBreak: "break-word",
overflow: "visible",
flex: 1,
}}
>
{/* Non-someday only: priority/delegation icons inline. Someday shows them in the leading column. */}
{!isSomeday && showPriorityIcons && (() => {
const pb = getPriorityBadge(task, priorityStyle, 11);
return pb ? (
<span title={pb.label} style={{ marginRight: "3px", verticalAlign: "middle", display: "inline-flex", alignItems: "center" }}>
{pb.node}
</span>
) : null;
})()}
{!isSomeday && task.delegatedTo && (
<span
title={task.delegatedTo === "AI" ? `AI: ${task.delegationNote || ""}` : task.delegatedTo}
style={{ marginRight: "3px", verticalAlign: "middle", display: "inline-flex", alignItems: "center" }}
>
{task.delegatedTo === "AI"
? <Bot size={11} color="#8b5cf6" />
: <User size={11} color="#8b5cf6" />}
</span>
)}
{/* Non-someday: inline project icon; someday uses the column above */}
{showProjectIcons && task.project && !isSomeday && (
<span style={{ marginRight: "4px", verticalAlign: "middle" }}>
<ProjectIcon icon={task.project.icon} size={13} color={task.project.color || "#888"} />
</span>
)}
{task.title}
</span>
{(() => {
const provider = task.externalProvider
|| (task.externalId?.startsWith("synology::") ? "synology" : null);
if (!provider) return null;
const iconMap: Record<string, { icon: any; color: string; label: string }> = {
google: { icon: faGoogle, color: "#4285F4", label: "Google" },
outlook: { icon: faMicrosoft, color: "#0078D4", label: "Microsoft" },
apple: { icon: faApple, color: "#555", label: "Apple" },
synology: { icon: faServer, color: "#007AFF", label: "Synology" },
notion: { icon: faNotion, color: "#000000", label: "Notion" },
};
const info = iconMap[provider];
if (!info) return null;
return (
<span
className="flex-shrink-0"
title={`Synced with ${info.label}`}
style={{ display: "inline-flex", alignItems: "center", opacity: 0.55, paddingLeft: "4px" }}
>
<FontAwesomeIcon icon={info.icon} style={{ width: 12, height: 12, color: info.color }} />
</span>
);
})()}
</span>
{/* Subtask indicator - toggles subtask list */}
{task.subTasks && task.subTasks.length > 0 && !isSubTask && (() => {
const completed = task.subTasks!.filter(s => s.completed).length;
const total = task.subTasks!.length;
const allDone = completed === total;
const expanded = isSubTasksOpen || isSubTaskInputOpen;
const pct = total > 0 ? (completed / total) * 100 : 0;
return (
<button
onClick={(e) => {
e.stopPropagation();
setIsSubTasksOpen(!isSubTasksOpen);
}}
className="subtask-indicator-badge"
title={expanded ? "Collapse subtasks" : `${completed}/${total} subtasks done`}
style={{
display: "inline-flex",
alignItems: "center",
gap: "4px",
padding: "2px 8px 2px 4px",
borderRadius: "12px",
background: expanded ? "rgba(99,102,241,0.08)" : "transparent",
color: allDone ? "var(--weekly-teal, #0d9488)" : "var(--weekly-text-light, #9ca3af)",
border: "none",
cursor: "pointer",
fontSize: "0.7rem",
fontWeight: 600,
lineHeight: 1,
flexShrink: 0,
whiteSpace: "nowrap",
transition: "all 0.15s",
}}
>
<ChevronDown size={12} style={{ transform: expanded ? "rotate(0deg)" : "rotate(-90deg)", transition: "transform 0.2s", flexShrink: 0 }} />
<span style={{
display: "inline-block",
width: "36px",
height: "5px",
borderRadius: "3px",
background: "var(--weekly-border, #e5e7eb)",
position: "relative",
overflow: "hidden",
flexShrink: 0,
}}>
<span style={{
position: "absolute",
left: 0,
top: 0,
height: "100%",
width: `${pct}%`,
borderRadius: "3px",
background: allDone ? "var(--weekly-teal, #0d9488)" : "var(--weekly-accent, #6366f1)",
transition: "width 0.3s ease",
}} />
</span>
<span style={{ opacity: 0.8 }}>{completed}/{total}</span>
</button>
);
})()}
{/* URL indicator */}
{task.url && (
<a
href={task.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
title={task.url}
style={{
display: "inline-flex", alignItems: "center",
padding: "2px 5px", borderRadius: "4px",
background: "rgba(37,99,235,0.10)", color: "#2563eb",
textDecoration: "none", flexShrink: 0,
}}
>
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
</a>
)}
{/* Note indicator - toggles inline notes */}
{task.markdownContent && task.markdownContent.trim().length > 0 && (
<button
onClick={(e) => {
e.stopPropagation();
setIsNotesOpen(!isNotesOpen);
}}
className="focus:outline-none flex-shrink-0"
title={isNotesOpen ? "Collapse note" : "Expand note"}
style={{
display: "inline-flex",
alignItems: "center",
gap: "2px",
padding: "2px 5px",
borderRadius: "4px",
background: isNotesOpen ? "rgba(245, 158, 11, 0.1)" : "transparent",
color: isNotesOpen ? "#f59e0b" : "var(--weekly-text-muted, #999)",
border: "none",
cursor: "pointer",
lineHeight: 1,
transition: "background 0.15s, color 0.15s",
}}
>
<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>
<svg viewBox="0 0 24 24" width="8" height="8" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ transform: isNotesOpen ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.2s" }}>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
)}
<div className="task-actions z-20">
{/* Complete */}
<button
className={`task-action-btn ${task.completed ? "active text-green-600 dark:text-green-500" : ""}`}
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
title={task.completed ? "Mark incomplete" : "Mark complete"}
>
<svg
viewBox="0 0 24 24"
width="12"
height="12"
stroke="currentColor"
strokeWidth="3"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
{/* Edit */}
<button
className="task-action-btn"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
title="Edit"
>
<svg
viewBox="0 0 24 24"
width="12"
height="12"
stroke="currentColor"
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
{/* Add Sub-task */}
{!isSubTask && onAddSubTask && (
<button
className={`task-action-btn ${isSubTaskInputOpen ? "active" : ""}`}
onClick={(e) => {
e.stopPropagation();
setIsSubTaskInputOpen(!isSubTaskInputOpen);
if (!isSubTaskInputOpen) {
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"
>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
)}
{/* Recurrence (hidden for someday tasks) */}
{!isSomeday && (
<button
className={`task-action-btn ${task.isRecurring ? "active" : ""}`}
onClick={(e) => {
e.stopPropagation();
onRecurrence();
}}
title={
task.isRecurring ? "Edit recurrence" : "Make recurring"
}
>
<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>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
</svg>
</button>
)}
{/* Notes */}
<button
className={`task-action-btn ${isNotesOpen || (task.markdownContent && task.markdownContent.trim().length > 0) ? "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"
>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
{/* Project Assignment */}
{!isSubTask && projects.length > 0 && onProjectAssign && (
<div className="relative" ref={projectPickerRef}>
<button
className={`task-action-btn ${task.project ? "active" : ""}`}
onClick={(e) => {
e.stopPropagation();
setShowProjectPicker(!showProjectPicker);
}}
title={task.project ? task.project.name : "Assign project"}
>
{task.project?.icon ? (
<span style={{ fontSize: "12px", lineHeight: 1 }}>{task.project.icon}</span>
) : (
<Circle
size={12}
fill={task.project?.color || "none"}
stroke={task.project?.color || "currentColor"}
strokeWidth={2}
/>
)}
</button>
{showProjectPicker && (
<div className="absolute z-50 top-full left-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 min-w-[140px]" style={{ whiteSpace: "nowrap" }}>
{task.projectId && (
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-500"
onClick={(e) => {
e.stopPropagation();
onProjectAssign(task.id, null);
setShowProjectPicker(false);
}}
>
<X size={10} /> Remove
</button>
)}
{projects.map((p) => (
<button
key={p.id}
className={`flex items-center gap-2 w-full px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-gray-700 ${task.projectId === p.id ? "font-bold" : ""}`}
onClick={(e) => {
e.stopPropagation();
onProjectAssign(task.id, task.projectId === p.id ? null : p.id);
setShowProjectPicker(false);
}}
>
<span style={{ fontSize: "12px" }}><ProjectIcon icon={p.icon} size={12} color={p.color || "#999"} /></span>
<Circle size={8} fill={p.color || "#999"} stroke={p.color || "#999"} strokeWidth={0} />
{p.name}
</button>
))}
</div>
)}
</div>
)}
{/* Roll Toggle - Active State Colored (hidden for someday tasks) */}
{!task.completed && !isSomeday && (
<button
className={`task-action-btn ${task.isRolling ? "active" : ""}`}
onClick={(e) => {
e.stopPropagation();
onRollToggle();
}}
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"
>
<polyline points="1 4 1 10 7 10"></polyline>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
</svg>
</button>
)}
{/* Link */}
<button
className={`task-action-btn ${task.url || isUrlOpen ? "active" : ""}`}
onClick={(e) => { e.stopPropagation(); setIsUrlOpen(!isUrlOpen); }}
title={task.url || "Add link"}
style={task.url ? { color: "#2563eb" } : {}}
>
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
</button>
{/* Delete */}
<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();
onDelete();
}}
title="Delete"
>
<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>
<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>
</button>
</div>
</>
)}
</div>
{/* URL editor */}
{isUrlOpen && (
<div className="weekly-notes-inline" onClick={(e) => e.stopPropagation()} style={{ padding: "6px 8px", display: "flex", alignItems: "center", gap: "6px" }}>
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="#2563eb" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
<input
ref={urlInputRef}
type="url"
value={urlValue}
onChange={(e) => setUrlValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); handleUrlSave(urlValue); }
if (e.key === "Escape") { setIsUrlOpen(false); setUrlValue(task.url || ""); }
}}
onBlur={() => handleUrlSave(urlValue)}
placeholder="https://..."
style={{ flex: 1, fontSize: "0.78rem", border: "1px solid var(--weekly-border, #ddd)", borderRadius: "4px", padding: "3px 6px", background: "var(--weekly-bg, white)", color: "var(--weekly-text, #333)", outline: "none" }}
/>
{urlValue && (
<button onMouseDown={(e) => { e.preventDefault(); setUrlValue(""); if (onUrl) onUrl(""); setIsUrlOpen(false); }} title="Remove link" style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: "#999", flexShrink: 0 }}>
<svg viewBox="0 0 24 24" width="12" height="12" 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>
)}
{urlValue && (
<a href={urlValue.startsWith("http") ? urlValue : `https://${urlValue}`} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()} title="Open link" style={{ color: "#2563eb", flexShrink: 0, display: "inline-flex" }}>
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</a>
)}
</div>
)}
{/* Inline Notes Editor */}
{isNotesOpen && (
<div className="weekly-notes-inline" onClick={(e) => e.stopPropagation()} style={{ padding: "0" }}>
<div style={{ display: "flex", justifyContent: "flex-end", padding: "4px 6px 0" }}>
<button onMouseDown={(e) => { e.preventDefault(); setIsNotesOpen(false); }} style={{ background: "none", border: "none", cursor: "pointer", fontSize: "1rem", lineHeight: 1, color: "#999", padding: "2px 4px" }} title="Close">×</button>
</div>
<div style={{ padding: "0 6px 8px" }}>
<RichTextEditor
value={notesValue}
onChange={(html) => { setNotesValue(html); onNotes(html); }}
placeholder="Add notes..."
minHeight="80px"
/>
</div>
</div>
)}
{/* Sub-tasks section */}
{
!isSubTask && (isSubTasksOpen || isSubTaskInputOpen) && task.subTasks && task.subTasks.length > 0 && (
<ul className="subtask-list" onClick={(e) => e.stopPropagation()} style={task.project?.color ? { borderLeft: `2px solid ${task.project.color}`, marginLeft: "2px" } : undefined}>
{task.subTasks.map((subTask) => (
<li
key={subTask.id}
className={`subtask-item ${subTask.completed ? "completed" : ""}`}
draggable
onDragStart={(e) => {
e.stopPropagation();
if (onDragStart) {
onDragStart(e as any, { ...subTask, parentTaskId: task.id, scheduledDate: task.scheduledDate } as any);
}
}}
onDragEnd={(e) => { e.stopPropagation(); onDragEnd?.(); }}
>
<button
className="subtask-checkbox"
onClick={() => onToggleSubTask?.(subTask.id)}
aria-label={subTask.completed ? "Mark incomplete" : "Mark complete"}
>
{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" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
</svg>
)}
</button>
{editingTaskId === subTask.id ? (
<form
onSubmit={(e) => {
e.preventDefault();
const input = e.currentTarget.querySelector("input");
if (input) {
onUpdateSubTask?.(subTask.id, input.value);
onSetEditingTaskId?.(null);
}
}}
style={{ flex: 1 }}
>
<input
type="text"
defaultValue={subTask.title}
autoFocus
className="subtask-edit-input"
onBlur={(e) => {
onUpdateSubTask?.(subTask.id, e.target.value);
onSetEditingTaskId?.(null);
}}
onKeyDown={(e) => {
if (e.key === "Escape") onSetEditingTaskId?.(null);
}}
/>
</form>
) : (
<span
className={`subtask-title ${subTask.completed ? "completed" : ""}`}
onClick={() => onSetEditingTaskId?.(subTask.id)}
>
{subTask.title}
</span>
)}
<button
className="subtask-delete-btn"
onClick={() => onDeleteSubTask?.(subTask.id)}
title="Remove sub-task"
>
<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>
))}
</ul>
)
}
{/* Add sub-task input */}
{
!isSubTask && isSubTaskInputOpen && (
<div className="subtask-add-row" onClick={(e) => e.stopPropagation()}>
<form
onSubmit={(e) => {
e.preventDefault();
if (newSubTaskTitle.trim()) {
onAddSubTask?.(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>
<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>
</div>
)
}
</div >
</li >
);
}

View File

@ -0,0 +1,251 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import dynamic from "next/dynamic";
import { X, Trash2 } from "lucide-react";
import { ProjectIcon } from "./ProjectIcon";
const IconPicker = dynamic(() => import("../IconPicker"), { ssr: false });
/**
* Auswahl von Farbe und Symbol für Listen und Reiter der "Irgendwann"-Ansicht.
* Lagen bis v1.119.2 in WeeklyView.tsx.
*/
// Punkt 7+8 — popover for editing a someday list's icon and color.
export function ListVisualsPopover({
list,
darkMode,
language,
onChange,
onClose,
anchorRect,
}: {
list: { id: string; title: string; color?: string | null; icon?: string | null };
darkMode: boolean;
language: string;
anchorRect?: DOMRect | null;
onChange: (updates: { color?: string | null; icon?: string | null }) => void;
onClose: () => void;
}) {
const [iconPickerOpen, setIconPickerOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const onCloseRef = useRef(onClose);
useEffect(() => { onCloseRef.current = onClose; }, [onClose]);
useEffect(() => {
const onDoc = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) onCloseRef.current();
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, []);
const de = language === "de";
const top = anchorRect ? anchorRect.top + 32 : -9999;
const left = anchorRect ? anchorRect.left + 4 : -9999;
return (
<div
ref={containerRef}
style={{
position: "fixed",
top,
left,
zIndex: 10000,
background: darkMode ? "#1e1e2e" : "#fff",
border: `1px solid ${darkMode ? "#444" : "#e5e7eb"}`,
borderRadius: "10px",
padding: "12px",
boxShadow: "0 8px 24px rgba(0,0,0,0.18)",
minWidth: "240px",
display: "flex",
flexDirection: "column",
gap: "10px",
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span style={{ fontSize: "0.78rem", fontWeight: 600, color: darkMode ? "#d1d5db" : "#444" }}>
{de ? "Listen-Stil" : "List style"}
</span>
<button onClick={onClose} style={{ background: "none", border: "none", cursor: "pointer", color: darkMode ? "#9ca3af" : "#888", padding: "2px" }}>
<X size={14} />
</button>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span style={{ fontSize: "0.75rem", color: darkMode ? "#9ca3af" : "#666", flex: 1 }}>{de ? "Icon" : "Icon"}</span>
<button
onClick={() => setIconPickerOpen((v) => !v)}
style={{ width: "32px", height: "32px", borderRadius: "8px", border: `1px solid ${darkMode ? "#444" : "#e5e7eb"}`, background: darkMode ? "#2a2a3a" : "#fff", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}
>
<ProjectIcon icon={list.icon} size={16} color={list.color || (darkMode ? "#d1d5db" : "#555")} />
</button>
{list.icon && (
<button
onClick={() => onChange({ icon: null })}
title={de ? "Icon entfernen" : "Remove icon"}
style={{ background: "none", border: "none", cursor: "pointer", color: "#ef4444", padding: "2px" }}
>
<X size={12} />
</button>
)}
</div>
{iconPickerOpen && (
<div style={{ position: "fixed", top: top + 78, left, zIndex: 10001 }}>
<IconPicker
selectedIcon={list.icon || ""}
onSelect={(name) => { onChange({ icon: name }); setIconPickerOpen(false); }}
darkMode={darkMode}
/>
</div>
)}
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span style={{ fontSize: "0.75rem", color: darkMode ? "#9ca3af" : "#666", flex: 1 }}>{de ? "Farbe" : "Color"}</span>
<input
type="color"
value={list.color || "#6366f1"}
onChange={(e) => onChange({ color: e.target.value })}
style={{ width: "32px", height: "32px", border: "none", cursor: "pointer", padding: 0, borderRadius: "6px" }}
/>
{list.color && (
<button
onClick={() => onChange({ color: null })}
title={de ? "Farbe entfernen" : "Remove color"}
style={{ background: "none", border: "none", cursor: "pointer", color: "#ef4444", padding: "2px" }}
>
<X size={12} />
</button>
)}
</div>
</div>
);
}
// Punkt 7 — popover for editing a tab's icon and color (stored in viewSettings JSON).
export function TabVisualsPopover({
tabName,
visuals,
darkMode,
language,
onChange,
onRename,
onDissolve,
onClose,
anchorRect,
}: {
tabName: string;
visuals: { color?: string; icon?: string };
darkMode: boolean;
language: string;
anchorRect?: DOMRect | null;
onChange: (updates: { color?: string | null; icon?: string | null }) => void;
onRename: (newName: string) => void;
onDissolve: () => void;
onClose: () => void;
}) {
const [iconPickerOpen, setIconPickerOpen] = useState(false);
const [renameValue, setRenameValue] = useState(tabName);
const containerRef = useRef<HTMLDivElement>(null);
const onCloseRef = useRef(onClose);
useEffect(() => { onCloseRef.current = onClose; }, [onClose]);
useEffect(() => {
const onDoc = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) onCloseRef.current();
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, []);
const de = language === "de";
const labelStyle = { fontSize: "0.75rem", color: darkMode ? "#9ca3af" : "#666", flex: 1 } as const;
return (
<div
ref={containerRef}
style={{
position: "fixed",
top: anchorRect ? anchorRect.bottom + 6 : -9999,
left: anchorRect ? anchorRect.left : -9999,
zIndex: 10000,
background: darkMode ? "#1e1e2e" : "#fff",
border: `1px solid ${darkMode ? "#444" : "#e5e7eb"}`,
borderRadius: "10px",
padding: "12px",
boxShadow: "0 8px 24px rgba(0,0,0,0.18)",
minWidth: "230px",
display: "flex",
flexDirection: "column",
gap: "10px",
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span style={{ fontSize: "0.78rem", fontWeight: 700, color: darkMode ? "#d1d5db" : "#333" }}>
{de ? "Tab bearbeiten" : "Edit tab"}
</span>
<button onClick={onClose} style={{ background: "none", border: "none", cursor: "pointer", color: darkMode ? "#9ca3af" : "#888", padding: "2px" }}>
<X size={14} />
</button>
</div>
{/* Rename */}
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span style={labelStyle}>{de ? "Name" : "Name"}</span>
<input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => { if (renameValue.trim() && renameValue.trim() !== tabName) onRename(renameValue.trim()); }}
onKeyDown={(e) => { if (e.key === "Enter") { if (renameValue.trim() && renameValue.trim() !== tabName) onRename(renameValue.trim()); (e.target as HTMLInputElement).blur(); } }}
style={{ flex: 1, fontSize: "0.8rem", border: `1px solid ${darkMode ? "#444" : "#e5e7eb"}`, borderRadius: "6px", padding: "4px 8px", background: darkMode ? "#2a2a3a" : "#f9f9f9", color: darkMode ? "#e5e7eb" : "#222", outline: "none" }}
/>
</div>
{/* Icon */}
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span style={labelStyle}>{de ? "Icon" : "Icon"}</span>
<button
onClick={() => setIconPickerOpen((v) => !v)}
style={{ width: "32px", height: "32px", borderRadius: "8px", border: `1px solid ${darkMode ? "#444" : "#e5e7eb"}`, background: darkMode ? "#2a2a3a" : "#fff", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}
>
<ProjectIcon icon={visuals.icon} size={16} color={visuals.color || (darkMode ? "#d1d5db" : "#555")} />
</button>
{visuals.icon && (
<button onClick={() => onChange({ icon: null })} title={de ? "Icon entfernen" : "Remove icon"} style={{ background: "none", border: "none", cursor: "pointer", color: "#ef4444", padding: "2px" }}>
<X size={12} />
</button>
)}
</div>
{iconPickerOpen && (
<div style={{ position: "absolute", top: "110px", left: "10px", zIndex: 110 }}>
<IconPicker
selectedIcon={visuals.icon || ""}
onSelect={(name) => { onChange({ icon: name }); setIconPickerOpen(false); }}
darkMode={darkMode}
/>
</div>
)}
{/* Color */}
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span style={labelStyle}>{de ? "Farbe" : "Color"}</span>
<input
type="color"
value={visuals.color || "#6366f1"}
onChange={(e) => onChange({ color: e.target.value })}
style={{ width: "32px", height: "32px", border: "none", cursor: "pointer", padding: 0, borderRadius: "6px" }}
/>
{visuals.color && (
<button onClick={() => onChange({ color: null })} title={de ? "Farbe entfernen" : "Remove color"} style={{ background: "none", border: "none", cursor: "pointer", color: "#ef4444", padding: "2px" }}>
<X size={12} />
</button>
)}
</div>
{/* Dissolve / delete */}
<div style={{ borderTop: `1px solid ${darkMode ? "#374151" : "#e5e7eb"}`, paddingTop: "8px" }}>
<button
onClick={() => { onDissolve(); onClose(); }}
style={{ width: "100%", display: "flex", alignItems: "center", gap: "8px", padding: "6px 8px", fontSize: "0.8rem", color: "#ef4444", background: "none", border: "none", cursor: "pointer", borderRadius: "6px", textAlign: "left" }}
onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(239,68,68,0.08)")}
onMouseLeave={(e) => (e.currentTarget.style.background = "none")}
>
<Trash2 size={13} />
{de ? "Tab auflösen (Listen bleiben)" : "Dissolve tab (lists stay)"}
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,97 @@
"use client";
import React from "react";
import { Archive, CalendarClock, CornerUpRight, Star, Zap } from "lucide-react";
import type { Task } from "@/lib/weekly/types";
/**
* Prioritäts-Abzeichen für Aufgaben.
*
* Lag bis v1.119.2 in WeeklyView.tsx — obwohl GridTaskBlock `getPriorityBadge` von dort
* importierte und sich damit die gesamte Wochenansicht in seinen Abhängigkeitsgraphen zog.
*/
// ── Priority indicator helper ─────────────────────────────────────
// Renders a small priority badge for a task. The visual style is selected
// by the user's `priorityStyle` setting:
// - "eisenhower" → quadrant icons + colors (urgency × importance)
// - "abcde" → letter A–E in a colored circle
// - "ivylee" → digit 1–6 in a colored circle (rank persisted in priority)
// - "pareto" → star for the "vital few" (importance flag)
// Returns null when the task has no priority data for the active style.
type PriorityBadge = { node: React.ReactNode; label: string };
const PRIORITY_NUMBER_COLORS: Record<string, string> = {
"1": "#ef4444", "2": "#f97316", "3": "#eab308",
"4": "#22c55e", "5": "#3b82f6", "6": "#8b5cf6",
};
const PRIORITY_LETTER_COLORS: Record<string, string> = {
A: "#ef4444", B: "#3b82f6", C: "#eab308", D: "#f97316", E: "#9ca3af",
};
function makeBadgeCircle(text: string, color: string, size: number, label: string): PriorityBadge {
return {
label,
node: (
<span style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: size,
height: size,
borderRadius: "50%",
background: color,
color: "#fff",
fontSize: Math.max(8, size - 6),
fontWeight: 700,
lineHeight: 1,
flexShrink: 0,
}}>{text}</span>
),
};
}
export function getPriorityBadge(task: Task, style: string, size = 12): PriorityBadge | null {
// Universal "important" star — shown for any task explicitly marked important
// (e.g. Outlook To-Do tasks with the star flag) regardless of the active priority style.
const importantStar = (): PriorityBadge => ({
label: "Important",
node: <Star size={size + 1} fill="#eab308" color="#eab308" style={{ flexShrink: 0 }} />,
});
if (style === "abcde") {
if (task.priority && PRIORITY_LETTER_COLORS[task.priority]) {
return makeBadgeCircle(task.priority, PRIORITY_LETTER_COLORS[task.priority], size + 2, task.priority);
}
if (task.importance === true) return importantStar();
return null;
}
if (style === "ivylee") {
if (task.priority && PRIORITY_NUMBER_COLORS[task.priority]) {
return makeBadgeCircle(task.priority, PRIORITY_NUMBER_COLORS[task.priority], size + 2, `Ivy Lee #${task.priority}`);
}
if (task.importance === true) return importantStar();
return null;
}
if (style === "pareto") {
if (task.importance !== true && task.priority !== "A" && task.priority !== "B") return null;
return {
label: "Vital Few (80/20)",
node: <Star size={size + 1} fill="#eab308" color="#eab308" style={{ flexShrink: 0 }} />,
};
}
// eisenhower (default)
if (task.urgency != null && task.importance != null) {
if (task.urgency && task.importance)
return { label: "Sofort erledigen", node: <Zap size={size} color="#ef4444" style={{ flexShrink: 0 }} /> };
if (!task.urgency && task.importance)
return { label: "Planen", node: <CalendarClock size={size} color="#3b82f6" style={{ flexShrink: 0 }} /> };
if (task.urgency && !task.importance)
return { label: "Delegieren", node: <CornerUpRight size={size} color="#f97316" style={{ flexShrink: 0 }} /> };
return { label: "Eliminieren", node: <Archive size={size} color="#9ca3af" style={{ flexShrink: 0 }} /> };
}
// Eisenhower with only importance set (e.g. imported from Outlook with no urgency)
if (task.importance === true) return importantStar();
return null;
}