feat: projects tab, priority icons, list/tab visuals

Wave-1 user feedback (points 7–10):
- Settings: new Projects tab with full CRUD (name, color, icon picker)
- Priority Icons render in simple/calendar/list views with style-specific
  visuals (Eisenhower icons, ABCDE letters, Ivy Lee 1–6, Pareto star).
  Toggle + style selector in Settings; Ivy Lee ranks now persist to
  task.priority so cross-view badges stay consistent.
- Someday Lists gain optional icon (left of title) and color (left
  border tint), edited via a pencil-popup with IconPicker + color picker.
- Tabs gain optional icon and color, stored in user.viewSettings JSON
  and edited via the same popup pattern.

Schema: SomedayList.color/icon, User.showPriorityIcons/priorityStyle.

v1.99.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-05-01 14:23:56 +02:00
parent ac17ca2635
commit 52d160ccea
10 changed files with 711 additions and 51 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.97.2", "version": "1.99.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,7 @@
-- User: priority style + visibility toggle
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "showPriorityIcons" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "priorityStyle" TEXT NOT NULL DEFAULT 'eisenhower';
-- SomedayList: per-list color and icon (used by Punkt 7 + 8)
ALTER TABLE "SomedayList" ADD COLUMN IF NOT EXISTS "color" TEXT;
ALTER TABLE "SomedayList" ADD COLUMN IF NOT EXISTS "icon" TEXT;

View File

@ -93,6 +93,8 @@ model User {
yearFontWeight String? @default("700") yearFontWeight String? @default("700")
showTaskCheckboxes Boolean @default(false) showTaskCheckboxes Boolean @default(false)
showProjectIcons Boolean @default(false) showProjectIcons Boolean @default(false)
showPriorityIcons Boolean @default(true)
priorityStyle String @default("eisenhower")
weekStartDay Int @default(1) weekStartDay Int @default(1)
emailVerificationCode String? emailVerificationCode String?
dayHeaderGap String? @default("0.75em") dayHeaderGap String? @default("0.75em")
@ -226,6 +228,8 @@ model SomedayList {
title String title String
order Int @default(0) order Int @default(0)
tab String? tab String?
color String?
icon String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
externalId String? externalId String?

View File

@ -209,8 +209,8 @@ export async function PATCH(request: NextRequest) {
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} }
// Handle Single Update (Title and/or Tab) // Handle Single Update (Title, Tab, Color, Icon)
const { id, title, tab } = body; const { id, title, tab, color, icon } = body;
if (!id) { if (!id) {
return NextResponse.json( return NextResponse.json(
@ -234,6 +234,8 @@ export async function PATCH(request: NextRequest) {
const data: Record<string, any> = {}; const data: Record<string, any> = {};
if (title !== undefined) data.title = title; if (title !== undefined) data.title = title;
if (tab !== undefined) data.tab = tab; if (tab !== undefined) data.tab = tab;
if (color !== undefined) data.color = color || null;
if (icon !== undefined) data.icon = icon || null;
const list = await prisma.somedayList.update({ const list = await prisma.somedayList.update({
where: { id }, where: { id },

View File

@ -34,6 +34,8 @@ export async function GET(request: NextRequest) {
showSchedule: true, showSchedule: true,
showTaskCheckboxes: true, showTaskCheckboxes: true,
showProjectIcons: true, showProjectIcons: true,
showPriorityIcons: true,
priorityStyle: true,
cellDuration: true, cellDuration: true,
viewStyle: true, viewStyle: true,
viewDays: true, viewDays: true,
@ -150,7 +152,7 @@ export async function PATCH(request: NextRequest) {
hourLabelFormat, showSubHourSlots, allDayPosition, hourLabelFormat, showSubHourSlots, allDayPosition,
cwFontFamily, cwFontSize, cwFontWeight, cwColor, cwFontFamily, cwFontSize, cwFontWeight, cwColor,
yearFontFamily, yearFontSize, yearFontWeight, yearColor, yearFontFamily, yearFontSize, yearFontWeight, yearColor,
showTaskCheckboxes, showProjectIcons, dayHeaderGap, showTaskCheckboxes, showProjectIcons, showPriorityIcons, priorityStyle, dayHeaderGap,
showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, weekStartDay, quoteSourceUrls, quoteLanguages, showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, weekStartDay, quoteSourceUrls, quoteLanguages,
kanbanStages, lightTheme, darkTheme, notificationsEnabled, mobileFontScale, kanbanStages, lightTheme, darkTheme, notificationsEnabled, mobileFontScale,
weatherEnabled, weatherLat, weatherLon, weatherLocation, weatherRecentCities, viewSettings, weatherEnabled, weatherLat, weatherLon, weatherLocation, weatherRecentCities, viewSettings,
@ -178,6 +180,8 @@ export async function PATCH(request: NextRequest) {
...(showSchedule !== undefined && { showSchedule }), ...(showSchedule !== undefined && { showSchedule }),
...(showTaskCheckboxes !== undefined && { showTaskCheckboxes }), ...(showTaskCheckboxes !== undefined && { showTaskCheckboxes }),
...(showProjectIcons !== undefined && { showProjectIcons }), ...(showProjectIcons !== undefined && { showProjectIcons }),
...(showPriorityIcons !== undefined && { showPriorityIcons }),
...(priorityStyle !== undefined && { priorityStyle }),
...(cellDuration !== undefined && !isNaN(cellDuration) && { cellDuration }), ...(cellDuration !== undefined && !isNaN(cellDuration) && { cellDuration }),
...(viewStyle !== undefined && { viewStyle }), ...(viewStyle !== undefined && { viewStyle }),
...(viewDays !== undefined && !isNaN(viewDays) && { viewDays }), ...(viewDays !== undefined && !isNaN(viewDays) && { viewDays }),
@ -283,6 +287,8 @@ export async function PATCH(request: NextRequest) {
showSchedule: true, showSchedule: true,
showTaskCheckboxes: true, showTaskCheckboxes: true,
showProjectIcons: true, showProjectIcons: true,
showPriorityIcons: true,
priorityStyle: true,
cellDuration: true, cellDuration: true,
viewStyle: true, viewStyle: true,
viewDays: true, viewDays: true,

View File

@ -39,6 +39,8 @@ interface GridTaskBlockProps {
workingHoursStart: number; workingHoursStart: number;
showTaskCheckboxes?: boolean; showTaskCheckboxes?: boolean;
showProjectIcons?: boolean; showProjectIcons?: boolean;
showPriorityIcons?: boolean;
priorityStyle?: string;
projects?: any[]; projects?: any[];
onProjectAssign?: (taskId: string, projectId: string | null) => void; onProjectAssign?: (taskId: string, projectId: string | null) => void;
kanbanStages?: KanbanStage[]; kanbanStages?: KanbanStage[];
@ -76,6 +78,8 @@ export function GridTaskBlock({
workingHoursStart, workingHoursStart,
showTaskCheckboxes, showTaskCheckboxes,
showProjectIcons, showProjectIcons,
showPriorityIcons = true,
priorityStyle = "eisenhower",
projects, projects,
onProjectAssign, onProjectAssign,
kanbanStages = [], kanbanStages = [],

View File

@ -70,6 +70,8 @@ interface PriorityViewProps {
darkMode: boolean; darkMode: boolean;
language?: string; language?: string;
onUpdateTask: (id: string, fields: Partial<PriorityTask>) => Promise<void>; onUpdateTask: (id: string, fields: Partial<PriorityTask>) => Promise<void>;
initialMethod?: PriorityMethod;
onMethodChange?: (m: PriorityMethod) => void;
} }
const ABCDE_LABELS: Record<string, { label: string; desc: string; color: string; bg: string }> = { const ABCDE_LABELS: Record<string, { label: string; desc: string; color: string; bg: string }> = {
@ -123,8 +125,14 @@ export default function PriorityView({
darkMode, darkMode,
language = "en", language = "en",
onUpdateTask, onUpdateTask,
initialMethod,
onMethodChange,
}: PriorityViewProps) { }: PriorityViewProps) {
const [method, setMethod] = useState<PriorityMethod>("eisenhower"); const [method, setMethodState] = useState<PriorityMethod>(initialMethod || "eisenhower");
const setMethod = useCallback((m: PriorityMethod) => {
setMethodState(m);
onMethodChange?.(m);
}, [onMethodChange]);
const [filterProject, setFilterProject] = useState(""); const [filterProject, setFilterProject] = useState("");
const [filterList, setFilterList] = useState(""); const [filterList, setFilterList] = useState("");
const [filterTimespan, setFilterTimespan] = useState("all"); const [filterTimespan, setFilterTimespan] = useState("all");
@ -135,7 +143,15 @@ export default function PriorityView({
const [delegateTo, setDelegateTo] = useState(""); const [delegateTo, setDelegateTo] = useState("");
const [delegateNote, setDelegateNote] = useState(""); const [delegateNote, setDelegateNote] = useState("");
const [delegateType, setDelegateType] = useState<"person" | "ai">("person"); const [delegateType, setDelegateType] = useState<"person" | "ai">("person");
const [ivyLeeSelected, setIvyLeeSelected] = useState<Set<string>>(new Set()); const [ivyLeeSelected, setIvyLeeSelected] = useState<Set<string>>(() => {
// Hydrate from any task that already has a numeric priority "1"-"6"
const init = new Set<string>();
const ranked = tasks
.filter((t) => t.priority && /^[1-6]$/.test(t.priority))
.sort((a, b) => Number(a.priority) - Number(b.priority));
for (const t of ranked) init.add(t.id);
return init;
});
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
@ -251,12 +267,21 @@ export default function PriorityView({
const toggleIvyLee = useCallback((id: string) => { const toggleIvyLee = useCallback((id: string) => {
setIvyLeeSelected((prev) => { setIvyLeeSelected((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(id)) { next.delete(id); return next; } const removing = next.has(id);
if (next.size >= 6) return prev; // max 6 if (removing) {
next.delete(id);
onUpdateTask(id, { priority: null });
} else {
if (next.size >= 6) return prev;
next.add(id); next.add(id);
}
// Re-rank all selected tasks 1..N so cross-view badges stay consistent
Array.from(next).forEach((tid, idx) => {
onUpdateTask(tid, { priority: String(idx + 1) });
});
return next; return next;
}); });
}, []); }, [onUpdateTask]);
const openDelegate = useCallback((task: PriorityTask) => { const openDelegate = useCallback((task: PriorityTask) => {
setDelegateModal(task); setDelegateModal(task);

View File

@ -1,5 +1,5 @@
"use client"; "use client";
import { X, Type, Space, CheckSquare, Calendar, Minus } from "lucide-react"; import { X, Type, Space, CheckSquare, Calendar, Minus, Target } from "lucide-react";
interface QuickSettingsProps { interface QuickSettingsProps {
fontSize: string; fontSize: string;
@ -12,6 +12,8 @@ interface QuickSettingsProps {
onStartDayOffsetChange: (offset: number) => void; onStartDayOffsetChange: (offset: number) => void;
showLines: boolean; showLines: boolean;
onShowLinesChange: (show: boolean) => void; onShowLinesChange: (show: boolean) => void;
showPriorityIcons?: boolean;
onShowPriorityIconsChange?: (show: boolean) => void;
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
darkMode?: boolean; darkMode?: boolean;
@ -28,6 +30,8 @@ export default function QuickSettingsSidebar({
onStartDayOffsetChange, onStartDayOffsetChange,
showLines, showLines,
onShowLinesChange, onShowLinesChange,
showPriorityIcons,
onShowPriorityIconsChange,
isOpen, isOpen,
onClose, onClose,
darkMode, darkMode,
@ -198,6 +202,17 @@ export default function QuickSettingsSidebar({
</div> </div>
<Toggle checked={showLines} onChange={onShowLinesChange} /> <Toggle checked={showLines} onChange={onShowLinesChange} />
</div> </div>
{/* Show Priority Icons */}
{onShowPriorityIconsChange && (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<Target size={14} style={{ color: labelColor }} />
<span style={{ fontSize: "0.75rem", color: labelColor }}>Priority Icons</span>
</div>
<Toggle checked={showPriorityIcons ?? true} onChange={onShowPriorityIconsChange} />
</div>
)}
</div> </div>
</div> </div>
</> </>

View File

@ -4,7 +4,7 @@ import React, { useState, useEffect, useRef, useMemo, useCallback } from "react"
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faApple, faGoogle, faMicrosoft, faNotion } from "@fortawesome/free-brands-svg-icons"; import { faApple, faGoogle, faMicrosoft, faNotion } from "@fortawesome/free-brands-svg-icons";
import { faServer } from "@fortawesome/free-solid-svg-icons"; import { faServer, faFolder } from "@fortawesome/free-solid-svg-icons";
import CalendarSyncRulesPanel from "./CalendarSyncRulesPanel"; import CalendarSyncRulesPanel from "./CalendarSyncRulesPanel";
import { ViewStyle, KanbanStage, Task } from "./WeeklyView"; import { ViewStyle, KanbanStage, Task } from "./WeeklyView";
import { AVAILABLE_FONTS, isCustomFont } from "../lib/fontConstants"; import { AVAILABLE_FONTS, isCustomFont } from "../lib/fontConstants";
@ -13,14 +13,18 @@ import { WeatherDisplayKey, WEATHER_DISPLAY_DEFAULTS } from "../lib/weeklyViewCo
import { EXPORT_FIELDS, type ExportFieldKey } from "../app/api/user/export/route"; import { EXPORT_FIELDS, type ExportFieldKey } from "../app/api/user/export/route";
import { import {
ArrowLeftRight, ArrowLeftRight,
Briefcase,
Calendar, Calendar,
CalendarDays, CalendarDays,
Check,
FolderOpen,
Globe, Globe,
Info, Info,
Kanban, Kanban,
Link, Link,
ListTodo, ListTodo,
Palette, Palette,
Pencil,
Play, Play,
Plus, Plus,
Settings, Settings,
@ -28,6 +32,25 @@ import {
Trash2, Trash2,
User, User,
} from "lucide-react"; } from "lucide-react";
import IconPicker from "./IconPicker";
import { allIcons } from "./iconRegistry";
import Icon from "@mdi/react";
// Minimal ProjectIcon — resolves an icon name from the unified registry.
function ProjectIcon({ icon, size = 18, color }: { icon?: string | null; size?: number; color?: string }) {
if (!icon) return <FontAwesomeIcon icon={faFolder} style={{ fontSize: size, color }} />;
const normalised = icon.startsWith("fa") && icon.length > 2 && icon[2] === icon[2].toUpperCase()
? icon.slice(2, 3).toLowerCase() + icon.slice(3)
: icon;
const found = allIcons.find((i) => i.name === normalised || i.name === icon);
if (found) {
if (found.type === "fa") {
return <FontAwesomeIcon icon={found.icon as any} style={{ fontSize: size, color }} />;
}
return <Icon path={found.icon as string} size={size / 24} color={color} />;
}
return <span style={{ fontSize: size, lineHeight: 1 }}>{icon}</span>;
}
export interface SomedayList { export interface SomedayList {
id: string; id: string;
@ -193,7 +216,7 @@ interface SettingsSidebarProps {
fetchAvailableTaskLists: ( fetchAvailableTaskLists: (
provider: "google" | "apple" | "outlook" | "synology", provider: "google" | "apple" | "outlook" | "synology",
) => Promise<void>; ) => Promise<void>;
initialTab?: "calendar" | "general" | "account" | "styling" | "motivation" | "about" | "sync"; initialTab?: "calendar" | "general" | "account" | "styling" | "motivation" | "about" | "sync" | "projects";
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[]; kanbanStages: KanbanStage[];
@ -326,7 +349,7 @@ function SettingsSidebar({
onRunSetupAssistant, onRunSetupAssistant,
}: SettingsSidebarProps) { }: SettingsSidebarProps) {
const [activeTab, setActiveTab] = useState< const [activeTab, setActiveTab] = useState<
"calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation" | "sync" "calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation" | "sync" | "projects"
>(initialTab || "general"); >(initialTab || "general");
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
@ -831,6 +854,7 @@ function SettingsSidebar({
{ key: "localisation", icon: <Globe size={18} />, label: t.localisation }, { key: "localisation", icon: <Globe size={18} />, label: t.localisation },
{ key: "calendar", icon: <Link size={18} />, label: t.calendar }, { key: "calendar", icon: <Link size={18} />, label: t.calendar },
{ key: "sync", icon: <ArrowLeftRight size={18} />, label: t.calendarSync || "Sync" }, { key: "sync", icon: <ArrowLeftRight size={18} />, label: t.calendarSync || "Sync" },
{ key: "projects", icon: <Briefcase size={18} />, label: t.projects || "Projects" },
{ key: "account", icon: <User size={18} />, label: t.account }, { key: "account", icon: <User size={18} />, label: t.account },
{ key: "styling", icon: <Palette size={18} />, label: t.styling }, { key: "styling", icon: <Palette size={18} />, label: t.styling },
{ key: "motivation", icon: <Sparkles size={18} />, label: t.motivation }, { key: "motivation", icon: <Sparkles size={18} />, label: t.motivation },
@ -1365,6 +1389,37 @@ function SettingsSidebar({
<label htmlFor="showProjectIcons" style={{ fontSize: "0.9rem", fontWeight: 600 }}>{t.showProjectIcons}</label> <label htmlFor="showProjectIcons" style={{ fontSize: "0.9rem", fontWeight: 600 }}>{t.showProjectIcons}</label>
</div> </div>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<input type="checkbox" id="showPriorityIcons"
checked={profile.showPriorityIcons !== false}
onChange={(e) => {
saveField("showPriorityIcons", e.target.checked);
perView.saveViewSetting("showPriorityIcons", e.target.checked, false);
}}
style={{ width: "16px", height: "16px" }} />
<label htmlFor="showPriorityIcons" style={{ fontSize: "0.9rem", fontWeight: 600 }}>
{profile.language === "de" ? "Prioritäts-Icons anzeigen" : "Show Priority Icons"}
</label>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<label htmlFor="priorityStyle" style={{ fontSize: "0.9rem", fontWeight: 600 }}>
{profile.language === "de" ? "Prioritäts-Stil" : "Priority Style"}
</label>
<select
id="priorityStyle"
value={profile.priorityStyle || "eisenhower"}
onChange={(e) => saveField("priorityStyle", e.target.value)}
className="weekly-input"
style={{ padding: "4px 8px", fontSize: "0.85rem", borderRadius: "6px", border: "1px solid var(--weekly-border, #e5e7eb)" }}
>
<option value="eisenhower">Eisenhower</option>
<option value="abcde">ABCDE</option>
<option value="ivylee">Ivy Lee</option>
<option value="pareto">80/20 (Pareto)</option>
</select>
</div>
{viewStyle !== "kanban" && ( {viewStyle !== "kanban" && (
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}> <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<input type="checkbox" id="protectEventTimes" checked={profile.protectEventTimes || false} <input type="checkbox" id="protectEventTimes" checked={profile.protectEventTimes || false}
@ -3928,6 +3983,165 @@ function SettingsSidebar({
connections={connections} connections={connections}
t={t} t={t}
/> />
) : activeTab === "projects" ? (
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
<div>
<h3 style={{ fontSize: "1rem", fontWeight: 600, margin: 0, display: "flex", alignItems: "center", gap: "8px" }}>
<FolderOpen size={18} /> {t.projects || "Projects"}
</h3>
<p style={{ fontSize: "0.8rem", color: "var(--weekly-settings-label)", marginTop: "4px" }}>
{t.projectsDesc || "Organize tasks with color-coded projects"}
</p>
</div>
{/* Existing projects list */}
{projects.length === 0 ? (
<div style={{ textAlign: "center", padding: "24px 12px", border: "1px dashed var(--weekly-border, #e5e7eb)", borderRadius: "10px" }}>
<ProjectIcon icon="folder" size={28} color="#aaa" />
<p style={{ fontSize: "0.85rem", color: "#aaa", fontStyle: "italic", marginTop: "8px" }}>
{t.noProjects || "No projects yet"}
</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{projects.map((p) => (
<div key={p.id} style={{
display: "flex", alignItems: "center", gap: "10px",
padding: "10px 14px", borderRadius: "10px",
background: "var(--weekly-bg-soft, #f9fafb)",
borderLeft: `4px solid ${p.color || "#999"}`,
}}>
{editingProjectId === p.id ? (
<div style={{ display: "flex", flexDirection: "column", gap: "8px", width: "100%" }}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<div style={{ position: "relative" }}>
<button onClick={() => setShowEditProjectIconPicker(!showEditProjectIconPicker)} style={{ width: "38px", height: "38px", borderRadius: "8px", border: "1px solid var(--weekly-border, #e5e7eb)", background: "var(--weekly-bg, #fff)", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}>
<ProjectIcon icon={editProjectIcon} size={16} color="#555" />
</button>
{showEditProjectIconPicker && (
<div style={{ position: "absolute", top: "100%", left: 0, marginTop: "4px", zIndex: 50 }}>
<IconPicker selectedIcon={editProjectIcon} onSelect={(name) => { setEditProjectIcon(name); setShowEditProjectIconPicker(false); }} darkMode={false} />
</div>
)}
</div>
<input
type="text"
value={editProjectName}
onChange={(e) => setEditProjectName(e.target.value)}
className="weekly-input"
style={{ flex: 1, padding: "8px 12px", fontSize: "0.9rem" }}
onKeyDown={(e) => {
if (e.key === "Enter") {
fetch("/api/projects", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: p.id, name: editProjectName, color: editProjectColor, icon: editProjectIcon }) })
.then(() => { onProjectsChanged(); setEditingProjectId(null); });
}
if (e.key === "Escape") setEditingProjectId(null);
}}
autoFocus
/>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<input type="color" value={editProjectColor} onChange={(e) => setEditProjectColor(e.target.value)} style={{ width: "30px", height: "30px", border: "none", cursor: "pointer", padding: 0, borderRadius: "6px" }} />
<span style={{ fontSize: "0.8rem", color: "var(--weekly-settings-label)" }}>{profile.language === "de" ? "Farbe" : "Color"}</span>
<div style={{ flex: 1 }} />
<button onClick={() => setEditingProjectId(null)} style={{ padding: "6px 12px", fontSize: "0.8rem", background: "none", border: "1px solid var(--weekly-border, #ddd)", borderRadius: "8px", cursor: "pointer" }}>
{profile.language === "de" ? "Abbrechen" : "Cancel"}
</button>
<button
onClick={() => {
fetch("/api/projects", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: p.id, name: editProjectName, color: editProjectColor, icon: editProjectIcon }) })
.then(() => { onProjectsChanged(); setEditingProjectId(null); });
}}
className="weekly-btn-primary"
style={{ padding: "6px 12px", fontSize: "0.8rem", display: "inline-flex", alignItems: "center", gap: "4px" }}
>
<Check size={14} /> {profile.language === "de" ? "Speichern" : "Save"}
</button>
</div>
</div>
) : (
<>
<ProjectIcon icon={p.icon} size={20} color={p.color || "#999"} />
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ fontSize: "0.9rem", fontWeight: 600, display: "block" }}>{p.name}</span>
</div>
<button
onClick={() => {
setEditingProjectId(p.id);
setEditProjectName(p.name);
setEditProjectColor(p.color || "#3b82f6");
setEditProjectIcon(p.icon || "folder");
setShowEditProjectIconPicker(false);
}}
style={{ padding: "6px", opacity: 0.6, cursor: "pointer", background: "none", border: "none", borderRadius: "6px" }}
title={profile.language === "de" ? "Bearbeiten" : "Edit"}
>
<Pencil size={14} />
</button>
<button
onClick={() => {
const msg = profile.language === "de" ? `Projekt "${p.name}" löschen?` : `Delete project "${p.name}"?`;
if (confirm(msg)) {
fetch(`/api/projects?id=${p.id}`, { method: "DELETE" }).then(() => onProjectsChanged());
}
}}
style={{ padding: "6px", opacity: 0.6, cursor: "pointer", color: "#ef4444", background: "none", border: "none", borderRadius: "6px" }}
title={profile.language === "de" ? "Löschen" : "Delete"}
>
<Trash2 size={14} />
</button>
</>
)}
</div>
))}
</div>
)}
{/* Add new project form */}
<div style={{ borderTop: "1px solid var(--weekly-border, #e5e7eb)", paddingTop: "16px" }}>
<p style={{ fontSize: "0.8rem", fontWeight: 600, color: "var(--weekly-settings-label)", marginBottom: "10px" }}>
{profile.language === "de" ? "Projekt hinzufügen" : "Add Project"}
</p>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<div style={{ position: "relative" }}>
<button onClick={() => setShowNewProjectIconPicker(!showNewProjectIconPicker)} style={{ width: "38px", height: "38px", borderRadius: "8px", border: "1px solid var(--weekly-border, #e5e7eb)", background: "var(--weekly-bg, #fff)", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}>
<ProjectIcon icon={newProjectIcon} size={16} color="#555" />
</button>
{showNewProjectIconPicker && (
<div style={{ position: "absolute", bottom: "100%", left: 0, marginBottom: "4px", zIndex: 50 }}>
<IconPicker selectedIcon={newProjectIcon} onSelect={(name) => { setNewProjectIcon(name); setShowNewProjectIconPicker(false); }} darkMode={false} />
</div>
)}
</div>
<input type="color" value={newProjectColor} onChange={(e) => setNewProjectColor(e.target.value)} style={{ width: "30px", height: "30px", border: "none", cursor: "pointer", padding: 0, borderRadius: "6px" }} />
<input
type="text"
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
placeholder={profile.language === "de" ? "Name" : "Name"}
className="weekly-input"
style={{ flex: 1, padding: "8px 12px", fontSize: "0.9rem" }}
onKeyDown={(e) => {
if (e.key === "Enter" && newProjectName.trim()) {
fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newProjectName.trim(), color: newProjectColor, icon: newProjectIcon }) })
.then(() => { onProjectsChanged(); setNewProjectName(""); setNewProjectIcon("folder"); });
}
}}
/>
<button
onClick={() => {
if (!newProjectName.trim()) return;
fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newProjectName.trim(), color: newProjectColor, icon: newProjectIcon }) })
.then(() => { onProjectsChanged(); setNewProjectName(""); setNewProjectIcon("folder"); });
}}
className="weekly-btn-primary"
style={{ padding: "8px 14px", fontSize: "0.85rem", display: "inline-flex", alignItems: "center", gap: "4px", whiteSpace: "nowrap" }}
>
<Plus size={14} /> {profile.language === "de" ? "Hinzufügen" : "Add"}
</button>
</div>
</div>
</div>
) : activeTab === "about" ? ( ) : activeTab === "about" ? (
<div <div
style={{ display: "flex", flexDirection: "column", gap: "20px" }} style={{ display: "flex", flexDirection: "column", gap: "20px" }}

View File

@ -89,6 +89,7 @@ import {
Archive, Archive,
Bot, Bot,
Printer, Printer,
Star,
} from "lucide-react"; } from "lucide-react";
const stripHtml = (html: string) => html.replace(/<[^>]*>/g, '').trim(); const stripHtml = (html: string) => html.replace(/<[^>]*>/g, '').trim();
@ -116,7 +117,7 @@ const PriorityView = dynamic(() => import("./PriorityView"), { ssr: false });
const DEVICE_SETTINGS_KEYS = ["viewDays", "cellDuration", "startHour", "endHour", "fontSize", "showSubHourSlots"]; const DEVICE_SETTINGS_KEYS = ["viewDays", "cellDuration", "startHour", "endHour", "fontSize", "showSubHourSlots"];
// Per-view toggle keys that are also device-specific (sidebar eye toggles) // Per-view toggle keys that are also device-specific (sidebar eye toggles)
const DEVICE_VIEW_SETTINGS_KEYS = ["showSomeday", "showAllDayEvents", "showTaskCheckboxes", "showProjectIcons", "weatherEnabled"] as const; const DEVICE_VIEW_SETTINGS_KEYS = ["showSomeday", "showAllDayEvents", "showTaskCheckboxes", "showProjectIcons", "showPriorityIcons", "weatherEnabled"] as const;
type DeviceViewSettingKey = typeof DEVICE_VIEW_SETTINGS_KEYS[number]; type DeviceViewSettingKey = typeof DEVICE_VIEW_SETTINGS_KEYS[number];
// Settings that save to DB (cross-device default) AND to cookie (device override wins on load) // Settings that save to DB (cross-device default) AND to cookie (device override wins on load)
@ -222,6 +223,8 @@ export interface SomedayList {
title: string; title: string;
tasks: Task[]; tasks: Task[];
tab?: string | null; tab?: string | null;
color?: string | null;
icon?: string | null;
externalProvider?: string | null; externalProvider?: string | null;
externalId?: string | null; externalId?: string | null;
externalListId?: string | null; externalListId?: string | null;
@ -712,6 +715,9 @@ export default function WeeklyView() {
const [editingTaskId, setEditingTaskId] = useState<string | null>(null); const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
const [draggingListId, setDraggingListId] = useState<string | null>(null); const [draggingListId, setDraggingListId] = useState<string | null>(null);
const [listToDelete, setListToDelete] = useState<string | null>(null); const [listToDelete, setListToDelete] = useState<string | null>(null);
// Punkt 7+8: per-list color/icon edit popover, and per-tab settings popover
const [editingListVisualsId, setEditingListVisualsId] = useState<string | null>(null);
const [editingTabVisualsName, setEditingTabVisualsName] = useState<string | null>(null);
const [activeSomedayTab, setActiveSomedayTab] = useState<string | null>(null); const [activeSomedayTab, setActiveSomedayTab] = useState<string | null>(null);
const [editingTabName, setEditingTabName] = useState<string | null>(null); const [editingTabName, setEditingTabName] = useState<string | null>(null);
const [renamingTabValue, setRenamingTabValue] = useState(""); const [renamingTabValue, setRenamingTabValue] = useState("");
@ -827,6 +833,44 @@ export default function WeeklyView() {
} }
}; };
// Update a list's color or icon, and persist to the API.
const updateListVisuals = async (listId: string, updates: { color?: string | null; icon?: string | null }) => {
setSomedayLists(prev => prev.map(l => l.id === listId ? { ...l, ...updates } : l));
try {
await fetch("/api/someday-lists", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: listId, ...updates }),
});
} catch (e) {
console.error("Failed to update list visuals:", e);
}
};
// Tab visuals are not first-class entities — store color/icon in user.viewSettings JSON.
const getTabVisuals = (tabName: string): { color?: string; icon?: string } => {
const cfg = (viewSettingsRef.current as any).somedayTabConfig || {};
return cfg[tabName] || {};
};
const updateTabVisuals = (tabName: string, updates: { color?: string | null; icon?: string | null }) => {
const cfg = { ...((viewSettingsRef.current as any).somedayTabConfig || {}) };
const existing = cfg[tabName] || {};
const next: any = { ...existing, ...updates };
if (!next.color) delete next.color;
if (!next.icon) delete next.icon;
if (Object.keys(next).length === 0) delete cfg[tabName];
else cfg[tabName] = next;
const updated = { ...(viewSettingsRef.current as any), somedayTabConfig: cfg };
viewSettingsRef.current = updated;
setViewSettings(updated);
fetch("/api/user/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ viewSettings: updated }),
}).catch(e => console.error("[tabs] Failed to save tab visuals:", e));
};
const dissolveTab = async (tabName: string) => { const dissolveTab = async (tabName: string) => {
const listsToUpdate = somedayLists.filter(l => l.tab === tabName); const listsToUpdate = somedayLists.filter(l => l.tab === tabName);
setSomedayLists(prev => prev.map(l => l.tab === tabName ? { ...l, tab: null } : l)); setSomedayLists(prev => prev.map(l => l.tab === tabName ? { ...l, tab: null } : l));
@ -1040,6 +1084,7 @@ export default function WeeklyView() {
weatherDisplay?: WeatherDisplayKey[]; weatherDisplay?: WeatherDisplayKey[];
showTaskCheckboxes?: boolean; showTaskCheckboxes?: boolean;
showProjectIcons?: boolean; showProjectIcons?: boolean;
showPriorityIcons?: boolean;
showSomeday?: boolean; showSomeday?: boolean;
showAllDayEvents?: boolean; showAllDayEvents?: boolean;
allDayPosition?: "above" | "below"; allDayPosition?: "above" | "below";
@ -1048,7 +1093,7 @@ export default function WeeklyView() {
startHour?: number; startHour?: number;
endHour?: number; endHour?: number;
}; };
const PER_VIEW_KEYS = ["hourLabelFormat", "showSubHourSlots", "weatherEnabled", "weatherDisplay", "showTaskCheckboxes", "showProjectIcons", "showSomeday", "showAllDayEvents", "allDayPosition", "showCompletedTasks", "cellDuration", "startHour", "endHour"] as const; const PER_VIEW_KEYS = ["hourLabelFormat", "showSubHourSlots", "weatherEnabled", "weatherDisplay", "showTaskCheckboxes", "showProjectIcons", "showPriorityIcons", "showSomeday", "showAllDayEvents", "allDayPosition", "showCompletedTasks", "cellDuration", "startHour", "endHour"] as const;
const [viewSettings, setViewSettings] = useState<Record<string, PerViewOverrides>>({}); const [viewSettings, setViewSettings] = useState<Record<string, PerViewOverrides>>({});
const viewSettingsRef = useRef<Record<string, PerViewOverrides>>({}); const viewSettingsRef = useRef<Record<string, PerViewOverrides>>({});
viewSettingsRef.current = viewSettings; viewSettingsRef.current = viewSettings;
@ -1139,6 +1184,8 @@ export default function WeeklyView() {
const effectiveWeatherDisplay = (getEffective("weatherDisplay", WEATHER_DISPLAY_DEFAULTS) || WEATHER_DISPLAY_DEFAULTS) as WeatherDisplayKey[]; const effectiveWeatherDisplay = (getEffective("weatherDisplay", WEATHER_DISPLAY_DEFAULTS) || WEATHER_DISPLAY_DEFAULTS) as WeatherDisplayKey[];
const effectiveShowTaskCheckboxes = getEffective("showTaskCheckboxes", profile.showTaskCheckboxes); const effectiveShowTaskCheckboxes = getEffective("showTaskCheckboxes", profile.showTaskCheckboxes);
const effectiveShowProjectIcons = getEffective("showProjectIcons", profile.showProjectIcons); const effectiveShowProjectIcons = getEffective("showProjectIcons", profile.showProjectIcons);
const effectiveShowPriorityIcons = getEffective("showPriorityIcons", profile.showPriorityIcons !== false) as boolean;
const effectivePriorityStyle = (profile.priorityStyle || "eisenhower") as string;
const effectiveShowSomeday = getEffective("showSomeday", profile.showSomeday ?? true); const effectiveShowSomeday = getEffective("showSomeday", profile.showSomeday ?? true);
const effectiveShowAllDay = getEffective("showAllDayEvents", profile.showAllDayEvents ?? true); const effectiveShowAllDay = getEffective("showAllDayEvents", profile.showAllDayEvents ?? true);
const effectiveAllDayPosition = getEffective("allDayPosition", profile.allDayPosition ?? "above") || "above"; const effectiveAllDayPosition = getEffective("allDayPosition", profile.allDayPosition ?? "above") || "above";
@ -2562,6 +2609,8 @@ export default function WeeklyView() {
id: l.id, id: l.id,
title: l.title, title: l.title,
tab: l.tab || null, tab: l.tab || null,
color: l.color || null,
icon: l.icon || null,
tasks: l.tasks || [], tasks: l.tasks || [],
externalId: l.externalId || null, externalId: l.externalId || null,
externalProvider: l.externalProvider || null, externalProvider: l.externalProvider || null,
@ -2625,6 +2674,8 @@ export default function WeeklyView() {
id: l.id, id: l.id,
title: l.title, title: l.title,
tab: l.tab || null, tab: l.tab || null,
color: l.color || null,
icon: l.icon || null,
tasks: [], tasks: [],
externalId: l.externalId || null, externalId: l.externalId || null,
externalProvider: l.externalProvider || null, externalProvider: l.externalProvider || null,
@ -6253,6 +6304,11 @@ export default function WeeklyView() {
darkMode={darkMode} darkMode={darkMode}
language={profile.language} language={profile.language}
onUpdateTask={async (id, fields) => { await updateTaskFields(id, fields as any); }} onUpdateTask={async (id, fields) => { await updateTaskFields(id, fields as any); }}
initialMethod={(profile.priorityStyle || "eisenhower") as any}
onMethodChange={(m) => {
setProfile((p: any) => ({ ...p, priorityStyle: m }));
saveSetting("priorityStyle", m);
}}
/> />
</div> </div>
<div <div
@ -6345,11 +6401,11 @@ export default function WeeklyView() {
className="kanban-card-checkbox" className="kanban-card-checkbox"
/> />
)} )}
{(() => { {effectiveShowPriorityIcons && (() => {
const pm = getPriorityMeta(task); const pb = getPriorityBadge(task, profile.priorityStyle || "eisenhower", 12);
return pm ? ( return pb ? (
<span title={pm.label} style={{ flexShrink: 0, display: "flex", alignItems: "center", marginRight: "4px" }}> <span title={pb.label} style={{ flexShrink: 0, display: "flex", alignItems: "center", marginRight: "4px" }}>
<pm.Icon size={12} color={pm.color} /> {pb.node}
</span> </span>
) : null; ) : null;
})()} })()}
@ -7164,6 +7220,8 @@ export default function WeeklyView() {
workingHoursStart={workingHoursStart} workingHoursStart={workingHoursStart}
showTaskCheckboxes={effectiveShowTaskCheckboxes} showTaskCheckboxes={effectiveShowTaskCheckboxes}
showProjectIcons={effectiveShowProjectIcons} showProjectIcons={effectiveShowProjectIcons}
showPriorityIcons={effectiveShowPriorityIcons}
priorityStyle={effectivePriorityStyle}
projects={projects} projects={projects}
onProjectAssign={assignProject} onProjectAssign={assignProject}
kanbanStages={kanbanStages} kanbanStages={kanbanStages}
@ -7582,6 +7640,8 @@ export default function WeeklyView() {
onSetEditingTaskId={setEditingTaskId} onSetEditingTaskId={setEditingTaskId}
showTaskCheckboxes={effectiveShowTaskCheckboxes} showTaskCheckboxes={effectiveShowTaskCheckboxes}
showProjectIcons={effectiveShowProjectIcons} showProjectIcons={effectiveShowProjectIcons}
showPriorityIcons={effectiveShowPriorityIcons}
priorityStyle={effectivePriorityStyle}
projects={projects} projects={projects}
onProjectAssign={assignProject} onProjectAssign={assignProject}
kanbanStages={kanbanStages} kanbanStages={kanbanStages}
@ -7770,6 +7830,7 @@ export default function WeeklyView() {
<div <div
key={tab} key={tab}
className="someday-tab-wrapper-h" className="someday-tab-wrapper-h"
style={{ position: "relative" }}
onDragOver={(e) => { onDragOver={(e) => {
if (e.dataTransfer.types.includes("text/list-id")) { if (e.dataTransfer.types.includes("text/list-id")) {
e.preventDefault(); e.preventDefault();
@ -7790,6 +7851,9 @@ export default function WeeklyView() {
setDragOverTab(null); setDragOverTab(null);
}} }}
> >
{(() => {
const tv = getTabVisuals(tab);
return (
<button <button
className={`someday-tab-btn-h ${activeSomedayTab === tab ? "active" : ""} ${dragOverTab === tab ? "drag-over" : ""}`} className={`someday-tab-btn-h ${activeSomedayTab === tab ? "active" : ""} ${dragOverTab === tab ? "drag-over" : ""}`}
onClick={() => setSomedayTab(tab)} onClick={() => setSomedayTab(tab)}
@ -7797,13 +7861,42 @@ export default function WeeklyView() {
setEditingTabName(tab); setEditingTabName(tab);
setRenamingTabValue(tab); setRenamingTabValue(tab);
}} }}
style={tv.color ? {
background: activeSomedayTab === tab ? tv.color : `${tv.color}22`,
color: activeSomedayTab === tab ? "#fff" : tv.color,
borderColor: tv.color,
} : undefined}
title={t.renameTab} title={t.renameTab}
>{tab} <span className="someday-tab-count">{somedayLists.filter(l => l.tab === tab).length}</span></button> >
{tv.icon && (
<span style={{ display: "inline-flex", alignItems: "center", marginRight: "4px" }}>
<ProjectIcon icon={tv.icon} size={12} color={activeSomedayTab === tab && tv.color ? "#fff" : (tv.color || "currentColor")} />
</span>
)}
{tab} <span className="someday-tab-count">{somedayLists.filter(l => l.tab === tab).length}</span>
</button>
);
})()}
<button
onClick={(e) => { e.stopPropagation(); setEditingTabVisualsName(editingTabVisualsName === tab ? null : tab); }}
title={profile.language === "de" ? "Tab-Stil bearbeiten" : "Edit tab style"}
style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: "#bbb", display: "inline-flex", alignItems: "center" }}
><Pencil size={10} /></button>
<button <button
className="someday-tab-dissolve-h" className="someday-tab-dissolve-h"
onClick={(e) => { e.stopPropagation(); dissolveTab(tab); }} onClick={(e) => { e.stopPropagation(); dissolveTab(tab); }}
title={t.dissolveTab} title={t.dissolveTab}
><X size={10} /></button> ><X size={10} /></button>
{editingTabVisualsName === tab && (
<TabVisualsPopover
tabName={tab}
visuals={getTabVisuals(tab)}
darkMode={darkMode}
language={profile.language}
onChange={(updates) => updateTabVisuals(tab, updates)}
onClose={() => setEditingTabVisualsName(null)}
/>
)}
</div> </div>
) )
))} ))}
@ -7858,6 +7951,8 @@ export default function WeeklyView() {
cursor: "text", cursor: "text",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
position: "relative",
...(list.color ? { borderLeft: `4px solid ${list.color}`, paddingLeft: "8px" } : {}),
}} }}
onMouseDown={(e) => { onMouseDown={(e) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
@ -8012,6 +8107,15 @@ export default function WeeklyView() {
> >
<GripVertical size={14} /> <GripVertical size={14} />
</div> </div>
{list.icon && (
<span
onClick={(e) => { e.stopPropagation(); setEditingListVisualsId(list.id); }}
title={profile.language === "de" ? "Listen-Stil bearbeiten" : "Edit list style"}
style={{ display: "inline-flex", alignItems: "center", marginRight: "6px", cursor: "pointer", flexShrink: 0 }}
>
<ProjectIcon icon={list.icon} size={14} color={list.color || "var(--weekly-text, #555)"} />
</span>
)}
<input <input
type="text" type="text"
defaultValue={list.title} defaultValue={list.title}
@ -8143,6 +8247,26 @@ export default function WeeklyView() {
}} }}
/> />
)} )}
<button
onClick={(e) => {
e.stopPropagation();
setEditingListVisualsId(editingListVisualsId === list.id ? null : list.id);
}}
style={{
border: "none",
background: "none",
cursor: "pointer",
color: "#ccc",
marginLeft: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "4px"
}}
title={profile.language === "de" ? "Listen-Stil bearbeiten" : "Edit list style"}
>
<Pencil size={14} />
</button>
<button <button
className="someday-list-delete-btn" className="someday-list-delete-btn"
onClick={(e) => { onClick={(e) => {
@ -8167,6 +8291,16 @@ export default function WeeklyView() {
</> </>
)} )}
</div> </div>
{/* List visuals popover (Punkt 7+8) */}
{editingListVisualsId === list.id && (
<ListVisualsPopover
list={list}
darkMode={darkMode}
language={profile.language}
onChange={(updates) => updateListVisuals(list.id, updates)}
onClose={() => setEditingListVisualsId(null)}
/>
)}
<div <div
className="weekly-task-list" className="weekly-task-list"
@ -8283,6 +8417,8 @@ export default function WeeklyView() {
onSetEditingTaskId={setEditingTaskId} onSetEditingTaskId={setEditingTaskId}
showTaskCheckboxes={effectiveShowTaskCheckboxes} showTaskCheckboxes={effectiveShowTaskCheckboxes}
showProjectIcons={effectiveShowProjectIcons} showProjectIcons={effectiveShowProjectIcons}
showPriorityIcons={effectiveShowPriorityIcons}
priorityStyle={effectivePriorityStyle}
projects={projects} projects={projects}
onProjectAssign={assignProject} onProjectAssign={assignProject}
kanbanStages={kanbanStages} kanbanStages={kanbanStages}
@ -8356,6 +8492,8 @@ export default function WeeklyView() {
onSetEditingTaskId={setEditingTaskId} onSetEditingTaskId={setEditingTaskId}
showTaskCheckboxes={effectiveShowTaskCheckboxes} showTaskCheckboxes={effectiveShowTaskCheckboxes}
showProjectIcons={effectiveShowProjectIcons} showProjectIcons={effectiveShowProjectIcons}
showPriorityIcons={effectiveShowPriorityIcons}
priorityStyle={effectivePriorityStyle}
projects={projects} projects={projects}
onProjectAssign={assignProject} onProjectAssign={assignProject}
kanbanStages={kanbanStages} kanbanStages={kanbanStages}
@ -9526,31 +9664,78 @@ interface TaskItemProps {
isSubTask?: boolean; isSubTask?: boolean;
showTaskCheckboxes?: boolean; showTaskCheckboxes?: boolean;
showProjectIcons?: boolean; showProjectIcons?: boolean;
showPriorityIcons?: boolean;
priorityStyle?: string;
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[]; kanbanStages?: KanbanStage[];
} }
// ── Priority indicator helper ───────────────────────────────────── // ── Priority indicator helper ─────────────────────────────────────
// Returns icon + color for a task based on Eisenhower quadrant (primary) // Renders a small priority badge for a task. The visual style is selected
// or ABCDE grade (fallback). Returns null when no priority is set. // by the user's `priorityStyle` setting:
function getPriorityMeta(task: Task): { Icon: React.ElementType; color: string; label: string } | null { // - "eisenhower" → quadrant icons + colors (urgency × importance)
if (task.urgency != null && task.importance != null) { // - "abcde" → letter AE in a colored circle
if (task.urgency && task.importance) return { Icon: Zap, color: "#ef4444", label: "Sofort erledigen" }; // - "ivylee" → digit 16 in a colored circle (rank persisted in priority)
if (!task.urgency && task.importance) return { Icon: CalendarClock, color: "#3b82f6", label: "Planen" }; // - "pareto" → star for the "vital few" (importance flag)
if (task.urgency && !task.importance) return { Icon: CornerUpRight, color: "#f97316", label: "Delegieren" }; // Returns null when the task has no priority data for the active style.
return { Icon: Archive, color: "#9ca3af", label: "Eliminieren" }; type PriorityBadge = { node: React.ReactNode; label: string };
}
if (task.priority) { const PRIORITY_NUMBER_COLORS: Record<string, string> = {
const map: Record<string, { Icon: React.ElementType; color: string }> = { "1": "#ef4444", "2": "#f97316", "3": "#eab308",
A: { Icon: Zap, color: "#ef4444" }, "4": "#22c55e", "5": "#3b82f6", "6": "#8b5cf6",
B: { Icon: CalendarClock, color: "#3b82f6" },
C: { Icon: Clock, color: "#eab308" },
D: { Icon: CornerUpRight, color: "#f97316" },
E: { Icon: Archive, color: "#9ca3af" },
}; };
const m = map[task.priority]; const PRIORITY_LETTER_COLORS: Record<string, string> = {
return m ? { ...m, label: task.priority } : null; 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>
),
};
}
function getPriorityBadge(task: Task, style: string, size = 12): PriorityBadge | null {
if (style === "abcde") {
if (!task.priority || !PRIORITY_LETTER_COLORS[task.priority]) return null;
return makeBadgeCircle(task.priority, PRIORITY_LETTER_COLORS[task.priority], size + 2, task.priority);
}
if (style === "ivylee") {
if (!task.priority || !PRIORITY_NUMBER_COLORS[task.priority]) return null;
return makeBadgeCircle(task.priority, PRIORITY_NUMBER_COLORS[task.priority], size + 2, `Ivy Lee #${task.priority}`);
}
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 }} /> };
} }
return null; return null;
} }
@ -9579,6 +9764,8 @@ function TaskItem({
isSubTask = false, isSubTask = false,
showTaskCheckboxes = false, showTaskCheckboxes = false,
showProjectIcons = false, showProjectIcons = false,
showPriorityIcons = true,
priorityStyle = "eisenhower",
projects = [], projects = [],
onProjectAssign, onProjectAssign,
kanbanStages = [], kanbanStages = [],
@ -9842,11 +10029,11 @@ function TaskItem({
flex: 1, flex: 1,
}} }}
> >
{(() => { {showPriorityIcons && (() => {
const pm = getPriorityMeta(task); const pb = getPriorityBadge(task, priorityStyle, 11);
return pm ? ( return pb ? (
<span title={pm.label} style={{ marginRight: "3px", verticalAlign: "middle", display: "inline-flex", alignItems: "center" }}> <span title={pb.label} style={{ marginRight: "3px", verticalAlign: "middle", display: "inline-flex", alignItems: "center" }}>
<pm.Icon size={11} color={pm.color} /> {pb.node}
</span> </span>
) : null; ) : null;
})()} })()}
@ -10444,6 +10631,202 @@ function TaskItem({
); );
} }
// Punkt 7+8 — popover for editing a someday list's icon and color.
function ListVisualsPopover({
list,
darkMode,
language,
onChange,
onClose,
}: {
list: { id: string; title: string; color?: string | null; icon?: string | null };
darkMode: boolean;
language: string;
onChange: (updates: { color?: string | null; icon?: string | null }) => void;
onClose: () => void;
}) {
const [iconPickerOpen, setIconPickerOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const onDoc = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) onClose();
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [onClose]);
const de = language === "de";
return (
<div
ref={containerRef}
style={{
position: "absolute",
top: "32px",
right: "4px",
zIndex: 50,
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: "absolute", top: "78px", right: "12px", zIndex: 60 }}>
<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).
function TabVisualsPopover({
tabName,
visuals,
darkMode,
language,
onChange,
onClose,
}: {
tabName: string;
visuals: { color?: string; icon?: string };
darkMode: boolean;
language: string;
onChange: (updates: { color?: string | null; icon?: string | null }) => void;
onClose: () => void;
}) {
const [iconPickerOpen, setIconPickerOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const onDoc = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) onClose();
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [onClose]);
const de = language === "de";
return (
<div
ref={containerRef}
style={{
position: "absolute",
top: "calc(100% + 4px)",
left: 0,
zIndex: 100,
background: darkMode ? "#1e1e2e" : "#fff",
border: `1px solid ${darkMode ? "#444" : "#e5e7eb"}`,
borderRadius: "10px",
padding: "10px",
boxShadow: "0 8px 24px rgba(0,0,0,0.18)",
minWidth: "220px",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
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 ? `Tab-Stil: ${tabName}` : `Tab style: ${tabName}`}
</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={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: "70px", left: "10px", zIndex: 110 }}>
<IconPicker
selectedIcon={visuals.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={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>
</div>
);
}
// Projects Sidebar Component // Projects Sidebar Component
const projectIconsGlobal: { name: string; icon: IconDefinition }[] = [ const projectIconsGlobal: { name: string; icon: IconDefinition }[] = [
{ name: "folder", icon: faFolder }, { name: "briefcase", icon: faBriefcase }, { name: "folder", icon: faFolder }, { name: "briefcase", icon: faBriefcase },