11850 lines
724 KiB
TypeScript
11850 lines
724 KiB
TypeScript
"use client";
|
||
|
||
import React, {
|
||
useState,
|
||
useEffect,
|
||
useRef,
|
||
useCallback,
|
||
useMemo,
|
||
DragEvent,
|
||
} from "react";
|
||
import { useSession, signOut } from "next-auth/react";
|
||
import CalendarEventModal from "./CalendarEventModal";
|
||
import TaskRecurrenceModal from "./RecurrenceModal";
|
||
import { GridTaskBlock } from "./GridTaskBlock";
|
||
import dynamic from "next/dynamic";
|
||
const IconPicker = dynamic(() => import("./IconPicker"), { ssr: false });
|
||
import { allIcons } from "./iconRegistry";
|
||
import MdiIcon from "@mdi/react";
|
||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||
import { faApple, faGoogle, faMicrosoft, faNotion } from "@fortawesome/free-brands-svg-icons";
|
||
import {
|
||
faServer, faFolder, faBriefcase, faBullseye, faRocket, faStar,
|
||
faLightbulb, faFire, faPalette, faMusic, faMobileScreen, faLaptop,
|
||
faGlobe, faHouse, faBuilding, faChartBar, faChartLine, faWrench,
|
||
faBolt, faGamepad, faPen, faBook, faGraduationCap, faFlask,
|
||
faMicroscope, faDumbbell, faUtensils, faPlane, faLeaf, faHeart,
|
||
faCartShopping, faCoins, faGift, faCamera, faFilm, faBroom,
|
||
faPaw, faEarthAmericas, faLock, faCheck, faCode, faCube,
|
||
faUsers, faCar, faMountain, faUmbrella, faClock, faTag,
|
||
IconDefinition,
|
||
} from "@fortawesome/free-solid-svg-icons";
|
||
|
||
import FocusModeOverlay from "./FocusModeOverlay";
|
||
import {
|
||
LayoutGrid,
|
||
LayoutPanelLeft,
|
||
LayoutPanelTop,
|
||
Calendar,
|
||
ChevronDown,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
ChevronsLeft,
|
||
ChevronsRight,
|
||
Search,
|
||
Settings,
|
||
User,
|
||
Clock,
|
||
Menu,
|
||
Target,
|
||
Sun,
|
||
Moon,
|
||
Repeat,
|
||
GripVertical,
|
||
Play,
|
||
Zap,
|
||
Plus,
|
||
RefreshCcw,
|
||
Layout,
|
||
Palette,
|
||
Sparkles,
|
||
Info,
|
||
Trash2,
|
||
Undo2,
|
||
Redo2,
|
||
AlertCircle,
|
||
MoreVertical,
|
||
Check,
|
||
Eye,
|
||
EyeOff,
|
||
PanelLeftClose,
|
||
PanelLeftOpen,
|
||
Type,
|
||
FolderOpen,
|
||
FolderPlus,
|
||
ListPlus,
|
||
Circle,
|
||
X,
|
||
Cable,
|
||
Link,
|
||
Globe,
|
||
Tag,
|
||
Kanban,
|
||
CalendarDays,
|
||
ListTodo,
|
||
Filter,
|
||
Pencil,
|
||
FileText,
|
||
ArrowLeftRight,
|
||
CalendarClock,
|
||
CornerUpRight,
|
||
Archive,
|
||
Bot,
|
||
Printer,
|
||
Star,
|
||
LogOut,
|
||
Briefcase,
|
||
SlidersHorizontal,
|
||
} from "lucide-react";
|
||
|
||
const stripHtml = (html: string) => html.replace(/<[^>]*>/g, '').trim();
|
||
|
||
// Types
|
||
import UserMenu from "./UserMenu";
|
||
import SearchModal from "./SearchModal";
|
||
import SimpleDatePicker from "./SimpleDatePicker";
|
||
import RecurringTasksManager from "./RecurringTasksManager";
|
||
import WeekPrintModal from "./WeekPrintModal";
|
||
export interface RecurringTaskException { id: string; taskId: string; originalDate: string; newDate?: string | null; isCancelled: boolean; createdAt: Date; updatedAt: Date; }
|
||
import { ImportListModal } from "./ImportListModal";
|
||
import OnboardingWizard from "./OnboardingWizard";
|
||
import CalendarSyncRulesPanel from "./CalendarSyncRulesPanel";
|
||
import { getRandomLocalQuote } from "@/lib/quotes";
|
||
import { AVAILABLE_FONTS, isCustomFont } from "../lib/fontConstants";
|
||
import { translations } from "../lib/weeklyViewTranslations";
|
||
import { TIMEZONE_OPTIONS as TIMEZONE_OPTIONS_LOCAL } from "../lib/timezones";
|
||
import { WeatherDisplayKey, WEATHER_DISPLAY_DEFAULTS } from "../lib/weeklyViewConstants";
|
||
const SettingsSidebar = dynamic(() => import("./SettingsSidebar"), { ssr: false });
|
||
const RichTextEditor = dynamic(() => import("./RichTextEditor"), { ssr: false });
|
||
const PriorityView = dynamic(() => import("./PriorityView"), { ssr: false });
|
||
|
||
// Cookie helpers for per-device settings
|
||
// Keys here bypass the DB and save/load from cookie only — each device keeps its own value
|
||
const DEVICE_SETTINGS_KEYS = ["viewDays", "cellDuration", "startHour", "endHour", "fontSize", "showSubHourSlots"];
|
||
|
||
// Per-view toggle keys that are also device-specific (sidebar eye toggles)
|
||
const DEVICE_VIEW_SETTINGS_KEYS = ["showSomeday", "showAllDayEvents", "showTaskCheckboxes", "showProjectIcons", "showPriorityIcons", "weatherEnabled"] as const;
|
||
type DeviceViewSettingKey = typeof DEVICE_VIEW_SETTINGS_KEYS[number];
|
||
|
||
// Settings that save to DB (cross-device default) AND to cookie (device override wins on load)
|
||
const DEVICE_ALSO_COOKIE_KEYS = ["viewStyle", "showTimeGrid", "startDayOffset"];
|
||
const VIEW_SETTINGS_PROFILE_KEYS = ["menuPosition", "showHeaderControls"];
|
||
|
||
function getCookie(name: string): string | null {
|
||
if (typeof document === "undefined") return null;
|
||
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
||
return match ? decodeURIComponent(match[1]) : null;
|
||
}
|
||
|
||
function setCookie(name: string, value: string, days: number = 365) {
|
||
if (typeof document === "undefined") return;
|
||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`;
|
||
}
|
||
|
||
function readDeviceViewCookie(): Record<string, Record<string, any>> {
|
||
const raw = getCookie("device_view_settings");
|
||
if (!raw) return {};
|
||
try { return JSON.parse(raw); } catch { return {}; }
|
||
}
|
||
|
||
function writeDeviceViewCookie(settings: Record<string, Record<string, any>>) {
|
||
setCookie("device_view_settings", JSON.stringify(settings));
|
||
}
|
||
|
||
export type ViewStyle = "simple" | "calendar" | "list" | "grid" | "kanban" | "priority";
|
||
|
||
export interface KanbanStage {
|
||
id: string;
|
||
name: string;
|
||
color: string;
|
||
}
|
||
|
||
export interface Task {
|
||
id: string;
|
||
title: string;
|
||
completed: boolean;
|
||
dayOfWeek?: number | null;
|
||
scheduledDate?: string | null;
|
||
markdownContent?: string | null;
|
||
createdAt?: Date;
|
||
updatedAt: Date;
|
||
order: number;
|
||
completedAt?: Date | null;
|
||
externalId?: string | null;
|
||
externalProvider?: string | null;
|
||
lastSyncedAt?: Date | null;
|
||
syncStatus?: string | null;
|
||
subTasks?: Task[];
|
||
parentId?: string | null;
|
||
isRolling?: boolean;
|
||
isRecurring?: boolean;
|
||
somedayListId?: string | null;
|
||
somedaySlotIndex?: number | null;
|
||
repeatPattern?: string | null;
|
||
repeatEndDate?: string | null;
|
||
repeatStartDate?: string | null;
|
||
originalRecurringId?: string | null;
|
||
baseRecurringTask?: Task | null;
|
||
recurringExceptions?: RecurringTaskException[];
|
||
startTime?: string | null;
|
||
duration?: number | null;
|
||
parentTaskId?: string | null;
|
||
userId: string;
|
||
recurrenceInterval?: number | null;
|
||
recurrenceUnit?: string | null;
|
||
recurrenceTime?: string | null;
|
||
recurrenceEndDate?: Date | null;
|
||
recurrenceDays?: number[] | null;
|
||
externalListId?: string | null;
|
||
projectId?: string | null;
|
||
project?: { id: string; name: string; icon?: string | null; color?: string | null } | null;
|
||
kanbanStage?: string | null;
|
||
url?: string | null;
|
||
urgency?: boolean | null;
|
||
importance?: boolean | null;
|
||
priority?: string | null;
|
||
delegatedTo?: string | null;
|
||
delegationNote?: string | null;
|
||
}
|
||
|
||
interface CalendarEvent {
|
||
id: string;
|
||
title: string;
|
||
startTime: string;
|
||
endTime: string;
|
||
source: "google" | "apple" | "outlook" | "synology" | "notion";
|
||
calendarId?: string;
|
||
calendarTitle?: string;
|
||
calendarColor?: string;
|
||
editable?: boolean;
|
||
recurringEventId?: string;
|
||
isRecurring?: boolean;
|
||
description?: string;
|
||
location?: string;
|
||
url?: string;
|
||
}
|
||
|
||
export interface SomedayList {
|
||
id: string;
|
||
title: string;
|
||
tasks: Task[];
|
||
tab?: string | null;
|
||
color?: string | null;
|
||
icon?: string | null;
|
||
externalProvider?: string | null;
|
||
externalId?: string | null;
|
||
externalListId?: string | null;
|
||
}
|
||
|
||
// Time grid configuration options
|
||
export type CellDuration = 15 | 20 | 30 | 60;
|
||
const DEFAULT_SOMEDAY_SLOT_COUNT = 5;
|
||
|
||
const getSomedaySlotCount = (tasks: Task[]) => {
|
||
const maxIdx = tasks.reduce((max, t) => {
|
||
if (t.somedaySlotIndex !== null && t.somedaySlotIndex !== undefined) {
|
||
return Math.max(max, t.somedaySlotIndex);
|
||
}
|
||
return max;
|
||
}, -1);
|
||
// Add 1 extra slot if more than 4 tasks exist, or at least 5 slots total.
|
||
// "add 5 rows and then when 4 are taken add another row"
|
||
// Let's ensure there's always at least one empty slot at the bottom.
|
||
return Math.max(DEFAULT_SOMEDAY_SLOT_COUNT, maxIdx + 2);
|
||
};
|
||
|
||
// Helper to load Google Fonts
|
||
const useGoogleFonts = (fonts: string[]) => {
|
||
useEffect(() => {
|
||
if (typeof window === "undefined") return;
|
||
const fontsToLoad = fonts.filter((f) => f && f !== "Inter");
|
||
if (fontsToLoad.length === 0) return;
|
||
|
||
const linkId = "google-fonts-link";
|
||
let link = document.getElementById(linkId) as HTMLLinkElement;
|
||
|
||
const fontQuery = fontsToLoad.map((f) => f.replace(" ", "+")).join("|");
|
||
const href = `https://fonts.googleapis.com/css2?family=${fontsToLoad.map((f) => `${f.replace(" ", "+")}:wght@300;400;500;700`).join("&family=")}&subset=latin,latin-ext&display=swap`;
|
||
|
||
if (!link) {
|
||
link = document.createElement("link");
|
||
link.id = linkId;
|
||
link.rel = "stylesheet";
|
||
document.head.appendChild(link);
|
||
}
|
||
link.href = href;
|
||
}, [fonts]);
|
||
};
|
||
|
||
// Date utilities
|
||
function getStartOfWeek(date: Date, startDay: number = 0): Date {
|
||
const d = new Date(date);
|
||
const day = d.getDay();
|
||
const diff = d.getDate() - day + (day < startDay ? -7 : 0) + startDay; // if today is sun(0) and start is mon(1), day < start (0 < 1) -> -7 + 1 = -6. 0 - 6 = -6. Correct.
|
||
// Wait, let's re-verify:
|
||
// Start Mon(1). Today Sun(0). day=0. diff = date - 0 + (-7) + 1 = date - 6. Correct (last Monday).
|
||
// Start Mon(1). Today Mon(1). day=1. diff = date - 1 + (0) + 1 = date. Correct.
|
||
// Start Sun(0). Today Mon(1). day=1. diff = date - 1 + (0) + 0 = date - 1. Correct (last Sunday).
|
||
// Start Sun(0). Today Sun(0). day=0. diff = date - 0 + (0) + 0 = date. Correct.
|
||
// What if Start Mon(1), Today Tue(2). day=2. diff = date - 2 + 0 + 1 = date - 1. Correct.
|
||
|
||
// Better logic:
|
||
// const day = d.getDay();
|
||
// const diff = (day < startDay ? 7 : 0) + day - startDay;
|
||
// d.setDate(d.getDate() - diff);
|
||
//
|
||
// Let's stick to a robust one:
|
||
const currentDay = d.getDay();
|
||
const distance = (currentDay - startDay + 7) % 7;
|
||
d.setDate(d.getDate() - distance);
|
||
return d;
|
||
}
|
||
|
||
function formatDateHeader(date: Date, locale: string = "en-US"): string {
|
||
return date.toLocaleDateString(locale, { day: "numeric", month: "short" }); // e.g. 12. Feb.
|
||
}
|
||
|
||
function getDayName(date: Date, locale: string = "en-US", format?: string, customNames?: string, weekStartDay: number = 0, dayCase: string = "capitalize"): string {
|
||
let name: string;
|
||
if (format === "custom" && customNames) {
|
||
// Split by comma or semicolon to allow spaces in names
|
||
const names = customNames.split(/[,;]+/).map(s => s.trim()).filter(Boolean);
|
||
if (names.length === 7) {
|
||
// Adjust index based on weekStartDay (0=Sun, 1=Mon)
|
||
const index = (date.getDay() - weekStartDay + 7) % 7;
|
||
name = names[index];
|
||
} else {
|
||
name = date.toLocaleDateString(locale, { weekday: "long" });
|
||
}
|
||
} else {
|
||
const weekdayOption = format === "narrow" ? "narrow" : (format === "short" ? "short" : "long");
|
||
try {
|
||
name = date.toLocaleDateString(locale, { weekday: weekdayOption });
|
||
} catch (e) {
|
||
name = date.toLocaleDateString("en-US", { weekday: weekdayOption });
|
||
}
|
||
}
|
||
if (dayCase === "uppercase") return name.toUpperCase();
|
||
if (dayCase === "normal") return name.toLowerCase();
|
||
// capitalize: first letter uppercase, rest lowercase
|
||
return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase();
|
||
}
|
||
|
||
function isSameDay(d1: Date, d2: Date): boolean {
|
||
return d1.toDateString() === d2.toDateString();
|
||
}
|
||
|
||
function formatDateToISO(date: Date): string {
|
||
const year = date.getFullYear();
|
||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||
const day = String(date.getDate()).padStart(2, "0");
|
||
return `${year}-${month}-${day}`;
|
||
}
|
||
|
||
/**
|
||
* Parses a date string from a calendar event.
|
||
* If strictly a date (YYYY-MM-DD), it's parsed as local mid-night.
|
||
* If an ISO string with time, it's parsed regularly.
|
||
*/
|
||
function parseCalendarDate(dateStr: string): Date {
|
||
if (!dateStr) return new Date();
|
||
// If it's date-only (YYYY-MM-DD), parse as local midnight
|
||
if (!dateStr.includes("T")) {
|
||
const parts = dateStr.split("-").map(Number);
|
||
if (parts.length === 3) {
|
||
return new Date(parts[0], parts[1] - 1, parts[2], 0, 0, 0);
|
||
}
|
||
}
|
||
// If it's an ISO string but we want local midnight (e.g. from cache or older backend)
|
||
// we still parse it. The fix in the backend should reduce this.
|
||
return new Date(dateStr);
|
||
}
|
||
|
||
function formatHour(hour: number, minutes: number = 0, format: "short" | "full" = "short", timeFormat: string = "24h"): string {
|
||
if (timeFormat === "12h") {
|
||
const h = hour % 12 || 12;
|
||
const ampm = hour >= 12 ? "PM" : "AM";
|
||
const m = minutes > 0 ? `:${minutes.toString().padStart(2, "0")}` : "";
|
||
return format === "full" || minutes > 0 ? `${h}:${minutes.toString().padStart(2, "0")} ${ampm}` : `${h}${m} ${ampm}`;
|
||
}
|
||
// 24h format
|
||
if (format === "short" && minutes === 0) {
|
||
return `${hour}`;
|
||
}
|
||
return `${hour.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
|
||
}
|
||
|
||
function getTimeSlots(
|
||
cellDuration: CellDuration,
|
||
startHour: number,
|
||
endHour: number,
|
||
): string[] {
|
||
const slots: string[] = [];
|
||
const startMins = startHour * 60;
|
||
const endMins = endHour * 60;
|
||
for (let mins = startMins; mins < endMins; mins += cellDuration) {
|
||
const h = Math.floor(mins / 60);
|
||
const m = mins % 60;
|
||
slots.push(`${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`);
|
||
}
|
||
return slots;
|
||
}
|
||
|
||
function getHourFromSlot(slot: string): number {
|
||
return parseInt(slot.split(":")[0], 10);
|
||
}
|
||
|
||
function getWeekNumber(date: Date): number {
|
||
// ISO 8601 week number: weeks start on Monday
|
||
const d = new Date(
|
||
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()),
|
||
);
|
||
const dayNum = d.getUTCDay() || 7;
|
||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
||
}
|
||
|
||
// Get the Monday of the ISO week that the first visible day belongs to.
|
||
// This ensures CW changes when the first visible day crosses into a new ISO week.
|
||
function getCWReferenceDate(days: Date[]): Date {
|
||
if (days.length === 0) return new Date();
|
||
const first = days[0];
|
||
const dow = first.getDay(); // 0=Sun, 1=Mon, ..., 6=Sat
|
||
// Calculate distance back to Monday (ISO week start)
|
||
// Sunday (0) → go back 6 days to previous Monday
|
||
// Monday (1) → 0, Tuesday (2) → 1, etc.
|
||
const distToMonday = dow === 0 ? 6 : dow - 1;
|
||
return new Date(first.getTime() - distToMonday * 86400000);
|
||
}
|
||
|
||
// Format a custom header string using tokens
|
||
function formatCustomHeader(format: string, days: Date[], language: string, t: any, refDateOverride?: Date): string {
|
||
if (!format) return "";
|
||
|
||
// Choose the reference date: if today is within the visible days, use today.
|
||
// Otherwise, use the standard CW reference date (start of week).
|
||
const today = new Date();
|
||
const isTodayInWeek = days.some(d =>
|
||
d.getDate() === today.getDate() &&
|
||
d.getMonth() === today.getMonth() &&
|
||
d.getFullYear() === today.getFullYear()
|
||
);
|
||
const refDate = refDateOverride ?? (isTodayInWeek ? today : getCWReferenceDate(days));
|
||
|
||
// Define token mappings
|
||
const tokens: Record<string, string> = {
|
||
"YYYY": refDate.getFullYear().toString(),
|
||
"WW": getWeekNumber(refDate).toString().padStart(2, '0'),
|
||
"MMMM": refDate.toLocaleDateString(language, { month: 'long' }),
|
||
"MMM": refDate.toLocaleDateString(language, { month: 'short' }),
|
||
"MM": (refDate.getMonth() + 1).toString().padStart(2, '0'),
|
||
"M": (refDate.getMonth() + 1).toString(),
|
||
"DDDD": refDate.toLocaleDateString(language, { weekday: 'long' }),
|
||
"DDD": refDate.toLocaleDateString(language, { weekday: 'short' }),
|
||
"DD": refDate.getDate().toString().padStart(2, '0'),
|
||
"D": refDate.getDate().toString(),
|
||
"[TODAY]": today.toLocaleDateString(language, { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||
};
|
||
|
||
// Single-pass replacement using regex to avoid nested replacements (e.g. M in MMMM)
|
||
// Standalone 'W' removed to allow literal 'W' (like in 'KW')
|
||
const regex = /\[TODAY\]|YYYY|WW|MMMM|MMM|MM|M|DDDD|DDD|DD|D/g;
|
||
|
||
return format.replace(regex, (match) => tokens[match] || match);
|
||
}
|
||
|
||
// Get the "selected" day for current_day header.
|
||
// Priority: explicit selection → today if visible → first visible day
|
||
function getSelectedDay(days: Date[], explicitSelection?: Date | null): Date {
|
||
if (explicitSelection) return explicitSelection;
|
||
const today = new Date();
|
||
return days.some(d =>
|
||
d.getDate() === today.getDate() &&
|
||
d.getMonth() === today.getMonth() &&
|
||
d.getFullYear() === today.getFullYear()
|
||
) ? today : days[0];
|
||
}
|
||
|
||
// Check if an event is an all-day event
|
||
// Defined outside component to avoid stale closure issues in useCallbacks
|
||
const isAllDayEvent = (event: CalendarEvent): boolean => {
|
||
if (!event.startTime) return false;
|
||
|
||
// Date-only format (YYYY-MM-DD)
|
||
if (!event.startTime.includes("T")) return true;
|
||
|
||
const start = parseCalendarDate(event.startTime);
|
||
const end = parseCalendarDate(event.endTime);
|
||
const durationHours = (end.getTime() - start.getTime()) / (1000 * 60 * 60);
|
||
|
||
// Check if strictly midnight to midnight in local time
|
||
const isLocalMidnight = start.getHours() === 0 && start.getMinutes() === 0;
|
||
|
||
// Check if UTC midnight (common for API-converted date strings)
|
||
const isUTCMidnight =
|
||
start.getUTCHours() === 0 && start.getUTCMinutes() === 0;
|
||
|
||
// If it's effectively 24h+ and starts at midnight (local or UTC), treat as all-day
|
||
return durationHours >= 24 && (isLocalMidnight || isUTCMidnight);
|
||
};
|
||
|
||
// Helper to invert colors for dark mode
|
||
function invertColor(hex: string): string {
|
||
if (!hex) return hex;
|
||
let color = hex.startsWith("#") ? hex.slice(1) : hex;
|
||
if (color.length === 3) {
|
||
color = color
|
||
.split("")
|
||
.map((c) => c + c)
|
||
.join("");
|
||
}
|
||
if (color.length !== 6) return hex;
|
||
|
||
try {
|
||
const r = (255 - parseInt(color.slice(0, 2), 16))
|
||
.toString(16)
|
||
.padStart(2, "0");
|
||
const g = (255 - parseInt(color.slice(2, 4), 16))
|
||
.toString(16)
|
||
.padStart(2, "0");
|
||
const b = (255 - parseInt(color.slice(4, 6), 16))
|
||
.toString(16)
|
||
.padStart(2, "0");
|
||
return `#${r}${g}${b}`;
|
||
} catch (e) {
|
||
return hex;
|
||
}
|
||
}
|
||
|
||
// Helper to lighten color for dark mode
|
||
function adjustColorForDarkMode(hex: string, isDarkMode: boolean): string {
|
||
if (!isDarkMode || !hex || !hex.startsWith("#")) return hex;
|
||
|
||
// Simple hex to RGB
|
||
let r = parseInt(hex.slice(1, 3), 16);
|
||
let g = parseInt(hex.slice(3, 5), 16);
|
||
let b = parseInt(hex.slice(5, 7), 16);
|
||
|
||
// Calculate brightness (0-255)
|
||
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
||
|
||
// If it's too dark for dark mode, lighten it
|
||
if (brightness < 120) {
|
||
r = Math.min(255, r + 100);
|
||
g = Math.min(255, g + 100);
|
||
b = Math.min(255, b + 100);
|
||
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
|
||
}
|
||
|
||
return hex;
|
||
}
|
||
|
||
// Main Component
|
||
export default function WeeklyView() {
|
||
const { data: session } = useSession();
|
||
const [tasks, setTasks] = useState<Task[]>([]);
|
||
const [connections, setConnections] = useState<any[]>([]); // Lifted state
|
||
const [rawCalendarEvents, setRawCalendarEvents] = useState<CalendarEvent[]>(
|
||
[],
|
||
);
|
||
|
||
// Weather data: { "2026-03-17T08:00": { temp: 5, code: 2, wind: 12, ... }, ... }
|
||
type WeatherHour = { temp: number; code: number; feelsLike?: number; wind?: number; gusts?: number; precipProb?: number; precip?: number; humidity?: number; uv?: number };
|
||
const [weatherData, setWeatherData] = useState<Record<string, WeatherHour>>({});
|
||
|
||
// Extend events with editable flag from connections, deduplicate by id
|
||
// Also deduplicate recurring series masters vs expanded instances:
|
||
// When a recurring event is created, the master is cached. Then the sync
|
||
// returns expanded instances with different IDs but the same recurringEventId.
|
||
// We keep instances and discard masters that overlap with them.
|
||
|
||
// Build calendarId → editable map once per connections change (O(connections × calendars))
|
||
const calendarEditabilityMap = useMemo(() => {
|
||
const map = new Map<string, boolean>();
|
||
for (const conn of connections) {
|
||
if (conn.calendars && Array.isArray(conn.calendars)) {
|
||
for (const cal of conn.calendars as any[]) {
|
||
if (cal.id && !map.has(cal.id)) {
|
||
map.set(cal.id, !!cal.editable);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return map;
|
||
}, [connections]);
|
||
|
||
const calendarEvents = useMemo(() => {
|
||
const seen = new Set<string>();
|
||
const seenSlot = new Set<string>();
|
||
// Collect recurring event IDs that have expanded instances
|
||
const seriesWithInstances = new Set<string>();
|
||
for (const event of rawCalendarEvents) {
|
||
if (event.recurringEventId && event.id !== event.recurringEventId) {
|
||
seriesWithInstances.add(event.recurringEventId);
|
||
}
|
||
}
|
||
return rawCalendarEvents.filter((event) => {
|
||
if (seen.has(event.id)) return false;
|
||
seen.add(event.id);
|
||
// Skip series master if expanded instances exist for this series
|
||
if (seriesWithInstances.has(event.id)) return false;
|
||
// Deduplicate by title+startTime+calendarId (catches optimistic add + cache read)
|
||
const slotKey = `${event.title}|${event.startTime}|${event.calendarId}`;
|
||
if (seenSlot.has(slotKey)) return false;
|
||
seenSlot.add(slotKey);
|
||
return true;
|
||
}).map((event) => ({
|
||
...event,
|
||
editable: event.calendarId ? (calendarEditabilityMap.get(event.calendarId) ?? false) : false,
|
||
}));
|
||
}, [rawCalendarEvents, calendarEditabilityMap]);
|
||
const [currentWeekStart, setCurrentWeekStart] = useState(() => {
|
||
const d = new Date();
|
||
d.setHours(0, 0, 0, 0);
|
||
return d;
|
||
});
|
||
const [viewDays, setViewDays] = useState(7);
|
||
const savedViewDaysRef = useRef(7); // Track user's saved preference for restoring on resize
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
|
||
// Responsive: auto-adjust viewDays based on screen orientation / width
|
||
useEffect(() => {
|
||
const getResponsiveViewDays = (width: number, height: number): number => {
|
||
if (width <= 768) {
|
||
// Mobile: portrait → 1 day, landscape → 3 days
|
||
return height > width ? 1 : 3;
|
||
}
|
||
if (width <= 1024) return Math.min(savedViewDaysRef.current, 5);
|
||
return savedViewDaysRef.current;
|
||
};
|
||
|
||
const handleResize = () => {
|
||
const responsiveDays = getResponsiveViewDays(window.innerWidth, window.innerHeight);
|
||
setViewDays(responsiveDays);
|
||
};
|
||
|
||
// Set initial value
|
||
handleResize();
|
||
|
||
window.addEventListener('resize', handleResize);
|
||
return () => window.removeEventListener('resize', handleResize);
|
||
}, []); // savedViewDaysRef is a ref, so no dependency needed
|
||
const [isSyncing, setIsSyncing] = useState(false);
|
||
const [isFetchingCalendar, setIsFetchingCalendar] = useState(false);
|
||
const [syncError, setSyncError] = useState<string | null>(null);
|
||
const syncCountRef = useRef(0);
|
||
const startSync = useCallback(() => { syncCountRef.current++; setIsSyncing(true); }, []);
|
||
const endSync = useCallback(() => { syncCountRef.current = Math.max(0, syncCountRef.current - 1); if (syncCountRef.current === 0) setIsSyncing(false); }, []);
|
||
const [darkMode, setDarkMode] = useState(false);
|
||
const [timeFormat, setTimeFormat] = useState("24h");
|
||
const [dateFormat, setDateFormat] = useState("yyyy-MM-dd");
|
||
const [hourLabelFormat, setHourLabelFormat] = useState<"short" | "full">("short");
|
||
const [showSubHourSlots, setShowSubHourSlots] = useState<boolean>(() => {
|
||
const c = getCookie("setting_showSubHourSlots");
|
||
return c !== null ? c === "true" : true;
|
||
});
|
||
const [allDayPosition, setAllDayPosition] = useState<"above" | "below">("below");
|
||
|
||
const [somedayExpanded, setSomedayExpanded] = useState(true);
|
||
const [isAllDayExpanded, setIsAllDayExpanded] = useState(true);
|
||
const [somedayHeight, setSomedayHeight] = useState<number | null>(() => {
|
||
if (typeof document !== 'undefined') {
|
||
const c = document.cookie.match(/somedayHeight=(\d+)/);
|
||
return c ? parseInt(c[1]) : null;
|
||
}
|
||
return null;
|
||
});
|
||
const [allDayHeight, setAllDayHeight] = useState<number | null>(() => {
|
||
if (typeof document !== 'undefined') {
|
||
const c = document.cookie.match(/allDayHeight=(\d+)/);
|
||
return c ? parseInt(c[1]) : null;
|
||
}
|
||
return null;
|
||
});
|
||
const [priorityViewHeight, setPriorityViewHeight] = useState<number>(() => {
|
||
if (typeof document !== 'undefined') {
|
||
const c = document.cookie.match(/priorityViewHeight=(\d+)/);
|
||
return c ? parseInt(c[1]) : 340;
|
||
}
|
||
return 340;
|
||
});
|
||
const priorityViewRef = useRef<HTMLDivElement | null>(null);
|
||
|
||
const resizingRef = useRef<{ target: 'someday' | 'allday' | 'priority'; startY: number; startHeight: number; handleOnTop: boolean } | null>(null);
|
||
|
||
const startResize = useCallback((e: React.MouseEvent | React.TouchEvent, target: 'someday' | 'allday' | 'priority', handleOnTop = false) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
||
const section = target === 'someday' ? somedaySectionRef.current : target === 'priority' ? priorityViewRef.current : (document.querySelector('.all-day-events-section') as HTMLElement);
|
||
if (!section) return;
|
||
resizingRef.current = { target, startY: clientY, startHeight: section.getBoundingClientRect().height, handleOnTop };
|
||
|
||
const onMove = (ev: MouseEvent | TouchEvent) => {
|
||
if (!resizingRef.current) return;
|
||
const y = 'touches' in ev ? ev.touches[0].clientY : ev.clientY;
|
||
const rawDelta = y - resizingRef.current.startY;
|
||
// Top handle: dragging up = increase height (invert delta); bottom handle: normal
|
||
const delta = resizingRef.current.handleOnTop ? -rawDelta : rawDelta;
|
||
if (resizingRef.current.target === 'priority') {
|
||
setPriorityViewHeight(Math.max(80, Math.min(800, resizingRef.current.startHeight + delta)));
|
||
} else {
|
||
const newHeight = Math.max(40, Math.min(600, resizingRef.current.startHeight + delta));
|
||
if (resizingRef.current.target === 'someday') setSomedayHeight(newHeight);
|
||
else setAllDayHeight(newHeight);
|
||
}
|
||
};
|
||
const onEnd = () => {
|
||
if (resizingRef.current) {
|
||
const cookieKey = resizingRef.current.target === 'someday' ? 'somedayHeight' : resizingRef.current.target === 'priority' ? 'priorityViewHeight' : 'allDayHeight';
|
||
const section2 = resizingRef.current.target === 'someday' ? somedaySectionRef.current : resizingRef.current.target === 'priority' ? priorityViewRef.current : (document.querySelector('.all-day-events-section') as HTMLElement);
|
||
if (section2) {
|
||
const h = Math.round(section2.getBoundingClientRect().height);
|
||
document.cookie = `${cookieKey}=${h};path=/;max-age=31536000`;
|
||
}
|
||
}
|
||
resizingRef.current = null;
|
||
window.removeEventListener('mousemove', onMove);
|
||
window.removeEventListener('mouseup', onEnd);
|
||
window.removeEventListener('touchmove', onMove);
|
||
window.removeEventListener('touchend', onEnd);
|
||
};
|
||
window.addEventListener('mousemove', onMove);
|
||
window.addEventListener('mouseup', onEnd);
|
||
window.addEventListener('touchmove', onMove);
|
||
window.addEventListener('touchend', onEnd);
|
||
}, []);
|
||
|
||
const [somedayLists, setSomedayLists] = useState<SomedayList[]>([]);
|
||
const [projects, setProjects] = useState<{ id: string; name: string; icon?: string | null; color?: string | null }[]>([]);
|
||
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
|
||
const [draggingListId, setDraggingListId] = useState<string | null>(null);
|
||
const [listToDelete, setListToDelete] = useState<string | null>(null);
|
||
const [menuOpenListId, setMenuOpenListId] = 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 [editingListVisualsRect, setEditingListVisualsRect] = useState<DOMRect | null>(null);
|
||
const [editingTabVisualsName, setEditingTabVisualsName] = useState<string | null>(null);
|
||
const [editingTabVisualsRect, setEditingTabVisualsRect] = useState<DOMRect | null>(null);
|
||
const [activeSomedayTab, setActiveSomedayTab] = useState<string | null>(null);
|
||
const [editingTabName, setEditingTabName] = useState<string | null>(null);
|
||
const [renamingTabValue, setRenamingTabValue] = useState("");
|
||
const [newTabForListId, setNewTabForListId] = useState<string | null>(null);
|
||
const [newTabNameValue, setNewTabNameValue] = useState("");
|
||
const [creatingNewTab, setCreatingNewTab] = useState(false);
|
||
const [creatingNewTabName, setCreatingNewTabName] = useState("");
|
||
const [dragOverTab, setDragOverTab] = useState<string | null>(null);
|
||
const [customTabs, setCustomTabs] = useState<string[]>([]);
|
||
|
||
useEffect(() => {
|
||
const email = session?.user?.email;
|
||
if (typeof window !== "undefined" && email) {
|
||
const saved = localStorage.getItem(`weekly_active_someday_tab_${email}`);
|
||
if (saved) setActiveSomedayTab(saved === "__all__" ? null : saved);
|
||
try {
|
||
const savedTabs = localStorage.getItem(`weekly_custom_tabs_${email}`);
|
||
if (savedTabs) setCustomTabs(JSON.parse(savedTabs));
|
||
} catch { /* ignore */ }
|
||
// Migrate old non-namespaced keys (one-time cleanup)
|
||
if (localStorage.getItem("weekly_custom_tabs") && !localStorage.getItem(`weekly_custom_tabs_${email}_migrated`)) {
|
||
const oldTabs = localStorage.getItem("weekly_custom_tabs");
|
||
const oldActive = localStorage.getItem("weekly_active_someday_tab");
|
||
if (oldTabs && !localStorage.getItem(`weekly_custom_tabs_${email}`)) {
|
||
localStorage.setItem(`weekly_custom_tabs_${email}`, oldTabs);
|
||
try { setCustomTabs(JSON.parse(oldTabs)); } catch { /* ignore */ }
|
||
}
|
||
if (oldActive && !localStorage.getItem(`weekly_active_someday_tab_${email}`)) {
|
||
localStorage.setItem(`weekly_active_someday_tab_${email}`, oldActive);
|
||
setActiveSomedayTab(oldActive === "__all__" ? null : oldActive);
|
||
}
|
||
localStorage.removeItem("weekly_custom_tabs");
|
||
localStorage.removeItem("weekly_active_someday_tab");
|
||
localStorage.setItem(`weekly_custom_tabs_${email}_migrated`, "1");
|
||
}
|
||
}
|
||
}, [session?.user?.email]);
|
||
|
||
const saveCustomTabs = (tabs: string[]) => {
|
||
setCustomTabs(tabs);
|
||
const email = session?.user?.email;
|
||
if (email) localStorage.setItem(`weekly_custom_tabs_${email}`, JSON.stringify(tabs));
|
||
// Also persist to DB so tabs survive on other devices and reconnects
|
||
const updated = { ...(viewSettingsRef.current as any), somedayCustomTabs: tabs };
|
||
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 custom tabs to DB:", e));
|
||
};
|
||
|
||
const somedayTabs = useMemo(() => {
|
||
const tabs = new Set<string>();
|
||
somedayLists.forEach(l => { if (l.tab) tabs.add(l.tab); });
|
||
customTabs.forEach(t => tabs.add(t));
|
||
return Array.from(tabs).sort();
|
||
}, [somedayLists, customTabs]);
|
||
|
||
const setSomedayTab = (tab: string | null) => {
|
||
setActiveSomedayTab(tab);
|
||
const email = session?.user?.email;
|
||
if (email) localStorage.setItem(`weekly_active_someday_tab_${email}`, tab ?? "__all__");
|
||
};
|
||
|
||
const assignListToTab = async (listId: string, tab: string | null) => {
|
||
setSomedayLists(prev => prev.map(l => l.id === listId ? { ...l, tab } : l));
|
||
// Persist title→tab preference so it survives reconnects
|
||
const list = somedayLists.find(l => l.id === listId);
|
||
if (list) {
|
||
const prefs: Record<string, string> = { ...((viewSettingsRef.current as any).somedayTabPrefs || {}) };
|
||
if (tab) { prefs[list.title] = tab; } else { delete prefs[list.title]; }
|
||
const updated = { ...(viewSettingsRef.current as any), somedayTabPrefs: prefs };
|
||
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 pref:", e));
|
||
}
|
||
try {
|
||
await fetch("/api/someday-lists", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ id: listId, tab }),
|
||
});
|
||
} catch (e) {
|
||
console.error("Failed to update list tab:", e);
|
||
}
|
||
};
|
||
|
||
const renameTab = async (oldName: string, newName: string) => {
|
||
if (!newName.trim() || newName === oldName) return;
|
||
const trimmed = newName.trim();
|
||
const listsToUpdate = somedayLists.filter(l => l.tab === oldName);
|
||
setSomedayLists(prev => prev.map(l => l.tab === oldName ? { ...l, tab: trimmed } : l));
|
||
if (customTabs.includes(oldName)) {
|
||
saveCustomTabs(customTabs.map(t => t === oldName ? trimmed : t));
|
||
}
|
||
if (activeSomedayTab === oldName) setSomedayTab(trimmed);
|
||
for (const list of listsToUpdate) {
|
||
try {
|
||
await fetch("/api/someday-lists", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ id: list.id, tab: trimmed }),
|
||
});
|
||
} catch (e) {
|
||
console.error("Failed to rename tab for list:", e);
|
||
}
|
||
}
|
||
};
|
||
|
||
// 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 listsToUpdate = somedayLists.filter(l => l.tab === tabName);
|
||
setSomedayLists(prev => prev.map(l => l.tab === tabName ? { ...l, tab: null } : l));
|
||
if (customTabs.includes(tabName)) {
|
||
saveCustomTabs(customTabs.filter(t => t !== tabName));
|
||
}
|
||
if (activeSomedayTab === tabName) setSomedayTab(null);
|
||
for (const list of listsToUpdate) {
|
||
try {
|
||
await fetch("/api/someday-lists", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ id: list.id, tab: null }),
|
||
});
|
||
} catch (e) {
|
||
console.error("Failed to dissolve tab for list:", e);
|
||
}
|
||
}
|
||
};
|
||
|
||
const filteredSomedayLists = useMemo(() => {
|
||
if (activeSomedayTab === null) return somedayLists;
|
||
return somedayLists.filter(l => (l.tab || null) === activeSomedayTab);
|
||
}, [somedayLists, activeSomedayTab]);
|
||
const [dropTargetListIndex, setDropTargetListIndex] = useState<number | null>(null);
|
||
const [activeAddSlot, setActiveAddSlot] = useState<{ listId: string; slotIdx: number } | null>(null);
|
||
const isDragFromHandle = useRef(false);
|
||
|
||
// Undo/Redo state
|
||
const undoStackRef = useRef<{ tasks: Task[]; somedayLists: SomedayList[] }[]>([]);
|
||
const redoStackRef = useRef<{ tasks: Task[]; somedayLists: SomedayList[] }[]>([]);
|
||
const [undoCount, setUndoCount] = useState(0);
|
||
const [redoCount, setRedoCount] = useState(0);
|
||
const skipSnapshotRef = useRef(false);
|
||
|
||
// Mobile detection
|
||
const [isMobile, setIsMobile] = useState(false);
|
||
const [isPortrait, setIsPortrait] = useState(false);
|
||
const [isCompactHeight, setIsCompactHeight] = useState(false);
|
||
|
||
// Tracks the last day column the user interacted with (for "selected day" header display)
|
||
const [selectedDay, setSelectedDay] = useState<Date | null>(null);
|
||
|
||
const [showMobileFabSheet, setShowMobileFabSheet] = useState(false);
|
||
const [showMobileRail, setShowMobileRail] = useState(false);
|
||
const [showMobileFabMenu, setShowMobileFabMenu] = useState(false);
|
||
const [mobileStickyDay, setMobileStickyDay] = useState<string | null>(null);
|
||
const [mobileStickyDayVisible, setMobileStickyDayVisible] = useState(false);
|
||
const [showHeaderMore, setShowHeaderMore] = useState(false);
|
||
const [fabTaskTitle, setFabTaskTitle] = useState("");
|
||
|
||
const fabTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||
|
||
// Moved state definitions to the top
|
||
const [showSettings, setShowSettings] = useState(false);
|
||
const [showOnboarding, setShowOnboarding] = useState(false);
|
||
const [activeTab, setActiveTab] = useState<
|
||
"calendar" | "general" | "account" | "styling" | "motivation" | "about"
|
||
>("general");
|
||
const [exportStartDate, setExportStartDate] = useState("");
|
||
const [exportEndDate, setExportEndDate] = useState("");
|
||
const [showWeekPrintModal, setShowWeekPrintModal] = useState(false);
|
||
const [passwords, setPasswords] = useState({ new: "", confirm: "" });
|
||
const [accountMsg, setAccountMsg] = useState<string>("");
|
||
const [importingTasksState, setImportingTasksState] =
|
||
useState<boolean>(false);
|
||
const [importStatusMsg, setImportStatusMsg] = useState<{
|
||
type: "success" | "error";
|
||
text: string;
|
||
} | null>(null);
|
||
const [unsyncConfirm, setUnsyncConfirm] = useState<{
|
||
provider: "google" | "apple" | "outlook" | "synology";
|
||
list: { id: string; title: string };
|
||
} | null>(null);
|
||
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
||
const [importProvider, setImportProvider] = useState<
|
||
"google" | "apple" | "outlook" | "synology" | null
|
||
>(null);
|
||
const [importLists, setImportLists] = useState<
|
||
{ id: string; title: string }[]
|
||
>([]);
|
||
const [isFetchingLists, setIsFetchingLists] = useState(false);
|
||
const [availableTaskLists, setAvailableTaskLists] = useState<{
|
||
[key in "google" | "apple" | "outlook" | "synology"]?: { id: string; title: string }[];
|
||
}>({});
|
||
const [isFetchingProviderLists, setIsFetchingProviderLists] = useState<
|
||
Record<string, boolean>
|
||
>({});
|
||
const [isVisible, setIsVisible] = useState(false);
|
||
const [profile, setProfile] = useState<any>({
|
||
name: session?.user?.name || "",
|
||
email: session?.user?.email || "",
|
||
timezone: "UTC",
|
||
language: "de",
|
||
dateFormat: "yyyy-MM-dd",
|
||
timeFormat: "24h",
|
||
startHour: 8,
|
||
endHour: 18,
|
||
autoRolling: false,
|
||
protectEventTimes: false,
|
||
showTimeGrid: true,
|
||
cellDuration: 30,
|
||
viewStyle: "simple",
|
||
fontSize: "M",
|
||
showNextTask: false,
|
||
showSomeday: true,
|
||
showAllDayEvents: true,
|
||
showSchedule: true,
|
||
hourLabelFormat: "short",
|
||
showSubHourSlots: true,
|
||
dayHeaderGap: "0.75em",
|
||
dateVerticalAlign: "middle",
|
||
allDayPosition: "above",
|
||
weekStartDay: 1,
|
||
focusTimerDuration: 25,
|
||
focusBreakDuration: 5,
|
||
headlineFont: "Oswald",
|
||
headlineFontSize: "1.5rem",
|
||
headlineFontWeight: "900",
|
||
weekdayColor: "#0ea5e9",
|
||
dateFontFamily: "Inter",
|
||
dateFontSize: "0.65rem",
|
||
dateFontWeight: "400",
|
||
timeTaskFontFamily: "Inter",
|
||
timeTaskFontSize: "0.75rem",
|
||
timeTaskFontWeight: "500",
|
||
bodyFont: "Inter",
|
||
taskFontFamily: "Inter",
|
||
taskFontSize: "0.9rem",
|
||
taskFontWeight: "400",
|
||
eventFontFamily: "Inter",
|
||
eventFontSize: "0.85rem",
|
||
eventFontWeight: "400",
|
||
goalFallbackType: "quote",
|
||
goalFontFamily: "Lato",
|
||
goalFontSize: "1rem",
|
||
goalFontWeight: "500",
|
||
goalScope: "week",
|
||
dateLayout: "right",
|
||
mobileDateLayout: "below",
|
||
dateAlignment: "center",
|
||
weekendColorSat: "#ffc107",
|
||
weekendColorSun: "#dc2626",
|
||
pastDayColor: "#a6a6a7",
|
||
cwFontFamily: "Oswald",
|
||
cwFontSize: "1.5rem",
|
||
cwFontWeight: "700",
|
||
yearFontFamily: "Oswald",
|
||
yearFontSize: "1.5rem",
|
||
yearFontWeight: "700",
|
||
quoteSourceUrl: "",
|
||
quoteSourceUrls: [],
|
||
quoteLanguages: ["en", "de"],
|
||
weatherEnabled: false,
|
||
weatherLat: null,
|
||
weatherLon: null,
|
||
weatherLocation: "",
|
||
showCalendarProviderIcon: false,
|
||
menuPosition: "left",
|
||
showHeaderControls: true,
|
||
});
|
||
const [motivationalQuote, setMotivationalQuote] = useState("");
|
||
const [showSummary, setShowSummary] = useState(false);
|
||
|
||
const [isAddingSomedayList, setIsAddingSomedayList] = useState(false);
|
||
const [newSomedayListName, setNewSomedayListName] = useState("");
|
||
const [selectedSomedayProvider, setSelectedSomedayProvider] = useState<
|
||
string | null
|
||
>(null);
|
||
const [language, setLanguage] = useState("de");
|
||
const [syncStatus, setSyncStatus] = useState<"idle" | "syncing" | "synced">(
|
||
"idle",
|
||
);
|
||
const [announceMsg, setAnnounceMsg] = useState('');
|
||
const announceTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const announce = (msg: string) => {
|
||
if (announceTimeout.current) clearTimeout(announceTimeout.current);
|
||
setAnnounceMsg(msg);
|
||
announceTimeout.current = setTimeout(() => setAnnounceMsg(''), 3000);
|
||
};
|
||
const [cellDuration, setCellDuration] = useState<CellDuration>(30);
|
||
const [draggedTask, setDraggedTask] = useState<Task | null>(null);
|
||
const [showTimeGrid, setShowTimeGrid] = useState<boolean>(() => {
|
||
const c = getCookie("setting_showTimeGrid");
|
||
return c !== null ? c === "true" : true;
|
||
});
|
||
const [slideDirection, setSlideDirection] = useState<"next" | "prev" | null>(
|
||
null,
|
||
);
|
||
const [activeSlot, setActiveSlot] = useState<{
|
||
day: number;
|
||
slot: string;
|
||
} | null>(null);
|
||
const [newSlotTask, setNewSlotTask] = useState("");
|
||
const [selectedTaskForNotes, setSelectedTaskForNotes] = useState<Task | null>(
|
||
null,
|
||
);
|
||
const [currentTime, setCurrentTime] = useState(new Date());
|
||
const [dropPreview, setDropPreview] = useState<{
|
||
day?: number;
|
||
slot?: string;
|
||
listId?: string;
|
||
slotIdx?: number;
|
||
} | null>(null);
|
||
const [viewStyle, setViewStyle] = useState<ViewStyle>(() => {
|
||
const c = getCookie("setting_viewStyle");
|
||
return (c as ViewStyle) || "simple";
|
||
});
|
||
|
||
// Per-view settings: overrides that apply only to a specific view
|
||
type PerViewOverrides = {
|
||
hourLabelFormat?: "short" | "full";
|
||
showSubHourSlots?: boolean;
|
||
weatherEnabled?: boolean;
|
||
weatherDisplay?: WeatherDisplayKey[];
|
||
showTaskCheckboxes?: boolean;
|
||
showProjectIcons?: boolean;
|
||
showPriorityIcons?: boolean;
|
||
showSomeday?: boolean;
|
||
showAllDayEvents?: boolean;
|
||
allDayPosition?: "above" | "below";
|
||
showCompletedTasks?: boolean;
|
||
cellDuration?: number;
|
||
startHour?: number;
|
||
endHour?: number;
|
||
};
|
||
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 viewSettingsRef = useRef<Record<string, PerViewOverrides>>({});
|
||
viewSettingsRef.current = viewSettings;
|
||
|
||
// Device-local per-view overrides (cookie-based, not synced to DB)
|
||
const [deviceViewSettings, setDeviceViewSettings] = useState<Record<string, Record<string, any>>>(() => readDeviceViewCookie());
|
||
const deviceViewSettingsRef = useRef<Record<string, Record<string, any>>>({});
|
||
deviceViewSettingsRef.current = deviceViewSettings;
|
||
|
||
const getEffective = <K extends keyof PerViewOverrides>(key: K, globalVal: PerViewOverrides[K]): PerViewOverrides[K] => {
|
||
// Device-specific settings take priority (cookie-based, not synced across devices)
|
||
if ((DEVICE_VIEW_SETTINGS_KEYS as readonly string[]).includes(key)) {
|
||
const dvs = deviceViewSettingsRef.current[profile.viewStyle];
|
||
if (dvs && dvs[key] !== undefined) return dvs[key] as PerViewOverrides[K];
|
||
}
|
||
const vs = viewSettingsRef.current[profile.viewStyle];
|
||
if (vs && vs[key] !== undefined) return vs[key] as PerViewOverrides[K];
|
||
return globalVal;
|
||
};
|
||
const isPerView = (key: keyof PerViewOverrides): boolean => {
|
||
const vs = viewSettingsRef.current[profile.viewStyle];
|
||
return !!(vs && vs[key] !== undefined);
|
||
};
|
||
const saveViewSetting = async <K extends keyof PerViewOverrides>(key: K, value: PerViewOverrides[K], perView: boolean) => {
|
||
// Device-specific keys: save to cookie only, not DB
|
||
if ((DEVICE_VIEW_SETTINGS_KEYS as readonly string[]).includes(key) && perView) {
|
||
const dvs = { ...deviceViewSettingsRef.current };
|
||
dvs[profile.viewStyle] = { ...(dvs[profile.viewStyle] || {}), [key]: value };
|
||
deviceViewSettingsRef.current = dvs;
|
||
setDeviceViewSettings(dvs);
|
||
writeDeviceViewCookie(dvs);
|
||
return;
|
||
}
|
||
const updated = { ...viewSettingsRef.current };
|
||
if (perView) {
|
||
updated[profile.viewStyle] = { ...(updated[profile.viewStyle] || {}), [key]: value };
|
||
} else {
|
||
// Remove per-view overrides for this key from ALL views and set globally
|
||
for (const v of Object.keys(updated)) {
|
||
if (updated[v] && updated[v][key] !== undefined) {
|
||
const { [key]: _, ...rest } = updated[v] as any;
|
||
updated[v] = rest;
|
||
}
|
||
}
|
||
}
|
||
viewSettingsRef.current = updated;
|
||
setViewSettings(updated);
|
||
// Save to DB
|
||
try {
|
||
await fetch("/api/user/profile", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ viewSettings: updated }),
|
||
});
|
||
} catch (e) { console.error("Failed to save view settings:", e); }
|
||
};
|
||
const togglePerView = async (key: keyof PerViewOverrides, globalVal: any) => {
|
||
if (isPerView(key)) {
|
||
// Remove per-view override (revert to global)
|
||
const updated = { ...viewSettingsRef.current };
|
||
if (updated[profile.viewStyle]) {
|
||
const { [key]: _, ...rest } = updated[profile.viewStyle] as any;
|
||
updated[profile.viewStyle] = rest;
|
||
}
|
||
viewSettingsRef.current = updated;
|
||
setViewSettings(updated);
|
||
try {
|
||
await fetch("/api/user/profile", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ viewSettings: updated }),
|
||
});
|
||
} catch (e) { console.error("Failed to save view settings:", e); }
|
||
} else {
|
||
// Set per-view override to current global value
|
||
saveViewSetting(key, globalVal, true);
|
||
}
|
||
};
|
||
|
||
// State declarations needed before effective per-view values
|
||
const [showSomeday, setShowSomeday] = useState(true);
|
||
const [showAllDay, setShowAllDay] = useState(true);
|
||
|
||
// Effective per-view values (override if set for current view, else global)
|
||
const effectiveHourLabelFormat = getEffective("hourLabelFormat", profile.hourLabelFormat ?? "short");
|
||
const effectiveShowSubHourSlots = getEffective("showSubHourSlots", profile.showSubHourSlots ?? true);
|
||
const effectiveWeatherEnabled = getEffective("weatherEnabled", profile.weatherEnabled);
|
||
const effectiveWeatherDisplay = (getEffective("weatherDisplay", WEATHER_DISPLAY_DEFAULTS) || WEATHER_DISPLAY_DEFAULTS) as WeatherDisplayKey[];
|
||
const effectiveShowTaskCheckboxes = getEffective("showTaskCheckboxes", profile.showTaskCheckboxes);
|
||
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 effectiveShowAllDay = getEffective("showAllDayEvents", profile.showAllDayEvents ?? true);
|
||
const effectiveAllDayPosition = getEffective("allDayPosition", profile.allDayPosition ?? "above") || "above";
|
||
const effectiveShowCompletedTasks = getEffective("showCompletedTasks", profile.showCompletedTasks !== false);
|
||
const effectiveCellDuration = getEffective("cellDuration", cellDuration) as CellDuration;
|
||
const effectiveStartHour = getEffective("startHour", profile.startHour ?? 8) ?? 8;
|
||
const effectiveEndHour = getEffective("endHour", profile.endHour ?? 18) ?? 18;
|
||
|
||
const defaultKanbanStages: KanbanStage[] = [
|
||
{ id: "backlog", name: "Backlog", color: "#94a3b8" },
|
||
{ id: "todo", name: "To Do", color: "#3b82f6" },
|
||
{ id: "in-progress", name: "In Progress", color: "#f59e0b" },
|
||
{ id: "review", name: "Review", color: "#8b5cf6" },
|
||
{ id: "done", name: "Done", color: "#22c55e" },
|
||
];
|
||
const [kanbanStages, setKanbanStages] = useState<KanbanStage[]>(defaultKanbanStages);
|
||
const saveKanbanStages = async (stages: KanbanStage[]) => {
|
||
setKanbanStages(stages);
|
||
try {
|
||
await fetch("/api/user/profile", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ kanbanStages: JSON.stringify(stages) }),
|
||
});
|
||
} catch (e) { console.error("Failed to save kanban stages:", e); }
|
||
};
|
||
// Kanban filters
|
||
const [kanbanFilterProject, setKanbanFilterProject] = useState<string>("");
|
||
const [kanbanFilterList, setKanbanFilterList] = useState<string>("");
|
||
const [kanbanFilterWeek, setKanbanFilterWeek] = useState<string>("");
|
||
const [kanbanSearch, setKanbanSearch] = useState("");
|
||
const [kanbanDeleteStageId, setKanbanDeleteStageId] = useState<string | null>(null);
|
||
const [kanbanAddingStageId, setKanbanAddingStageId] = useState<string | null>(null);
|
||
const [kanbanNewTaskTitle, setKanbanNewTaskTitle] = useState("");
|
||
const [kanbanDetailTask, setKanbanDetailTask] = useState<Task | null>(null);
|
||
const [kanbanExpandedCards, setKanbanExpandedCards] = useState<Set<string>>(new Set());
|
||
|
||
const [protectEventTimes, setProtectEventTimes] = useState(false);
|
||
const [unlockedEvents, setUnlockedEvents] = useState<Set<string>>(new Set());
|
||
|
||
const [startHour, setStartHour] = useState(8);
|
||
const [endHour, setEndHour] = useState(18);
|
||
const [weekStartDay, setWeekStartDay] = useState(1); // 1 = Monday, 0 = Sunday
|
||
const [goal, setGoal] = useState("your goal of this week");
|
||
const [isEditingGoal, setIsEditingGoal] = useState(false);
|
||
const [showNextTask, setShowNextTask] = useState(false);
|
||
const [calendarEditMode, setCalendarEditMode] = useState(false);
|
||
const [selectedTaskForRecurrence, setSelectedTaskForRecurrence] =
|
||
useState<Task | null>(null);
|
||
const [showFocusMode, setShowFocusMode] = useState(false);
|
||
const [showSchedule, setShowSchedule] = useState(true);
|
||
const [focusBreakDuration, setFocusBreakDuration] = useState(5);
|
||
const [weekdayFormat, setWeekdayFormat] = useState<"long" | "short" | "narrow" | "custom">("long");
|
||
const [weekdayCase, setWeekdayCase] = useState<"normal" | "capitalize" | "uppercase">("capitalize");
|
||
const [customWeekdayNames, setCustomWeekdayNames] = useState("");
|
||
|
||
// New UI State
|
||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||
const [isRecurringTasksOpen, setIsRecurringTasksOpen] = useState(false);
|
||
const [showDatePicker, setShowDatePicker] = useState(false);
|
||
const datePickerBtnRef = useRef<HTMLDivElement>(null);
|
||
const [showQuickSettings, setShowQuickSettings] = useState(false);
|
||
const [leftRailExpanded, setLeftRailExpanded] = useState(false);
|
||
const [flyoutSection, setFlyoutSection] = useState<string | null>(null);
|
||
const [showRailUserMenu, setShowRailUserMenu] = useState(false);
|
||
const [flyoutY, setFlyoutY] = useState(0);
|
||
const flyoutTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||
const [showProjectsSidebar, setShowProjectsSidebar] = useState(false);
|
||
const [activeProjectFilter, setActiveProjectFilter] = useState<string | null>(null);
|
||
const [showIntroHints, setShowIntroHints] = useState(true);
|
||
|
||
const [focusTimerDuration, setFocusTimerDuration] = useState(25);
|
||
const [fontSize, setFontSize] = useState<"S" | "M" | "L">(() => {
|
||
const c = getCookie("setting_fontSize");
|
||
return (c as "S" | "M" | "L") || "M";
|
||
});
|
||
const [headlineFont, setHeadlineFont] = useState("Inter");
|
||
const [headlineFontSize, setHeadlineFontSize] = useState("1.25rem");
|
||
const [headlineFontWeight, setHeadlineFontWeight] = useState("900");
|
||
const [dateFontFamily, setDateFontFamily] = useState("Inter");
|
||
const [dateFontSize, setDateFontSize] = useState("0.65rem");
|
||
const [dateFontWeight, setDateFontWeight] = useState("400");
|
||
const [timeTaskFontFamily, setTimeTaskFontFamily] = useState("Inter");
|
||
const [timeTaskFontSize, setTimeTaskFontSize] = useState("0.75rem");
|
||
const [timeTaskFontWeight, setTimeTaskFontWeight] = useState("500");
|
||
const [bodyFont, setBodyFont] = useState("Inter");
|
||
const [taskFontFamily, setTaskFontFamily] = useState("Inter");
|
||
const [taskFontSize, setTaskFontSize] = useState("0.9rem");
|
||
const [taskFontWeight, setTaskFontWeight] = useState("400");
|
||
const [eventFontFamily, setEventFontFamily] = useState("Inter");
|
||
const [eventFontSize, setEventFontSize] = useState("0.85rem");
|
||
const [eventFontWeight, setEventFontWeight] = useState("400");
|
||
const [fontWeight, setFontWeight] = useState("400");
|
||
const [weekendColorSat, setWeekendColorSat] = useState("#666666");
|
||
const [weekendColorSun, setWeekendColorSun] = useState("#dc2626");
|
||
|
||
// Load fonts
|
||
// Dynamic font loading is handled by the main useGoogleFonts hook call below
|
||
|
||
// Collect custom font names from profile settings
|
||
const customFonts = useMemo(() => {
|
||
const fontProps = [
|
||
profile.headlineFont, profile.dateFontFamily, profile.taskFontFamily,
|
||
profile.timeTaskFontFamily, profile.eventFontFamily, profile.goalFontFamily,
|
||
profile.cwFontFamily, profile.yearFontFamily, profile.bodyFont,
|
||
];
|
||
return fontProps.filter((f): f is string => !!f && isCustomFont(f));
|
||
}, [profile.headlineFont, profile.dateFontFamily, profile.taskFontFamily,
|
||
profile.timeTaskFontFamily, profile.eventFontFamily, profile.goalFontFamily,
|
||
profile.cwFontFamily, profile.yearFontFamily, profile.bodyFont]);
|
||
|
||
// Load ALL available fonts + any custom fonts at the top level
|
||
useGoogleFonts([
|
||
...AVAILABLE_FONTS.filter((f) => f.value !== "__custom__").map((f) => f.value),
|
||
...customFonts,
|
||
]);
|
||
|
||
// Dynamic font loading is handled by useGoogleFonts hook call above
|
||
|
||
// Calendar Event Modal State
|
||
const [calendarEventModal, setCalendarEventModal] = useState<{
|
||
isOpen: boolean;
|
||
event?: CalendarEvent;
|
||
initialDate?: Date;
|
||
initialStartTime?: string;
|
||
initialEndTime?: string;
|
||
}>({ isOpen: false });
|
||
|
||
// Slot drag-to-create calendar event state (mouse + touch)
|
||
const slotDragJustEndedRef = useRef(false);
|
||
const slotDragRef = useRef<{
|
||
active: boolean;
|
||
date: Date;
|
||
startSlot: string;
|
||
currentSlot: string;
|
||
startY: number;
|
||
} | null>(null);
|
||
const [slotDragSelection, setSlotDragSelection] = useState<{
|
||
dateStr: string;
|
||
startSlot: string;
|
||
endSlot: string;
|
||
} | null>(null);
|
||
// Touch long-press state for mobile drag-to-create
|
||
const touchLongPressRef = useRef<{
|
||
timerId: ReturnType<typeof setTimeout>;
|
||
startX: number;
|
||
startY: number;
|
||
date: Date;
|
||
slot: string;
|
||
activated: boolean;
|
||
} | null>(null);
|
||
|
||
// Calendar event resize/drag state
|
||
const [eventDragState, setEventDragState] = useState<{
|
||
eventId: string;
|
||
mode: 'move' | 'resize-top' | 'resize-bottom';
|
||
startY: number;
|
||
startX: number;
|
||
originalStartTime: string;
|
||
originalEndTime: string;
|
||
currentStartTime: string;
|
||
currentEndTime: string;
|
||
calendarId?: string;
|
||
source?: string;
|
||
hasMoved?: boolean;
|
||
} | null>(null);
|
||
|
||
// Pending recurring event edit after drag/resize — asks "this" or "all"
|
||
const [pendingRecurringDrag, setPendingRecurringDrag] = useState<{
|
||
eventId: string;
|
||
calendarId: string;
|
||
recurringEventId?: string;
|
||
startTime: string;
|
||
endTime: string;
|
||
originalStartTime: string;
|
||
originalEndTime: string;
|
||
} | null>(null);
|
||
const [recurringDragEditMode, setRecurringDragEditMode] = useState<'this' | 'future' | 'all'>('this');
|
||
|
||
// Dark Mode Persistence & Class Toggle
|
||
const [mounted, setMounted] = useState(false);
|
||
|
||
const [recurringDeleteModal, setRecurringDeleteModal] = useState<{
|
||
isOpen: boolean;
|
||
taskId: string | null;
|
||
}>({ isOpen: false, taskId: null });
|
||
|
||
useEffect(() => {
|
||
setMounted(true);
|
||
const savedDarkMode = localStorage.getItem("weekly-dark-mode");
|
||
if (savedDarkMode) {
|
||
setDarkMode(JSON.parse(savedDarkMode));
|
||
}
|
||
const savedWeekStart = localStorage.getItem("weekly-week-start");
|
||
if (savedWeekStart) {
|
||
setWeekStartDay(Number(savedWeekStart));
|
||
}
|
||
}, []);
|
||
|
||
// Mobile detection — track viewport width and orientation
|
||
useEffect(() => {
|
||
const check = () => {
|
||
setIsMobile(window.innerWidth <= 768);
|
||
setIsPortrait(window.innerHeight > window.innerWidth);
|
||
setIsCompactHeight(window.innerHeight <= 720);
|
||
};
|
||
check();
|
||
window.addEventListener("resize", check);
|
||
return () => window.removeEventListener("resize", check);
|
||
}, []);
|
||
|
||
const profileLoadedRef = useRef(false);
|
||
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||
const dragJustEndedRef = useRef(false);
|
||
|
||
const fetchProfile = async () => {
|
||
try {
|
||
const res = await fetch("/api/user/profile");
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
if (data && data.user) {
|
||
const viewPrefs = (data.user.viewSettings || {}) as any;
|
||
const profileData = {
|
||
...data.user,
|
||
menuPosition: viewPrefs.menuPosition ?? data.user.menuPosition ?? "left",
|
||
showHeaderControls: viewPrefs.showHeaderControls ?? data.user.showHeaderControls ?? true,
|
||
};
|
||
setProfile(profileData);
|
||
|
||
// Sync individual states to profile data
|
||
if (profileData.viewStyle) setViewStyle(profileData.viewStyle);
|
||
if (profileData.viewDays) {
|
||
savedViewDaysRef.current = profileData.viewDays;
|
||
const w = window.innerWidth, h = window.innerHeight;
|
||
if (w <= 768) setViewDays(h > w ? 1 : 3);
|
||
else if (w <= 1024) setViewDays(Math.min(profileData.viewDays, 5));
|
||
else setViewDays(profileData.viewDays);
|
||
}
|
||
if (profileData.showTimeGrid !== undefined) setShowTimeGrid(profileData.showTimeGrid);
|
||
if (profileData.showSomeday !== undefined) setShowSomeday(profileData.showSomeday);
|
||
if (profileData.showAllDayEvents !== undefined) setShowAllDay(profileData.showAllDayEvents);
|
||
if (profileData.showSchedule !== undefined) setShowSchedule(profileData.showSchedule);
|
||
if (profileData.cellDuration) setCellDuration(profileData.cellDuration);
|
||
if (profileData.language) setLanguage(profileData.language);
|
||
if (profileData.dateFormat) setDateFormat(profileData.dateFormat);
|
||
if (profileData.timeFormat) setTimeFormat(profileData.timeFormat);
|
||
if (profileData.startHour !== undefined) setStartHour(profileData.startHour);
|
||
if (profileData.endHour !== undefined) setEndHour(profileData.endHour);
|
||
if (profileData.fontSize) setFontSize(profileData.fontSize);
|
||
if (profileData.showNextTask !== undefined) setShowNextTask(profileData.showNextTask);
|
||
if (profileData.protectEventTimes !== undefined) setProtectEventTimes(profileData.protectEventTimes);
|
||
if (profileData.headlineFont) setHeadlineFont(profileData.headlineFont);
|
||
if (profileData.headlineFontSize) setHeadlineFontSize(profileData.headlineFontSize);
|
||
if (profileData.headlineFontWeight) setHeadlineFontWeight(profileData.headlineFontWeight);
|
||
if (profileData.dateFontFamily) setDateFontFamily(profileData.dateFontFamily);
|
||
if (profileData.dateFontSize) setDateFontSize(profileData.dateFontSize);
|
||
if (profileData.dateFontWeight) setDateFontWeight(profileData.dateFontWeight);
|
||
if (profileData.timeTaskFontFamily) setTimeTaskFontFamily(profileData.timeTaskFontFamily);
|
||
if (profileData.timeTaskFontSize) setTimeTaskFontSize(profileData.timeTaskFontSize);
|
||
if (profileData.timeTaskFontWeight) setTimeTaskFontWeight(profileData.timeTaskFontWeight);
|
||
if (profileData.bodyFont) setBodyFont(profileData.bodyFont);
|
||
if (profileData.taskFontFamily) setTaskFontFamily(profileData.taskFontFamily);
|
||
if (profileData.taskFontSize) setTaskFontSize(profileData.taskFontSize);
|
||
if (profileData.taskFontWeight) setTaskFontWeight(profileData.taskFontWeight);
|
||
if (profileData.fontWeight) setFontWeight(profileData.fontWeight);
|
||
if (profileData.weekendColorSat) setWeekendColorSat(profileData.weekendColorSat);
|
||
if (profileData.weekendColorSun) setWeekendColorSun(profileData.weekendColorSun);
|
||
if (profileData.hourLabelFormat) setHourLabelFormat(profileData.hourLabelFormat);
|
||
if (profileData.showSubHourSlots !== undefined) setShowSubHourSlots(profileData.showSubHourSlots);
|
||
if (profileData.allDayPosition) setAllDayPosition(profileData.allDayPosition);
|
||
if (profileData.viewSettings) {
|
||
setViewSettings(profileData.viewSettings);
|
||
// Load customTabs from DB (cross-device, survives reconnects)
|
||
const dbTabs = (profileData.viewSettings as any).somedayCustomTabs;
|
||
if (Array.isArray(dbTabs) && dbTabs.length > 0) {
|
||
setCustomTabs(prev => {
|
||
const merged = new Set([...dbTabs, ...prev]);
|
||
return Array.from(merged);
|
||
});
|
||
}
|
||
}
|
||
|
||
// Re-apply per-device cookie overrides — these always win over DB values
|
||
const _cFS = getCookie("setting_fontSize");
|
||
if (_cFS) setFontSize(_cFS as "S" | "M" | "L");
|
||
const _cSH = getCookie("setting_showSubHourSlots");
|
||
if (_cSH !== null) setShowSubHourSlots(_cSH === "true");
|
||
const _cCD = getCookie("setting_cellDuration");
|
||
if (_cCD) setCellDuration(Number(_cCD) as CellDuration);
|
||
const _cStart = getCookie("setting_startHour");
|
||
if (_cStart) setStartHour(Number(_cStart));
|
||
const _cEnd = getCookie("setting_endHour");
|
||
if (_cEnd) setEndHour(Number(_cEnd));
|
||
const _cVS = getCookie("setting_viewStyle");
|
||
if (_cVS) { setViewStyle(_cVS as ViewStyle); setProfile((p: any) => ({ ...p, viewStyle: _cVS })); }
|
||
const _cTG = getCookie("setting_showTimeGrid");
|
||
if (_cTG !== null) { const v = _cTG === "true"; setShowTimeGrid(v); setProfile((p: any) => ({ ...p, showTimeGrid: v })); }
|
||
const _cSDO = getCookie("setting_startDayOffset");
|
||
if (_cSDO !== null) setProfile((p: any) => ({ ...p, startDayOffset: Number(_cSDO) }));
|
||
|
||
// Show onboarding wizard for new users
|
||
if (profileData.hasCompletedOnboarding === false) {
|
||
setShowOnboarding(true);
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error("Failed to fetch profile:", err);
|
||
} finally {
|
||
profileLoadedRef.current = true;
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchProfile();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!profileLoadedRef.current) return;
|
||
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
|
||
autoSaveTimerRef.current = setTimeout(async () => {
|
||
try {
|
||
await fetch("/api/user/profile", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(profile),
|
||
});
|
||
console.log("[SETTINGS] Auto-saved profile");
|
||
} catch (err) {
|
||
console.error("[SETTINGS] Auto-save failed:", err);
|
||
}
|
||
}, 800);
|
||
return () => {
|
||
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
|
||
};
|
||
}, [profile]);
|
||
|
||
// Auto-focus FAB bottom sheet textarea
|
||
useEffect(() => {
|
||
if (showMobileFabSheet && fabTextareaRef.current) {
|
||
setTimeout(() => fabTextareaRef.current?.focus(), 100);
|
||
}
|
||
}, [showMobileFabSheet]);
|
||
|
||
useEffect(() => {
|
||
if (!mounted) return;
|
||
localStorage.setItem("weekly-dark-mode", JSON.stringify(darkMode));
|
||
if (darkMode) {
|
||
document.documentElement.classList.add("dark");
|
||
} else {
|
||
document.documentElement.classList.remove("dark");
|
||
}
|
||
}, [darkMode, mounted]);
|
||
|
||
useEffect(() => {
|
||
if (!mounted) return;
|
||
localStorage.setItem("weekly-week-start", String(profile.weekStartDay ?? 1));
|
||
// REMOVED: Re-align current week start when start day changes
|
||
// This was forcing the view to snap to Monday, breaking the "Yesterday as first column" setting.
|
||
// setCurrentWeekStart(prev => getStartOfWeek(prev, weekStartDay));
|
||
}, [profile.weekStartDay, mounted]);
|
||
|
||
// Translation helper
|
||
const t = translations[profile.language] || translations["en"];
|
||
|
||
// Refs for scroll
|
||
const dayColumnsRef = useRef<HTMLDivElement[]>([]);
|
||
const isScrollSyncing = useRef(false);
|
||
const isInitialScrollDone = useRef(false);
|
||
const intendedScrollTop = useRef<number | null>(null);
|
||
const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState(65);
|
||
const dayHeaderRef = useRef<HTMLElement>(null);
|
||
const somedayGridRef = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
const updateHeight = () => {
|
||
if (dayHeaderRef.current) {
|
||
const height = dayHeaderRef.current.offsetHeight;
|
||
// Allow some tolerance to avoid infinite loops across various browsers
|
||
if (height > 0 && Math.abs(height - measuredHeaderHeight) > 1) {
|
||
setMeasuredHeaderHeight(height);
|
||
}
|
||
}
|
||
};
|
||
|
||
// Initial measurement
|
||
updateHeight();
|
||
|
||
// Measurement after a short delay for layout stabilization
|
||
const timer = setTimeout(updateHeight, 800);
|
||
|
||
// Also track window resize
|
||
window.addEventListener('resize', updateHeight);
|
||
|
||
// ResizeObserver for more robust tracking of layout shifts
|
||
let resizeObserver: ResizeObserver | null = null;
|
||
if (typeof window !== 'undefined' && 'ResizeObserver' in window && dayHeaderRef.current) {
|
||
resizeObserver = new ResizeObserver(updateHeight);
|
||
resizeObserver.observe(dayHeaderRef.current);
|
||
}
|
||
|
||
return () => {
|
||
clearTimeout(timer);
|
||
window.removeEventListener('resize', updateHeight);
|
||
if (resizeObserver) resizeObserver.disconnect();
|
||
};
|
||
}, [dayHeaderRef.current, profile.cellDuration, viewDays, isMobile, profile.mobileDateLayout, profile.dateLayout, profile.dateAlignment, profile.dayHeaderGap, profile.headlineFontSize, profile.headlineFontWeight, profile.dateFontSize, profile.dateVerticalAlign, profile.headerDisplay, profile.weekdayFormat, profile.viewStyle]);
|
||
const somedaySectionRef = useRef<HTMLElement | null>(null);
|
||
|
||
// Unified scroll sync handlers
|
||
// handleTimeColumnScroll no longer needed — single scroll container via time-grid-wrapper
|
||
|
||
const handleGridScroll = (_e: React.UIEvent<HTMLElement>) => {
|
||
// Single scroll container — no sync needed
|
||
// Mobile sticky day is handled by the native scroll listener in the useEffect
|
||
};
|
||
|
||
const jumpToHour = (hour: number) => {
|
||
const slotsPerHour = 60 / effectiveCellDuration;
|
||
const slotHeight = getSlotHeight(effectiveCellDuration);
|
||
const scrollOffset = hour * slotsPerHour * slotHeight;
|
||
|
||
console.log(`[SCROLL] Jumping to hour ${hour} (offset ${scrollOffset}px)`);
|
||
|
||
// Clear old stabilization
|
||
isInitialScrollDone.current = true;
|
||
isScrollSyncing.current = true;
|
||
intendedScrollTop.current = scrollOffset;
|
||
|
||
const perform = () => {
|
||
if (gridRef.current) gridRef.current.scrollTop = scrollOffset;
|
||
};
|
||
|
||
// Repeated enforcement
|
||
perform();
|
||
requestAnimationFrame(perform);
|
||
setTimeout(perform, 50);
|
||
setTimeout(perform, 100);
|
||
setTimeout(perform, 250);
|
||
|
||
setTimeout(() => {
|
||
isScrollSyncing.current = false;
|
||
}, 500);
|
||
};
|
||
|
||
// Slot and Header height based on cell duration
|
||
// WMO weather code → emoji icon
|
||
const getWeatherIcon = (code: number): string => {
|
||
if (code === 0) return "☀️";
|
||
if (code <= 3) return "⛅";
|
||
if (code >= 45 && code <= 48) return "🌫️";
|
||
if (code >= 51 && code <= 55) return "🌦️";
|
||
if (code >= 56 && code <= 57) return "🌧️";
|
||
if (code >= 61 && code <= 65) return "🌧️";
|
||
if (code >= 66 && code <= 67) return "🌨️";
|
||
if (code >= 71 && code <= 77) return "❄️";
|
||
if (code >= 80 && code <= 82) return "🌧️";
|
||
if (code >= 85 && code <= 86) return "❄️";
|
||
if (code >= 95) return "⛈️";
|
||
return "☁️";
|
||
};
|
||
|
||
const getSlotHeight = (duration: number) => {
|
||
switch (duration) {
|
||
case 15: return 25;
|
||
case 20: return 30;
|
||
case 30: return 35;
|
||
case 60: return 50;
|
||
default: return 50;
|
||
}
|
||
};
|
||
|
||
const getHeaderHeight = (duration: number) => {
|
||
if (measuredHeaderHeight > 0) return measuredHeaderHeight;
|
||
switch (duration) {
|
||
case 15: return 65;
|
||
case 30: return 55;
|
||
case 60: return 50;
|
||
case 120: return 50;
|
||
default: return 50;
|
||
}
|
||
};
|
||
|
||
// Working hours range (configurable)
|
||
const workingHoursStart = profile.startHour ?? 8;
|
||
const workingHoursEnd = profile.endHour ?? 18;
|
||
|
||
// Fetch calendar events
|
||
// Find connectionId for a given calendarId
|
||
const getConnectionIdForCalendar = useCallback((calId?: string) => {
|
||
if (!calId) return undefined;
|
||
const conn = connections.find((c: any) =>
|
||
(c.calendars || []).some((cal: any) => cal.id === calId)
|
||
);
|
||
return conn?.id;
|
||
}, [connections]);
|
||
|
||
const fetchCalendarEvents = useCallback(async (forceRefresh = false, connectionId?: string) => {
|
||
startSync();
|
||
setIsFetchingCalendar(true);
|
||
try {
|
||
const response = await fetch("/api/calendar/sync", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
timeMin: new Date(
|
||
currentWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000,
|
||
).toISOString(),
|
||
timeMax: new Date(
|
||
currentWeekStart.getTime() + 14 * 24 * 60 * 60 * 1000,
|
||
).toISOString(),
|
||
forceRefresh,
|
||
...(connectionId ? { connectionId } : {}),
|
||
}),
|
||
});
|
||
|
||
if (response.ok) {
|
||
const text = await response.text();
|
||
try {
|
||
const data = JSON.parse(text);
|
||
if (data.events) {
|
||
setRawCalendarEvents(data.events);
|
||
}
|
||
// If stale connections were refreshing in background, re-fetch after they finish
|
||
if (data.staleConnectionCount > 0 && !forceRefresh) {
|
||
setTimeout(() => {
|
||
fetch("/api/calendar/sync", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
timeMin: new Date(currentWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||
timeMax: new Date(currentWeekStart.getTime() + 14 * 24 * 60 * 60 * 1000).toISOString(),
|
||
}),
|
||
}).then(r => r.json()).then(d => {
|
||
if (d.events) setRawCalendarEvents(d.events);
|
||
}).catch(() => {});
|
||
}, 5000); // 5s delay for background refresh to finish
|
||
}
|
||
} catch (e) {
|
||
console.error(
|
||
"Failed to parse calendar sync response:",
|
||
text.substring(0, 100),
|
||
);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("Error fetching calendar events:", error);
|
||
} finally {
|
||
setIsFetchingCalendar(false);
|
||
endSync();
|
||
}
|
||
}, [currentWeekStart, startSync, endSync]);
|
||
|
||
// Weather fetch
|
||
const fetchWeather = useCallback(async () => {
|
||
if (!effectiveWeatherEnabled) return;
|
||
if (!profile.weatherLat || !profile.weatherLon) return;
|
||
try {
|
||
const start = new Date(currentWeekStart.getTime() - 1 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||
const end = new Date(currentWeekStart.getTime() + 8 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||
const res = await fetch(`/api/weather?start=${start}&end=${end}`);
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
if (data.hourly) setWeatherData(data.hourly);
|
||
}
|
||
} catch (e) {
|
||
console.error("Weather fetch failed:", e);
|
||
}
|
||
}, [currentWeekStart, effectiveWeatherEnabled, profile.weatherLat, profile.weatherLon]);
|
||
|
||
useEffect(() => {
|
||
if (effectiveWeatherEnabled) fetchWeather();
|
||
}, [fetchWeather, effectiveWeatherEnabled]);
|
||
|
||
// Calendar Event Handlers
|
||
const handleEventSave = async (eventData: any) => {
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), 15000); // 15s timeout
|
||
|
||
try {
|
||
const method = eventData.id ? "PATCH" : "POST";
|
||
const body = {
|
||
...eventData,
|
||
eventId: eventData.id, // For PATCH
|
||
};
|
||
|
||
const res = await fetch("/api/calendar/events", {
|
||
method,
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
signal: controller.signal,
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const err = await res.json();
|
||
throw new Error(err.error || "Failed to save event");
|
||
}
|
||
|
||
// Optimistically add/update from API response, then force refresh cache
|
||
const data = await res.json();
|
||
const isRecurring = !!(eventData.recurrence);
|
||
if (data.event && !isRecurring) {
|
||
// For non-recurring events: optimistic update before sync
|
||
// For recurring events: skip — sync will fetch all expanded instances
|
||
const ev = data.event;
|
||
// Find calendar info from connections to fill in missing color/title
|
||
const calInfo = connections.flatMap((c: any) =>
|
||
(c.calendars || []).map((cal: any) => ({ ...cal, provider: c.provider }))
|
||
).find((c: any) => c.id === (ev.calendarId || eventData.calendarId));
|
||
const frontendEvent: CalendarEvent = {
|
||
id: ev.id,
|
||
title: ev.title,
|
||
startTime: ev.start?.dateTime || ev.start?.date || ev.startTime || '',
|
||
endTime: ev.end?.dateTime || ev.end?.date || ev.endTime || '',
|
||
source: ev.source || calInfo?.provider || 'google',
|
||
calendarId: ev.calendarId || eventData.calendarId,
|
||
calendarTitle: ev.calendarTitle || calInfo?.summary || calInfo?.title || '',
|
||
calendarColor: ev.backgroundColor || ev.calendarColor || calInfo?.backgroundColor || calInfo?.color || '#3b82f6',
|
||
};
|
||
setRawCalendarEvents(prev => {
|
||
if (eventData.id) {
|
||
return prev.map(e => e.id === eventData.id ? frontendEvent : e);
|
||
}
|
||
return [...prev, frontendEvent];
|
||
});
|
||
} else if (data.event && eventData.id) {
|
||
// Recurring update: keep optimistic update for the edited instance only
|
||
const ev = data.event;
|
||
const calInfo = connections.flatMap((c: any) =>
|
||
(c.calendars || []).map((cal: any) => ({ ...cal, provider: c.provider }))
|
||
).find((c: any) => c.id === (ev.calendarId || eventData.calendarId));
|
||
const frontendEvent: CalendarEvent = {
|
||
id: ev.id,
|
||
title: ev.title,
|
||
startTime: ev.start?.dateTime || ev.start?.date || ev.startTime || '',
|
||
endTime: ev.end?.dateTime || ev.end?.date || ev.endTime || '',
|
||
source: ev.source || calInfo?.provider || 'google',
|
||
calendarId: ev.calendarId || eventData.calendarId,
|
||
calendarTitle: ev.calendarTitle || calInfo?.summary || calInfo?.title || '',
|
||
calendarColor: ev.backgroundColor || ev.calendarColor || calInfo?.backgroundColor || calInfo?.color || '#3b82f6',
|
||
};
|
||
setRawCalendarEvents(prev => prev.map(e => e.id === eventData.id ? frontendEvent : e));
|
||
}
|
||
// Force refresh only the affected provider
|
||
const connId = getConnectionIdForCalendar(eventData.calendarId);
|
||
await fetchCalendarEvents(true, connId);
|
||
} catch (error: any) {
|
||
console.error("Error saving event:", error);
|
||
if (error.name === "AbortError") {
|
||
throw new Error("Request timed out. Please try again.");
|
||
}
|
||
throw error;
|
||
} finally {
|
||
clearTimeout(timeoutId);
|
||
}
|
||
};
|
||
|
||
const handleEventDelete = async (eventId: string, calendarId: string, deleteMode?: string) => {
|
||
try {
|
||
const params = new URLSearchParams({ calendarId, eventId });
|
||
if (deleteMode) params.set('deleteMode', deleteMode);
|
||
|
||
const res = await fetch(
|
||
`/api/calendar/events?${params.toString()}`,
|
||
{
|
||
method: "DELETE",
|
||
},
|
||
);
|
||
|
||
if (!res.ok) {
|
||
const err = await res.json();
|
||
throw new Error(err.error || "Failed to delete event");
|
||
}
|
||
|
||
// Optimistically remove affected events
|
||
if (deleteMode === 'this') {
|
||
setRawCalendarEvents(prev => prev.filter(e => e.id !== eventId));
|
||
} else if (deleteMode === 'past') {
|
||
// Remove this and past instances of the same recurring series
|
||
const targetEvent = calendarEvents.find(e => e.id === eventId);
|
||
if (targetEvent) {
|
||
const targetTime = new Date(targetEvent.startTime).getTime();
|
||
const seriesId = targetEvent.recurringEventId || eventId;
|
||
setRawCalendarEvents(prev => prev.filter(e => {
|
||
if (e.recurringEventId !== seriesId && e.id !== seriesId) return true;
|
||
return new Date(e.startTime).getTime() > targetTime;
|
||
}));
|
||
} else {
|
||
setRawCalendarEvents(prev => prev.filter(e => e.id !== eventId));
|
||
}
|
||
} else if (deleteMode === 'future') {
|
||
// Remove this and future instances of the same recurring series
|
||
const targetEvent = calendarEvents.find(e => e.id === eventId);
|
||
if (targetEvent) {
|
||
const targetTime = new Date(targetEvent.startTime).getTime();
|
||
const seriesId = targetEvent.recurringEventId || eventId;
|
||
setRawCalendarEvents(prev => prev.filter(e => {
|
||
if (e.recurringEventId !== seriesId && e.id !== seriesId) return true;
|
||
return new Date(e.startTime).getTime() < targetTime;
|
||
}));
|
||
} else {
|
||
setRawCalendarEvents(prev => prev.filter(e => e.id !== eventId));
|
||
}
|
||
} else {
|
||
// 'all' — remove all instances of the series
|
||
const targetEvent = calendarEvents.find(e => e.id === eventId);
|
||
const seriesId = targetEvent?.recurringEventId || eventId;
|
||
setRawCalendarEvents(prev => prev.filter(e =>
|
||
e.id !== eventId && e.recurringEventId !== seriesId && e.id !== seriesId
|
||
));
|
||
}
|
||
// Force refresh only the affected provider
|
||
const connId = getConnectionIdForCalendar(calendarId);
|
||
await fetchCalendarEvents(true, connId);
|
||
} catch (error) {
|
||
console.error("Error deleting event:", error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
const handleRecurrenceSave = async (taskId: string, recurrence: any) => {
|
||
try {
|
||
const res = await fetch("/api/tasks", {
|
||
// Uses PATCH endpoint which handles ID in body
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
id: taskId,
|
||
...recurrence,
|
||
}),
|
||
});
|
||
|
||
if (!res.ok) {
|
||
throw new Error("Failed to update recurrence");
|
||
}
|
||
|
||
const data = await res.json();
|
||
|
||
// Update local state and REFRESH all tasks to show virtual instances
|
||
setTasks((prev) => prev.map((t) => (t.id === taskId ? data.task : t)));
|
||
await fetchTasks();
|
||
} catch (error) {
|
||
console.error(error);
|
||
alert("Failed to save recurrence settings");
|
||
}
|
||
};
|
||
|
||
const fetchMotivationalQuote = useCallback(async () => {
|
||
if (profile.goalFallbackType !== "quote") return;
|
||
|
||
const urls = profile.quoteSourceUrls && profile.quoteSourceUrls.length > 0
|
||
? profile.quoteSourceUrls
|
||
: profile.quoteSourceUrl ? [profile.quoteSourceUrl] : [];
|
||
|
||
// Strategy: try sources until one works
|
||
for (const url of urls) {
|
||
try {
|
||
const res = await fetch(url);
|
||
if (!res.ok) continue;
|
||
const contentType = res.headers.get("content-type") || "";
|
||
if (!contentType.includes("application/json")) continue;
|
||
// Ensure proper UTF-8 decoding for quotes with special characters
|
||
const rawText = await res.text();
|
||
const data = JSON.parse(rawText);
|
||
|
||
let quoteText = "";
|
||
if (Array.isArray(data) && data.length > 0) {
|
||
const item = data[0];
|
||
quoteText = item.quote || item.text || item.content || (typeof item === 'string' ? item : "");
|
||
if (item.author) quoteText += ` - ${item.author}`;
|
||
} else if (data && typeof data === 'object') {
|
||
quoteText = data.quote || data.text || data.content || "";
|
||
if (data.author) quoteText += ` - ${data.author}`;
|
||
} else if (typeof data === 'string') {
|
||
quoteText = data;
|
||
}
|
||
|
||
if (quoteText) {
|
||
setMotivationalQuote(quoteText);
|
||
return; // Success!
|
||
}
|
||
} catch (error) {
|
||
console.error(`Error fetching quote from ${url}:`, error);
|
||
}
|
||
}
|
||
|
||
// Final fallback: use local curated quotes in user-selected languages
|
||
const quoteLangs = profile.quoteLanguages && profile.quoteLanguages.length > 0
|
||
? profile.quoteLanguages
|
||
: [profile.language || "en"];
|
||
const randomLang = quoteLangs[Math.floor(Math.random() * quoteLangs.length)];
|
||
const localQuote = getRandomLocalQuote(randomLang);
|
||
if (localQuote) {
|
||
setMotivationalQuote(`${localQuote.text} — ${localQuote.author}`);
|
||
} else {
|
||
setMotivationalQuote(randomLang === "de" ? "Bleib fokussiert und produktiv." : "Stay focused and productive.");
|
||
}
|
||
}, [profile.goalFallbackType, profile.quoteSourceUrl, profile.quoteSourceUrls, profile.language, profile.quoteLanguages]);
|
||
|
||
// Fetch tasks on mount
|
||
useEffect(() => {
|
||
if (session) {
|
||
fetchTasks();
|
||
fetchConnections();
|
||
fetchCalendarEvents();
|
||
fetchMotivationalQuote();
|
||
}
|
||
}, [session]); // Removed fetchMotivationalQuote from deps to avoid re-runs
|
||
|
||
// Auto-open settings to calendar tab after OAuth redirect
|
||
useEffect(() => {
|
||
const params = new URLSearchParams(window.location.search);
|
||
if (params.get('openSettings') === 'calendars') {
|
||
setShowSettings(true);
|
||
setActiveTab('calendar');
|
||
// Clean up URL
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.delete('openSettings');
|
||
url.searchParams.delete('calendar');
|
||
window.history.replaceState({}, '', url.pathname);
|
||
// Refresh connections to pick up the new one
|
||
fetchConnections();
|
||
}
|
||
}, []);
|
||
|
||
// Pull-sync from external task providers.
|
||
//
|
||
// Strategy: trigger on user-visible moments (tab gains focus, page becomes
|
||
// visible, app mounts) plus a slow safety-net interval. The pull uses the
|
||
// Microsoft Graph delta endpoint where possible, so each call is a few hundred
|
||
// bytes when nothing changed — cheap to run on every focus event.
|
||
//
|
||
// Throttle: at most one pull every 5 seconds to absorb rapid focus toggles.
|
||
useEffect(() => {
|
||
if (!session) return;
|
||
|
||
let lastSyncAt = 0;
|
||
const MIN_INTERVAL_MS = 5_000;
|
||
|
||
const runPullSync = async (reason: string) => {
|
||
const now = Date.now();
|
||
if (now - lastSyncAt < MIN_INTERVAL_MS) return;
|
||
lastSyncAt = now;
|
||
try {
|
||
const res = await fetch("/api/tasks/sync");
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
if (data.updated > 0 || data.deleted > 0 || data.created > 0) {
|
||
console.log(
|
||
`[SYNC] (${reason}) Pulled ${data.updated} updates, ${data.deleted} deletions, ${data.created || 0} new tasks`,
|
||
);
|
||
fetchTasks();
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error("[SYNC] Task sync error:", e);
|
||
}
|
||
};
|
||
|
||
// Eager pull on mount
|
||
runPullSync('mount');
|
||
|
||
// Pull when the tab becomes visible or the window regains focus —
|
||
// this is when the user actually expects fresh data.
|
||
const onVisible = () => {
|
||
if (document.visibilityState === 'visible') runPullSync('visibility');
|
||
};
|
||
const onFocus = () => runPullSync('focus');
|
||
document.addEventListener('visibilitychange', onVisible);
|
||
window.addEventListener('focus', onFocus);
|
||
|
||
// Slow safety-net interval (covers the case of the tab being open & focused
|
||
// for a long time while changes happen on another device).
|
||
const interval = setInterval(() => runPullSync('interval'), 15 * 60 * 1000);
|
||
|
||
return () => {
|
||
document.removeEventListener('visibilitychange', onVisible);
|
||
window.removeEventListener('focus', onFocus);
|
||
clearInterval(interval);
|
||
};
|
||
}, [session]);
|
||
|
||
// SSE real-time sync: listen for server-pushed task/list changes
|
||
useEffect(() => {
|
||
if (!session) return;
|
||
let eventSource: EventSource | null = null;
|
||
let reconnectTimeout: NodeJS.Timeout | null = null;
|
||
|
||
const connect = () => {
|
||
eventSource = new EventSource("/api/events/stream");
|
||
|
||
eventSource.addEventListener("connected", () => {
|
||
console.log("[SSE] Connected for real-time sync");
|
||
});
|
||
|
||
eventSource.addEventListener("tasks-changed", () => {
|
||
console.log("[SSE] Tasks changed remotely, refetching...");
|
||
fetchTasks();
|
||
});
|
||
|
||
eventSource.addEventListener("list-changed", () => {
|
||
console.log("[SSE] Lists changed remotely, refetching...");
|
||
fetchTasks();
|
||
});
|
||
|
||
eventSource.onerror = () => {
|
||
console.log("[SSE] Connection lost, reconnecting in 5s...");
|
||
eventSource?.close();
|
||
reconnectTimeout = setTimeout(connect, 5000);
|
||
};
|
||
};
|
||
|
||
connect();
|
||
|
||
return () => {
|
||
eventSource?.close();
|
||
if (reconnectTimeout) clearTimeout(reconnectTimeout);
|
||
};
|
||
}, [session]);
|
||
|
||
// Periodic background calendar cache refresh (every 2 minutes)
|
||
useEffect(() => {
|
||
if (!session) return;
|
||
const interval = setInterval(
|
||
async () => {
|
||
try {
|
||
const now = new Date();
|
||
const res = await fetch("/api/calendar/background-sync", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
timeMin: new Date(
|
||
now.getTime() - 7 * 24 * 60 * 60 * 1000,
|
||
).toISOString(),
|
||
timeMax: new Date(
|
||
now.getTime() + 14 * 24 * 60 * 60 * 1000,
|
||
).toISOString(),
|
||
}),
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
if (data.queued > 0) {
|
||
// Stale connections are refreshing in background; re-read cache after delay
|
||
setTimeout(() => fetchCalendarEvents(), 10000);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error("[SYNC] Calendar sync error:", e);
|
||
}
|
||
},
|
||
15 * 60 * 1000, // 15 min — avoid iCloud rate limiting
|
||
);
|
||
return () => clearInterval(interval);
|
||
}, [session, fetchCalendarEvents]);
|
||
|
||
// Sync when tab regains focus (catches external changes in other apps)
|
||
// Throttled: at most once per 5 minutes to avoid iCloud rate limiting
|
||
const lastFocusSyncRef = useRef(0);
|
||
useEffect(() => {
|
||
if (!session) return;
|
||
const handleVisibility = () => {
|
||
if (!document.hidden) {
|
||
const now = Date.now();
|
||
if (now - lastFocusSyncRef.current < 5 * 60 * 1000) return; // throttle
|
||
lastFocusSyncRef.current = now;
|
||
fetchCalendarEvents();
|
||
fetchTasks();
|
||
}
|
||
};
|
||
document.addEventListener('visibilitychange', handleVisibility);
|
||
return () => document.removeEventListener('visibilitychange', handleVisibility);
|
||
}, [session, fetchCalendarEvents]);
|
||
|
||
async function fetchConnections() {
|
||
try {
|
||
setIsLoading(true);
|
||
const response = await fetch("/api/calendar/connections");
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
setConnections(data.connections || []);
|
||
}
|
||
} catch (error) {
|
||
console.error("Error fetching connections:", error);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}
|
||
|
||
const handleRemoveConnection = async (connectionId: string) => {
|
||
console.log("Disconnecting connection:", connectionId);
|
||
const res = await fetch(`/api/calendar/connections?id=${connectionId}`, {
|
||
method: "DELETE",
|
||
});
|
||
|
||
if (res.ok) {
|
||
// Update state immediately
|
||
setConnections((prev) => prev.filter((c) => c.id !== connectionId));
|
||
// Refresh connections to be sure
|
||
fetchConnections();
|
||
// Optionally refresh events too as they might be gone
|
||
fetchCalendarEvents();
|
||
} else {
|
||
const err = await res.json();
|
||
console.error("Failed to disconnect calendar", err);
|
||
throw new Error(err.error || "Unknown error");
|
||
}
|
||
};
|
||
|
||
// Refetch calendar events when week changes
|
||
useEffect(() => {
|
||
if (session) {
|
||
fetchCalendarEvents();
|
||
}
|
||
}, [currentWeekStart, session, fetchCalendarEvents]);
|
||
|
||
// Disable browser scroll restoration so Safari doesn't fight our initial scroll position
|
||
useEffect(() => {
|
||
if (typeof window !== 'undefined' && window.history.scrollRestoration) {
|
||
window.history.scrollRestoration = 'manual';
|
||
}
|
||
}, []);
|
||
|
||
// Scroll to preferred start hour (initial load + when user changes startHour)
|
||
useEffect(() => {
|
||
if (!isLoading) {
|
||
const slotsPerHour = 60 / cellDuration;
|
||
const slotHeight = getSlotHeight(cellDuration);
|
||
const scrollOffset = workingHoursStart * slotsPerHour * slotHeight;
|
||
|
||
// Single scroll — no interval, no enforcement loop.
|
||
// We set scrollRestoration='manual' so the browser won't override this.
|
||
const delay = isInitialScrollDone.current ? 50 : 300;
|
||
const timer = setTimeout(() => {
|
||
if (gridRef.current) gridRef.current.scrollTop = scrollOffset;
|
||
intendedScrollTop.current = scrollOffset;
|
||
isInitialScrollDone.current = true;
|
||
}, delay);
|
||
return () => clearTimeout(timer);
|
||
}
|
||
}, [isLoading, workingHoursStart, cellDuration]);
|
||
|
||
// Update current time every 30 seconds for the "Now" line and clock
|
||
useEffect(() => {
|
||
const interval = setInterval(() => {
|
||
setCurrentTime(new Date());
|
||
}, 30000);
|
||
return () => clearInterval(interval);
|
||
}, []);
|
||
|
||
// Intro hint: briefly reveal hidden header controls and bounce-scroll the main grid on first load
|
||
useEffect(() => {
|
||
const headerTimer = setTimeout(() => setShowIntroHints(false), 2200);
|
||
|
||
const scrollTimer = setTimeout(() => {
|
||
const grid = gridRef.current;
|
||
if (!grid || grid.scrollHeight <= grid.clientHeight + 20) return;
|
||
const baseTop = grid.scrollTop;
|
||
const peek = Math.min(80, grid.scrollHeight - grid.clientHeight - baseTop);
|
||
if (peek <= 10) return;
|
||
const prevBehavior = grid.style.scrollBehavior;
|
||
grid.style.scrollBehavior = "smooth";
|
||
grid.scrollTo({ top: baseTop + peek });
|
||
const back = setTimeout(() => {
|
||
grid.scrollTo({ top: baseTop });
|
||
setTimeout(() => { grid.style.scrollBehavior = prevBehavior; }, 600);
|
||
}, 700);
|
||
return () => { clearTimeout(back); grid.style.scrollBehavior = prevBehavior; };
|
||
}, 900);
|
||
|
||
return () => { clearTimeout(headerTimer); clearTimeout(scrollTimer); };
|
||
}, []);
|
||
|
||
// Mobile: show a sticky day bar by reading scroll position on the actual grid scroll container
|
||
useEffect(() => {
|
||
if (!isMobile || !profile.showTimeGrid) return;
|
||
const grid = gridRef.current;
|
||
if (!grid) return;
|
||
|
||
const updateStickyDay = () => {
|
||
const scrollTop = grid.scrollTop;
|
||
setMobileStickyDayVisible(scrollTop > 40);
|
||
|
||
// Find which day column header is at the top of the scroll container
|
||
const columns = grid.querySelectorAll('.weekly-day-column[data-date]');
|
||
let currentCol: Element | null = null;
|
||
const gridTop = grid.getBoundingClientRect().top;
|
||
|
||
columns.forEach(col => {
|
||
const rect = col.getBoundingClientRect();
|
||
// Column whose top is at or above the grid's top edge
|
||
if (rect.top <= gridTop + 60) {
|
||
currentCol = col;
|
||
}
|
||
});
|
||
|
||
if (currentCol) {
|
||
const dateStr = (currentCol as Element).getAttribute('data-date');
|
||
if (dateStr) {
|
||
const d = new Date(dateStr + 'T00:00:00');
|
||
const dayNames = profile.language === 'de'
|
||
? ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']
|
||
: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||
const label = `${dayNames[d.getDay()]} ${d.getDate()}.${d.getMonth() + 1}.`;
|
||
setMobileStickyDay(label);
|
||
}
|
||
}
|
||
};
|
||
|
||
grid.addEventListener('scroll', updateStickyDay, { passive: true });
|
||
updateStickyDay();
|
||
return () => grid.removeEventListener('scroll', updateStickyDay);
|
||
}, [isMobile, profile.showTimeGrid, currentWeekStart, viewDays, profile.language]);
|
||
|
||
// Compute the goal date key: for "week" scope, normalize to Monday of that week; for "day", use the exact date
|
||
const getGoalDateKey = useCallback(
|
||
(date: Date): string => {
|
||
const scope = profile.goalScope || "week";
|
||
if (scope === "day") {
|
||
const d = new Date(date);
|
||
d.setHours(0, 0, 0, 0);
|
||
return d.toISOString();
|
||
}
|
||
// Normalize to Monday of the week containing this date
|
||
const d = new Date(date);
|
||
d.setHours(0, 0, 0, 0);
|
||
const day = d.getDay(); // 0=Sun, 1=Mon, ...
|
||
const diff = day === 0 ? -6 : 1 - day; // Monday offset
|
||
d.setDate(d.getDate() + diff);
|
||
return d.toISOString();
|
||
},
|
||
[profile.goalScope],
|
||
);
|
||
|
||
const goalDateKey = useMemo(
|
||
() => getGoalDateKey(currentWeekStart),
|
||
[currentWeekStart, getGoalDateKey],
|
||
);
|
||
|
||
// Fetch goal for current week/day
|
||
useEffect(() => {
|
||
const fetchGoal = async () => {
|
||
try {
|
||
const res = await fetch(`/api/goal?weekStart=${goalDateKey}`);
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setGoal(data.goal);
|
||
}
|
||
} catch (err) {
|
||
console.error("Failed to fetch goal:", err);
|
||
}
|
||
};
|
||
fetchGoal();
|
||
}, [goalDateKey]);
|
||
|
||
const saveGoal = async (newGoal: string) => {
|
||
setGoal(newGoal);
|
||
try {
|
||
const res = await fetch("/api/goal", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
weekStart: goalDateKey,
|
||
text: newGoal,
|
||
}),
|
||
});
|
||
if (!res.ok) {
|
||
console.error("Goal save failed:", res.status);
|
||
}
|
||
} catch (error) {
|
||
console.error("Error saving goal:", error);
|
||
}
|
||
};
|
||
|
||
|
||
|
||
// Horizontal scroll: convert vertical wheel to horizontal in someday area
|
||
// Callback ref ensures handler is attached as soon as element mounts
|
||
const somedayWheelCleanup = useRef<(() => void) | null>(null);
|
||
const somedaySectionRefCb = useCallback((node: HTMLElement | null) => {
|
||
// Cleanup previous
|
||
if (somedayWheelCleanup.current) {
|
||
somedayWheelCleanup.current();
|
||
somedayWheelCleanup.current = null;
|
||
}
|
||
somedaySectionRef.current = node;
|
||
if (!node) return;
|
||
|
||
const handler = (e: WheelEvent) => {
|
||
const grid = somedayGridRef.current;
|
||
if (!grid) return;
|
||
|
||
// Let native horizontal scroll (trackpad) pass through
|
||
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
|
||
if (e.deltaY === 0) return;
|
||
|
||
// Only convert if grid has horizontal overflow
|
||
if (grid.scrollWidth <= grid.clientWidth + 1) return;
|
||
|
||
// Check boundaries - allow page scroll when at edges
|
||
const atLeft = grid.scrollLeft <= 0;
|
||
const atRight = grid.scrollLeft + grid.clientWidth >= grid.scrollWidth - 1;
|
||
if (e.deltaY < 0 && atLeft) return;
|
||
if (e.deltaY > 0 && atRight) return;
|
||
|
||
e.preventDefault();
|
||
grid.scrollLeft += e.deltaY;
|
||
};
|
||
|
||
node.addEventListener("wheel", handler, { passive: false });
|
||
somedayWheelCleanup.current = () => node.removeEventListener("wheel", handler);
|
||
}, []);
|
||
const saveSetting = async (key: string, value: any) => {
|
||
if (VIEW_SETTINGS_PROFILE_KEYS.includes(key)) {
|
||
const updated = { ...(viewSettingsRef.current as any), [key]: value };
|
||
viewSettingsRef.current = updated;
|
||
setViewSettings(updated as any);
|
||
setProfile((p: any) => ({ ...p, [key]: value }));
|
||
try {
|
||
await fetch("/api/user/profile", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ viewSettings: updated }),
|
||
});
|
||
} catch (err) {
|
||
console.error(`Failed to save setting ${key}:`, err);
|
||
}
|
||
return;
|
||
}
|
||
// Per-device settings: save to cookie ONLY (not DB) so each device keeps its own value
|
||
if (DEVICE_SETTINGS_KEYS.includes(key)) {
|
||
setCookie(`setting_${key}`, String(value));
|
||
setProfile((p: any) => ({ ...p, [key]: value })); // still update profile so rendering reacts immediately
|
||
return; // Don't write to DB — that would overwrite other devices
|
||
}
|
||
// Also persist to device cookie so this device remembers its own preference on reload
|
||
if (DEVICE_ALSO_COOKIE_KEYS.includes(key)) {
|
||
setCookie(`setting_${key}`, String(value));
|
||
}
|
||
// Keep profile object in sync so the debounced auto-save never sends stale values
|
||
setProfile((p: any) => ({ ...p, [key]: value }));
|
||
try {
|
||
await fetch("/api/user/profile", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ [key]: value }),
|
||
});
|
||
} catch (err) {
|
||
console.error(`Failed to save setting ${key}:`, err);
|
||
}
|
||
};
|
||
|
||
const handleSettingsChanged = (newSettings: any) => {
|
||
setShowTimeGrid(newSettings.showTimeGrid);
|
||
setCellDuration(newSettings.cellDuration);
|
||
setViewStyle(newSettings.viewStyle);
|
||
setLanguage(newSettings.language);
|
||
setDateFormat(newSettings.dateFormat);
|
||
setTimeFormat(newSettings.timeFormat);
|
||
setStartHour(newSettings.startHour);
|
||
setEndHour(newSettings.endHour);
|
||
setFontSize(newSettings.fontSize);
|
||
setShowNextTask(newSettings.showNextTask);
|
||
setShowSomeday(newSettings.showSomeday);
|
||
setShowAllDay(newSettings.showAllDayEvents);
|
||
setShowSchedule(newSettings.showSchedule);
|
||
if (newSettings.weekdayFormat) setWeekdayFormat(newSettings.weekdayFormat);
|
||
if (newSettings.weekdayCase) setWeekdayCase(newSettings.weekdayCase);
|
||
if (newSettings.customWeekdayNames !== undefined) setCustomWeekdayNames(newSettings.customWeekdayNames);
|
||
setHeadlineFont(newSettings.headlineFont);
|
||
setHeadlineFontSize(newSettings.headlineFontSize);
|
||
setHeadlineFontWeight(newSettings.headlineFontWeight);
|
||
setDateFontFamily(newSettings.dateFontFamily);
|
||
setDateFontSize(newSettings.dateFontSize);
|
||
setDateFontWeight(newSettings.dateFontWeight);
|
||
setTimeTaskFontFamily(newSettings.timeTaskFontFamily);
|
||
setTimeTaskFontSize(newSettings.timeTaskFontSize);
|
||
setTimeTaskFontWeight(newSettings.timeTaskFontWeight);
|
||
setBodyFont(newSettings.bodyFont);
|
||
setTaskFontFamily(newSettings.taskFontFamily);
|
||
setTaskFontSize(newSettings.taskFontSize);
|
||
setTaskFontWeight(newSettings.taskFontWeight);
|
||
if (newSettings.eventFontFamily)
|
||
setEventFontFamily(newSettings.eventFontFamily);
|
||
if (newSettings.eventFontSize) setEventFontSize(newSettings.eventFontSize);
|
||
if (newSettings.eventFontWeight)
|
||
setEventFontWeight(newSettings.eventFontWeight);
|
||
if (newSettings.fontWeight) setFontWeight(newSettings.fontWeight);
|
||
if (newSettings.weekendColorSat)
|
||
setWeekendColorSat(newSettings.weekendColorSat);
|
||
if (newSettings.weekendColorSun)
|
||
setWeekendColorSun(newSettings.weekendColorSun);
|
||
|
||
setProfile((prev: any) => ({
|
||
...prev,
|
||
...newSettings,
|
||
weekdayColor: newSettings.weekdayColor || prev.weekdayColor,
|
||
dateColor: newSettings.dateColor || prev.dateColor,
|
||
taskColor: newSettings.taskColor || prev.taskColor,
|
||
todayHighlightColor:
|
||
newSettings.todayHighlightColor || prev.todayHighlightColor,
|
||
eventFontFamily: newSettings.eventFontFamily || prev.eventFontFamily,
|
||
eventFontSize: newSettings.eventFontSize || prev.eventFontSize,
|
||
eventFontWeight: newSettings.eventFontWeight || prev.eventFontWeight,
|
||
}));
|
||
|
||
// Custom start/end hours might affect task placement if we filter strictly
|
||
fetchTasks();
|
||
};
|
||
|
||
const fetchUserInfo = async () => {
|
||
try {
|
||
const res = await fetch("/api/user/profile");
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
if (data.user) {
|
||
setProtectEventTimes(data.user.protectEventTimes || false);
|
||
setTimeFormat(data.user.timeFormat || "12h");
|
||
setDateFormat(data.user.dateFormat || "MM/dd/yyyy");
|
||
setLanguage(data.user.language || "en");
|
||
if (data.user.startHour !== undefined)
|
||
setStartHour(data.user.startHour);
|
||
if (data.user.endHour !== undefined)
|
||
setEndHour(data.user.endHour);
|
||
if (data.user.viewStyle !== undefined) {
|
||
setViewStyle(data.user.viewStyle as ViewStyle);
|
||
setShowTimeGrid(data.user.showTimeGrid ?? true);
|
||
}
|
||
if (data.user.kanbanStages) {
|
||
try {
|
||
const parsed = JSON.parse(data.user.kanbanStages);
|
||
if (Array.isArray(parsed) && parsed.length > 0) setKanbanStages(parsed);
|
||
} catch { /* use defaults */ }
|
||
}
|
||
if (data.user.viewDays !== undefined) {
|
||
savedViewDaysRef.current = data.user.viewDays;
|
||
const w = window.innerWidth, h = window.innerHeight;
|
||
if (w <= 768) setViewDays(h > w ? 1 : 3);
|
||
else if (w <= 1024) setViewDays(Math.min(data.user.viewDays, 5));
|
||
else setViewDays(data.user.viewDays);
|
||
}
|
||
if (data.user.cellDuration !== undefined)
|
||
setCellDuration(data.user.cellDuration as CellDuration);
|
||
|
||
// Cookie overrides for per-device settings (always apply, even if DB has no value)
|
||
const cookieViewDays = getCookie("setting_viewDays");
|
||
if (cookieViewDays) {
|
||
const v = Number(cookieViewDays);
|
||
savedViewDaysRef.current = v;
|
||
const w = window.innerWidth, h = window.innerHeight;
|
||
if (w <= 768) setViewDays(h > w ? 1 : 3);
|
||
else if (w <= 1024) setViewDays(Math.min(v, 5));
|
||
else setViewDays(v);
|
||
}
|
||
if (data.user.weekdayFormat) {
|
||
setProfile((prev: any) => ({ ...prev, weekdayFormat: data.user.weekdayFormat }));
|
||
}
|
||
if (data.user.customWeekdayNames) {
|
||
setProfile((prev: any) => ({ ...prev, customWeekdayNames: data.user.customWeekdayNames }));
|
||
}
|
||
const cookieCellDuration = getCookie("setting_cellDuration");
|
||
if (cookieCellDuration) setCellDuration(Number(cookieCellDuration) as CellDuration);
|
||
const cookieStartHour = getCookie("setting_startHour");
|
||
if (cookieStartHour) setStartHour(Number(cookieStartHour));
|
||
const cookieEndHour = getCookie("setting_endHour");
|
||
if (cookieEndHour) setEndHour(Number(cookieEndHour));
|
||
setShowNextTask(data.user.showNextTask || false);
|
||
setCalendarEditMode(data.user.calendarEditMode || false);
|
||
if (data.user.fontSize)
|
||
setFontSize(data.user.fontSize as "S" | "M" | "L");
|
||
// Cookie wins over DB for per-device settings
|
||
const cookieFontSize2 = getCookie("setting_fontSize");
|
||
if (cookieFontSize2) setFontSize(cookieFontSize2 as "S" | "M" | "L");
|
||
|
||
if (data.user.showSomeday !== undefined)
|
||
setShowSomeday(data.user.showSomeday);
|
||
if (data.user.showAllDayEvents !== undefined)
|
||
setShowAllDay(data.user.showAllDayEvents);
|
||
if (data.user.showSchedule !== undefined)
|
||
setShowSchedule(data.user.showSchedule);
|
||
if (data.user.weekdayFormat)
|
||
setWeekdayFormat(data.user.weekdayFormat as any);
|
||
if (data.user.weekdayCase)
|
||
setWeekdayCase(data.user.weekdayCase as any);
|
||
if (data.user.customWeekdayNames)
|
||
setCustomWeekdayNames(data.user.customWeekdayNames);
|
||
if (data.user.hourLabelFormat)
|
||
setHourLabelFormat(data.user.hourLabelFormat as "short" | "full");
|
||
if (data.user.showSubHourSlots !== undefined)
|
||
setShowSubHourSlots(data.user.showSubHourSlots);
|
||
// Cookie wins over DB for showSubHourSlots (per-device)
|
||
const _cSH2 = getCookie("setting_showSubHourSlots");
|
||
if (_cSH2 !== null) setShowSubHourSlots(_cSH2 === "true");
|
||
if (data.user.allDayPosition)
|
||
setAllDayPosition(data.user.allDayPosition as "above" | "below");
|
||
if (data.user.viewSettings) setViewSettings(data.user.viewSettings);
|
||
if (data.user.headlineFont) setHeadlineFont(data.user.headlineFont);
|
||
if (data.user.headlineFontSize)
|
||
setHeadlineFontSize(data.user.headlineFontSize);
|
||
if (data.user.headlineFontWeight)
|
||
setHeadlineFontWeight(data.user.headlineFontWeight);
|
||
if (data.user.dateFontFamily)
|
||
setDateFontFamily(data.user.dateFontFamily);
|
||
if (data.user.dateFontSize) setDateFontSize(data.user.dateFontSize);
|
||
if (data.user.dateFontWeight)
|
||
setDateFontWeight(data.user.dateFontWeight);
|
||
if (data.user.timeTaskFontFamily)
|
||
setTimeTaskFontFamily(data.user.timeTaskFontFamily);
|
||
if (data.user.timeTaskFontSize)
|
||
setTimeTaskFontSize(data.user.timeTaskFontSize);
|
||
if (data.user.timeTaskFontWeight)
|
||
setTimeTaskFontWeight(data.user.timeTaskFontWeight);
|
||
if (data.user.bodyFont) setBodyFont(data.user.bodyFont);
|
||
if (data.user.taskFontFamily)
|
||
setTaskFontFamily(data.user.taskFontFamily);
|
||
if (data.user.taskFontSize) setTaskFontSize(data.user.taskFontSize);
|
||
if (data.user.taskFontWeight)
|
||
setTaskFontWeight(data.user.taskFontWeight);
|
||
if (data.user.eventFontFamily)
|
||
setEventFontFamily(data.user.eventFontFamily);
|
||
if (data.user.eventFontSize)
|
||
setEventFontSize(data.user.eventFontSize);
|
||
if (data.user.eventFontWeight)
|
||
setEventFontWeight(data.user.eventFontWeight);
|
||
if (data.user.fontWeight) setFontWeight(data.user.fontWeight);
|
||
if (data.user.weekendColorSat)
|
||
setWeekendColorSat(data.user.weekendColorSat);
|
||
if (data.user.weekendColorSun)
|
||
setWeekendColorSun(data.user.weekendColorSun);
|
||
|
||
setProfile((prev: any) => ({
|
||
...prev,
|
||
...data.user,
|
||
name: data.user.name || prev.name,
|
||
email: data.user.email || prev.email,
|
||
weekdayColor: data.user.weekdayColor || "#888888",
|
||
dateColor: data.user.dateColor || "#888888",
|
||
taskColor: data.user.taskColor || "#333333",
|
||
todayHighlightColor: data.user.todayHighlightColor || "#f0fafa",
|
||
}));
|
||
|
||
// Apply start day offset (e.g. -1 for yesterday) — only for multi-day views
|
||
// On single-day view (phones), always start on today
|
||
const effectiveViewDays = (() => {
|
||
const width = window.innerWidth;
|
||
if (width <= 480) return 1;
|
||
if (width <= 768) return 3;
|
||
if (width <= 1024) return Math.min(data.user.viewDays || 7, 5);
|
||
return data.user.viewDays || 7;
|
||
})();
|
||
if (data.user.startDayOffset && data.user.startDayOffset !== 0 && effectiveViewDays > 1) {
|
||
const d = new Date();
|
||
d.setHours(0, 0, 0, 0);
|
||
d.setDate(d.getDate() + data.user.startDayOffset);
|
||
setCurrentWeekStart(d);
|
||
}
|
||
|
||
if (data.user.focusTimerDuration)
|
||
setFocusTimerDuration(data.user.focusTimerDuration);
|
||
if (data.user.focusBreakDuration)
|
||
setFocusBreakDuration(data.user.focusBreakDuration);
|
||
if (data.user.showTimeGrid !== undefined)
|
||
setShowTimeGrid(data.user.showTimeGrid);
|
||
if (data.user.cellDuration)
|
||
setCellDuration(data.user.cellDuration as CellDuration);
|
||
if (data.user.viewStyle)
|
||
setViewStyle(data.user.viewStyle as ViewStyle);
|
||
|
||
// Cookie overrides for settings that are also device-local
|
||
const _cVS2 = getCookie("setting_viewStyle");
|
||
if (_cVS2) { setViewStyle(_cVS2 as ViewStyle); setProfile((p: any) => ({ ...p, viewStyle: _cVS2, showTimeGrid: _cVS2 === "simple" || _cVS2 === "calendar" })); }
|
||
const _cTG2 = getCookie("setting_showTimeGrid");
|
||
if (_cTG2 !== null) setShowTimeGrid(_cTG2 === "true");
|
||
const _cSDO2 = getCookie("setting_startDayOffset");
|
||
if (_cSDO2 !== null) {
|
||
const offset = Number(_cSDO2);
|
||
setProfile((p: any) => ({ ...p, startDayOffset: offset }));
|
||
if (offset !== 0) {
|
||
const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() + offset);
|
||
setCurrentWeekStart(d);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchUserInfo();
|
||
}, []);
|
||
|
||
async function fetchSomedayLists() {
|
||
try {
|
||
const response = await fetch("/api/someday-lists");
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
// Map tasks is handled in fetchTasks or we can merge here if needed.
|
||
// But fetchTasks fetches ALL tasks.
|
||
// Optimally we fetch lists, then tasks, then merge.
|
||
// For now, let's just set the lists structure.
|
||
setSomedayLists(
|
||
data.lists.map((l: any) => ({
|
||
id: l.id,
|
||
title: l.title,
|
||
tab: l.tab || null,
|
||
color: l.color || null,
|
||
icon: l.icon || null,
|
||
tasks: l.tasks || [],
|
||
externalId: l.externalId || null,
|
||
externalProvider: l.externalProvider || null,
|
||
})),
|
||
);
|
||
return data.lists;
|
||
}
|
||
} catch (error) {
|
||
console.error("Error fetching someday lists:", error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async function fetchProjects() {
|
||
try {
|
||
const res = await fetch("/api/projects");
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
const updatedProjects = data.projects || [];
|
||
setProjects(updatedProjects);
|
||
|
||
// Update project references on tasks so color changes take effect immediately
|
||
const projectMap = new Map<string, { id: string; name: string; icon?: string | null; color?: string | null }>(
|
||
updatedProjects.map((p: any) => [p.id, p])
|
||
);
|
||
setTasks(prev => prev.map(t => {
|
||
if (t.projectId && projectMap.has(t.projectId)) {
|
||
return { ...t, project: projectMap.get(t.projectId) || null };
|
||
}
|
||
return t;
|
||
}));
|
||
setSomedayLists(prev => prev.map(list => ({
|
||
...list,
|
||
tasks: list.tasks.map(t => {
|
||
if (t.projectId && projectMap.has(t.projectId)) {
|
||
return { ...t, project: projectMap.get(t.projectId) || null };
|
||
}
|
||
return t;
|
||
}),
|
||
})));
|
||
}
|
||
} catch (error) {
|
||
console.error("Error fetching projects:", error);
|
||
}
|
||
}
|
||
|
||
async function fetchTasks() {
|
||
startSync();
|
||
try {
|
||
const [tasksResponse, listsResponse] = await Promise.all([
|
||
fetch("/api/tasks"),
|
||
fetch("/api/someday-lists"), // Fetch lists in parallel
|
||
]);
|
||
// Also fetch projects in background
|
||
fetchProjects();
|
||
|
||
let fetchedLists: SomedayList[] = [];
|
||
if (listsResponse.ok) {
|
||
const listData = await listsResponse.json();
|
||
fetchedLists = listData.lists.map((l: any) => ({
|
||
id: l.id,
|
||
title: l.title,
|
||
tab: l.tab || null,
|
||
color: l.color || null,
|
||
icon: l.icon || null,
|
||
tasks: [],
|
||
externalId: l.externalId || null,
|
||
externalProvider: l.externalProvider || null,
|
||
}));
|
||
}
|
||
|
||
// If no lists exist, maybe create default 'Someday'?
|
||
// TeuxDeux usually starts with one.
|
||
// If DB is empty, maybe create one?
|
||
// For now, if empty, we might show empty.
|
||
if (fetchedLists.length === 0) {
|
||
// Optionally create default list if none exist?
|
||
// Let's stick to what's in DB.
|
||
}
|
||
|
||
if (tasksResponse.ok) {
|
||
const data = await tasksResponse.json();
|
||
const fetchedTasks = data.tasks.map((t: any) => ({
|
||
...t,
|
||
createdAt: new Date(t.createdAt),
|
||
updatedAt: new Date(t.updatedAt),
|
||
recurrenceDays: t.recurrenceDays ? (typeof t.recurrenceDays === 'string' ? JSON.parse(t.recurrenceDays) : t.recurrenceDays) : null,
|
||
}));
|
||
|
||
// Calendar tasks: anything NOT in a someday list (includes tasks with scheduledDate OR dayOfWeek)
|
||
const dayTasks = fetchedTasks.filter((t: Task) => !t.somedayListId);
|
||
const somedayTasks = fetchedTasks.filter((t: Task) => t.somedayListId);
|
||
|
||
setTasks(dayTasks);
|
||
|
||
// Populate lists with tasks
|
||
const listIds = new Set(fetchedLists.map((l: SomedayList) => l.id));
|
||
const orphanedSomedayTasks = somedayTasks.filter(
|
||
(t: Task) => !listIds.has(t.somedayListId || ""),
|
||
);
|
||
|
||
const populatedLists = fetchedLists.map((list) => ({
|
||
...list,
|
||
tasks: somedayTasks.filter((t: Task) => t.somedayListId === list.id && !t.parentTaskId),
|
||
}));
|
||
|
||
// Rescue orphaned someday tasks: if their list was deleted, move them to calendar
|
||
if (orphanedSomedayTasks.length > 0) {
|
||
console.warn(
|
||
`[RESCUE] Found ${orphanedSomedayTasks.length} orphaned someday tasks, recovering to calendar`,
|
||
);
|
||
const rescuedTasks = orphanedSomedayTasks.map((t: Task) => ({
|
||
...t,
|
||
somedayListId: null,
|
||
scheduledDate: t.scheduledDate || new Date().toISOString(),
|
||
}));
|
||
setTasks((prev) => [...prev, ...rescuedTasks]);
|
||
// Persist the rescue to DB
|
||
for (const t of orphanedSomedayTasks) {
|
||
fetch("/api/tasks", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
id: t.id,
|
||
somedayListId: null,
|
||
scheduledDate: new Date().toISOString(),
|
||
}),
|
||
}).catch((e) =>
|
||
console.error("Failed to rescue orphaned task:", e),
|
||
);
|
||
}
|
||
}
|
||
|
||
setSomedayLists(populatedLists);
|
||
}
|
||
} catch (error) {
|
||
console.error("Error fetching data:", error);
|
||
} finally {
|
||
setIsLoading(false);
|
||
endSync();
|
||
}
|
||
}
|
||
|
||
// Get visible days based on current view setting
|
||
const getVisibleDays = useCallback(() => {
|
||
const days: Date[] = [];
|
||
for (let i = 0; i < viewDays; i++) {
|
||
days.push(new Date(currentWeekStart.getTime() + i * 24 * 60 * 60 * 1000));
|
||
}
|
||
return days;
|
||
}, [currentWeekStart, viewDays]);
|
||
|
||
// Get tasks for a specific date
|
||
const getTasksForDate = useCallback(
|
||
(date: Date): Task[] => {
|
||
const dateStr = formatDateToISO(date); // Use local date formatting
|
||
return tasks
|
||
.filter((task) => {
|
||
if (!task.scheduledDate) return false;
|
||
// Exclude sub-tasks from top-level list (they render inside their parent)
|
||
if (task.parentTaskId) return false;
|
||
// Hide completed tasks if setting is off
|
||
if (!effectiveShowCompletedTasks && task.completed) return false;
|
||
if (activeProjectFilter && task.projectId !== activeProjectFilter) return false;
|
||
// Use string comparison to avoid timezone shifts
|
||
const taskDateStr =
|
||
typeof task.scheduledDate === "string"
|
||
? task.scheduledDate.substring(0, 10)
|
||
: formatDateToISO(new Date(task.scheduledDate));
|
||
return taskDateStr === dateStr;
|
||
})
|
||
.sort((a, b) => {
|
||
// Sort by time if available
|
||
if (a.startTime && b.startTime) {
|
||
return a.startTime.localeCompare(b.startTime);
|
||
}
|
||
if (a.startTime) return -1;
|
||
if (b.startTime) return 1;
|
||
return a.order - b.order;
|
||
});
|
||
},
|
||
[tasks, effectiveShowCompletedTasks, activeProjectFilter],
|
||
);
|
||
|
||
// Get tasks for a specific time slot
|
||
const getTasksForSlot = useCallback(
|
||
(date: Date, slot: string): Task[] => {
|
||
const dateStr = formatDateToISO(date);
|
||
return tasks.filter((task) => {
|
||
if (!task.scheduledDate) return false;
|
||
// Exclude sub-tasks from top-level list
|
||
if (task.parentTaskId) return false;
|
||
// Hide completed tasks if setting is off
|
||
if (!effectiveShowCompletedTasks && task.completed) return false;
|
||
if (activeProjectFilter && task.projectId !== activeProjectFilter) return false;
|
||
// Use string comparison to avoid timezone shifts
|
||
const taskDateStr =
|
||
typeof task.scheduledDate === "string"
|
||
? task.scheduledDate.substring(0, 10)
|
||
: formatDateToISO(new Date(task.scheduledDate));
|
||
if (taskDateStr !== dateStr || !task.startTime) return false;
|
||
|
||
// Extract hour:minute from task start time and compare with slot
|
||
const [taskHour, taskMinute] = task.startTime.split(":").map(Number);
|
||
const taskStart = taskHour * 60 + taskMinute;
|
||
|
||
const [slotHour, slotMinute] = slot.split(":").map(Number);
|
||
const slotStart = slotHour * 60 + slotMinute;
|
||
const slotEnd = slotStart + effectiveCellDuration;
|
||
|
||
return taskStart >= slotStart && taskStart < slotEnd;
|
||
});
|
||
},
|
||
[tasks, effectiveCellDuration, effectiveShowCompletedTasks, activeProjectFilter],
|
||
);
|
||
|
||
// Get calendar events for a specific date
|
||
const getEventsForDate = useCallback(
|
||
(date: Date): CalendarEvent[] => {
|
||
return calendarEvents.filter((event) => {
|
||
// Skip all-day events (handled separately)
|
||
if (isAllDayEvent(event)) return false;
|
||
|
||
const eventDate = new Date(event.startTime);
|
||
return isSameDay(eventDate, date);
|
||
});
|
||
},
|
||
[calendarEvents],
|
||
);
|
||
|
||
// Get calendar events for a specific time slot
|
||
const getEventsForSlot = useCallback(
|
||
(date: Date, slot: string): CalendarEvent[] => {
|
||
return calendarEvents.filter((event) => {
|
||
// Skip all-day events (handled separately)
|
||
const isAllDay = isAllDayEvent(event);
|
||
if (isAllDay) return false;
|
||
|
||
// During drag, use the drag state's current start time for slot assignment
|
||
const isDraggedEvent = eventDragState?.eventId === event.id && eventDragState?.hasMoved && eventDragState?.mode === 'move';
|
||
const startTime = isDraggedEvent ? eventDragState!.currentStartTime : event.startTime;
|
||
|
||
const eventDate = new Date(startTime);
|
||
if (!isSameDay(eventDate, date)) return false;
|
||
|
||
// Extract hour:minute from event start time and compare with slot
|
||
const eventHour = eventDate.getHours();
|
||
const eventMinute = eventDate.getMinutes();
|
||
|
||
// Match if event starts within this slot
|
||
const [slotHour, slotMinute] = slot.split(":").map(Number);
|
||
const slotStart = slotHour * 60 + slotMinute;
|
||
const slotEnd = slotStart + effectiveCellDuration;
|
||
const eventStart = eventHour * 60 + eventMinute;
|
||
|
||
return eventStart >= slotStart && eventStart < slotEnd;
|
||
});
|
||
},
|
||
[calendarEvents, effectiveCellDuration, eventDragState],
|
||
);
|
||
|
||
// Precompute overlap layout: { [eventId]: { column, totalColumns } }
|
||
const eventOverlapLayout = useMemo(() => {
|
||
const layout: Record<string, { column: number; totalColumns: number }> = {};
|
||
// Group events by day
|
||
const dayMap = new Map<string, CalendarEvent[]>();
|
||
for (const event of calendarEvents) {
|
||
if (isAllDayEvent(event)) continue;
|
||
const d = new Date(event.startTime);
|
||
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
||
if (!dayMap.has(key)) dayMap.set(key, []);
|
||
dayMap.get(key)!.push(event);
|
||
}
|
||
for (const events of dayMap.values()) {
|
||
// Sort by start time, then by duration (longer first)
|
||
events.sort((a, b) => {
|
||
const diff = new Date(a.startTime).getTime() - new Date(b.startTime).getTime();
|
||
if (diff !== 0) return diff;
|
||
return (new Date(b.endTime).getTime() - new Date(b.startTime).getTime()) -
|
||
(new Date(a.endTime).getTime() - new Date(a.startTime).getTime());
|
||
});
|
||
// Build overlap groups using a greedy column assignment
|
||
const columns: { end: number; eventId: string }[][] = [];
|
||
for (const event of events) {
|
||
const start = new Date(event.startTime).getTime();
|
||
const end = new Date(event.endTime).getTime();
|
||
// Find first column where this event doesn't overlap
|
||
let placed = false;
|
||
for (let col = 0; col < columns.length; col++) {
|
||
const lastInCol = columns[col][columns[col].length - 1];
|
||
if (lastInCol.end <= start) {
|
||
columns[col].push({ end, eventId: event.id });
|
||
placed = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!placed) {
|
||
columns.push([{ end, eventId: event.id }]);
|
||
}
|
||
}
|
||
// Now find the max columns each event actually shares with
|
||
// For each event, find all events that overlap it and determine the group width
|
||
for (const event of events) {
|
||
const start = new Date(event.startTime).getTime();
|
||
const end = new Date(event.endTime).getTime();
|
||
// Count how many columns have events overlapping this time range
|
||
let overlappingCols = 0;
|
||
for (const col of columns) {
|
||
for (const item of col) {
|
||
const itemStart = calendarEvents.find(e => e.id === item.eventId);
|
||
if (itemStart) {
|
||
const iStart = new Date(itemStart.startTime).getTime();
|
||
const iEnd = new Date(itemStart.endTime).getTime();
|
||
if (iStart < end && iEnd > start) {
|
||
overlappingCols++;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Find which column this event is in
|
||
let eventCol = 0;
|
||
for (let col = 0; col < columns.length; col++) {
|
||
if (columns[col].some(item => item.eventId === event.id)) {
|
||
eventCol = col;
|
||
break;
|
||
}
|
||
}
|
||
layout[event.id] = { column: eventCol, totalColumns: Math.max(overlappingCols, 1) };
|
||
}
|
||
}
|
||
return layout;
|
||
}, [calendarEvents]);
|
||
|
||
// Calculate event duration in pixels for proper height display
|
||
const getEventDuration = (event: CalendarEvent): number => {
|
||
if (isAllDayEvent(event)) return 0; // All-day events handled separately
|
||
|
||
const start = new Date(event.startTime);
|
||
const end = new Date(event.endTime);
|
||
const durationMinutes = (end.getTime() - start.getTime()) / (1000 * 60);
|
||
|
||
// Guard against NaN or negative durations (missing/invalid end time)
|
||
if (!isFinite(durationMinutes) || durationMinutes <= 0) {
|
||
return getSlotHeight(effectiveCellDuration); // Default to one slot height
|
||
}
|
||
|
||
// Calculate height based on duration and slot height
|
||
const pixelsPerMinute = getSlotHeight(effectiveCellDuration) / effectiveCellDuration;
|
||
return Math.max(
|
||
durationMinutes * pixelsPerMinute,
|
||
getSlotHeight(effectiveCellDuration),
|
||
);
|
||
};
|
||
|
||
// Snap minutes to nearest 5-minute increment
|
||
const snapMinutes = (mins: number) => Math.round(mins / 5) * 5;
|
||
|
||
// Handle calendar event drag/resize
|
||
const handleEventDragStart = (e: React.MouseEvent, event: CalendarEvent, mode: 'move' | 'resize-top' | 'resize-bottom') => {
|
||
if (!event.editable) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setEventDragState({
|
||
eventId: event.id,
|
||
mode,
|
||
startY: e.clientY,
|
||
startX: e.clientX,
|
||
originalStartTime: event.startTime,
|
||
originalEndTime: event.endTime,
|
||
currentStartTime: event.startTime,
|
||
currentEndTime: event.endTime,
|
||
calendarId: event.calendarId,
|
||
source: event.source,
|
||
});
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!eventDragState) return;
|
||
const pixelsPerMinute = getSlotHeight(effectiveCellDuration) / effectiveCellDuration;
|
||
|
||
const handleMouseMove = (e: MouseEvent) => {
|
||
const deltaY = e.clientY - eventDragState.startY;
|
||
const deltaX = e.clientX - eventDragState.startX;
|
||
// Require minimum 3px movement to start actual drag
|
||
if (!eventDragState.hasMoved && Math.abs(deltaY) < 3 && Math.abs(deltaX) < 3) return;
|
||
if (!eventDragState.hasMoved) {
|
||
setEventDragState(prev => prev ? { ...prev, hasMoved: true } : null);
|
||
}
|
||
const deltaMinutes = snapMinutes(deltaY / pixelsPerMinute);
|
||
const origStart = new Date(eventDragState.originalStartTime);
|
||
const origEnd = new Date(eventDragState.originalEndTime);
|
||
|
||
if (eventDragState.mode === 'move') {
|
||
// Detect day column under cursor for cross-day drag
|
||
let dayOffset = 0;
|
||
const dayColumns = document.querySelectorAll('.weekly-day-column');
|
||
if (dayColumns.length > 0) {
|
||
const origDate = `${origStart.getFullYear()}-${String(origStart.getMonth() + 1).padStart(2, '0')}-${String(origStart.getDate()).padStart(2, '0')}`;
|
||
let origColIndex = -1;
|
||
let hoverColIndex = -1;
|
||
dayColumns.forEach((col, i) => {
|
||
const rect = col.getBoundingClientRect();
|
||
const colDate = col.getAttribute('data-date');
|
||
if (colDate === origDate) origColIndex = i;
|
||
if (e.clientX >= rect.left && e.clientX <= rect.right) hoverColIndex = i;
|
||
});
|
||
if (origColIndex >= 0 && hoverColIndex >= 0) {
|
||
dayOffset = hoverColIndex - origColIndex;
|
||
}
|
||
}
|
||
const newStart = new Date(origStart.getTime() + deltaMinutes * 60000 + dayOffset * 86400000);
|
||
const newEnd = new Date(origEnd.getTime() + deltaMinutes * 60000 + dayOffset * 86400000);
|
||
setEventDragState(prev => prev ? { ...prev, currentStartTime: newStart.toISOString(), currentEndTime: newEnd.toISOString() } : null);
|
||
} else if (eventDragState.mode === 'resize-bottom') {
|
||
const newEnd = new Date(origEnd.getTime() + deltaMinutes * 60000);
|
||
if (newEnd.getTime() > origStart.getTime() + 5 * 60000) {
|
||
setEventDragState(prev => prev ? { ...prev, currentEndTime: newEnd.toISOString() } : null);
|
||
}
|
||
} else if (eventDragState.mode === 'resize-top') {
|
||
const newStart = new Date(origStart.getTime() + deltaMinutes * 60000);
|
||
if (newStart.getTime() < origEnd.getTime() - 5 * 60000) {
|
||
setEventDragState(prev => prev ? { ...prev, currentStartTime: newStart.toISOString() } : null);
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleMouseUp = async () => {
|
||
if (!eventDragState) return;
|
||
const startChanged = eventDragState.currentStartTime !== eventDragState.originalStartTime;
|
||
const endChanged = eventDragState.currentEndTime !== eventDragState.originalEndTime;
|
||
// Set drag-ended ref immediately (before async) to prevent click from opening popup
|
||
if (eventDragState.hasMoved) {
|
||
dragJustEndedRef.current = true;
|
||
setTimeout(() => { dragJustEndedRef.current = false; }, 300);
|
||
}
|
||
if (eventDragState.hasMoved && (startChanged || endChanged)) {
|
||
// Optimistically update the UI
|
||
setRawCalendarEvents(prev => prev.map(ev =>
|
||
ev.id === eventDragState.eventId
|
||
? { ...ev, startTime: eventDragState.currentStartTime, endTime: eventDragState.currentEndTime }
|
||
: ev
|
||
));
|
||
|
||
// Check if this is a recurring event — if so, ask before saving
|
||
const draggedEvent = calendarEvents.find(e => e.id === eventDragState.eventId);
|
||
if (draggedEvent?.isRecurring) {
|
||
setRecurringDragEditMode('this');
|
||
setPendingRecurringDrag({
|
||
eventId: eventDragState.eventId,
|
||
calendarId: eventDragState.calendarId || '',
|
||
recurringEventId: draggedEvent.recurringEventId,
|
||
startTime: eventDragState.currentStartTime,
|
||
endTime: eventDragState.currentEndTime,
|
||
originalStartTime: eventDragState.originalStartTime,
|
||
originalEndTime: eventDragState.originalEndTime,
|
||
});
|
||
} else {
|
||
// Non-recurring: save immediately
|
||
try {
|
||
const res = await fetch("/api/calendar/events", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
calendarId: eventDragState.calendarId,
|
||
eventId: eventDragState.eventId,
|
||
start: { dateTime: eventDragState.currentStartTime },
|
||
end: { dateTime: eventDragState.currentEndTime },
|
||
}),
|
||
});
|
||
if (!res.ok) {
|
||
setRawCalendarEvents(prev => prev.map(ev =>
|
||
ev.id === eventDragState.eventId
|
||
? { ...ev, startTime: eventDragState.originalStartTime, endTime: eventDragState.originalEndTime }
|
||
: ev
|
||
));
|
||
}
|
||
} catch {
|
||
setRawCalendarEvents(prev => prev.map(ev =>
|
||
ev.id === eventDragState.eventId
|
||
? { ...ev, startTime: eventDragState.originalStartTime, endTime: eventDragState.originalEndTime }
|
||
: ev
|
||
));
|
||
}
|
||
}
|
||
}
|
||
setEventDragState(null);
|
||
};
|
||
|
||
document.addEventListener('mousemove', handleMouseMove);
|
||
document.addEventListener('mouseup', handleMouseUp);
|
||
return () => {
|
||
document.removeEventListener('mousemove', handleMouseMove);
|
||
document.removeEventListener('mouseup', handleMouseUp);
|
||
};
|
||
}, [eventDragState, effectiveCellDuration, calendarEvents]);
|
||
|
||
// Slot drag-to-create: mousemove + mouseup on document (always active, ref-gated)
|
||
const effectiveCellDurationRef = useRef(effectiveCellDuration);
|
||
effectiveCellDurationRef.current = effectiveCellDuration;
|
||
useEffect(() => {
|
||
const handleMouseMove = (e: MouseEvent) => {
|
||
const drag = slotDragRef.current;
|
||
if (!drag) return;
|
||
// Require minimum movement to distinguish from click
|
||
if (!drag.active && Math.abs(e.clientY - drag.startY) < 5) return;
|
||
if (!drag.active) {
|
||
drag.active = true;
|
||
document.body.style.userSelect = 'none';
|
||
}
|
||
|
||
// Find which slot the mouse is over
|
||
const el = document.elementFromPoint(e.clientX, e.clientY);
|
||
const slotEl = el?.closest('[data-slot]') as HTMLElement | null;
|
||
if (slotEl) {
|
||
const slot = slotEl.getAttribute('data-slot');
|
||
if (slot) {
|
||
drag.currentSlot = slot;
|
||
const dateStr = `${drag.date.getFullYear()}-${String(drag.date.getMonth() + 1).padStart(2, '0')}-${String(drag.date.getDate()).padStart(2, '0')}`;
|
||
// Determine visual range (start <= end)
|
||
const startSlot = drag.startSlot <= slot ? drag.startSlot : slot;
|
||
const endSlot = drag.startSlot <= slot ? slot : drag.startSlot;
|
||
setSlotDragSelection({ dateStr, startSlot, endSlot });
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleMouseUp = () => {
|
||
const drag = slotDragRef.current;
|
||
slotDragRef.current = null;
|
||
if (!drag || !drag.active) {
|
||
setSlotDragSelection(null);
|
||
document.body.style.userSelect = '';
|
||
return;
|
||
}
|
||
setSlotDragSelection(null);
|
||
document.body.style.userSelect = '';
|
||
// Suppress the click event that follows mouseup
|
||
slotDragJustEndedRef.current = true;
|
||
setTimeout(() => { slotDragJustEndedRef.current = false; }, 300);
|
||
|
||
// Calculate start and end times
|
||
const startSlot = drag.startSlot <= drag.currentSlot ? drag.startSlot : drag.currentSlot;
|
||
const endSlot = drag.startSlot <= drag.currentSlot ? drag.currentSlot : drag.startSlot;
|
||
|
||
// End time = endSlot + cellDuration
|
||
const [eh, em] = endSlot.split(':').map(Number);
|
||
const endMinutes = eh * 60 + em + effectiveCellDurationRef.current;
|
||
const endH = Math.floor(endMinutes / 60);
|
||
const endM = endMinutes % 60;
|
||
const endTime = `${String(endH).padStart(2, '0')}:${String(endM).padStart(2, '0')}`;
|
||
|
||
setCalendarEventModal({
|
||
isOpen: true,
|
||
initialDate: drag.date,
|
||
initialStartTime: startSlot,
|
||
initialEndTime: endTime,
|
||
});
|
||
};
|
||
|
||
document.addEventListener('mousemove', handleMouseMove);
|
||
document.addEventListener('mouseup', handleMouseUp);
|
||
return () => {
|
||
document.removeEventListener('mousemove', handleMouseMove);
|
||
document.removeEventListener('mouseup', handleMouseUp);
|
||
};
|
||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
// Touch long-press drag-to-create: listeners are attached ONCE at mount (not per-slot-touch).
|
||
// This avoids any DOM manipulation inside onTouchStart on slot divs, which can
|
||
// confuse iOS Safari's scroll-intent detection and block vertical scrolling.
|
||
// The slot onTouchStart only updates touchLongPressRef (pure ref, no DOM side effects).
|
||
const longPressDragActiveRef = useRef(false); // true when non-passive drag listener is attached
|
||
|
||
const upgradeToDragListeners = useCallback(() => {
|
||
if (longPressDragActiveRef.current) return;
|
||
longPressDragActiveRef.current = true;
|
||
// Non-passive listener added only when drag is actually active (500ms hold)
|
||
const handleDragMove = (e: TouchEvent) => {
|
||
if (!longPressDragActiveRef.current) return;
|
||
e.preventDefault();
|
||
const touch = e.touches[0];
|
||
const el = document.elementFromPoint(touch.clientX, touch.clientY);
|
||
const slotEl = el?.closest('[data-slot]') as HTMLElement | null;
|
||
if (slotEl) {
|
||
const slot = slotEl.getAttribute('data-slot');
|
||
if (slot) {
|
||
const drag = slotDragRef.current;
|
||
if (drag) {
|
||
drag.currentSlot = slot;
|
||
const dateStr = `${drag.date.getFullYear()}-${String(drag.date.getMonth() + 1).padStart(2, '0')}-${String(drag.date.getDate()).padStart(2, '0')}`;
|
||
const startSlot = drag.startSlot <= slot ? drag.startSlot : slot;
|
||
const endSlot = drag.startSlot <= slot ? slot : drag.startSlot;
|
||
setSlotDragSelection({ dateStr, startSlot, endSlot });
|
||
}
|
||
}
|
||
}
|
||
};
|
||
document.addEventListener('touchmove', handleDragMove, { passive: false });
|
||
// Store for cleanup
|
||
(upgradeToDragListeners as any)._handler = handleDragMove;
|
||
}, []);
|
||
|
||
const cancelDragListeners = useCallback(() => {
|
||
if (!longPressDragActiveRef.current) return;
|
||
longPressDragActiveRef.current = false;
|
||
const handler = (upgradeToDragListeners as any)._handler;
|
||
if (handler) {
|
||
document.removeEventListener('touchmove', handler);
|
||
(upgradeToDragListeners as any)._handler = null;
|
||
}
|
||
}, [upgradeToDragListeners]);
|
||
|
||
// All touch listeners for the time-grid are attached ONCE at mount as native listeners.
|
||
// CRITICAL: do NOT use React onTouchStart on slot divs — React registers those as
|
||
// non-passive at the app root, causing iOS Safari to wait for JS before committing
|
||
// a scroll gesture, which blocks vertical scrolling on day columns entirely.
|
||
useEffect(() => {
|
||
// TOUCHSTART — passive, on document. Reads data attributes from slot element.
|
||
// Slot divs must have data-slot, data-slot-blocked, and their column must have data-date.
|
||
const handleTouchStart = (e: TouchEvent) => {
|
||
const target = e.target as Element;
|
||
const slotEl = target.closest('[data-slot]') as HTMLElement | null;
|
||
if (!slotEl) return;
|
||
if (slotEl.dataset.slotBlocked) return;
|
||
if (target.closest('.calendar-event-block, .grid-task-block, .task-input-slot')) return;
|
||
|
||
const touch = e.touches[0];
|
||
const colEl = slotEl.closest('[data-date]') as HTMLElement | null;
|
||
if (!colEl?.dataset.date) return;
|
||
const slotDate = new Date(colEl.dataset.date);
|
||
const slotName = slotEl.dataset.slot!;
|
||
|
||
if (touchLongPressRef.current) {
|
||
clearTimeout(touchLongPressRef.current.timerId);
|
||
}
|
||
const timerId = setTimeout(() => {
|
||
if (navigator.vibrate) navigator.vibrate(50);
|
||
slotDragRef.current = {
|
||
active: true,
|
||
date: slotDate,
|
||
startSlot: slotName,
|
||
currentSlot: slotName,
|
||
startY: touch.clientY,
|
||
};
|
||
if (touchLongPressRef.current) {
|
||
touchLongPressRef.current.activated = true;
|
||
}
|
||
upgradeToDragListeners();
|
||
const ds = `${slotDate.getFullYear()}-${String(slotDate.getMonth() + 1).padStart(2, '0')}-${String(slotDate.getDate()).padStart(2, '0')}`;
|
||
setSlotDragSelection({ dateStr: ds, startSlot: slotName, endSlot: slotName });
|
||
}, 500);
|
||
touchLongPressRef.current = {
|
||
timerId,
|
||
startX: touch.clientX,
|
||
startY: touch.clientY,
|
||
date: slotDate,
|
||
slot: slotName,
|
||
activated: false,
|
||
};
|
||
};
|
||
|
||
// TOUCHMOVE — passive, cancels long-press if finger moves (user is scrolling)
|
||
const handlePassiveTouchMove = (e: TouchEvent) => {
|
||
const lp = touchLongPressRef.current;
|
||
if (!lp || lp.activated) return;
|
||
const touch = e.touches[0];
|
||
if (Math.abs(touch.clientX - lp.startX) > 10 || Math.abs(touch.clientY - lp.startY) > 10) {
|
||
clearTimeout(lp.timerId);
|
||
touchLongPressRef.current = null;
|
||
}
|
||
};
|
||
|
||
// TOUCHEND — passive, completes drag or cancels
|
||
const handleTouchEnd = () => {
|
||
const lp = touchLongPressRef.current;
|
||
if (lp) {
|
||
clearTimeout(lp.timerId);
|
||
touchLongPressRef.current = null;
|
||
}
|
||
cancelDragListeners();
|
||
|
||
const drag = slotDragRef.current;
|
||
slotDragRef.current = null;
|
||
if (!drag || !drag.active) {
|
||
setSlotDragSelection(null);
|
||
return;
|
||
}
|
||
setSlotDragSelection(null);
|
||
slotDragJustEndedRef.current = true;
|
||
setTimeout(() => { slotDragJustEndedRef.current = false; }, 300);
|
||
|
||
const startSlot = drag.startSlot <= drag.currentSlot ? drag.startSlot : drag.currentSlot;
|
||
const endSlot = drag.startSlot <= drag.currentSlot ? drag.currentSlot : drag.startSlot;
|
||
|
||
const [eh, em] = endSlot.split(':').map(Number);
|
||
const endMinutes = eh * 60 + em + effectiveCellDurationRef.current;
|
||
const endH = Math.floor(endMinutes / 60);
|
||
const endM = endMinutes % 60;
|
||
const endTime = `${String(endH).padStart(2, '0')}:${String(endM).padStart(2, '0')}`;
|
||
|
||
setCalendarEventModal({
|
||
isOpen: true,
|
||
initialDate: drag.date,
|
||
initialStartTime: startSlot,
|
||
initialEndTime: endTime,
|
||
});
|
||
};
|
||
|
||
document.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||
document.addEventListener('touchmove', handlePassiveTouchMove, { passive: true });
|
||
document.addEventListener('touchend', handleTouchEnd, { passive: true });
|
||
document.addEventListener('touchcancel', handleTouchEnd, { passive: true });
|
||
return () => {
|
||
document.removeEventListener('touchstart', handleTouchStart);
|
||
document.removeEventListener('touchmove', handlePassiveTouchMove);
|
||
document.removeEventListener('touchend', handleTouchEnd);
|
||
document.removeEventListener('touchcancel', handleTouchEnd);
|
||
};
|
||
}, [cancelDragListeners, upgradeToDragListeners]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
const attachTouchListeners = useCallback(() => { /* no-op — all listeners are permanent */ }, []);
|
||
|
||
// Handle recurring event drag confirm (this/all)
|
||
const handleRecurringDragConfirm = async (editMode: 'this' | 'future' | 'all') => {
|
||
if (!pendingRecurringDrag) return;
|
||
const { eventId, calendarId, recurringEventId, startTime, endTime, originalStartTime, originalEndTime } = pendingRecurringDrag;
|
||
try {
|
||
const res = await fetch("/api/calendar/events", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
calendarId,
|
||
eventId,
|
||
start: { dateTime: startTime },
|
||
end: { dateTime: endTime },
|
||
editMode,
|
||
recurringEventId,
|
||
}),
|
||
});
|
||
if (!res.ok) {
|
||
// Revert
|
||
setRawCalendarEvents(prev => prev.map(ev =>
|
||
ev.id === eventId ? { ...ev, startTime: originalStartTime, endTime: originalEndTime } : ev
|
||
));
|
||
}
|
||
} catch {
|
||
setRawCalendarEvents(prev => prev.map(ev =>
|
||
ev.id === eventId ? { ...ev, startTime: originalStartTime, endTime: originalEndTime } : ev
|
||
));
|
||
}
|
||
setPendingRecurringDrag(null);
|
||
};
|
||
|
||
const handleRecurringDragCancel = () => {
|
||
if (!pendingRecurringDrag) return;
|
||
// Revert to original times
|
||
const { eventId, originalStartTime, originalEndTime } = pendingRecurringDrag;
|
||
setRawCalendarEvents(prev => prev.map(ev =>
|
||
ev.id === eventId ? { ...ev, startTime: originalStartTime, endTime: originalEndTime } : ev
|
||
));
|
||
setPendingRecurringDrag(null);
|
||
};
|
||
|
||
// Get all-day events for a specific date
|
||
const getAllDayEventsForDate = useCallback(
|
||
(date: Date): CalendarEvent[] => {
|
||
return calendarEvents.filter((event) => {
|
||
if (!isAllDayEvent(event)) return false;
|
||
|
||
// Parse date safely using our local-time helper
|
||
const start = parseCalendarDate(event.startTime);
|
||
const end = event.endTime
|
||
? parseCalendarDate(event.endTime)
|
||
: new Date(start);
|
||
|
||
// Normalize dates to start of day for comparison
|
||
const targetDate = new Date(date);
|
||
targetDate.setHours(0, 0, 0, 0);
|
||
|
||
start.setHours(0, 0, 0, 0);
|
||
end.setHours(0, 0, 0, 0);
|
||
|
||
// Handle single day case where start == end
|
||
if (start.getTime() === end.getTime()) {
|
||
return start.getTime() === targetDate.getTime();
|
||
}
|
||
|
||
// Standard range comparison (inclusive start, exclusive end)
|
||
return (
|
||
targetDate.getTime() >= start.getTime() &&
|
||
targetDate.getTime() < end.getTime()
|
||
);
|
||
});
|
||
},
|
||
[calendarEvents],
|
||
);
|
||
|
||
// Get all all-day events for the visible week
|
||
const getAllDayEventsForWeek = useCallback((): Map<
|
||
string,
|
||
CalendarEvent[]
|
||
> => {
|
||
const eventsByDay = new Map<string, CalendarEvent[]>();
|
||
const visibleDays = getVisibleDays();
|
||
|
||
visibleDays.forEach((date) => {
|
||
const dateKey = formatDateToISO(date);
|
||
eventsByDay.set(dateKey, getAllDayEventsForDate(date));
|
||
});
|
||
|
||
return eventsByDay;
|
||
}, [calendarEvents, currentWeekStart, viewDays]);
|
||
|
||
const rollOverdueTasks = useCallback(
|
||
async (currentTasks: Task[]) => {
|
||
const autoRolling = profile.autoRolling ?? false;
|
||
|
||
const now = new Date();
|
||
const todayStr = formatDateToISO(now);
|
||
const today = new Date(todayStr);
|
||
|
||
// Roll tasks that are explicitly marked as rolling (per-task flag),
|
||
// OR all incomplete overdue tasks if global autoRolling is enabled
|
||
const overdue = currentTasks.filter(
|
||
(t) =>
|
||
!t.completed &&
|
||
(t.isRolling || autoRolling) &&
|
||
t.scheduledDate &&
|
||
formatDateToISO(new Date(t.scheduledDate)) < todayStr,
|
||
);
|
||
|
||
if (overdue.length === 0) return;
|
||
|
||
console.log(
|
||
`[ROLLING] Found ${overdue.length} overdue tasks to roll to today. autoRolling=${autoRolling}`,
|
||
);
|
||
|
||
const updatedTasks = [...currentTasks];
|
||
let hasChanges = false;
|
||
|
||
const dailyEvents = getEventsForDate(today);
|
||
|
||
for (const task of overdue) {
|
||
const targetSlot = task.startTime || "09:00"; // Default to 9am if no time
|
||
const taskDuration = task.duration || effectiveCellDuration;
|
||
|
||
// Collision detection — checks task duration overlap, not just exact slot match
|
||
const isBlocked = (date: Date, slot: string, tasksToCheck: Task[]) => {
|
||
const dateStr = formatDateToISO(date);
|
||
const [h, m] = slot.split(":").map(Number);
|
||
const slotStart = h * 60 + m;
|
||
const slotEnd = slotStart + taskDuration;
|
||
|
||
// Check other tasks (duration-aware overlap)
|
||
const taskConflict = tasksToCheck.some((t) => {
|
||
if (t.id === task.id || !t.startTime || t.completed) return false;
|
||
if (t.parentTaskId) return false;
|
||
const tDateStr = t.scheduledDate
|
||
? (typeof t.scheduledDate === "string" ? t.scheduledDate.substring(0, 10) : formatDateToISO(new Date(t.scheduledDate)))
|
||
: null;
|
||
if (tDateStr !== dateStr) return false;
|
||
const [th, tm] = t.startTime.split(":").map(Number);
|
||
const tStart = th * 60 + tm;
|
||
const tEnd = tStart + (t.duration || effectiveCellDuration);
|
||
return slotStart < tEnd && slotEnd > tStart;
|
||
});
|
||
if (taskConflict) return true;
|
||
|
||
// Check calendar events
|
||
const slotStartDate = new Date(date);
|
||
slotStartDate.setHours(h, m, 0, 0);
|
||
const slotEndDate = new Date(slotStartDate);
|
||
slotEndDate.setMinutes(slotEndDate.getMinutes() + taskDuration);
|
||
|
||
return dailyEvents.some((event) => {
|
||
const eventStart = new Date(event.startTime);
|
||
const eventEnd = new Date(event.endTime);
|
||
return slotStartDate < eventEnd && slotEndDate > eventStart;
|
||
});
|
||
};
|
||
|
||
const findFreeSlot = (
|
||
date: Date,
|
||
preferred: string,
|
||
tasksToCheck: Task[],
|
||
) => {
|
||
let current = preferred;
|
||
let [h, m] = current.split(":").map(Number);
|
||
|
||
while (isBlocked(date, current, tasksToCheck)) {
|
||
m += effectiveCellDuration;
|
||
if (m >= 60) {
|
||
h += 1;
|
||
m = 0;
|
||
}
|
||
if (h >= effectiveEndHour) break;
|
||
current = `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`;
|
||
}
|
||
return current;
|
||
};
|
||
|
||
const nextSlot = findFreeSlot(today, targetSlot, updatedTasks);
|
||
|
||
// Update in DB
|
||
try {
|
||
const res = await fetch("/api/tasks", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
id: task.id,
|
||
scheduledDate: todayStr,
|
||
startTime: nextSlot,
|
||
}),
|
||
});
|
||
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
const taskIndex = updatedTasks.findIndex((t) => t.id === task.id);
|
||
if (taskIndex !== -1) {
|
||
updatedTasks[taskIndex] = {
|
||
...data.task,
|
||
createdAt: new Date(data.task.createdAt),
|
||
updatedAt: new Date(data.task.updatedAt),
|
||
};
|
||
hasChanges = true;
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error(`Failed to roll task ${task.id}:`, err);
|
||
}
|
||
}
|
||
|
||
if (hasChanges) {
|
||
setTasks(updatedTasks.filter((t) => !t.somedayListId));
|
||
}
|
||
},
|
||
[profile.autoRolling, effectiveCellDuration, effectiveEndHour, getEventsForDate],
|
||
);
|
||
|
||
// Run rolling after profile is loaded and tasks are available
|
||
const rollingRanRef = useRef(false);
|
||
useEffect(() => {
|
||
if (rollingRanRef.current) return;
|
||
if (tasks.length === 0) return;
|
||
// Run if global autoRolling is on, OR if any task has per-task isRolling enabled
|
||
const hasRollingTasks = tasks.some((t) => t.isRolling && !t.completed);
|
||
if (!profile.autoRolling && !hasRollingTasks) return;
|
||
rollingRanRef.current = true;
|
||
rollOverdueTasks(tasks);
|
||
}, [profile.autoRolling, tasks, rollOverdueTasks]);
|
||
|
||
// Check if a slot is protected by calendar events (only if slot starts within event time range)
|
||
const isSlotProtected = useCallback(
|
||
(date: Date, slot: string): boolean => {
|
||
if (!protectEventTimes) return false;
|
||
|
||
const [slotHour, slotMinute] = slot.split(":").map(Number);
|
||
const slotStart = slotHour * 60 + slotMinute;
|
||
|
||
return calendarEvents.some((event) => {
|
||
if (isAllDayEvent(event)) return false;
|
||
// Skip events that have been unlocked by the user
|
||
if (unlockedEvents.has(event.id)) return false;
|
||
|
||
const eventDate = new Date(event.startTime);
|
||
if (!isSameDay(eventDate, date)) return false;
|
||
|
||
const eventStart = eventDate.getHours() * 60 + eventDate.getMinutes();
|
||
const eventEndDate = new Date(event.endTime);
|
||
const eventEndMinutes =
|
||
eventEndDate.getHours() * 60 + eventEndDate.getMinutes();
|
||
|
||
// Only protect if the slot start time falls within the event's actual duration
|
||
// This ensures protection matches exactly what the event covers
|
||
return slotStart >= eventStart && slotStart < eventEndMinutes;
|
||
});
|
||
},
|
||
[protectEventTimes, calendarEvents, unlockedEvents],
|
||
);
|
||
|
||
// Check if a slot is occupied by any task (to prevent stacking)
|
||
const isSlotOccupiedByTask = useCallback(
|
||
(date: Date, slot: string, excludeTaskId?: string): boolean => {
|
||
const dateStr = formatDateToISO(date);
|
||
const [slotHour, slotMinute] = slot.split(":").map(Number);
|
||
const slotStart = slotHour * 60 + slotMinute;
|
||
const slotEnd = slotStart + effectiveCellDuration;
|
||
|
||
return tasks.some(task => {
|
||
if (!task.scheduledDate || !task.startTime) return false;
|
||
if (excludeTaskId && task.id === excludeTaskId) return false;
|
||
// Exclude sub-tasks
|
||
if (task.parentTaskId) return false;
|
||
|
||
const taskDateStr =
|
||
typeof task.scheduledDate === "string"
|
||
? task.scheduledDate.substring(0, 10)
|
||
: formatDateToISO(new Date(task.scheduledDate));
|
||
if (taskDateStr !== dateStr) return false;
|
||
|
||
const [taskHour, taskMinute] = task.startTime.split(":").map(Number);
|
||
const taskStart = taskHour * 60 + taskMinute;
|
||
const taskDuration = task.duration || 15;
|
||
const taskEnd = taskStart + taskDuration;
|
||
|
||
// Skip completed tasks (they don't render in the grid)
|
||
if (task.completed) return false;
|
||
|
||
// Overlap condition: task starts before slot ends AND task ends after slot starts
|
||
return taskStart < slotEnd && taskEnd > slotStart;
|
||
});
|
||
},
|
||
[tasks, effectiveCellDuration],
|
||
);
|
||
|
||
// Navigation handlers with CSS class-based slide animation (works in all browsers)
|
||
const gridRef = useRef<HTMLElement>(null);
|
||
const allSlideClasses = ["slide-animate-next", "slide-animate-prev", "slide-animate-week-next", "slide-animate-week-prev"];
|
||
const navigate = (
|
||
newDate: Date,
|
||
direction: "left" | "right",
|
||
type: "day" | "week",
|
||
) => {
|
||
const grid = gridRef.current;
|
||
if (grid) {
|
||
// Remove any existing animation class
|
||
grid.classList.remove(...allSlideClasses);
|
||
// Trigger reflow to restart animation if same direction
|
||
void grid.offsetWidth;
|
||
// Pick class: week uses longer animation
|
||
const prefix = type === "week" ? "slide-animate-week-" : "slide-animate-";
|
||
grid.classList.add(prefix + (direction === "left" ? "next" : "prev"));
|
||
// Clean up after animation
|
||
const cleanup = () => {
|
||
grid.classList.remove(...allSlideClasses);
|
||
grid.removeEventListener("animationend", cleanup);
|
||
};
|
||
grid.addEventListener("animationend", cleanup, { once: true });
|
||
}
|
||
setCurrentWeekStart(newDate);
|
||
setSlideDirection(direction === "left" ? "next" : "prev");
|
||
setSelectedDay(null); // reset so header shows the new week's today/first day
|
||
};
|
||
|
||
const goToPrevWeek = () =>
|
||
navigate(
|
||
new Date(currentWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000),
|
||
"right",
|
||
"week",
|
||
);
|
||
const goToNextWeek = () =>
|
||
navigate(
|
||
new Date(currentWeekStart.getTime() + 7 * 24 * 60 * 60 * 1000),
|
||
"left",
|
||
"week",
|
||
);
|
||
const goToPrevDay = () =>
|
||
navigate(
|
||
new Date(currentWeekStart.getTime() - 24 * 60 * 60 * 1000),
|
||
"right",
|
||
"day",
|
||
);
|
||
const goToNextDay = () =>
|
||
navigate(
|
||
new Date(currentWeekStart.getTime() + 24 * 60 * 60 * 1000),
|
||
"left",
|
||
"day",
|
||
);
|
||
const goToToday = () => {
|
||
const d = new Date();
|
||
d.setHours(0, 0, 0, 0);
|
||
// Only apply startDayOffset for multi-day views; on single-day view, go directly to today
|
||
if (viewDays > 1) {
|
||
d.setDate(d.getDate() + (profile?.startDayOffset || 0));
|
||
}
|
||
setCurrentWeekStart(d);
|
||
};
|
||
|
||
// Touch swipe navigation for mobile
|
||
useEffect(() => {
|
||
let touchStartX = 0;
|
||
let touchStartY = 0;
|
||
let touchEndX = 0;
|
||
let touchEndY = 0;
|
||
|
||
let touchStartedInSomeday = false;
|
||
|
||
const handleTouchStart = (e: TouchEvent) => {
|
||
touchStartX = e.changedTouches[0].screenX;
|
||
touchStartY = e.changedTouches[0].screenY;
|
||
// Check if touch started inside the someday area (which has its own horizontal scroll)
|
||
touchStartedInSomeday = !!(e.target as HTMLElement)?.closest?.('.weekly-someday');
|
||
};
|
||
|
||
const handleTouchEnd = (e: TouchEvent) => {
|
||
if (touchStartedInSomeday) return; // Don't hijack someday horizontal scrolling
|
||
touchEndX = e.changedTouches[0].screenX;
|
||
touchEndY = e.changedTouches[0].screenY;
|
||
const diffX = touchEndX - touchStartX;
|
||
const diffY = touchEndY - touchStartY;
|
||
// Only trigger if horizontal swipe is dominant and > 80px
|
||
if (Math.abs(diffX) > 80 && Math.abs(diffX) > Math.abs(diffY) * 1.5) {
|
||
if (diffX > 0) {
|
||
// Swipe right → go to previous day
|
||
goToPrevDay();
|
||
} else {
|
||
// Swipe left → go to next day
|
||
goToNextDay();
|
||
}
|
||
}
|
||
};
|
||
|
||
const container = document.querySelector('.weekly-container') as HTMLElement | null;
|
||
if (container) {
|
||
container.addEventListener('touchstart', handleTouchStart as EventListener, { passive: true });
|
||
container.addEventListener('touchend', handleTouchEnd as EventListener, { passive: true });
|
||
}
|
||
return () => {
|
||
if (container) {
|
||
container.removeEventListener('touchstart', handleTouchStart as EventListener);
|
||
container.removeEventListener('touchend', handleTouchEnd as EventListener);
|
||
}
|
||
};
|
||
}, [currentWeekStart]); // Re-attach when week changes so closures are fresh
|
||
|
||
const executeImport = async (provider: "google" | "apple" | "outlook" | "synology") => {
|
||
setImportProvider(provider);
|
||
setIsImportModalOpen(true);
|
||
setIsFetchingLists(true);
|
||
setImportLists([]);
|
||
setImportStatusMsg(null);
|
||
|
||
try {
|
||
const res = await fetch(`/api/tasks/lists?provider=${provider}`);
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setImportLists(data.lists || []);
|
||
} else {
|
||
const errData = await res.json();
|
||
console.error("Failed to fetch lists", errData);
|
||
setIsImportModalOpen(false);
|
||
setImportStatusMsg({
|
||
type: "error",
|
||
text: errData.error || "Failed to fetch task lists.",
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.error("Error fetching lists:", e);
|
||
setIsImportModalOpen(false);
|
||
setImportStatusMsg({ type: "error", text: "Error fetching task lists." });
|
||
} finally {
|
||
setIsFetchingLists(false);
|
||
}
|
||
};
|
||
|
||
const fetchAvailableTaskLists = useCallback(
|
||
async (provider: "google" | "apple" | "outlook" | "synology") => {
|
||
setIsFetchingProviderLists((prev) => ({
|
||
...prev,
|
||
[provider]: true,
|
||
}));
|
||
try {
|
||
const res = await fetch(`/api/tasks/lists?provider=${provider}`);
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setAvailableTaskLists((prev) => ({
|
||
...prev,
|
||
[provider]: data.lists || [],
|
||
}));
|
||
}
|
||
} catch (error) {
|
||
console.error(`Failed to fetch lists for ${provider}`, error);
|
||
} finally {
|
||
setIsFetchingProviderLists((prev) => ({
|
||
...prev,
|
||
[provider]: false,
|
||
}));
|
||
}
|
||
},
|
||
[],
|
||
);
|
||
|
||
|
||
const handleToggleTaskList = async (
|
||
provider: "google" | "apple" | "outlook" | "synology",
|
||
list: { id: string; title: string },
|
||
) => {
|
||
const existing = somedayLists.find(
|
||
(l) => l.externalId === list.id && l.externalProvider === provider,
|
||
);
|
||
|
||
if (existing) {
|
||
// Show inline confirmation instead of browser confirm()
|
||
setUnsyncConfirm({ provider, list });
|
||
return;
|
||
} else {
|
||
// Sync/Import
|
||
await doImport(provider, [list]);
|
||
}
|
||
};
|
||
|
||
const confirmUnsync = async () => {
|
||
if (!unsyncConfirm) return;
|
||
const { provider, list } = unsyncConfirm;
|
||
const existing = somedayLists.find(
|
||
(l) => l.externalId === list.id && l.externalProvider === provider,
|
||
);
|
||
if (!existing) { setUnsyncConfirm(null); return; }
|
||
try {
|
||
// tasksOnly=true: soft-disconnect — removes tasks but keeps list record with its tab assignment
|
||
// so when the user reconnects, the tab is restored automatically without re-dragging
|
||
const res = await fetch(`/api/someday-lists?id=${existing.id}&tasksOnly=true`, {
|
||
method: "DELETE",
|
||
});
|
||
if (res.ok) {
|
||
// Remove from local state (clean UI); DB record is kept for tab restoration on reconnect
|
||
setSomedayLists((prev) => prev.filter((l) => l.id !== existing.id));
|
||
setImportStatusMsg({
|
||
type: "success",
|
||
text: `Stopped syncing "${list.title}".`,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to unsync list", error);
|
||
setImportStatusMsg({
|
||
type: "error",
|
||
text: "Failed to stop syncing list.",
|
||
});
|
||
}
|
||
setUnsyncConfirm(null);
|
||
};
|
||
|
||
const handleSyncAll = async (
|
||
provider: "google" | "outlook" | "synology",
|
||
lists: { id: string; title: string }[],
|
||
syncOn: boolean,
|
||
) => {
|
||
if (syncOn) {
|
||
const unsynced = lists.filter(
|
||
(list) => !somedayLists.some(
|
||
(sl) => sl.externalId === list.id && sl.externalProvider === provider,
|
||
),
|
||
);
|
||
if (unsynced.length > 0) await doImport(provider, unsynced);
|
||
} else {
|
||
// Unsync all synced lists
|
||
const synced = lists.filter(
|
||
(list) => somedayLists.some(
|
||
(sl) => sl.externalId === list.id && sl.externalProvider === provider,
|
||
),
|
||
);
|
||
for (const list of synced) {
|
||
const existing = somedayLists.find(
|
||
(l) => l.externalId === list.id && l.externalProvider === provider,
|
||
);
|
||
if (!existing) continue;
|
||
try {
|
||
// tasksOnly=true: soft-disconnect — keeps list record with tab for reconnect restoration
|
||
const res = await fetch(`/api/someday-lists?id=${existing.id}&tasksOnly=true`, {
|
||
method: "DELETE",
|
||
});
|
||
if (res.ok) {
|
||
setSomedayLists((prev) => prev.filter((l) => l.id !== existing.id));
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to unsync list", error);
|
||
}
|
||
}
|
||
setImportStatusMsg({
|
||
type: "success",
|
||
text: `Stopped syncing ${synced.length} list(s).`,
|
||
});
|
||
}
|
||
};
|
||
|
||
// Core import logic — accepts provider directly so it works both from modal and sidebar
|
||
const doImport = async (
|
||
provider: "google" | "apple" | "outlook" | "synology",
|
||
selectedLists: { id: string; title: string }[],
|
||
) => {
|
||
setImportingTasksState(true);
|
||
setImportStatusMsg(null);
|
||
|
||
try {
|
||
const response = await fetch("/api/tasks/import", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ provider, sourceLists: selectedLists }),
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (response.ok) {
|
||
setImportStatusMsg({
|
||
type: "success",
|
||
text: `Synced ${data.count} new tasks across ${data.listsCreated || 1} list(s).`,
|
||
});
|
||
// Trigger immediate pull-sync to get latest state
|
||
try {
|
||
await fetch("/api/tasks/sync");
|
||
} catch (e) {
|
||
// Non-critical, auto-sync will catch up
|
||
}
|
||
await fetchTasks();
|
||
} else {
|
||
setImportStatusMsg({
|
||
type: "error",
|
||
text: data.error || "Sync failed.",
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error("Import error:", error);
|
||
setImportStatusMsg({
|
||
type: "error",
|
||
text: "An error occurred during sync.",
|
||
});
|
||
} finally {
|
||
setImportingTasksState(false);
|
||
}
|
||
};
|
||
|
||
// Called from the Google Tasks modal
|
||
const handleConfirmImport = async (
|
||
selectedLists: { id: string; title: string }[],
|
||
) => {
|
||
if (!importProvider) return;
|
||
setIsImportModalOpen(false);
|
||
await doImport(importProvider, selectedLists);
|
||
setImportProvider(null);
|
||
};
|
||
|
||
// Undo/Redo helpers
|
||
const saveSnapshot = useCallback(() => {
|
||
if (skipSnapshotRef.current) return;
|
||
undoStackRef.current = [
|
||
...undoStackRef.current.slice(-29), // keep last 30 snapshots
|
||
{
|
||
tasks: JSON.parse(JSON.stringify(tasks)),
|
||
somedayLists: JSON.parse(JSON.stringify(somedayLists)),
|
||
},
|
||
];
|
||
redoStackRef.current = [];
|
||
setUndoCount(undoStackRef.current.length);
|
||
setRedoCount(0);
|
||
}, [tasks, somedayLists]);
|
||
|
||
const handleUndo = useCallback(() => {
|
||
if (undoStackRef.current.length === 0) return;
|
||
const snapshot = undoStackRef.current.pop()!;
|
||
redoStackRef.current.push({
|
||
tasks: JSON.parse(JSON.stringify(tasks)),
|
||
somedayLists: JSON.parse(JSON.stringify(somedayLists)),
|
||
});
|
||
skipSnapshotRef.current = true;
|
||
setTasks(snapshot.tasks);
|
||
setSomedayLists(snapshot.somedayLists);
|
||
skipSnapshotRef.current = false;
|
||
setUndoCount(undoStackRef.current.length);
|
||
setRedoCount(redoStackRef.current.length);
|
||
}, [tasks, somedayLists]);
|
||
|
||
const handleRedo = useCallback(() => {
|
||
if (redoStackRef.current.length === 0) return;
|
||
const snapshot = redoStackRef.current.pop()!;
|
||
undoStackRef.current.push({
|
||
tasks: JSON.parse(JSON.stringify(tasks)),
|
||
somedayLists: JSON.parse(JSON.stringify(somedayLists)),
|
||
});
|
||
skipSnapshotRef.current = true;
|
||
setTasks(snapshot.tasks);
|
||
setSomedayLists(snapshot.somedayLists);
|
||
skipSnapshotRef.current = false;
|
||
setUndoCount(undoStackRef.current.length);
|
||
setRedoCount(redoStackRef.current.length);
|
||
}, [tasks, somedayLists]);
|
||
|
||
// Keyboard shortcuts for undo/redo
|
||
useEffect(() => {
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if ((e.ctrlKey || e.metaKey) && e.key === "z" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleUndo();
|
||
}
|
||
if ((e.ctrlKey || e.metaKey) && (e.key === "y" || (e.key === "z" && e.shiftKey))) {
|
||
e.preventDefault();
|
||
handleRedo();
|
||
}
|
||
};
|
||
window.addEventListener("keydown", handleKeyDown);
|
||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||
}, [handleUndo, handleRedo]);
|
||
|
||
// Task CRUD operations
|
||
const addTask = async (date: Date, title: string, startTime?: string) => {
|
||
if (!title.trim()) return;
|
||
saveSnapshot();
|
||
announce(`Task "${title.trim()}" added`);
|
||
|
||
const scheduledDate = formatDateToISO(date); // Use local date formatting
|
||
|
||
if (!session?.user) {
|
||
// Local-only demo mode when not authenticated
|
||
const tempId = `temp-${Date.now()}`;
|
||
setTasks((prevTasks) => [
|
||
...prevTasks,
|
||
{
|
||
id: tempId,
|
||
title: title.trim(),
|
||
dayOfWeek: date.getDay(),
|
||
scheduledDate,
|
||
order: prevTasks.filter((t) => t.scheduledDate === scheduledDate)
|
||
.length,
|
||
completed: false,
|
||
userId: "temp",
|
||
startTime,
|
||
createdAt: new Date(),
|
||
updatedAt: new Date(),
|
||
},
|
||
]);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch("/api/tasks", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
title: title.trim(),
|
||
dayOfWeek: date.getDay(),
|
||
scheduledDate,
|
||
order: 0,
|
||
startTime,
|
||
}),
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
setTasks((prevTasks) => [
|
||
...prevTasks,
|
||
{
|
||
...data.task,
|
||
createdAt: new Date(data.task.createdAt),
|
||
updatedAt: new Date(data.task.updatedAt),
|
||
},
|
||
]);
|
||
} else {
|
||
console.error("Failed to add task:", await response.text());
|
||
}
|
||
} catch (error) {
|
||
console.error("Error adding task:", error);
|
||
}
|
||
};
|
||
|
||
// Create a task in kanban view — auto-creates a someday list if needed
|
||
const addKanbanTask = async (title: string, stageId: string | null) => {
|
||
if (!title.trim() || !session?.user) return;
|
||
saveSnapshot();
|
||
|
||
try {
|
||
// Determine the target someday list name
|
||
const activeProject = kanbanFilterProject
|
||
? projects.find(p => p.id === kanbanFilterProject)
|
||
: null;
|
||
const listName = activeProject ? activeProject.name : "Kanban";
|
||
|
||
// Find existing someday list with that name
|
||
let targetList = somedayLists.find(sl => sl.title === listName);
|
||
|
||
// Create the list if it doesn't exist
|
||
if (!targetList) {
|
||
const listRes = await fetch("/api/someday-lists", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ title: listName }),
|
||
});
|
||
if (listRes.ok) {
|
||
const listData = await listRes.json();
|
||
targetList = { ...listData.list, tasks: [] };
|
||
setSomedayLists(prev => [...prev, targetList!]);
|
||
} else {
|
||
console.error("Failed to create someday list:", await listRes.text());
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Create the task
|
||
const response = await fetch("/api/tasks", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
title: title.trim(),
|
||
somedayListId: targetList!.id,
|
||
kanbanStage: stageId,
|
||
projectId: activeProject?.id || undefined,
|
||
order: 0,
|
||
}),
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
const newTask = {
|
||
...data.task,
|
||
createdAt: new Date(data.task.createdAt),
|
||
updatedAt: new Date(data.task.updatedAt),
|
||
};
|
||
setSomedayLists(prev =>
|
||
prev.map(sl =>
|
||
sl.id === targetList!.id
|
||
? { ...sl, tasks: [...sl.tasks, newTask] }
|
||
: sl
|
||
)
|
||
);
|
||
} else {
|
||
console.error("Failed to add kanban task:", await response.text());
|
||
}
|
||
} catch (error) {
|
||
console.error("Error adding kanban task:", error);
|
||
}
|
||
|
||
setKanbanAddingStageId(null);
|
||
setKanbanNewTaskTitle("");
|
||
};
|
||
|
||
// Helper to find a task in both calendar tasks and someday lists
|
||
const findTaskAnywhere = (taskId: string): Task | undefined => {
|
||
const calTask = tasks.find((t) => t.id === taskId);
|
||
if (calTask) return calTask;
|
||
for (const list of somedayLists) {
|
||
const found = list.tasks.find((t) => t.id === taskId);
|
||
if (found) return found;
|
||
}
|
||
return undefined;
|
||
};
|
||
|
||
const toggleTask = async (taskId: string) => {
|
||
saveSnapshot();
|
||
const task = findTaskAnywhere(taskId);
|
||
if (!task) return;
|
||
|
||
const updatedCompleted = !task.completed;
|
||
announce(updatedCompleted ? `"${task.title}" marked complete` : `"${task.title}" marked incomplete`);
|
||
const isSomeday = !!task.somedayListId;
|
||
|
||
if (isSomeday) {
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) => ({
|
||
...l,
|
||
tasks: l.tasks.map((t) =>
|
||
t.id === taskId
|
||
? { ...t, completed: updatedCompleted, updatedAt: new Date() }
|
||
: t,
|
||
),
|
||
})),
|
||
);
|
||
} else {
|
||
setTasks(
|
||
tasks.map((t) =>
|
||
t.id === taskId
|
||
? { ...t, completed: updatedCompleted, updatedAt: new Date() }
|
||
: t,
|
||
),
|
||
);
|
||
}
|
||
|
||
try {
|
||
await fetch("/api/tasks", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ id: taskId, completed: updatedCompleted }),
|
||
});
|
||
|
||
if (task.externalId && task.externalProvider) {
|
||
fetch("/api/tasks/sync", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ taskId: taskId, completed: updatedCompleted }),
|
||
}).catch((e) => console.error("Sync error:", e));
|
||
}
|
||
} catch (error) {
|
||
console.error("Error toggling task:", error);
|
||
}
|
||
};
|
||
|
||
const updateTask = async (taskId: string, newTitle: string) => {
|
||
saveSnapshot();
|
||
if (!newTitle.trim()) {
|
||
await deleteTask(taskId);
|
||
return;
|
||
}
|
||
|
||
const task = findTaskAnywhere(taskId);
|
||
const isSomeday = !!task?.somedayListId;
|
||
|
||
if (isSomeday) {
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) => ({
|
||
...l,
|
||
tasks: l.tasks.map((t) =>
|
||
t.id === taskId
|
||
? { ...t, title: newTitle.trim(), updatedAt: new Date() }
|
||
: t,
|
||
),
|
||
})),
|
||
);
|
||
} else {
|
||
setTasks(
|
||
tasks.map((t) =>
|
||
t.id === taskId
|
||
? { ...t, title: newTitle.trim(), updatedAt: new Date() }
|
||
: t,
|
||
),
|
||
);
|
||
}
|
||
setEditingTaskId(null);
|
||
|
||
try {
|
||
await fetch("/api/tasks", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ id: taskId, title: newTitle.trim() }),
|
||
});
|
||
|
||
if (task?.externalId && task?.externalProvider) {
|
||
fetch("/api/tasks/sync", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ taskId, title: newTitle.trim() }),
|
||
}).catch((e) => console.error("Sync error:", e));
|
||
}
|
||
} catch (error) {
|
||
console.error("Error updating task:", error);
|
||
}
|
||
};
|
||
|
||
const updateTaskFields = async (taskId: string, fields: Partial<Task>) => {
|
||
setTasks((prev) =>
|
||
prev.map((t) =>
|
||
t.id === taskId ? { ...t, ...fields, updatedAt: new Date() } : t,
|
||
),
|
||
);
|
||
|
||
// Also update someday lists if the task is there
|
||
setSomedayLists((lists) =>
|
||
lists.map((list) => ({
|
||
...list,
|
||
tasks: list.tasks.map((t) =>
|
||
t.id === taskId ? { ...t, ...fields, updatedAt: new Date() } : t,
|
||
),
|
||
})),
|
||
);
|
||
|
||
try {
|
||
const res = await fetch("/api/tasks", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ id: taskId, ...fields }),
|
||
});
|
||
if (!res.ok) {
|
||
const errData = await res.json().catch(() => ({}));
|
||
console.error("Failed to update task fields:", res.status, errData);
|
||
}
|
||
} catch (error) {
|
||
console.error("Error updating task fields:", error);
|
||
}
|
||
};
|
||
|
||
// Sub-task CRUD operations
|
||
const addSubTask = async (parentId: string, title: string) => {
|
||
if (!title.trim() || !session?.user) return;
|
||
|
||
// Find parent task to inherit scheduling
|
||
const parentTask = findTaskAnywhere(parentId);
|
||
|
||
try {
|
||
const response = await fetch("/api/tasks", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
title: title.trim(),
|
||
parentTaskId: parentId,
|
||
scheduledDate: parentTask?.scheduledDate || null,
|
||
dayOfWeek: parentTask?.dayOfWeek ?? null,
|
||
order: (parentTask?.subTasks?.length || 0),
|
||
}),
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
const newSubTask = {
|
||
...data.task,
|
||
createdAt: new Date(data.task.createdAt),
|
||
updatedAt: new Date(data.task.updatedAt),
|
||
};
|
||
|
||
// Update local state: add sub-task to parent
|
||
setTasks((prev) =>
|
||
prev.map((t) =>
|
||
t.id === parentId
|
||
? { ...t, subTasks: [...(t.subTasks || []), newSubTask] }
|
||
: t,
|
||
),
|
||
);
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) => ({
|
||
...l,
|
||
tasks: l.tasks.map((t) =>
|
||
t.id === parentId
|
||
? { ...t, subTasks: [...(t.subTasks || []), newSubTask] }
|
||
: t,
|
||
),
|
||
})),
|
||
);
|
||
}
|
||
} catch (error) {
|
||
console.error("Error adding sub-task:", error);
|
||
}
|
||
};
|
||
|
||
const toggleSubTask = async (subTaskId: string) => {
|
||
// Find the sub-task in any parent
|
||
let foundSubTask: Task | undefined;
|
||
for (const task of tasks) {
|
||
foundSubTask = task.subTasks?.find((st) => st.id === subTaskId);
|
||
if (foundSubTask) break;
|
||
}
|
||
if (!foundSubTask) {
|
||
for (const list of somedayLists) {
|
||
for (const task of list.tasks) {
|
||
foundSubTask = task.subTasks?.find((st) => st.id === subTaskId);
|
||
if (foundSubTask) break;
|
||
}
|
||
if (foundSubTask) break;
|
||
}
|
||
}
|
||
if (!foundSubTask) return;
|
||
|
||
const newCompleted = !foundSubTask.completed;
|
||
|
||
// Optimistic update
|
||
const updateSubTasks = (taskList: Task[]) =>
|
||
taskList.map((t) => ({
|
||
...t,
|
||
subTasks: t.subTasks?.map((st) =>
|
||
st.id === subTaskId ? { ...st, completed: newCompleted } : st,
|
||
),
|
||
}));
|
||
|
||
setTasks((prev) => updateSubTasks(prev));
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) => ({ ...l, tasks: updateSubTasks(l.tasks) })),
|
||
);
|
||
|
||
try {
|
||
await fetch("/api/tasks", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ id: subTaskId, completed: newCompleted }),
|
||
});
|
||
} catch (error) {
|
||
console.error("Error toggling sub-task:", error);
|
||
}
|
||
};
|
||
|
||
const deleteSubTask = async (subTaskId: string) => {
|
||
// Optimistic update: remove from parent's subTasks
|
||
const removeSubTask = (taskList: Task[]) =>
|
||
taskList.map((t) => ({
|
||
...t,
|
||
subTasks: t.subTasks?.filter((st) => st.id !== subTaskId),
|
||
}));
|
||
|
||
setTasks((prev) => removeSubTask(prev));
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) => ({ ...l, tasks: removeSubTask(l.tasks) })),
|
||
);
|
||
|
||
try {
|
||
await fetch(`/api/tasks?id=${subTaskId}`, { method: "DELETE" });
|
||
} catch (error) {
|
||
console.error("Error deleting sub-task:", error);
|
||
}
|
||
};
|
||
|
||
const updateSubTask = async (subTaskId: string, newTitle: string) => {
|
||
if (!newTitle.trim()) {
|
||
await deleteSubTask(subTaskId);
|
||
return;
|
||
}
|
||
|
||
const updateSubTasks = (taskList: Task[]) =>
|
||
taskList.map((t) => ({
|
||
...t,
|
||
subTasks: t.subTasks?.map((st) =>
|
||
st.id === subTaskId ? { ...st, title: newTitle.trim() } : st,
|
||
),
|
||
}));
|
||
|
||
setTasks((prev) => updateSubTasks(prev));
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) => ({ ...l, tasks: updateSubTasks(l.tasks) })),
|
||
);
|
||
|
||
try {
|
||
await fetch("/api/tasks", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ id: subTaskId, title: newTitle.trim() }),
|
||
});
|
||
} catch (error) {
|
||
console.error("Error updating sub-task:", error);
|
||
}
|
||
};
|
||
|
||
const updateTaskDuration = async (
|
||
taskId: string,
|
||
durationMinutes: number,
|
||
) => {
|
||
const task = tasks.find((t) => t.id === taskId);
|
||
if (!task || !task.startTime) return;
|
||
|
||
try {
|
||
// Parse start time (HH:mm)
|
||
const [startHour, startMinute] = task.startTime.split(":").map(Number);
|
||
|
||
// Calculate end time
|
||
const totalStartMinutes = startHour * 60 + startMinute;
|
||
const totalEndMinutes = totalStartMinutes + durationMinutes;
|
||
|
||
const endHour = Math.floor(totalEndMinutes / 60) % 24; // Wrap around 24h
|
||
const endMinute = totalEndMinutes % 60;
|
||
|
||
const endTimeStr = `${endHour.toString().padStart(2, "0")}:${endMinute.toString().padStart(2, "0")}`;
|
||
|
||
// Optimistic update
|
||
setTasks(
|
||
tasks.map((t) =>
|
||
t.id === taskId
|
||
? { ...t, endTime: endTimeStr, updatedAt: new Date() }
|
||
: t,
|
||
),
|
||
);
|
||
|
||
await fetch("/api/tasks", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ id: taskId, endTime: endTimeStr }),
|
||
});
|
||
} catch (error) {
|
||
console.error("Error updating task duration:", error);
|
||
}
|
||
};
|
||
|
||
const updateTaskNotes = async (taskId: string, notes: string) => {
|
||
const task = findTaskAnywhere(taskId);
|
||
const isSomeday = !!task?.somedayListId;
|
||
|
||
if (isSomeday) {
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) => ({
|
||
...l,
|
||
tasks: l.tasks.map((t) =>
|
||
t.id === taskId
|
||
? { ...t, markdownContent: notes, updatedAt: new Date() }
|
||
: t,
|
||
),
|
||
})),
|
||
);
|
||
} else {
|
||
setTasks(
|
||
tasks.map((t) =>
|
||
t.id === taskId
|
||
? { ...t, markdownContent: notes, updatedAt: new Date() }
|
||
: t,
|
||
),
|
||
);
|
||
}
|
||
|
||
try {
|
||
await fetch("/api/tasks", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ id: taskId, markdownContent: notes }),
|
||
});
|
||
|
||
if (task?.externalId && task?.externalProvider) {
|
||
fetch("/api/tasks/sync", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ taskId, notes }),
|
||
}).catch((e) => console.error("Sync error:", e));
|
||
}
|
||
} catch (error) {
|
||
console.error("Error updating task notes:", error);
|
||
}
|
||
};
|
||
|
||
const updateTaskUrl = async (taskId: string, url: string) => {
|
||
const task = findTaskAnywhere(taskId);
|
||
const isSomeday = !!task?.somedayListId;
|
||
const normalised = url.trim() ? (url.trim().startsWith("http") ? url.trim() : `https://${url.trim()}`) : "";
|
||
|
||
if (isSomeday) {
|
||
setSomedayLists(prev => prev.map(l => ({
|
||
...l,
|
||
tasks: l.tasks.map(t => t.id === taskId ? { ...t, url: normalised || null, updatedAt: new Date() } : t),
|
||
})));
|
||
} else {
|
||
setTasks(prev => prev.map(t => t.id === taskId ? { ...t, url: normalised || null, updatedAt: new Date() } : t));
|
||
}
|
||
try {
|
||
await fetch("/api/tasks", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ id: taskId, url: normalised || null }),
|
||
});
|
||
} catch (error) {
|
||
console.error("Error updating task url:", error);
|
||
}
|
||
};
|
||
|
||
const toggleTaskRolling = async (taskId: string) => {
|
||
saveSnapshot();
|
||
const task = findTaskAnywhere(taskId);
|
||
if (!task) return;
|
||
|
||
const newRollingState = !task.isRolling;
|
||
const isSomeday = !!task.somedayListId;
|
||
|
||
if (isSomeday) {
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) => ({
|
||
...l,
|
||
tasks: l.tasks.map((t) =>
|
||
t.id === taskId
|
||
? { ...t, isRolling: newRollingState, updatedAt: new Date() }
|
||
: t,
|
||
),
|
||
})),
|
||
);
|
||
} else {
|
||
setTasks(
|
||
tasks.map((t) =>
|
||
t.id === taskId
|
||
? { ...t, isRolling: newRollingState, updatedAt: new Date() }
|
||
: t,
|
||
),
|
||
);
|
||
}
|
||
|
||
try {
|
||
await fetch("/api/tasks", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ id: taskId, isRolling: newRollingState }),
|
||
});
|
||
} catch (error) {
|
||
console.error("Error updating task rolling state:", error);
|
||
if (isSomeday) {
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) => ({
|
||
...l,
|
||
tasks: l.tasks.map((t) =>
|
||
t.id === taskId ? { ...t, isRolling: !newRollingState } : t,
|
||
),
|
||
})),
|
||
);
|
||
} else {
|
||
setTasks(
|
||
tasks.map((t) =>
|
||
t.id === taskId ? { ...t, isRolling: !newRollingState } : t,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
};
|
||
|
||
const moveTaskToSlot = async (
|
||
taskId: string,
|
||
dayOfWeek: number,
|
||
startTime: string,
|
||
scheduledDate?: Date,
|
||
) => {
|
||
const newScheduledDate = scheduledDate
|
||
? formatDateToISO(scheduledDate)
|
||
: undefined;
|
||
const task = tasks.find((t) => t.id === taskId);
|
||
setTasks(
|
||
tasks.map((t) =>
|
||
t.id === taskId
|
||
? {
|
||
...t,
|
||
dayOfWeek,
|
||
startTime,
|
||
scheduledDate: newScheduledDate || t.scheduledDate,
|
||
somedayListId: null,
|
||
somedaySlotIndex: null,
|
||
updatedAt: new Date(),
|
||
}
|
||
: t,
|
||
),
|
||
);
|
||
|
||
try {
|
||
await fetch("/api/tasks", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
id: taskId,
|
||
dayOfWeek,
|
||
startTime,
|
||
scheduledDate: newScheduledDate,
|
||
somedayListId: null,
|
||
somedaySlotIndex: null,
|
||
}),
|
||
});
|
||
|
||
// Sync due date change to external provider
|
||
if (task?.externalId && task?.externalProvider && newScheduledDate) {
|
||
fetch("/api/tasks/sync", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ taskId, scheduledDate: newScheduledDate }),
|
||
}).catch((e) => console.error("Sync error:", e));
|
||
}
|
||
} catch (error) {
|
||
console.error("Error moving task:", error);
|
||
}
|
||
};
|
||
|
||
const assignProject = async (taskId: string, projectId: string | null) => {
|
||
const proj = projectId ? projects.find((p) => p.id === projectId) || null : null;
|
||
// Optimistic update
|
||
const updateTask = (t: Task) =>
|
||
t.id === taskId ? { ...t, projectId: projectId, project: proj } : t;
|
||
setTasks((prev) => prev.map(updateTask));
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) => ({ ...l, tasks: l.tasks.map(updateTask) }))
|
||
);
|
||
try {
|
||
await fetch("/api/tasks", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ id: taskId, projectId }),
|
||
});
|
||
} catch (e) {
|
||
console.error("Failed to assign project:", e);
|
||
}
|
||
};
|
||
|
||
const deleteTask = async (taskId: string) => {
|
||
saveSnapshot();
|
||
const taskToDelete = findTaskAnywhere(taskId);
|
||
if (taskToDelete) announce(`"${taskToDelete.title}" deleted`);
|
||
const isSomeday = !!taskToDelete?.somedayListId;
|
||
const isVirtual = taskId.startsWith("virtual-");
|
||
|
||
let originalId = taskId;
|
||
if (isVirtual) {
|
||
const match = taskId.match(/^virtual-(.+)-(\d{4}-\d{2}-\d{2})$/);
|
||
if (match) {
|
||
originalId = match[1];
|
||
}
|
||
}
|
||
|
||
// Check if it's a series (virtual or real recurring)
|
||
const isSeries = isVirtual || (taskToDelete && taskToDelete.isRecurring);
|
||
|
||
if (isSeries) {
|
||
setRecurringDeleteModal({ isOpen: true, taskId });
|
||
return;
|
||
}
|
||
|
||
// NORMAL DELETE (Single instance)
|
||
if (isSomeday) {
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) => ({
|
||
...l,
|
||
tasks: l.tasks.filter((t) => t.id !== taskId),
|
||
})),
|
||
);
|
||
} else {
|
||
setTasks((prev) => prev.filter((t) => t.id !== taskId));
|
||
}
|
||
setEditingTaskId(null);
|
||
|
||
try {
|
||
if (taskToDelete?.externalId && taskToDelete?.externalProvider) {
|
||
fetch("/api/tasks/sync", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ taskId, action: "delete" }),
|
||
}).catch((e) => console.error("Sync delete error:", e));
|
||
}
|
||
|
||
await fetch(`/api/tasks?id=${taskId}`, { method: "DELETE" });
|
||
} catch (error) {
|
||
console.error("Error deleting task:", error);
|
||
}
|
||
};
|
||
|
||
const handleConfirmDeleteSeries = async (taskId: string) => {
|
||
let originalId = taskId;
|
||
if (taskId.startsWith("virtual-")) {
|
||
const match = taskId.match(/^virtual-(.+)-(\d{4}-\d{2}-\d{2})$/);
|
||
if (match) originalId = match[1];
|
||
}
|
||
|
||
const taskToDelete = findTaskAnywhere(originalId);
|
||
|
||
setTasks((prev) =>
|
||
prev.filter((t) => {
|
||
if (taskToDelete && t.title === taskToDelete.title &&
|
||
t.recurrenceInterval === taskToDelete.recurrenceInterval &&
|
||
t.recurrenceUnit === taskToDelete.recurrenceUnit) {
|
||
return false;
|
||
}
|
||
if (t.id === originalId) return false;
|
||
if (t.id.startsWith(`virtual-${originalId}-`)) return false;
|
||
if (t.id === taskId) return false;
|
||
return true;
|
||
}),
|
||
);
|
||
setEditingTaskId(null);
|
||
setRecurringDeleteModal({ isOpen: false, taskId: null });
|
||
|
||
try {
|
||
const origTask = findTaskAnywhere(originalId);
|
||
if (origTask?.externalId && origTask?.externalProvider) {
|
||
fetch("/api/tasks/sync", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ taskId: originalId, action: "delete" }),
|
||
}).catch((e) => console.error("Sync delete error:", e));
|
||
}
|
||
await fetch(`/api/tasks?id=${originalId}`, { method: "DELETE" });
|
||
} catch (error) {
|
||
console.error("Error deleting series:", error);
|
||
}
|
||
};
|
||
|
||
const handleConfirmDeleteOccurrence = async (taskId: string) => {
|
||
setTasks((prev) => prev.filter((t) => t.id !== taskId));
|
||
setEditingTaskId(null);
|
||
setRecurringDeleteModal({ isOpen: false, taskId: null });
|
||
|
||
try {
|
||
const taskToDelete = findTaskAnywhere(taskId);
|
||
if (taskToDelete?.externalId && taskToDelete?.externalProvider) {
|
||
fetch("/api/tasks/sync", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ taskId, action: "delete" }),
|
||
}).catch((e) => console.error("Sync delete error:", e));
|
||
}
|
||
await fetch(`/api/tasks?id=${taskId}`, { method: "DELETE" });
|
||
} catch (error) {
|
||
console.error("Error deleting instance:", error);
|
||
}
|
||
};
|
||
|
||
// Toggle rolling status
|
||
const toggleRolling = async (taskId: string) => {
|
||
const task = tasks.find((t) => t.id === taskId);
|
||
if (!task) return;
|
||
|
||
const updatedIsRolling = !task.isRolling;
|
||
|
||
setTasks(
|
||
tasks.map((t) =>
|
||
t.id === taskId
|
||
? { ...t, isRolling: updatedIsRolling, updatedAt: new Date() }
|
||
: t,
|
||
),
|
||
);
|
||
|
||
try {
|
||
await fetch("/api/tasks", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ id: taskId, isRolling: updatedIsRolling }),
|
||
});
|
||
} catch (error) {
|
||
console.error("Error toggling rolling status:", error);
|
||
}
|
||
};
|
||
|
||
// Roll task to tomorrow or next week
|
||
const rollTask = async (
|
||
taskId: string,
|
||
rollType: "tomorrow" | "nextWeek",
|
||
) => {
|
||
const task = tasks.find((t) => t.id === taskId);
|
||
if (!task || task.completed) return;
|
||
|
||
// Get current task date
|
||
const currentDate = task.scheduledDate
|
||
? new Date(task.scheduledDate)
|
||
: new Date();
|
||
|
||
// Calculate new date
|
||
const newDate = new Date(currentDate);
|
||
if (rollType === "tomorrow") {
|
||
newDate.setDate(newDate.getDate() + 1);
|
||
} else {
|
||
newDate.setDate(newDate.getDate() + 7);
|
||
}
|
||
|
||
const newScheduledDate = formatDateToISO(newDate);
|
||
|
||
// Preserve startTime — if the preferred slot is taken, find next free one
|
||
let resolvedStartTime = task.startTime || undefined;
|
||
if (resolvedStartTime) {
|
||
const targetSlotTasks = tasks.filter((t) => {
|
||
if (t.id === taskId || !t.scheduledDate) return false;
|
||
const tDate = formatDateToISO(new Date(t.scheduledDate));
|
||
return tDate === newScheduledDate && t.startTime === resolvedStartTime;
|
||
});
|
||
if (targetSlotTasks.length > 0) {
|
||
// Slot is taken — find next free slot
|
||
const allSlots = getTimeSlots(
|
||
effectiveCellDuration,
|
||
0,
|
||
24,
|
||
);
|
||
const startIndex = allSlots.indexOf(resolvedStartTime);
|
||
if (startIndex !== -1) {
|
||
for (let i = startIndex + 1; i < allSlots.length; i++) {
|
||
const candidate = allSlots[i];
|
||
const candidateTasks = tasks.filter((t) => {
|
||
if (t.id === taskId || !t.scheduledDate) return false;
|
||
const tDate = formatDateToISO(new Date(t.scheduledDate));
|
||
return tDate === newScheduledDate && t.startTime === candidate;
|
||
});
|
||
if (candidateTasks.length === 0) {
|
||
resolvedStartTime = candidate;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
setTasks(
|
||
tasks.map((t) =>
|
||
t.id === taskId
|
||
? {
|
||
...t,
|
||
scheduledDate: newScheduledDate,
|
||
dayOfWeek: newDate.getDay(),
|
||
startTime: resolvedStartTime || t.startTime,
|
||
updatedAt: new Date(),
|
||
}
|
||
: t,
|
||
),
|
||
);
|
||
|
||
try {
|
||
await fetch("/api/tasks", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
id: taskId,
|
||
scheduledDate: newScheduledDate,
|
||
dayOfWeek: newDate.getDay(),
|
||
startTime: resolvedStartTime,
|
||
}),
|
||
});
|
||
|
||
// Sync due date change to external provider
|
||
if (task.externalId && task.externalProvider) {
|
||
fetch("/api/tasks/sync", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ taskId, scheduledDate: newScheduledDate }),
|
||
}).catch((e) => console.error("Sync error:", e));
|
||
}
|
||
} catch (error) {
|
||
console.error("Error rolling task:", error);
|
||
}
|
||
};
|
||
|
||
// Drag and drop handlers
|
||
const handleDragStart = (e: DragEvent, task: Task) => {
|
||
setDraggedTask(task);
|
||
if (e.dataTransfer) {
|
||
e.dataTransfer.effectAllowed = "move";
|
||
e.dataTransfer.setData("text/plain", task.id);
|
||
}
|
||
// Add drag-source class for styling
|
||
if (e.currentTarget instanceof HTMLElement) {
|
||
e.currentTarget.classList.add("drag-source");
|
||
}
|
||
};
|
||
|
||
const handleDragOver = (
|
||
e: DragEvent | React.DragEvent,
|
||
dayOfWeek?: number,
|
||
slot?: string,
|
||
) => {
|
||
// Reject someday list drags on day slots
|
||
if (draggingListId) {
|
||
e.preventDefault();
|
||
if (e.dataTransfer) {
|
||
e.dataTransfer.dropEffect = "none";
|
||
}
|
||
return;
|
||
}
|
||
e.preventDefault();
|
||
if (e.dataTransfer) {
|
||
e.dataTransfer.dropEffect = "move";
|
||
}
|
||
// Update drop preview if we have day and slot info
|
||
if (dayOfWeek !== undefined && slot) {
|
||
setDropPreview({ day: dayOfWeek, slot });
|
||
}
|
||
};
|
||
|
||
const handleDrop = async (e: DragEvent, dayOfWeek: number, slot?: string) => {
|
||
e.preventDefault();
|
||
if (draggedTask) {
|
||
const visibleDays = getVisibleDays();
|
||
const targetDateObj =
|
||
visibleDays.find((d) => d.getDay() === dayOfWeek) || new Date();
|
||
|
||
let targetSlot = slot;
|
||
|
||
// If no slot provided (dropped on header/background), try to keep original time
|
||
if (!targetSlot && draggedTask.startTime) {
|
||
targetSlot = draggedTask.startTime;
|
||
}
|
||
|
||
// Collision detection / Find next free slot
|
||
if (targetSlot) {
|
||
if (isSlotOccupiedByTask(targetDateObj, targetSlot, draggedTask.id)) {
|
||
const allSlots = getTimeSlots(
|
||
effectiveCellDuration,
|
||
0,
|
||
24,
|
||
);
|
||
const startIndex = allSlots.indexOf(targetSlot);
|
||
if (startIndex !== -1) {
|
||
for (let i = startIndex + 1; i < allSlots.length; i++) {
|
||
const nextSlot = allSlots[i];
|
||
if (!isSlotOccupiedByTask(targetDateObj, nextSlot, draggedTask.id) && !isSlotProtected(targetDateObj, nextSlot)) {
|
||
targetSlot = nextSlot;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// If the task is a subtask, promote it to a standalone task
|
||
if (draggedTask.parentTaskId) {
|
||
const newScheduledDate = formatDateToISO(targetDateObj);
|
||
// Remove subtask from parent in UI
|
||
setTasks((prev) =>
|
||
prev.map((t) =>
|
||
t.id === draggedTask.parentTaskId
|
||
? { ...t, subTasks: (t.subTasks || []).filter((s) => s.id !== draggedTask.id) }
|
||
: t
|
||
)
|
||
);
|
||
// Add as standalone task in UI
|
||
setTasks((prev) => [
|
||
...prev,
|
||
{
|
||
...draggedTask,
|
||
parentTaskId: null,
|
||
scheduledDate: newScheduledDate,
|
||
dayOfWeek,
|
||
startTime: targetSlot || "",
|
||
} as Task,
|
||
]);
|
||
// Persist
|
||
try {
|
||
await fetch("/api/tasks", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
id: draggedTask.id,
|
||
parentTaskId: null,
|
||
scheduledDate: newScheduledDate,
|
||
dayOfWeek,
|
||
startTime: targetSlot || "",
|
||
}),
|
||
});
|
||
} catch (error) {
|
||
console.error("Error promoting subtask:", error);
|
||
}
|
||
setDraggedTask(null);
|
||
setDropPreview(null);
|
||
return;
|
||
}
|
||
|
||
// If the task was from a someday list, move it to the calendar
|
||
if (draggedTask.somedayListId) {
|
||
const newScheduledDate = formatDateToISO(targetDateObj);
|
||
// Inherit provider from someday list if task doesn't have one
|
||
const sourceList = somedayLists.find(l => l.id === draggedTask.somedayListId);
|
||
const taskProvider = draggedTask.externalProvider || sourceList?.externalProvider || null;
|
||
// Remove from someday list UI
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) => ({
|
||
...l,
|
||
tasks: l.tasks.filter((t) => t.id !== draggedTask.id),
|
||
})),
|
||
);
|
||
// Add to calendar tasks
|
||
setTasks((prev) => [
|
||
...prev,
|
||
{
|
||
...draggedTask,
|
||
somedayListId: null,
|
||
somedaySlotIndex: null,
|
||
scheduledDate: newScheduledDate,
|
||
dayOfWeek,
|
||
startTime: targetSlot || "",
|
||
externalProvider: taskProvider,
|
||
},
|
||
]);
|
||
// Persist
|
||
try {
|
||
await fetch("/api/tasks", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
id: draggedTask.id,
|
||
somedayListId: null,
|
||
somedaySlotIndex: null,
|
||
scheduledDate: newScheduledDate,
|
||
dayOfWeek,
|
||
startTime: targetSlot || "",
|
||
...(taskProvider && { externalProvider: taskProvider }),
|
||
}),
|
||
});
|
||
|
||
// Sync due date to external provider when moving from someday to calendar
|
||
if (draggedTask.externalId && draggedTask.externalProvider) {
|
||
fetch("/api/tasks/sync", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
taskId: draggedTask.id,
|
||
scheduledDate: newScheduledDate,
|
||
}),
|
||
}).catch((e) => console.error("Sync error:", e));
|
||
}
|
||
} catch (error) {
|
||
console.error("Error moving task from someday to calendar:", error);
|
||
}
|
||
} else {
|
||
moveTaskToSlot(
|
||
draggedTask.id,
|
||
dayOfWeek,
|
||
targetSlot || "",
|
||
targetDateObj,
|
||
);
|
||
}
|
||
setDraggedTask(null);
|
||
}
|
||
setDropPreview(null);
|
||
};
|
||
|
||
const handleDragEnd = () => {
|
||
setDraggedTask(null);
|
||
setDropPreview(null);
|
||
// Remove drag-source class from all elements
|
||
document
|
||
.querySelectorAll(".drag-source")
|
||
.forEach((el) => el.classList.remove("drag-source"));
|
||
};
|
||
|
||
const handleDragLeave = () => {
|
||
setDropPreview(null);
|
||
};
|
||
|
||
|
||
const handleSomedayDragOver = (e: React.DragEvent, listId: string, slotIdx: number) => {
|
||
e.preventDefault();
|
||
setDropPreview({ listId, slotIdx });
|
||
};
|
||
|
||
const handleSomedayDrop = async (e: React.DragEvent, listId: string, slotIndex: number) => {
|
||
e.preventDefault();
|
||
if (draggedTask) {
|
||
// If subtask, remove from parent first
|
||
if (draggedTask.parentTaskId) {
|
||
setTasks((prev) =>
|
||
prev.map((t) =>
|
||
t.id === draggedTask.parentTaskId
|
||
? { ...t, subTasks: (t.subTasks || []).filter((s) => s.id !== draggedTask.id) }
|
||
: t
|
||
)
|
||
);
|
||
}
|
||
|
||
// Update local state for someday lists
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) => {
|
||
// Remove the task from its current position in all lists
|
||
const filteredTasks = l.tasks.filter((t) => t.id !== draggedTask.id);
|
||
if (l.id === listId) {
|
||
const movedTask = {
|
||
...draggedTask,
|
||
parentTaskId: null,
|
||
somedayListId: listId,
|
||
somedaySlotIndex: slotIndex,
|
||
scheduledDate: null as any,
|
||
dayOfWeek: null as any,
|
||
startTime: null as any,
|
||
};
|
||
return {
|
||
...l,
|
||
tasks: [...filteredTasks, movedTask],
|
||
};
|
||
}
|
||
return { ...l, tasks: filteredTasks };
|
||
}),
|
||
);
|
||
|
||
// If it was a calendar task (not someday, not subtask), remove from calendar tasks
|
||
if (!draggedTask.somedayListId && !draggedTask.parentTaskId) {
|
||
setTasks((prev) => prev.filter((t) => t.id !== draggedTask.id));
|
||
}
|
||
|
||
// Persist the change
|
||
try {
|
||
await fetch("/api/tasks", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
id: draggedTask.id,
|
||
parentTaskId: null,
|
||
somedayListId: listId,
|
||
somedaySlotIndex: slotIndex,
|
||
scheduledDate: null,
|
||
dayOfWeek: null,
|
||
startTime: null,
|
||
}),
|
||
});
|
||
|
||
// If the target list is synced to an external provider and
|
||
// the task doesn't already exist at that provider/list, create it there
|
||
const targetList = somedayLists.find((l) => l.id === listId);
|
||
const needsSync = targetList?.externalId && targetList?.externalProvider && (
|
||
!draggedTask.externalId ||
|
||
draggedTask.externalProvider !== targetList.externalProvider ||
|
||
draggedTask.externalListId !== targetList.externalId
|
||
);
|
||
if (needsSync) {
|
||
try {
|
||
const syncRes = await fetch("/api/tasks/sync", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ taskId: draggedTask.id }),
|
||
});
|
||
if (syncRes.ok) {
|
||
const syncData = await syncRes.json();
|
||
// Update local state with external IDs
|
||
if (syncData.task) {
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) => ({
|
||
...l,
|
||
tasks: l.tasks.map((t) =>
|
||
t.id === draggedTask.id
|
||
? {
|
||
...t,
|
||
externalId: syncData.task.externalId,
|
||
externalProvider: syncData.task.externalProvider,
|
||
externalListId: syncData.task.externalListId,
|
||
}
|
||
: t
|
||
),
|
||
}))
|
||
);
|
||
}
|
||
}
|
||
} catch (syncError) {
|
||
console.error("Failed to sync task to external provider:", syncError);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("Error moving task to someday slot:", error);
|
||
}
|
||
setDraggedTask(null);
|
||
setDropPreview(null);
|
||
}
|
||
};
|
||
|
||
// Sync calendar
|
||
const handleSync = async () => {
|
||
setSyncStatus("syncing");
|
||
try {
|
||
// Pull changes from Google Tasks, then force-refresh calendar cache
|
||
await fetch("/api/tasks/sync").catch((e) =>
|
||
console.error("Task pull sync error:", e),
|
||
);
|
||
// Force live refresh from providers (bypass staleness check)
|
||
const syncRes = await fetch("/api/calendar/sync", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
timeMin: new Date(
|
||
currentWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000,
|
||
).toISOString(),
|
||
timeMax: new Date(
|
||
currentWeekStart.getTime() + 14 * 24 * 60 * 60 * 1000,
|
||
).toISOString(),
|
||
forceRefresh: true,
|
||
}),
|
||
});
|
||
if (syncRes.ok) {
|
||
const data = await syncRes.json();
|
||
if (data.events) setRawCalendarEvents(data.events);
|
||
}
|
||
await fetchTasks();
|
||
// Re-fetch after background refresh completes
|
||
if (true) {
|
||
setTimeout(() => fetchCalendarEvents(), 8000);
|
||
}
|
||
setSyncStatus("synced");
|
||
announce("Sync complete");
|
||
setTimeout(() => setSyncStatus("idle"), 3000);
|
||
} catch (error) {
|
||
console.error("Error syncing:", error);
|
||
setSyncStatus("idle");
|
||
announce("Sync failed");
|
||
setSyncError("Sync failed");
|
||
setTimeout(() => setSyncError(null), 10000);
|
||
}
|
||
};
|
||
|
||
// Start adding someday list UI
|
||
const handleStartAddSomedayList = () => {
|
||
setIsAddingSomedayList(true);
|
||
// Focus will happen in render logic if possible or via ref, but let's render conditional input first
|
||
};
|
||
|
||
const saveSomedayList = async () => {
|
||
if (!newSomedayListName.trim()) {
|
||
setIsAddingSomedayList(false);
|
||
setNewSomedayListName("");
|
||
setSelectedSomedayProvider(null);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const url = selectedSomedayProvider
|
||
? "/api/someday-lists/external"
|
||
: "/api/someday-lists";
|
||
|
||
const response = await fetch(url, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
title: newSomedayListName.trim(),
|
||
provider: selectedSomedayProvider,
|
||
}),
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
setSomedayLists((prev) => [
|
||
...prev,
|
||
{
|
||
...(data.somedayList || data.list),
|
||
tasks: [], // Initially empty
|
||
},
|
||
]);
|
||
setNewSomedayListName("");
|
||
setSelectedSomedayProvider(null);
|
||
setIsAddingSomedayList(false);
|
||
} else {
|
||
const error = await response.json();
|
||
alert(error.error || "Failed to create list");
|
||
}
|
||
} catch (error) {
|
||
console.error("Error adding someday list:", error);
|
||
alert("An error occurred while creating the list");
|
||
}
|
||
};
|
||
|
||
// Get time slots to display
|
||
const visibleSlots = getTimeSlots(
|
||
effectiveCellDuration,
|
||
0,
|
||
24,
|
||
);
|
||
|
||
const fontSizeScale = profile.fontSize === "S" ? 0.85 : profile.fontSize === "L" ? 1.15 : 1;
|
||
const mobileScale = isMobile ? (profile.mobileFontScale || 1.0) : 1.0;
|
||
const fontVal = (v: string | undefined) => v && v !== "__custom__" ? v : "";
|
||
const scaleRem = (base: string) => {
|
||
const num = parseFloat(base);
|
||
return `${(num * fontSizeScale * mobileScale).toFixed(3)}rem`;
|
||
};
|
||
const activeTheme = (darkMode ? profile.darkTheme : profile.lightTheme) as Record<string, string> | null;
|
||
const useLeftRail = !isMobile && profile.menuPosition !== "top";
|
||
const showHeaderControls = profile.showHeaderControls !== false;
|
||
const compactRail = useLeftRail && isCompactHeight;
|
||
const collapsedRailWidth = compactRail ? 38 : 44;
|
||
const expandedRailWidth = compactRail ? 220 : 240;
|
||
|
||
const containerStyle = {
|
||
...(activeTheme ? {
|
||
"--weekly-bg": activeTheme.background,
|
||
"--weekly-text": activeTheme.foreground,
|
||
"--weekly-text-light": activeTheme.color8 || activeTheme.color7,
|
||
"--weekly-border": activeTheme.color0,
|
||
"--weekly-teal": activeTheme.color4 || activeTheme.color6,
|
||
"--weekly-settings-item-bg": activeTheme.color0,
|
||
"--weekly-item-hover": activeTheme.color0,
|
||
} : {}),
|
||
"--weekly-font-headline":
|
||
fontVal(profile.headlineFont)
|
||
? `"${fontVal(profile.headlineFont)}", sans-serif`
|
||
: "var(--font-headline)",
|
||
"--weekly-headline-size": scaleRem(profile.headlineFontSize || "1.25rem"),
|
||
"--weekly-headline-weight": profile.headlineFontWeight || "900",
|
||
"--weekly-date-font": fontVal(profile.dateFontFamily)
|
||
? `"${fontVal(profile.dateFontFamily)}", sans-serif`
|
||
: "var(--weekly-font-headline)",
|
||
"--weekly-date-size": scaleRem(profile.dateFontSize || "0.65rem"),
|
||
"--weekly-date-weight": profile.dateFontWeight || "400",
|
||
"--weekly-time-task-font": (() => {
|
||
// If time task font is explicitly customized (not default "Inter"), use it
|
||
// Otherwise inherit from task font
|
||
const ttf = fontVal(profile.timeTaskFontFamily);
|
||
const tf = fontVal(profile.taskFontFamily);
|
||
const isDefault = !ttf || ttf === "Inter";
|
||
if (!isDefault) return `"${ttf}", sans-serif`;
|
||
if (tf) return `"${tf}", sans-serif`;
|
||
return "var(--weekly-font)";
|
||
})(),
|
||
"--weekly-time-task-size": scaleRem(
|
||
// If time task size is the old default 0.75rem, use task size instead
|
||
profile.timeTaskFontSize && profile.timeTaskFontSize !== "0.75rem"
|
||
? profile.timeTaskFontSize
|
||
: profile.taskFontSize || "0.9rem"
|
||
),
|
||
"--weekly-time-task-weight":
|
||
// If time task weight is the old default 500, use task weight instead
|
||
profile.timeTaskFontWeight && profile.timeTaskFontWeight !== "500"
|
||
? profile.timeTaskFontWeight
|
||
: profile.taskFontWeight || "400",
|
||
"--weekly-font":
|
||
"var(--font-body)" /* Force default body font as requested */,
|
||
"--weekly-task-font": fontVal(profile.taskFontFamily)
|
||
? `"${fontVal(profile.taskFontFamily)}", sans-serif`
|
||
: "var(--weekly-font)",
|
||
"--weekly-task-size": scaleRem(profile.taskFontSize || "0.9rem"),
|
||
"--weekly-task-weight": profile.taskFontWeight || "400",
|
||
"--weekly-event-font":
|
||
fontVal(profile.eventFontFamily)
|
||
? `"${fontVal(profile.eventFontFamily)}", sans-serif`
|
||
: "var(--weekly-font)",
|
||
"--weekly-event-size": scaleRem(profile.eventFontSize || "0.85rem"),
|
||
"--weekly-event-weight": profile.eventFontWeight || "400",
|
||
"--font-weight-body": profile.fontWeight || "400",
|
||
"--weekly-weekend-sat": activeTheme?.color3 || (darkMode
|
||
? invertColor(profile.weekendColorSat || "#666666")
|
||
: profile.weekendColorSat || "#666666"),
|
||
"--weekly-weekend-sun": activeTheme?.color1 || (darkMode
|
||
? invertColor(profile.weekendColorSun || "#dc2626")
|
||
: profile.weekendColorSun || "#dc2626"),
|
||
"--weekly-weekday-color": activeTheme?.foreground || (darkMode
|
||
? invertColor(profile.weekdayColor || "#888888")
|
||
: profile.weekdayColor || "#888888"),
|
||
"--weekly-date-color": activeTheme?.color8 || (darkMode
|
||
? invertColor(profile.dateColor || "#888888")
|
||
: profile.dateColor || "#888888"),
|
||
"--weekly-task-color": activeTheme?.color7 || (darkMode
|
||
? invertColor(profile.taskColor || "#333333")
|
||
: profile.taskColor || "#333333"),
|
||
"--weekly-today-highlight": activeTheme?.color0 || (darkMode
|
||
? invertColor(profile.todayHighlightColor || "#f0fafa")
|
||
: profile.todayHighlightColor || "#f0fafa"),
|
||
"--weekly-past-color": activeTheme?.color8 || (darkMode
|
||
? invertColor(profile.pastDayColor || "#a6a6a7")
|
||
: profile.pastDayColor || "#a6a6a7"),
|
||
...(useLeftRail ? { paddingLeft: leftRailExpanded ? `${expandedRailWidth}px` : `${collapsedRailWidth}px`, transition: "padding-left 0.2s ease" } : {}),
|
||
} as React.CSSProperties;
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div
|
||
className="weekly-container"
|
||
style={{ alignItems: "center", justifyContent: "center" }}
|
||
>
|
||
<div style={{ color: "var(--weekly-text-light)" }}>
|
||
{translations[profile.language]?.loading || translations["en"].loading}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const activeDateLayout = isMobile ? (profile.mobileDateLayout || "below") : (profile.dateLayout || "right");
|
||
|
||
// Quick settings sidebar button styles
|
||
const qsBtnStyle = (dm: boolean): React.CSSProperties => ({
|
||
padding: "6px", borderRadius: "6px", border: "none", cursor: "pointer",
|
||
display: "flex", alignItems: "center", justifyContent: "center",
|
||
background: dm ? "#1f2937" : "#e5e7eb", color: dm ? "#9ca3af" : "#6b7280",
|
||
});
|
||
const qsActionStyle = (dm: boolean): React.CSSProperties => ({
|
||
display: "flex", alignItems: "center", gap: "8px", padding: "7px 8px",
|
||
fontSize: "0.8rem", borderRadius: "6px", border: "none", cursor: "pointer",
|
||
background: "none", color: dm ? "#d1d5db" : "#333", textAlign: "left" as const,
|
||
});
|
||
|
||
const railIconBtnStyle: React.CSSProperties = {
|
||
width: compactRail ? "30px" : "36px", height: compactRail ? "30px" : "36px", display: "flex", alignItems: "center", justifyContent: "center",
|
||
background: "none", border: "none", cursor: "pointer", borderRadius: compactRail ? "7px" : "8px",
|
||
color: darkMode ? "#9ca3af" : "#6b7280", flexShrink: 0,
|
||
};
|
||
const flyoutPanelStyle: React.CSSProperties = {
|
||
position: "fixed", left: `${collapsedRailWidth + 4}px`,
|
||
top: `${Math.min(flyoutY, (typeof window !== "undefined" ? window.innerHeight : 800) - 260)}px`,
|
||
background: darkMode ? "#1a1a2e" : "var(--paper)",
|
||
border: "1px solid var(--line)", borderRadius: "10px", padding: "12px 14px",
|
||
zIndex: 200, boxShadow: "2px 4px 16px rgba(0,0,0,0.10)", minWidth: "200px",
|
||
display: "flex", flexDirection: "column", gap: "10px",
|
||
};
|
||
const flyoutLabelStyle: React.CSSProperties = {
|
||
fontSize: "0.68rem", fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase" as const,
|
||
color: darkMode ? "#6b7280" : "var(--ink-3)", marginBottom: "2px",
|
||
};
|
||
const openFlyout = (section: string, e: React.MouseEvent) => {
|
||
if (flyoutTimerRef.current) clearTimeout(flyoutTimerRef.current);
|
||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||
setFlyoutY(rect.top);
|
||
setFlyoutSection(section);
|
||
};
|
||
const closeFlyoutDelayed = () => {
|
||
flyoutTimerRef.current = setTimeout(() => setFlyoutSection(null), 180);
|
||
};
|
||
const keepFlyoutOpen = () => {
|
||
if (flyoutTimerRef.current) clearTimeout(flyoutTimerRef.current);
|
||
};
|
||
const railSep = (
|
||
<div style={{ height: "1px", background: "var(--line)", width: compactRail ? "22px" : "28px", margin: compactRail ? "1px 0" : "3px 0", flexShrink: 0, alignSelf: "center" }} />
|
||
);
|
||
|
||
// All-Day Events Section (reusable for above/below positioning)
|
||
const allDaySection = (() => {
|
||
if (!effectiveShowAllDay) return null;
|
||
const allDayEvents = calendarEvents.filter((event) =>
|
||
isAllDayEvent(event),
|
||
);
|
||
if (allDayEvents.length === 0) return null;
|
||
|
||
const handleOnTop = effectiveAllDayPosition === "below";
|
||
const resizeHandle = isAllDayExpanded ? (
|
||
<div
|
||
className="resize-handle resize-handle-allday"
|
||
onMouseDown={(e) => startResize(e, 'allday', handleOnTop)}
|
||
onTouchStart={(e) => startResize(e, 'allday', handleOnTop)}
|
||
>
|
||
<div className="resize-handle-bar" />
|
||
</div>
|
||
) : null;
|
||
|
||
return (
|
||
<>
|
||
{effectiveAllDayPosition === "below" && resizeHandle}
|
||
<section
|
||
className={`all-day-events-section ${isAllDayExpanded ? "expanded" : "collapsed"}`}
|
||
style={isAllDayExpanded && allDayHeight ? { height: `${allDayHeight}px`, overflowY: 'auto' } : undefined}
|
||
>
|
||
<div style={{ display: "flex", flexDirection: "row" }}>
|
||
{profile.showTimeGrid && (
|
||
<div
|
||
className="all-day-label-column"
|
||
onClick={() => setIsAllDayExpanded(!isAllDayExpanded)}
|
||
style={{
|
||
width: "55px",
|
||
flexShrink: 0,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
alignItems: "center",
|
||
justifyContent: "flex-start",
|
||
cursor: "pointer",
|
||
borderRight: "1px solid var(--weekly-border)",
|
||
padding: "4px 4px 2px",
|
||
gap: "0px",
|
||
marginLeft: "-1px",
|
||
position: "relative",
|
||
}}
|
||
title={isAllDayExpanded ? "Collapse" : "Expand"}
|
||
>
|
||
<span
|
||
style={{
|
||
fontSize: "0.6rem",
|
||
fontWeight: 600,
|
||
color: "var(--weekly-text-light)",
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.05em",
|
||
lineHeight: 1.1,
|
||
textAlign: "center",
|
||
}}
|
||
>
|
||
all day
|
||
</span>
|
||
<span
|
||
className="all-day-events-count"
|
||
style={{
|
||
fontSize: "0.55rem",
|
||
padding: "0px 3px",
|
||
marginTop: "1px",
|
||
}}
|
||
>
|
||
{(() => {
|
||
const visibleDays = getVisibleDays();
|
||
const seen = new Set<string>();
|
||
visibleDays.forEach(d => getAllDayEventsForDate(d).forEach(e => seen.add(e.id)));
|
||
return seen.size;
|
||
})()}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{/* In list mode (no time grid), skip the label column so events align with day columns */}
|
||
{isAllDayExpanded && (
|
||
<div
|
||
className={`all-day-events-grid cols-${viewDays}`}
|
||
style={{ flex: 1 }}
|
||
>
|
||
{getVisibleDays().map((date) => {
|
||
const dayEvents = getAllDayEventsForDate(date);
|
||
return (
|
||
<div
|
||
key={date.toISOString()}
|
||
className="all-day-events-column"
|
||
>
|
||
{dayEvents.length > 0 ? (
|
||
dayEvents.map((event) => (
|
||
<div
|
||
key={event.id}
|
||
className="all-day-event"
|
||
title={`${event.calendarTitle}: ${event.title}`}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (event.editable) {
|
||
setCalendarEventModal({
|
||
isOpen: true,
|
||
event: event,
|
||
});
|
||
}
|
||
}}
|
||
style={{
|
||
backgroundColor:
|
||
event.calendarColor || "#3b82f6",
|
||
color: "white",
|
||
borderLeft: "none",
|
||
padding: "2px 4px",
|
||
borderRadius: "3px",
|
||
fontSize: "0.75rem",
|
||
marginBottom: "2px",
|
||
whiteSpace: "nowrap",
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: "4px",
|
||
cursor: event.editable ? "pointer" : "default",
|
||
transition: "filter 0.1s ease",
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (event.editable) e.currentTarget.style.filter = "brightness(0.9)";
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (event.editable) e.currentTarget.style.filter = "none";
|
||
}}
|
||
>
|
||
<span className="event-indicator">📅</span>
|
||
<span className="all-day-event-title">
|
||
{event.title}
|
||
</span>
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="all-day-empty"></div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
{effectiveAllDayPosition === "above" && resizeHandle}
|
||
</>
|
||
);
|
||
})();
|
||
|
||
return (
|
||
<div
|
||
className={`weekly-container ${darkMode ? "dark-mode" : ""} font-size-${(profile.fontSize ?? "M").toLowerCase()} ${profile.viewStyle}-view ${profile.showTimeGrid ? "time-grid-on" : "time-grid-off"}${(() => { const hd = isMobile ? (isPortrait ? (profile.mobilePortraitHeaderDisplay || "current_day") : (profile.mobileLandscapeHeaderDisplay || profile.headerDisplay || "kw")) : (profile.headerDisplay || "kw"); return (viewDays === 1 && hd === "current_day") ? " header-current-day-single" : ""; })()}`}
|
||
style={containerStyle}
|
||
>
|
||
{/* Visually hidden live region for screen reader announcements */}
|
||
<div
|
||
role="status"
|
||
aria-live="polite"
|
||
aria-atomic="true"
|
||
style={{ position: 'absolute', width: '1px', height: '1px', padding: 0, margin: '-1px', overflow: 'hidden', clip: 'rect(0,0,0,0)', whiteSpace: 'nowrap', border: 0 }}
|
||
>
|
||
{announceMsg}
|
||
</div>
|
||
{/* Mobile sticky day indicator */}
|
||
{isMobile && mobileStickyDay && mobileStickyDayVisible && (
|
||
<div
|
||
className="mobile-sticky-day-bar"
|
||
style={{
|
||
position: 'fixed',
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
zIndex: 90,
|
||
background: darkMode ? 'rgba(26,26,46,0.95)' : 'rgba(255,255,255,0.95)',
|
||
backdropFilter: 'blur(8px)',
|
||
WebkitBackdropFilter: 'blur(8px)',
|
||
borderBottom: `1px solid ${darkMode ? '#333' : '#e5e7eb'}`,
|
||
padding: '6px 16px',
|
||
fontSize: '0.82rem',
|
||
fontWeight: 700,
|
||
color: darkMode ? '#e5e7eb' : '#333',
|
||
fontFamily: 'var(--weekly-font-headline, var(--weekly-font))',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.02em',
|
||
}}
|
||
>
|
||
{mobileStickyDay}
|
||
</div>
|
||
)}
|
||
|
||
{/* Mobile Quick Settings — full overlay triggered from mobile header */}
|
||
{isMobile && showQuickSettings && (
|
||
<>
|
||
<div onClick={() => setShowQuickSettings(false)} style={{ position: "fixed", inset: 0, zIndex: 999 }} />
|
||
<div style={{ position: "fixed", left: 0, top: 0, bottom: 0, width: "240px", background: darkMode ? "#1a1a2e" : "var(--paper)", borderRight: "1px solid var(--line)", zIndex: 1000, display: "flex", flexDirection: "column", padding: "16px 14px", gap: "10px", overflowY: "auto", boxShadow: "2px 0 8px rgba(0,0,0,0.04)" }}>
|
||
<div style={{ fontSize: "0.85rem", fontWeight: 700, color: darkMode ? "#e5e7eb" : "#333", marginBottom: "2px" }}>{profile.language === "de" ? "Einstellungen" : "Preferences"}</div>
|
||
<div style={{ display: "flex", justifyContent: "center", gap: "4px", padding: "4px 0", borderBottom: "1px solid var(--line)", paddingBottom: "10px" }}>
|
||
<button onClick={() => { goToPrevWeek(); setShowQuickSettings(false); }} style={{ ...qsBtnStyle(darkMode), minWidth: "36px" }} title="Previous Week"><ChevronsLeft size={16} /></button>
|
||
<button onClick={() => { goToPrevDay(); setShowQuickSettings(false); }} style={{ ...qsBtnStyle(darkMode), minWidth: "36px" }} title="Previous Day"><ChevronLeft size={16} /></button>
|
||
<button onClick={() => { goToToday(); setShowQuickSettings(false); }} style={{ ...qsBtnStyle(darkMode), minWidth: "56px", fontWeight: 700, fontSize: "0.7rem" }} title="Today">{profile.language === "de" ? "Heute" : "Today"}</button>
|
||
<button onClick={() => { goToNextDay(); setShowQuickSettings(false); }} style={{ ...qsBtnStyle(darkMode), minWidth: "36px" }} title="Next Day"><ChevronRight size={16} /></button>
|
||
<button onClick={() => { goToNextWeek(); setShowQuickSettings(false); }} style={{ ...qsBtnStyle(darkMode), minWidth: "36px" }} title="Next Week"><ChevronsRight size={16} /></button>
|
||
</div>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "1px" }}>
|
||
<button onClick={() => { setIsSearchOpen(true); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><Search size={16} /> <span>{profile.language === "de" ? "Suche" : "Search"}</span></button>
|
||
<button onClick={() => { setShowDatePicker(true); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><Calendar size={16} /> <span>{profile.language === "de" ? "Datum wählen" : "Jump to date"}</span></button>
|
||
<button onClick={() => { const now = new Date(); setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` }); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><Plus size={16} /> <span>{profile.language === "de" ? "Kalendereintrag" : "Calendar Event"}</span></button>
|
||
<button onClick={() => { setShowProjectsSidebar(true); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><FolderPlus size={16} /> <span>{profile.language === "de" ? "Projekte" : "Projects"}</span></button>
|
||
<button onClick={() => { setIsRecurringTasksOpen(true); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><Repeat size={16} /> <span>{profile.language === "de" ? "Wiederkehrend" : "Recurring Tasks"}</span></button>
|
||
<button onClick={() => { const nv = !profile.showNextTask; setShowNextTask(nv); saveSetting("showNextTask", nv); }} style={qsActionStyle(darkMode)}>{profile.showNextTask ? <Play size={16} /> : <Target size={16} />}<span>{profile.showNextTask ? (profile.language === "de" ? "Nächste Aufgabe" : "Next Task") : (profile.language === "de" ? "Ziel" : "Goal")}</span></button>
|
||
<button onClick={() => { setShowFocusMode(true); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><Zap size={16} /> <span>{profile.language === "de" ? "Fokus" : "Focus"}</span></button>
|
||
</div>
|
||
<div style={{ borderTop: "1px solid var(--line)", paddingTop: "8px" }} />
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||
<span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "var(--ink-3)" }}>{profile.language === "de" ? "Ansicht" : "View"}</span>
|
||
<div style={{ display: "flex", gap: "4px" }}>{[{ key: "simple", icon: <Calendar size={13} /> }, { key: "calendar", icon: <CalendarDays size={13} /> }, { key: "list", icon: <ListTodo size={13} /> }, { key: "kanban", icon: <Kanban size={13} /> }, { key: "priority", icon: <Target size={13} /> }].map((v) => (<button key={v.key} onClick={() => { setViewStyle(v.key as any); saveSetting("viewStyle", v.key); if (v.key === "list") { setShowTimeGrid(false); saveSetting("showTimeGrid", false); } if (v.key === "simple" || v.key === "calendar") { setShowTimeGrid(true); saveSetting("showTimeGrid", true); } if (v.key === "priority" || v.key === "kanban") { setShowTimeGrid(false); saveSetting("showTimeGrid", false); } }} style={{ flex: 1, padding: "5px 0", borderRadius: "6px", border: "none", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: profile.viewStyle === v.key ? 700 : 400, background: profile.viewStyle === v.key ? "var(--accent)" : (darkMode ? "#1f2937" : "#e5e7eb"), color: profile.viewStyle === v.key ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)") }}>{v.icon}</button>))}</div>
|
||
</div>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||
<span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "var(--ink-3)" }}>{profile.language === "de" ? "Spalten" : "Days"}</span>
|
||
<div style={{ display: "flex", gap: "4px" }}>{[1, 2, 3, 5, 7].map((num) => (<button key={num} onClick={() => { setViewDays(num); savedViewDaysRef.current = num; saveSetting("viewDays", num); }} style={{ flex: 1, padding: "5px 0", fontSize: "0.8rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: viewDays === num ? 700 : 400, background: viewDays === num ? (darkMode ? "#374151" : "var(--ink)") : (darkMode ? "#1f2937" : "#e5e7eb"), color: viewDays === num ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)") }}>{num}</button>))}</div>
|
||
</div>
|
||
{profile.showTimeGrid && (<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}><span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "var(--ink-3)" }}>{profile.language === "de" ? "Zeitfenster" : "Slot"}</span><div style={{ display: "flex", gap: "4px" }}>{(([15, 20, 30, 60] as CellDuration[])).map((d) => (<button key={d} onClick={() => { setCellDuration(d); saveSetting("cellDuration", d); saveViewSetting("cellDuration", d, true); }} style={{ flex: 1, padding: "5px 0", fontSize: "0.8rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: effectiveCellDuration === d ? 700 : 400, background: effectiveCellDuration === d ? (darkMode ? "#374151" : "var(--ink)") : (darkMode ? "#1f2937" : "#e5e7eb"), color: effectiveCellDuration === d ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)") }}>{d}m</button>))}</div></div>)}
|
||
{profile.showTimeGrid && (<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}><span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>:15/:30/:45</span><button onClick={() => { saveViewSetting("showSubHourSlots", !effectiveShowSubHourSlots, true); }} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280" }}>{effectiveShowSubHourSlots ? <Eye size={16} /> : <EyeOff size={16} />}</button></div>)}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}><span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>{profile.language === "de" ? "Textgröße" : "Text size"}</span><div style={{ display: "flex", gap: "4px" }}>{(["S", "M", "L"] as const).map((size) => (<button key={size} onClick={() => { setFontSize(size); saveSetting("fontSize", size); }} style={{ padding: "4px 10px", fontSize: "0.8rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: profile.fontSize === size ? 700 : 400, background: profile.fontSize === size ? (darkMode ? "#374151" : "#333") : (darkMode ? "#1f2937" : "#e5e7eb"), color: profile.fontSize === size ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280") }}>{size}</button>))}</div></div>
|
||
<div style={{ borderTop: `1px solid ${darkMode ? "#333" : "#e5e7eb"}`, paddingTop: "4px" }} />
|
||
{[{ label: profile.language === "de" ? "Irgendwann" : "Someday", value: effectiveShowSomeday, toggle: () => saveViewSetting("showSomeday", !effectiveShowSomeday, true) }, { label: profile.language === "de" ? "Ganztägig" : "All-day", value: effectiveShowAllDay, toggle: () => saveViewSetting("showAllDayEvents", !effectiveShowAllDay, true) }, { label: profile.language === "de" ? "Checkboxen" : "Checkboxes", value: effectiveShowTaskCheckboxes, toggle: () => saveViewSetting("showTaskCheckboxes", !effectiveShowTaskCheckboxes, true) }, { label: profile.language === "de" ? "Projekt-Icons" : "Project Icons", value: effectiveShowProjectIcons, toggle: () => saveViewSetting("showProjectIcons", !effectiveShowProjectIcons, true) }, { label: profile.language === "de" ? "Prioritäts-Icons" : "Priority Icons", value: effectiveShowPriorityIcons, toggle: () => saveViewSetting("showPriorityIcons", !effectiveShowPriorityIcons, true) }, ...((profile.weatherEnabled || profile.weatherLat || profile.weatherLon) ? [{ label: profile.language === "de" ? "Wetter" : "Weather", value: effectiveWeatherEnabled, toggle: () => { const v = !effectiveWeatherEnabled; setProfile((p: any) => ({ ...p, weatherEnabled: v })); saveViewSetting("weatherEnabled", v, true); } }] : []) ].map(({ label, value, toggle }) => (<div key={label} style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}><span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>{label}</span><button onClick={toggle} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280" }}>{value ? <Eye size={16} /> : <EyeOff size={16} />}</button></div>))}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}><span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>{profile.language === "de" ? "Starten mit" : "Start on"}</span><div style={{ display: "flex", gap: "4px" }}><button onClick={() => { setProfile({ ...profile, startDayOffset: 0 }); saveSetting("startDayOffset", 0); const d = new Date(); d.setHours(0, 0, 0, 0); setCurrentWeekStart(d); }} style={{ padding: "4px 10px", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: (profile.startDayOffset || 0) === 0 ? 700 : 400, background: (profile.startDayOffset || 0) === 0 ? (darkMode ? "#374151" : "#333") : (darkMode ? "#1f2937" : "#e5e7eb"), color: (profile.startDayOffset || 0) === 0 ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280") }}>{profile.language === "de" ? "Heute" : "Today"}</button><button onClick={() => { setProfile({ ...profile, startDayOffset: -1 }); saveSetting("startDayOffset", -1); const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() - 1); setCurrentWeekStart(d); }} style={{ padding: "4px 10px", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: profile.startDayOffset === -1 ? 700 : 400, background: profile.startDayOffset === -1 ? (darkMode ? "#374151" : "#333") : (darkMode ? "#1f2937" : "#e5e7eb"), color: profile.startDayOffset === -1 ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280") }}>{profile.language === "de" ? "Gestern" : "Yesterday"}</button></div></div>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}><span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>{profile.language === "de" ? "Anzeige" : "Display"}</span><div style={{ display: "flex", gap: "4px" }}><button onClick={() => setDarkMode(false)} style={{ padding: "4px 10px", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer", display: "flex", alignItems: "center", gap: "4px", fontWeight: !darkMode ? 700 : 400, background: !darkMode ? "#333" : (darkMode ? "#1f2937" : "#e5e7eb"), color: !darkMode ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280") }}><Sun size={12} /> {profile.language === "de" ? "Hell" : "Light"}</button><button onClick={() => setDarkMode(true)} style={{ padding: "4px 10px", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer", display: "flex", alignItems: "center", gap: "4px", fontWeight: darkMode ? 700 : 400, background: darkMode ? "#374151" : "#e5e7eb", color: darkMode ? "#fff" : "#6b7280" }}><Moon size={12} /> {profile.language === "de" ? "Dunkel" : "Dark"}</button></div></div>
|
||
<div style={{ borderTop: `1px solid ${darkMode ? "#333" : "#e5e7eb"}`, paddingTop: "4px" }} />
|
||
<div style={{ display: "flex", justifyContent: "center", gap: "8px", padding: "2px 0" }}>
|
||
<button onClick={handleUndo} disabled={undoCount === 0} style={{ ...qsBtnStyle(darkMode), opacity: undoCount === 0 ? 0.3 : 1 }} title="Undo"><Undo2 size={15} /></button>
|
||
<button onClick={handleRedo} disabled={redoCount === 0} style={{ ...qsBtnStyle(darkMode), opacity: redoCount === 0 ? 0.3 : 1 }} title="Redo"><Redo2 size={15} /></button>
|
||
<button onClick={() => { fetchCalendarEvents(true); fetchTasks(); setShowQuickSettings(false); }} style={qsBtnStyle(darkMode)} title="Refresh"><RefreshCcw size={15} /></button>
|
||
</div>
|
||
<div style={{ flex: 1 }} />
|
||
<button onClick={() => { setShowMobileRail(true); setShowQuickSettings(false); }} style={{ display: "flex", alignItems: "center", gap: "6px", background: "none", border: "none", cursor: "pointer", fontSize: "0.75rem", color: darkMode ? "#9ca3af" : "#6b7280", padding: "4px 0" }}>
|
||
<LayoutPanelLeft size={16} />{profile.language === "de" ? "Icon-Leiste" : "Icon Rail"}
|
||
</button>
|
||
<button onClick={() => setShowQuickSettings(false)} style={{ display: "flex", alignItems: "center", gap: "6px", background: "none", border: "none", cursor: "pointer", fontSize: "0.75rem", color: darkMode ? "#6b7280" : "#9ca3af", padding: "4px 0" }}>
|
||
<PanelLeftClose size={16} />{profile.language === "de" ? "Ausblenden" : "Hide"}
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Desktop Top-mode overlay — opens like the mobile overlay, triggered by header button */}
|
||
{!isMobile && !useLeftRail && showQuickSettings && (
|
||
<>
|
||
<div onClick={() => setShowQuickSettings(false)} style={{ position: "fixed", inset: 0, zIndex: 999 }} />
|
||
<div style={{ position: "fixed", left: 0, top: 0, bottom: 0, width: "240px", background: darkMode ? "#1a1a2e" : "var(--paper)", borderRight: "1px solid var(--line)", zIndex: 1000, display: "flex", flexDirection: "column", padding: "16px 14px", gap: "10px", overflowY: "auto", boxShadow: "2px 0 8px rgba(0,0,0,0.04)" }}>
|
||
<div style={{ fontSize: "0.85rem", fontWeight: 700, color: darkMode ? "#e5e7eb" : "#333" }}>{profile.language === "de" ? "Einstellungen" : "Preferences"}</div>
|
||
{/* Menu position toggle */}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||
<span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "var(--ink-3)" }}>{profile.language === "de" ? "Menüposition" : "Menu position"}</span>
|
||
<div style={{ display: "flex", gap: "4px" }}>
|
||
<button onClick={() => { setProfile((p: any) => ({ ...p, menuPosition: "left" })); saveSetting("menuPosition", "left"); setShowQuickSettings(false); }} style={{ flex: 1, padding: "5px 0", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: "5px", fontWeight: (profile.menuPosition || "left") === "left" ? 700 : 400, background: (profile.menuPosition || "left") === "left" ? "var(--accent)" : (darkMode ? "#1f2937" : "#e5e7eb"), color: (profile.menuPosition || "left") === "left" ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)") }}><LayoutPanelLeft size={13} />{profile.language === "de" ? "Links" : "Left"}</button>
|
||
<button onClick={() => { setProfile((p: any) => ({ ...p, menuPosition: "top" })); saveSetting("menuPosition", "top"); }} style={{ flex: 1, padding: "5px 0", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: "5px", fontWeight: (profile.menuPosition || "left") === "top" ? 700 : 400, background: (profile.menuPosition || "left") === "top" ? "var(--accent)" : (darkMode ? "#1f2937" : "#e5e7eb"), color: (profile.menuPosition || "left") === "top" ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)") }}><LayoutPanelTop size={13} />{profile.language === "de" ? "Oben" : "Top"}</button>
|
||
</div>
|
||
</div>
|
||
<div style={{ borderTop: "1px solid var(--line)", paddingTop: "8px" }} />
|
||
<div style={{ display: "flex", justifyContent: "center", gap: "4px", borderBottom: "1px solid var(--line)", paddingBottom: "10px" }}>
|
||
<button onClick={() => { goToPrevWeek(); setShowQuickSettings(false); }} style={{ ...qsBtnStyle(darkMode), minWidth: "36px" }} title="Previous Week"><ChevronsLeft size={16} /></button>
|
||
<button onClick={() => { goToPrevDay(); setShowQuickSettings(false); }} style={{ ...qsBtnStyle(darkMode), minWidth: "36px" }} title="Previous Day"><ChevronLeft size={16} /></button>
|
||
<button onClick={() => { goToToday(); setShowQuickSettings(false); }} style={{ ...qsBtnStyle(darkMode), minWidth: "56px", fontWeight: 700, fontSize: "0.7rem" }}>{profile.language === "de" ? "Heute" : "Today"}</button>
|
||
<button onClick={() => { goToNextDay(); setShowQuickSettings(false); }} style={{ ...qsBtnStyle(darkMode), minWidth: "36px" }} title="Next Day"><ChevronRight size={16} /></button>
|
||
<button onClick={() => { goToNextWeek(); setShowQuickSettings(false); }} style={{ ...qsBtnStyle(darkMode), minWidth: "36px" }} title="Next Week"><ChevronsRight size={16} /></button>
|
||
</div>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "1px" }}>
|
||
<button onClick={() => { setIsSearchOpen(true); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><Search size={16} /> <span>{profile.language === "de" ? "Suche" : "Search"}</span></button>
|
||
<button onClick={() => { setShowDatePicker(true); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><Calendar size={16} /> <span>{profile.language === "de" ? "Datum wählen" : "Jump to date"}</span></button>
|
||
<button onClick={() => { const now = new Date(); setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` }); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><Plus size={16} /> <span>{profile.language === "de" ? "Kalendereintrag" : "Calendar Event"}</span></button>
|
||
<button onClick={() => { setShowProjectsSidebar(true); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><FolderPlus size={16} /> <span>{profile.language === "de" ? "Projekte" : "Projects"}</span></button>
|
||
<button onClick={() => { setIsRecurringTasksOpen(true); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><Repeat size={16} /> <span>{profile.language === "de" ? "Wiederkehrend" : "Recurring Tasks"}</span></button>
|
||
<button onClick={() => { const nv = !profile.showNextTask; setShowNextTask(nv); saveSetting("showNextTask", nv); }} style={qsActionStyle(darkMode)}>{profile.showNextTask ? <Play size={16} /> : <Target size={16} />}<span>{profile.showNextTask ? (profile.language === "de" ? "Nächste Aufgabe" : "Next Task") : (profile.language === "de" ? "Ziel" : "Goal")}</span></button>
|
||
<button onClick={() => { setShowFocusMode(true); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><Zap size={16} /> <span>{profile.language === "de" ? "Fokus" : "Focus"}</span></button>
|
||
</div>
|
||
<div style={{ borderTop: "1px solid var(--line)", paddingTop: "8px" }} />
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}><span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "var(--ink-3)" }}>{profile.language === "de" ? "Ansicht" : "View"}</span><div style={{ display: "flex", gap: "4px" }}>{[{ key: "simple", icon: <Calendar size={13} /> }, { key: "calendar", icon: <CalendarDays size={13} /> }, { key: "list", icon: <ListTodo size={13} /> }, { key: "kanban", icon: <Kanban size={13} /> }, { key: "priority", icon: <Target size={13} /> }].map((v) => (<button key={v.key} onClick={() => { setViewStyle(v.key as any); saveSetting("viewStyle", v.key); if (v.key === "list") { setShowTimeGrid(false); saveSetting("showTimeGrid", false); } if (v.key === "simple" || v.key === "calendar") { setShowTimeGrid(true); saveSetting("showTimeGrid", true); } if (v.key === "priority" || v.key === "kanban") { setShowTimeGrid(false); saveSetting("showTimeGrid", false); } }} style={{ flex: 1, padding: "5px 0", borderRadius: "6px", border: "none", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: profile.viewStyle === v.key ? 700 : 400, background: profile.viewStyle === v.key ? "var(--accent)" : (darkMode ? "#1f2937" : "#e5e7eb"), color: profile.viewStyle === v.key ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)") }}>{v.icon}</button>))}</div></div>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}><span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "var(--ink-3)" }}>{profile.language === "de" ? "Spalten" : "Days"}</span><div style={{ display: "flex", gap: "4px" }}>{[1, 2, 3, 5, 7].map((num) => (<button key={num} onClick={() => { setViewDays(num); savedViewDaysRef.current = num; saveSetting("viewDays", num); }} style={{ flex: 1, padding: "5px 0", fontSize: "0.8rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: viewDays === num ? 700 : 400, background: viewDays === num ? (darkMode ? "#374151" : "var(--ink)") : (darkMode ? "#1f2937" : "#e5e7eb"), color: viewDays === num ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)") }}>{num}</button>))}</div></div>
|
||
{profile.showTimeGrid && (<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}><span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "var(--ink-3)" }}>{profile.language === "de" ? "Zeitfenster" : "Slot"}</span><div style={{ display: "flex", gap: "4px" }}>{(([15, 20, 30, 60] as CellDuration[])).map((d) => (<button key={d} onClick={() => { setCellDuration(d); saveSetting("cellDuration", d); saveViewSetting("cellDuration", d, true); }} style={{ flex: 1, padding: "5px 0", fontSize: "0.8rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: effectiveCellDuration === d ? 700 : 400, background: effectiveCellDuration === d ? (darkMode ? "#374151" : "var(--ink)") : (darkMode ? "#1f2937" : "#e5e7eb"), color: effectiveCellDuration === d ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)") }}>{d}m</button>))}</div></div>)}
|
||
{profile.showTimeGrid && (<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}><span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>:15/:30/:45</span><button onClick={() => { saveViewSetting("showSubHourSlots", !effectiveShowSubHourSlots, true); }} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280" }}>{effectiveShowSubHourSlots ? <Eye size={16} /> : <EyeOff size={16} />}</button></div>)}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}><span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>{profile.language === "de" ? "Textgröße" : "Text size"}</span><div style={{ display: "flex", gap: "4px" }}>{(["S", "M", "L"] as const).map((size) => (<button key={size} onClick={() => { setFontSize(size); saveSetting("fontSize", size); }} style={{ padding: "4px 10px", fontSize: "0.8rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: profile.fontSize === size ? 700 : 400, background: profile.fontSize === size ? (darkMode ? "#374151" : "#333") : (darkMode ? "#1f2937" : "#e5e7eb"), color: profile.fontSize === size ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280") }}>{size}</button>))}</div></div>
|
||
<div style={{ borderTop: `1px solid ${darkMode ? "#333" : "#e5e7eb"}`, paddingTop: "4px" }} />
|
||
{[{ label: profile.language === "de" ? "Irgendwann" : "Someday", value: effectiveShowSomeday, toggle: () => saveViewSetting("showSomeday", !effectiveShowSomeday, true) }, { label: profile.language === "de" ? "Ganztägig" : "All-day", value: effectiveShowAllDay, toggle: () => saveViewSetting("showAllDayEvents", !effectiveShowAllDay, true) }, { label: profile.language === "de" ? "Checkboxen" : "Checkboxes", value: effectiveShowTaskCheckboxes, toggle: () => saveViewSetting("showTaskCheckboxes", !effectiveShowTaskCheckboxes, true) }, { label: profile.language === "de" ? "Projekt-Icons" : "Project Icons", value: effectiveShowProjectIcons, toggle: () => saveViewSetting("showProjectIcons", !effectiveShowProjectIcons, true) }, { label: profile.language === "de" ? "Prioritäts-Icons" : "Priority Icons", value: effectiveShowPriorityIcons, toggle: () => saveViewSetting("showPriorityIcons", !effectiveShowPriorityIcons, true) }, ...((profile.weatherEnabled || profile.weatherLat || profile.weatherLon) ? [{ label: profile.language === "de" ? "Wetter" : "Weather", value: effectiveWeatherEnabled, toggle: () => { const v = !effectiveWeatherEnabled; setProfile((p: any) => ({ ...p, weatherEnabled: v })); saveViewSetting("weatherEnabled", v, true); } }] : []) ].map(({ label, value, toggle }) => (<div key={label} style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}><span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>{label}</span><button onClick={toggle} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280" }}>{value ? <Eye size={16} /> : <EyeOff size={16} />}</button></div>))}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}><span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>{profile.language === "de" ? "Starten mit" : "Start on"}</span><div style={{ display: "flex", gap: "4px" }}><button onClick={() => { setProfile({ ...profile, startDayOffset: 0 }); saveSetting("startDayOffset", 0); const d = new Date(); d.setHours(0, 0, 0, 0); setCurrentWeekStart(d); }} style={{ padding: "4px 10px", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: (profile.startDayOffset || 0) === 0 ? 700 : 400, background: (profile.startDayOffset || 0) === 0 ? (darkMode ? "#374151" : "#333") : (darkMode ? "#1f2937" : "#e5e7eb"), color: (profile.startDayOffset || 0) === 0 ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280") }}>{profile.language === "de" ? "Heute" : "Today"}</button><button onClick={() => { setProfile({ ...profile, startDayOffset: -1 }); saveSetting("startDayOffset", -1); const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() - 1); setCurrentWeekStart(d); }} style={{ padding: "4px 10px", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: profile.startDayOffset === -1 ? 700 : 400, background: profile.startDayOffset === -1 ? (darkMode ? "#374151" : "#333") : (darkMode ? "#1f2937" : "#e5e7eb"), color: profile.startDayOffset === -1 ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280") }}>{profile.language === "de" ? "Gestern" : "Yesterday"}</button></div></div>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}><span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>{profile.language === "de" ? "Anzeige" : "Display"}</span><div style={{ display: "flex", gap: "4px" }}><button onClick={() => setDarkMode(false)} style={{ padding: "4px 10px", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer", display: "flex", alignItems: "center", gap: "4px", fontWeight: !darkMode ? 700 : 400, background: !darkMode ? "#333" : (darkMode ? "#1f2937" : "#e5e7eb"), color: !darkMode ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280") }}><Sun size={12} /> {profile.language === "de" ? "Hell" : "Light"}</button><button onClick={() => setDarkMode(true)} style={{ padding: "4px 10px", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer", display: "flex", alignItems: "center", gap: "4px", fontWeight: darkMode ? 700 : 400, background: darkMode ? "#374151" : "#e5e7eb", color: darkMode ? "#fff" : "#6b7280" }}><Moon size={12} /> {profile.language === "de" ? "Dunkel" : "Dark"}</button></div></div>
|
||
<div style={{ borderTop: `1px solid ${darkMode ? "#333" : "#e5e7eb"}`, paddingTop: "4px" }} />
|
||
<div style={{ display: "flex", justifyContent: "center", gap: "8px", padding: "2px 0" }}>
|
||
<button onClick={handleUndo} disabled={undoCount === 0} style={{ ...qsBtnStyle(darkMode), opacity: undoCount === 0 ? 0.3 : 1 }} title="Undo"><Undo2 size={15} /></button>
|
||
<button onClick={handleRedo} disabled={redoCount === 0} style={{ ...qsBtnStyle(darkMode), opacity: redoCount === 0 ? 0.3 : 1 }} title="Redo"><Redo2 size={15} /></button>
|
||
<button onClick={() => { fetchCalendarEvents(true); fetchTasks(); setShowQuickSettings(false); }} style={qsBtnStyle(darkMode)} title="Refresh"><RefreshCcw size={15} /></button>
|
||
</div>
|
||
<div style={{ flex: 1 }} />
|
||
<button onClick={() => setShowQuickSettings(false)} style={{ display: "flex", alignItems: "center", gap: "6px", background: "none", border: "none", cursor: "pointer", fontSize: "0.75rem", color: darkMode ? "#6b7280" : "#9ca3af", padding: "4px 0" }}>
|
||
<PanelLeftClose size={16} />{profile.language === "de" ? "Ausblenden" : "Hide"}
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Desktop Left Rail — persistent, collapses to icon strip */}
|
||
{useLeftRail && (
|
||
<>
|
||
{/* The rail panel */}
|
||
<div className={`weekly-left-sidebar ${leftRailExpanded ? "expanded" : "collapsed"} ${compactRail ? "compact-height" : ""}`} style={{ position: "fixed", left: 0, top: 0, bottom: 0, width: leftRailExpanded ? `${expandedRailWidth}px` : `${collapsedRailWidth}px`, transition: "width 0.2s ease", background: darkMode ? "#1a1a2e" : "var(--paper)", borderRight: "1px solid var(--line)", zIndex: 100, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||
{leftRailExpanded ? (
|
||
// ── EXPANDED MODE ──
|
||
<div style={{ padding: compactRail ? "10px 10px" : "16px 14px", display: "flex", flexDirection: "column", gap: compactRail ? "6px" : "10px", overflowY: "auto", flex: 1 }}>
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||
<span style={{ fontSize: "0.85rem", fontWeight: 700, color: darkMode ? "#e5e7eb" : "#333" }}>{profile.language === "de" ? "Einstellungen" : "Preferences"}</span>
|
||
<button onClick={() => setLeftRailExpanded(false)} style={{ background: "none", border: "none", cursor: "pointer", color: darkMode ? "#6b7280" : "#9ca3af", padding: "2px", display: "flex" }} title="Collapse">
|
||
<PanelLeftClose size={16} />
|
||
</button>
|
||
</div>
|
||
{/* Menu position toggle */}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||
<span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "var(--ink-3)" }}>{profile.language === "de" ? "Menüposition" : "Menu position"}</span>
|
||
<div style={{ display: "flex", gap: "4px" }}>
|
||
<button onClick={() => { setProfile((p: any) => ({ ...p, menuPosition: "left" })); saveSetting("menuPosition", "left"); }} style={{ flex: 1, padding: "5px 0", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: "5px", fontWeight: (profile.menuPosition || "left") === "left" ? 700 : 400, background: (profile.menuPosition || "left") === "left" ? "var(--accent)" : (darkMode ? "#1f2937" : "#e5e7eb"), color: (profile.menuPosition || "left") === "left" ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)") }}><LayoutPanelLeft size={13} />{profile.language === "de" ? "Links" : "Left"}</button>
|
||
<button onClick={() => { setProfile((p: any) => ({ ...p, menuPosition: "top" })); saveSetting("menuPosition", "top"); setLeftRailExpanded(false); }} style={{ flex: 1, padding: "5px 0", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: "5px", fontWeight: (profile.menuPosition || "left") === "top" ? 700 : 400, background: (profile.menuPosition || "left") === "top" ? "var(--accent)" : (darkMode ? "#1f2937" : "#e5e7eb"), color: (profile.menuPosition || "left") === "top" ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)") }}><LayoutPanelTop size={13} />{profile.language === "de" ? "Oben" : "Top"}</button>
|
||
</div>
|
||
</div>
|
||
<div style={{ borderTop: "1px solid var(--line)" }} />
|
||
{/* Navigation */}
|
||
<div style={{ display: "flex", justifyContent: "center", gap: "4px", padding: "4px 0", borderBottom: "1px solid var(--line)", paddingBottom: "10px" }}>
|
||
<button onClick={goToPrevWeek} style={{ ...qsBtnStyle(darkMode), minWidth: "36px" }} title="Previous Week"><ChevronsLeft size={16} /></button>
|
||
<button onClick={goToPrevDay} style={{ ...qsBtnStyle(darkMode), minWidth: "36px" }} title="Previous Day"><ChevronLeft size={16} /></button>
|
||
<button onClick={goToToday} style={{ ...qsBtnStyle(darkMode), minWidth: "56px", fontWeight: 700, fontSize: "0.7rem" }}>{profile.language === "de" ? "Heute" : "Today"}</button>
|
||
<button onClick={goToNextDay} style={{ ...qsBtnStyle(darkMode), minWidth: "36px" }} title="Next Day"><ChevronRight size={16} /></button>
|
||
<button onClick={goToNextWeek} style={{ ...qsBtnStyle(darkMode), minWidth: "36px" }} title="Next Week"><ChevronsRight size={16} /></button>
|
||
</div>
|
||
{/* Quick Actions */}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "1px" }}>
|
||
<button onClick={() => setIsSearchOpen(true)} style={qsActionStyle(darkMode)}><Search size={16} /> <span>{profile.language === "de" ? "Suche" : "Search"}</span></button>
|
||
<button onClick={() => setShowDatePicker(true)} style={qsActionStyle(darkMode)}><Calendar size={16} /> <span>{profile.language === "de" ? "Datum wählen" : "Jump to date"}</span></button>
|
||
<button onClick={() => { const now = new Date(); setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` }); }} style={qsActionStyle(darkMode)}><Plus size={16} /> <span>{profile.language === "de" ? "Kalendereintrag" : "Calendar Event"}</span></button>
|
||
<button onClick={() => setShowProjectsSidebar(true)} style={qsActionStyle(darkMode)}><FolderPlus size={16} /> <span>{profile.language === "de" ? "Projekte" : "Projects"}</span></button>
|
||
<button onClick={() => setIsRecurringTasksOpen(true)} style={qsActionStyle(darkMode)}><Repeat size={16} /> <span>{profile.language === "de" ? "Wiederkehrend" : "Recurring Tasks"}</span></button>
|
||
<button onClick={() => { const nv = !profile.showNextTask; setShowNextTask(nv); saveSetting("showNextTask", nv); }} style={qsActionStyle(darkMode)}>{profile.showNextTask ? <Play size={16} /> : <Target size={16} />}<span>{profile.showNextTask ? (profile.language === "de" ? "Nächste Aufgabe" : "Next Task") : (profile.language === "de" ? "Ziel" : "Goal")}</span></button>
|
||
<button onClick={() => setShowFocusMode(true)} style={qsActionStyle(darkMode)}><Zap size={16} /> <span>{profile.language === "de" ? "Fokus" : "Focus"}</span></button>
|
||
</div>
|
||
<div style={{ borderTop: "1px solid var(--line)", paddingTop: "8px" }} />
|
||
{/* View Style */}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||
<span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "var(--ink-3)" }}>{profile.language === "de" ? "Ansicht" : "View"}</span>
|
||
<div style={{ display: "flex", gap: "4px" }}>{[{ key: "simple", icon: <Calendar size={13} /> }, { key: "calendar", icon: <CalendarDays size={13} /> }, { key: "list", icon: <ListTodo size={13} /> }, { key: "kanban", icon: <Kanban size={13} /> }, { key: "priority", icon: <Target size={13} /> }].map((v) => (<button key={v.key} onClick={() => { setViewStyle(v.key as any); saveSetting("viewStyle", v.key); if (v.key === "list") { setShowTimeGrid(false); saveSetting("showTimeGrid", false); } if (v.key === "simple" || v.key === "calendar") { setShowTimeGrid(true); saveSetting("showTimeGrid", true); } if (v.key === "priority" || v.key === "kanban") { setShowTimeGrid(false); saveSetting("showTimeGrid", false); } }} style={{ flex: 1, padding: "5px 0", borderRadius: "6px", border: "none", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: profile.viewStyle === v.key ? 700 : 400, background: profile.viewStyle === v.key ? "var(--accent)" : (darkMode ? "#1f2937" : "#e5e7eb"), color: profile.viewStyle === v.key ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)") }}>{v.icon}</button>))}</div>
|
||
</div>
|
||
{/* Columns / Days */}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||
<span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "var(--ink-3)" }}>{profile.language === "de" ? "Spalten" : "Days"}</span>
|
||
<div style={{ display: "flex", gap: "4px" }}>{[1, 2, 3, 5, 7].map((num) => (<button key={num} onClick={() => { setViewDays(num); savedViewDaysRef.current = num; saveSetting("viewDays", num); }} style={{ flex: 1, padding: "5px 0", fontSize: "0.8rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: viewDays === num ? 700 : 400, background: viewDays === num ? (darkMode ? "#374151" : "var(--ink)") : (darkMode ? "#1f2937" : "#e5e7eb"), color: viewDays === num ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)") }}>{num}</button>))}</div>
|
||
</div>
|
||
{/* Slot Duration */}
|
||
{profile.showTimeGrid && (<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}><span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "var(--ink-3)" }}>{profile.language === "de" ? "Zeitfenster" : "Slot"}</span><div style={{ display: "flex", gap: "4px" }}>{(([15, 20, 30, 60] as CellDuration[])).map((d) => (<button key={d} onClick={() => { setCellDuration(d); saveSetting("cellDuration", d); saveViewSetting("cellDuration", d, true); }} style={{ flex: 1, padding: "5px 0", fontSize: "0.8rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: effectiveCellDuration === d ? 700 : 400, background: effectiveCellDuration === d ? (darkMode ? "#374151" : "var(--ink)") : (darkMode ? "#1f2937" : "#e5e7eb"), color: effectiveCellDuration === d ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)") }}>{d}m</button>))}</div></div>)}
|
||
{profile.showTimeGrid && (<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}><span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>:15/:30/:45</span><button onClick={() => { saveViewSetting("showSubHourSlots", !effectiveShowSubHourSlots, true); }} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280" }}>{effectiveShowSubHourSlots ? <Eye size={16} /> : <EyeOff size={16} />}</button></div>)}
|
||
{/* Text size */}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}><span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>{profile.language === "de" ? "Textgröße" : "Text size"}</span><div style={{ display: "flex", gap: "4px" }}>{(["S", "M", "L"] as const).map((size) => (<button key={size} onClick={() => { setFontSize(size); saveSetting("fontSize", size); }} style={{ padding: "4px 10px", fontSize: "0.8rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: profile.fontSize === size ? 700 : 400, background: profile.fontSize === size ? (darkMode ? "#374151" : "#333") : (darkMode ? "#1f2937" : "#e5e7eb"), color: profile.fontSize === size ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280") }}>{size}</button>))}</div></div>
|
||
<div style={{ borderTop: `1px solid ${darkMode ? "#333" : "#e5e7eb"}`, paddingTop: "4px" }} />
|
||
{/* Visibility toggles */}
|
||
{[{ label: profile.language === "de" ? "Irgendwann" : "Someday", value: effectiveShowSomeday, toggle: () => saveViewSetting("showSomeday", !effectiveShowSomeday, true) }, { label: profile.language === "de" ? "Ganztägig" : "All-day", value: effectiveShowAllDay, toggle: () => saveViewSetting("showAllDayEvents", !effectiveShowAllDay, true) }, { label: profile.language === "de" ? "Checkboxen" : "Checkboxes", value: effectiveShowTaskCheckboxes, toggle: () => saveViewSetting("showTaskCheckboxes", !effectiveShowTaskCheckboxes, true) }, { label: profile.language === "de" ? "Projekt-Icons" : "Project Icons", value: effectiveShowProjectIcons, toggle: () => saveViewSetting("showProjectIcons", !effectiveShowProjectIcons, true) }, { label: profile.language === "de" ? "Prioritäts-Icons" : "Priority Icons", value: effectiveShowPriorityIcons, toggle: () => saveViewSetting("showPriorityIcons", !effectiveShowPriorityIcons, true) }, ...((profile.weatherEnabled || profile.weatherLat || profile.weatherLon) ? [{ label: profile.language === "de" ? "Wetter" : "Weather", value: effectiveWeatherEnabled, toggle: () => { const v = !effectiveWeatherEnabled; setProfile((p: any) => ({ ...p, weatherEnabled: v })); saveViewSetting("weatherEnabled", v, true); } }] : []) ].map(({ label, value, toggle }) => (<div key={label} style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}><span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>{label}</span><button onClick={toggle} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280" }}>{value ? <Eye size={16} /> : <EyeOff size={16} />}</button></div>))}
|
||
{/* Start on */}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}><span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>{profile.language === "de" ? "Starten mit" : "Start on"}</span><div style={{ display: "flex", gap: "4px" }}><button onClick={() => { setProfile({ ...profile, startDayOffset: 0 }); saveSetting("startDayOffset", 0); const d = new Date(); d.setHours(0, 0, 0, 0); setCurrentWeekStart(d); }} style={{ padding: "4px 10px", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: (profile.startDayOffset || 0) === 0 ? 700 : 400, background: (profile.startDayOffset || 0) === 0 ? (darkMode ? "#374151" : "#333") : (darkMode ? "#1f2937" : "#e5e7eb"), color: (profile.startDayOffset || 0) === 0 ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280") }}>{profile.language === "de" ? "Heute" : "Today"}</button><button onClick={() => { setProfile({ ...profile, startDayOffset: -1 }); saveSetting("startDayOffset", -1); const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() - 1); setCurrentWeekStart(d); }} style={{ padding: "4px 10px", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: profile.startDayOffset === -1 ? 700 : 400, background: profile.startDayOffset === -1 ? (darkMode ? "#374151" : "#333") : (darkMode ? "#1f2937" : "#e5e7eb"), color: profile.startDayOffset === -1 ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280") }}>{profile.language === "de" ? "Gestern" : "Yesterday"}</button></div></div>
|
||
{/* Display */}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}><span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>{profile.language === "de" ? "Anzeige" : "Display"}</span><div style={{ display: "flex", gap: "4px" }}><button onClick={() => setDarkMode(false)} style={{ padding: "4px 10px", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer", display: "flex", alignItems: "center", gap: "4px", fontWeight: !darkMode ? 700 : 400, background: !darkMode ? "#333" : (darkMode ? "#1f2937" : "#e5e7eb"), color: !darkMode ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280") }}><Sun size={12} /> {profile.language === "de" ? "Hell" : "Light"}</button><button onClick={() => setDarkMode(true)} style={{ padding: "4px 10px", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer", display: "flex", alignItems: "center", gap: "4px", fontWeight: darkMode ? 700 : 400, background: darkMode ? "#374151" : "#e5e7eb", color: darkMode ? "#fff" : "#6b7280" }}><Moon size={12} /> {profile.language === "de" ? "Dunkel" : "Dark"}</button></div></div>
|
||
<div style={{ borderTop: `1px solid ${darkMode ? "#333" : "#e5e7eb"}`, paddingTop: "4px" }} />
|
||
{/* Undo / Redo / Refresh */}
|
||
<div style={{ display: "flex", justifyContent: "center", gap: "8px", padding: "2px 0" }}>
|
||
<button onClick={handleUndo} disabled={undoCount === 0} style={{ ...qsBtnStyle(darkMode), opacity: undoCount === 0 ? 0.3 : 1 }} title="Undo"><Undo2 size={15} /></button>
|
||
<button onClick={handleRedo} disabled={redoCount === 0} style={{ ...qsBtnStyle(darkMode), opacity: redoCount === 0 ? 0.3 : 1 }} title="Redo"><Redo2 size={15} /></button>
|
||
<button onClick={() => { fetchCalendarEvents(true); fetchTasks(); }} style={qsBtnStyle(darkMode)} title="Refresh"><RefreshCcw size={15} /></button>
|
||
<button onClick={() => setShowWeekPrintModal(true)} style={qsBtnStyle(darkMode)} title={profile.language === "de" ? "Drucken" : "Print"}><Printer size={15} /></button>
|
||
</div>
|
||
<div style={{ flex: 1 }} />
|
||
</div>
|
||
) : (
|
||
// ── COLLAPSED MODE — icon strip ──
|
||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", paddingTop: compactRail ? "4px" : "6px", paddingBottom: compactRail ? "4px" : "8px", height: "100%", overflowY: compactRail ? "hidden" : "auto", overflowX: "visible" }}>
|
||
{/* Expand toggle */}
|
||
<button onClick={() => setLeftRailExpanded(true)} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Erweitern" : "Expand"}><PanelLeftOpen size={16} /></button>
|
||
{railSep}
|
||
{/* Navigation flyout */}
|
||
<button style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Navigation" : "Navigation"} onMouseEnter={(e) => openFlyout("nav", e)} onMouseLeave={closeFlyoutDelayed}><ArrowLeftRight size={15} /></button>
|
||
{railSep}
|
||
{/* View flyout */}
|
||
<button style={{ ...railIconBtnStyle, color: "var(--accent)" }} title={profile.language === "de" ? "Ansicht" : "View"} onMouseEnter={(e) => openFlyout("view", e)} onMouseLeave={closeFlyoutDelayed}>
|
||
{profile.viewStyle === "simple" ? <Calendar size={15} /> : profile.viewStyle === "calendar" ? <CalendarDays size={15} /> : profile.viewStyle === "list" ? <ListTodo size={15} /> : profile.viewStyle === "kanban" ? <Kanban size={15} /> : <Target size={15} />}
|
||
</button>
|
||
{/* Columns flyout */}
|
||
<button style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Spalten" : "Columns"} onMouseEnter={(e) => openFlyout("columns", e)} onMouseLeave={closeFlyoutDelayed}><LayoutGrid size={15} /></button>
|
||
{/* Slot flyout */}
|
||
{profile.showTimeGrid && (<button style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Zeitfenster" : "Slot"} onMouseEnter={(e) => openFlyout("slot", e)} onMouseLeave={closeFlyoutDelayed}><Clock size={15} /></button>)}
|
||
{railSep}
|
||
{/* Quick Actions */}
|
||
<button onClick={() => setIsSearchOpen(true)} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Suche" : "Search"}><Search size={15} /></button>
|
||
<button onClick={() => setShowDatePicker(true)} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Datum wählen" : "Jump to date"}><CalendarDays size={15} /></button>
|
||
<button onClick={() => { const now = new Date(); setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` }); }} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Kalendereintrag" : "Calendar Event"}><Plus size={15} /></button>
|
||
<button onClick={() => setShowProjectsSidebar(true)} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Projekte" : "Projects"}><FolderOpen size={15} /></button>
|
||
<button onClick={() => setIsRecurringTasksOpen(true)} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Wiederkehrend" : "Recurring"}><Repeat size={15} /></button>
|
||
<button onClick={() => { const nv = !profile.showNextTask; setShowNextTask(nv); saveSetting("showNextTask", nv); }} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Ziel" : "Goal"}>{profile.showNextTask ? <Play size={15} /> : <Target size={15} />}</button>
|
||
<button onClick={() => setShowFocusMode(true)} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Fokus" : "Focus"}><Zap size={15} /></button>
|
||
{railSep}
|
||
{/* Visibility + Appearance merged flyout */}
|
||
<button style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Darstellung" : "Display"} onMouseEnter={(e) => openFlyout("vis-display", e)} onMouseLeave={closeFlyoutDelayed}><Eye size={15} /></button>
|
||
{railSep}
|
||
{/* History flyout (undo / redo) */}
|
||
<button style={{ ...railIconBtnStyle, opacity: undoCount === 0 && redoCount === 0 ? 0.5 : 1 }} title={profile.language === "de" ? "Verlauf" : "History"} onMouseEnter={(e) => openFlyout("history", e)} onMouseLeave={closeFlyoutDelayed}><Undo2 size={15} /></button>
|
||
{/* Refresh + Print */}
|
||
<button onClick={() => { fetchCalendarEvents(true); fetchTasks(); }} style={{ ...railIconBtnStyle }} title="Refresh"><RefreshCcw size={15} /></button>
|
||
<button onClick={() => setShowWeekPrintModal(true)} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Drucken" : "Print"}><Printer size={15} /></button>
|
||
<div style={{ flex: 1 }} />
|
||
{railSep}
|
||
{/* Settings (cogwheel) + User menu at bottom */}
|
||
<button onClick={() => setShowSettings(true)} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Einstellungen" : "Settings"}><Settings size={15} /></button>
|
||
<div style={{ position: "relative" }}>
|
||
<button onClick={() => setShowRailUserMenu(!showRailUserMenu)} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Benutzerprofil" : "User profile"}><User size={15} /></button>
|
||
{showRailUserMenu && (
|
||
<>
|
||
<div style={{ position: "fixed", inset: 0, zIndex: 998 }} onClick={() => setShowRailUserMenu(false)} />
|
||
<div style={{ position: "fixed", left: "52px", bottom: "60px", width: "200px", background: darkMode ? "#1f2937" : "#ffffff", border: "1px solid var(--line)", borderRadius: "10px", boxShadow: "2px 4px 16px rgba(0,0,0,0.15)", zIndex: 999, overflow: "hidden" }}>
|
||
<div style={{ padding: "10px 14px", borderBottom: "1px solid var(--line)" }}>
|
||
<p style={{ fontSize: "0.8rem", fontWeight: 600, color: darkMode ? "#e5e7eb" : "#374151", margin: 0 }}>{profile.language === "de" ? "Angemeldet als" : "Signed in as"}</p>
|
||
<p style={{ fontSize: "0.75rem", color: darkMode ? "#9ca3af" : "#6b7280", margin: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{session?.user?.email || "User"}</p>
|
||
</div>
|
||
<button onClick={() => { setActiveTab("account"); setShowSettings(true); setShowRailUserMenu(false); }} style={{ width: "100%", textAlign: "left", padding: "8px 14px", fontSize: "0.85rem", color: darkMode ? "#e5e7eb" : "#374151", background: "none", border: "none", cursor: "pointer", display: "flex", alignItems: "center", gap: "8px" }}
|
||
onMouseEnter={(e) => (e.currentTarget.style.background = darkMode ? "#374151" : "#f3f4f6")}
|
||
onMouseLeave={(e) => (e.currentTarget.style.background = "none")}>
|
||
<Settings size={15} style={{ opacity: 0.5 }} />
|
||
{profile.language === "de" ? "Einstellungen" : "Settings"}
|
||
</button>
|
||
<div style={{ borderTop: "1px solid var(--line)", margin: "2px 0" }} />
|
||
<button onClick={() => signOut()} style={{ width: "100%", textAlign: "left", padding: "8px 14px", fontSize: "0.85rem", color: "#ef4444", background: "none", border: "none", cursor: "pointer", display: "flex", alignItems: "center", gap: "8px" }}
|
||
onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(239,68,68,0.08)")}
|
||
onMouseLeave={(e) => (e.currentTarget.style.background = "none")}>
|
||
<LogOut size={15} style={{ opacity: 0.7 }} />
|
||
{profile.language === "de" ? "Abmelden" : "Sign Out"}
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Flyout panels — rendered outside the rail so they can overflow */}
|
||
{!leftRailExpanded && flyoutSection && (
|
||
<div style={{ ...flyoutPanelStyle }} onMouseEnter={keepFlyoutOpen} onMouseLeave={closeFlyoutDelayed}>
|
||
{flyoutSection === "nav" && (
|
||
<>
|
||
<div style={flyoutLabelStyle}>{profile.language === "de" ? "Navigation" : "Navigation"}</div>
|
||
<div style={{ display: "flex", gap: "4px" }}>
|
||
<button onClick={goToPrevWeek} style={{ flex: 1, padding: "6px 0", borderRadius: "6px", border: "none", cursor: "pointer", background: darkMode ? "#1f2937" : "#e5e7eb", color: darkMode ? "#9ca3af" : "var(--ink-3)", display: "flex", alignItems: "center", justifyContent: "center" }} title={profile.language === "de" ? "Vorherige Woche" : "Prev Week"}><ChevronsLeft size={14} /></button>
|
||
<button onClick={goToPrevDay} style={{ flex: 1, padding: "6px 0", borderRadius: "6px", border: "none", cursor: "pointer", background: darkMode ? "#1f2937" : "#e5e7eb", color: darkMode ? "#9ca3af" : "var(--ink-3)", display: "flex", alignItems: "center", justifyContent: "center" }} title={profile.language === "de" ? "Vorheriger Tag" : "Prev Day"}><ChevronLeft size={14} /></button>
|
||
<button onClick={goToToday} style={{ flex: 2, padding: "6px 0", borderRadius: "6px", border: "none", cursor: "pointer", background: darkMode ? "#374151" : "var(--ink)", color: "#fff", fontSize: "0.55rem", fontWeight: 800, letterSpacing: "0.05em" }} title="Today">{profile.language === "de" ? "HEUTE" : "TODAY"}</button>
|
||
<button onClick={goToNextDay} style={{ flex: 1, padding: "6px 0", borderRadius: "6px", border: "none", cursor: "pointer", background: darkMode ? "#1f2937" : "#e5e7eb", color: darkMode ? "#9ca3af" : "var(--ink-3)", display: "flex", alignItems: "center", justifyContent: "center" }} title={profile.language === "de" ? "Nächster Tag" : "Next Day"}><ChevronRight size={14} /></button>
|
||
<button onClick={goToNextWeek} style={{ flex: 1, padding: "6px 0", borderRadius: "6px", border: "none", cursor: "pointer", background: darkMode ? "#1f2937" : "#e5e7eb", color: darkMode ? "#9ca3af" : "var(--ink-3)", display: "flex", alignItems: "center", justifyContent: "center" }} title={profile.language === "de" ? "Nächste Woche" : "Next Week"}><ChevronsRight size={14} /></button>
|
||
</div>
|
||
</>
|
||
)}
|
||
{flyoutSection === "history" && (
|
||
<>
|
||
<div style={flyoutLabelStyle}>{profile.language === "de" ? "Verlauf" : "History"}</div>
|
||
<div style={{ display: "flex", gap: "4px" }}>
|
||
<button onClick={handleUndo} disabled={undoCount === 0} style={{ flex: 1, padding: "6px 0", borderRadius: "6px", border: "none", cursor: undoCount === 0 ? "default" : "pointer", background: darkMode ? "#1f2937" : "#e5e7eb", color: darkMode ? "#9ca3af" : "var(--ink-3)", display: "flex", alignItems: "center", justifyContent: "center", opacity: undoCount === 0 ? 0.3 : 1 }} title="Undo"><Undo2 size={14} /></button>
|
||
<button onClick={handleRedo} disabled={redoCount === 0} style={{ flex: 1, padding: "6px 0", borderRadius: "6px", border: "none", cursor: redoCount === 0 ? "default" : "pointer", background: darkMode ? "#1f2937" : "#e5e7eb", color: darkMode ? "#9ca3af" : "var(--ink-3)", display: "flex", alignItems: "center", justifyContent: "center", opacity: redoCount === 0 ? 0.3 : 1 }} title="Redo"><Redo2 size={14} /></button>
|
||
</div>
|
||
</>
|
||
)}
|
||
{flyoutSection === "view" && (
|
||
<>
|
||
<div style={flyoutLabelStyle}>{profile.language === "de" ? "Ansicht" : "View"}</div>
|
||
<div style={{ display: "flex", gap: "4px" }}>{[{ key: "simple", icon: <Calendar size={13} />, label: profile.language === "de" ? "Einfach" : "Simple" }, { key: "calendar", icon: <CalendarDays size={13} />, label: profile.language === "de" ? "Kalender" : "Calendar" }, { key: "list", icon: <ListTodo size={13} />, label: profile.language === "de" ? "Liste" : "List" }, { key: "kanban", icon: <Kanban size={13} />, label: "Kanban" }, { key: "priority", icon: <Target size={13} />, label: profile.language === "de" ? "Priorität" : "Priority" }].map((v) => (<button key={v.key} onClick={() => { setViewStyle(v.key as any); saveSetting("viewStyle", v.key); if (v.key === "list") { setShowTimeGrid(false); saveSetting("showTimeGrid", false); } if (v.key === "simple" || v.key === "calendar") { setShowTimeGrid(true); saveSetting("showTimeGrid", true); } if (v.key === "priority" || v.key === "kanban") { setShowTimeGrid(false); saveSetting("showTimeGrid", false); } setFlyoutSection(null); }} title={v.label} style={{ flex: 1, padding: "6px 0", borderRadius: "6px", border: "none", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", background: profile.viewStyle === v.key ? "var(--accent)" : (darkMode ? "#1f2937" : "#e5e7eb"), color: profile.viewStyle === v.key ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)") }}>{v.icon}</button>))}</div>
|
||
</>
|
||
)}
|
||
{flyoutSection === "columns" && (
|
||
<>
|
||
<div style={flyoutLabelStyle}>{profile.language === "de" ? "Spalten" : "Days"}</div>
|
||
<div style={{ display: "flex", gap: "4px" }}>{[1, 2, 3, 5, 7].map((num) => (<button key={num} onClick={() => { setViewDays(num); savedViewDaysRef.current = num; saveSetting("viewDays", num); }} style={{ flex: 1, padding: "5px 0", fontSize: "0.8rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: viewDays === num ? 700 : 400, background: viewDays === num ? (darkMode ? "#374151" : "var(--ink)") : (darkMode ? "#1f2937" : "#e5e7eb"), color: viewDays === num ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)") }}>{num}</button>))}</div>
|
||
</>
|
||
)}
|
||
{flyoutSection === "slot" && (
|
||
<>
|
||
<div style={flyoutLabelStyle}>{profile.language === "de" ? "Zeitfenster" : "Slot Duration"}</div>
|
||
<div style={{ display: "flex", gap: "4px" }}>{(([15, 20, 30, 60] as CellDuration[])).map((d) => (<button key={d} onClick={() => { setCellDuration(d); saveSetting("cellDuration", d); saveViewSetting("cellDuration", d, true); }} style={{ flex: 1, padding: "5px 0", fontSize: "0.8rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: effectiveCellDuration === d ? 700 : 400, background: effectiveCellDuration === d ? (darkMode ? "#374151" : "var(--ink)") : (darkMode ? "#1f2937" : "#e5e7eb"), color: effectiveCellDuration === d ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)") }}>{d}m</button>))}</div>
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}><span style={{ fontSize: "0.75rem", color: darkMode ? "#9ca3af" : "#6b7280" }}>:15/:30/:45</span><button onClick={() => { saveViewSetting("showSubHourSlots", !effectiveShowSubHourSlots, true); }} style={{ background: "none", border: "none", cursor: "pointer", color: darkMode ? "#9ca3af" : "#6b7280" }}>{effectiveShowSubHourSlots ? <Eye size={15} /> : <EyeOff size={15} />}</button></div>
|
||
</>
|
||
)}
|
||
{flyoutSection === "vis-display" && (
|
||
<>
|
||
<div style={flyoutLabelStyle}>{profile.language === "de" ? "Sichtbarkeit" : "Visibility"}</div>
|
||
{[{ label: profile.language === "de" ? "Irgendwann" : "Someday", value: effectiveShowSomeday, toggle: () => saveViewSetting("showSomeday", !effectiveShowSomeday, true) }, { label: profile.language === "de" ? "Ganztägig" : "All-day", value: effectiveShowAllDay, toggle: () => saveViewSetting("showAllDayEvents", !effectiveShowAllDay, true) }, { label: profile.language === "de" ? "Checkboxen" : "Checkboxes", value: effectiveShowTaskCheckboxes, toggle: () => saveViewSetting("showTaskCheckboxes", !effectiveShowTaskCheckboxes, true) }, { label: profile.language === "de" ? "Projekt-Icons" : "Project Icons", value: effectiveShowProjectIcons, toggle: () => saveViewSetting("showProjectIcons", !effectiveShowProjectIcons, true) }, { label: profile.language === "de" ? "Prioritäts-Icons" : "Priority Icons", value: effectiveShowPriorityIcons, toggle: () => saveViewSetting("showPriorityIcons", !effectiveShowPriorityIcons, true) }, ...((profile.weatherEnabled || profile.weatherLat || profile.weatherLon) ? [{ label: profile.language === "de" ? "Wetter" : "Weather", value: effectiveWeatherEnabled, toggle: () => { const v = !effectiveWeatherEnabled; setProfile((p: any) => ({ ...p, weatherEnabled: v })); saveViewSetting("weatherEnabled", v, true); } }] : []) ].map(({ label, value, toggle }) => (<div key={label} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: "16px" }}><span style={{ fontSize: "0.8rem", color: darkMode ? "#d1d5db" : "#333", whiteSpace: "nowrap" }}>{label}</span><button onClick={toggle} style={{ background: "none", border: "none", cursor: "pointer", color: darkMode ? "#9ca3af" : "#6b7280", flexShrink: 0 }}>{value ? <Eye size={15} /> : <EyeOff size={15} />}</button></div>))}
|
||
<div style={{ borderTop: "1px solid var(--line)", margin: "6px 0" }} />
|
||
<div style={flyoutLabelStyle}>{profile.language === "de" ? "Darstellung" : "Appearance"}</div>
|
||
<div><div style={{ fontSize: "0.7rem", color: darkMode ? "#9ca3af" : "#6b7280", marginBottom: "4px" }}>{profile.language === "de" ? "Textgröße" : "Text size"}</div><div style={{ display: "flex", gap: "4px" }}>{(["S", "M", "L"] as const).map((size) => (<button key={size} onClick={() => { setFontSize(size); saveSetting("fontSize", size); }} style={{ flex: 1, padding: "4px 0", fontSize: "0.8rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: profile.fontSize === size ? 700 : 400, background: profile.fontSize === size ? (darkMode ? "#374151" : "#333") : (darkMode ? "#1f2937" : "#e5e7eb"), color: profile.fontSize === size ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280") }}>{size}</button>))}</div></div>
|
||
<div><div style={{ fontSize: "0.7rem", color: darkMode ? "#9ca3af" : "#6b7280", marginBottom: "4px" }}>{profile.language === "de" ? "Starten mit" : "Start on"}</div><div style={{ display: "flex", gap: "4px" }}><button onClick={() => { setProfile({ ...profile, startDayOffset: 0 }); saveSetting("startDayOffset", 0); const d = new Date(); d.setHours(0, 0, 0, 0); setCurrentWeekStart(d); }} style={{ flex: 1, padding: "4px 0", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: (profile.startDayOffset || 0) === 0 ? 700 : 400, background: (profile.startDayOffset || 0) === 0 ? (darkMode ? "#374151" : "#333") : (darkMode ? "#1f2937" : "#e5e7eb"), color: (profile.startDayOffset || 0) === 0 ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280") }}>{profile.language === "de" ? "Heute" : "Today"}</button><button onClick={() => { setProfile({ ...profile, startDayOffset: -1 }); saveSetting("startDayOffset", -1); const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() - 1); setCurrentWeekStart(d); }} style={{ flex: 1, padding: "4px 0", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: profile.startDayOffset === -1 ? 700 : 400, background: profile.startDayOffset === -1 ? (darkMode ? "#374151" : "#333") : (darkMode ? "#1f2937" : "#e5e7eb"), color: profile.startDayOffset === -1 ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280") }}>{profile.language === "de" ? "Gestern" : "Yesterday"}</button></div></div>
|
||
<div><div style={{ fontSize: "0.7rem", color: darkMode ? "#9ca3af" : "#6b7280", marginBottom: "4px" }}>{profile.language === "de" ? "Anzeige" : "Display"}</div><div style={{ display: "flex", gap: "4px" }}><button onClick={() => setDarkMode(false)} style={{ flex: 1, padding: "4px 0", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: "4px", fontWeight: !darkMode ? 700 : 400, background: !darkMode ? "#333" : (darkMode ? "#1f2937" : "#e5e7eb"), color: !darkMode ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280") }}><Sun size={12} /> {profile.language === "de" ? "Hell" : "Light"}</button><button onClick={() => setDarkMode(true)} style={{ flex: 1, padding: "4px 0", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: "4px", fontWeight: darkMode ? 700 : 400, background: darkMode ? "#374151" : "#e5e7eb", color: darkMode ? "#fff" : "#6b7280" }}><Moon size={12} /> {profile.language === "de" ? "Dunkel" : "Dark"}</button></div></div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* Projects Sidebar */}
|
||
{showProjectsSidebar && (
|
||
<ProjectsSidebar
|
||
darkMode={darkMode}
|
||
language={profile.language}
|
||
projects={projects}
|
||
onProjectsChanged={fetchProjects}
|
||
onClose={() => setShowProjectsSidebar(false)}
|
||
tasks={[...tasks, ...somedayLists.flatMap(l => l.tasks)]}
|
||
activeProjectFilter={activeProjectFilter}
|
||
onSetProjectFilter={setActiveProjectFilter}
|
||
/>
|
||
)}
|
||
|
||
{/* View Transitions Style Block */}
|
||
<style
|
||
dangerouslySetInnerHTML={{
|
||
__html: (() => {
|
||
// Generate View Transition styles for a wide range of days around current view
|
||
// to ensure both entering and exiting days have the 500ms duration.
|
||
const center = currentWeekStart;
|
||
const validNames = [];
|
||
// Cover +/- 2 weeks just to be safe (exiting days need styles too)
|
||
for (let i = -14; i <= 21; i++) {
|
||
const d = new Date(center);
|
||
d.setDate(d.getDate() + i);
|
||
validNames.push(
|
||
`day-${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`,
|
||
);
|
||
}
|
||
|
||
return validNames
|
||
.map(
|
||
(name) => `
|
||
::view-transition-group(${name}) {
|
||
animation-duration: 0.5s;
|
||
animation-timing-function: ease-in-out;
|
||
}
|
||
`,
|
||
)
|
||
.join("");
|
||
})(),
|
||
}}
|
||
/>
|
||
|
||
{/* Mobile Header */}
|
||
{isMobile && (
|
||
<header className="mobile-header" style={{ background: darkMode ? "#111827" : "#ffffff", borderBottom: `1px solid ${darkMode ? "#374151" : "#e5e7eb"}` }}>
|
||
{/* Left: Icon rail toggle */}
|
||
<button className="mobile-header-btn" onClick={() => { setShowMobileRail(v => !v); setFlyoutSection(null); }} title="Icon Rail" style={{ color: darkMode ? "#e5e7eb" : "#6b7280" }}>
|
||
<PanelLeftOpen size={22} />
|
||
</button>
|
||
|
||
{/* Center: Date display (tap to jump to date) + fixed-width sync slot */}
|
||
<button className="mobile-header-date" onClick={() => setShowDatePicker(true)} style={{
|
||
fontFamily: profile.cwFontFamily || "Inter",
|
||
fontSize: scaleRem(profile.cwFontSize || "0.9rem"),
|
||
fontWeight: Number(profile.cwFontWeight || "700"),
|
||
color: adjustColorForDarkMode(profile.cwColor || (darkMode ? "#e5e7eb" : "#333333"), darkMode),
|
||
}}>
|
||
<span>
|
||
{(() => {
|
||
const visibleDays = getVisibleDays();
|
||
const mobileDisplay = isPortrait
|
||
? (profile.mobilePortraitHeaderDisplay || "current_day")
|
||
: (profile.mobileLandscapeHeaderDisplay || profile.headerDisplay || "kw");
|
||
if (mobileDisplay === "none") return "";
|
||
if (mobileDisplay === "current_day") {
|
||
const fmt = profile.headerCurrentDayFormat || "DDD, DD. MMMM YYYY";
|
||
return formatCustomHeader(fmt, visibleDays, profile.language, t, getSelectedDay(visibleDays, selectedDay));
|
||
}
|
||
if (mobileDisplay === "date") return new Date().toLocaleDateString(profile.language, { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||
if (mobileDisplay === "month_year") return getCWReferenceDate(visibleDays).toLocaleDateString(profile.language, { month: 'long', year: 'numeric' });
|
||
if (mobileDisplay === "month") return getCWReferenceDate(visibleDays).toLocaleDateString(profile.language, { month: "long" });
|
||
if (mobileDisplay === "custom") return formatCustomHeader(profile.headerCustomFormat || "KW WW | YYYY", visibleDays, profile.language, t);
|
||
return `KW ${getWeekNumber(getCWReferenceDate(visibleDays)).toString().padStart(2, "0")} | ${getCWReferenceDate(visibleDays).getFullYear()}`;
|
||
})()}
|
||
</span>
|
||
<ChevronDown size={12} style={{ opacity: 0.5 }} />
|
||
</button>
|
||
|
||
{/* Sync status indicator — fixed width so date never shifts */}
|
||
<div style={{ width: 24, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
|
||
{syncError
|
||
? <span role="img" aria-label={syncError ?? 'Sync error'} style={{ display: 'inline-flex' }}><AlertCircle size={14} className="text-red-500" aria-hidden="true" /></span>
|
||
: (isLoading || isSyncing || syncStatus === "syncing")
|
||
? <div className="weekly-spinner" role="status" aria-label="Syncing..." style={{ width: 14, height: 14 }}></div>
|
||
: null}
|
||
</div>
|
||
|
||
{/* Right: Settings menu */}
|
||
<button className="mobile-header-btn" onClick={() => setShowSettings(true)} title="Settings" style={{ color: darkMode ? "#e5e7eb" : "#6b7280" }}>
|
||
<Menu size={22} />
|
||
</button>
|
||
</header>
|
||
)}
|
||
|
||
{/* Mobile left sidebar (icon rail — mirrors desktop collapsed rail) */}
|
||
{isMobile && showMobileRail && (
|
||
<>
|
||
<div
|
||
className="mobile-rail-backdrop"
|
||
onClick={() => { setShowMobileRail(false); setFlyoutSection(null); }}
|
||
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.35)", zIndex: 200 }}
|
||
/>
|
||
<div className="mobile-left-sidebar" style={{
|
||
position: "fixed", left: 0, top: 0, bottom: 0, width: "60px",
|
||
background: darkMode ? "#1a1a2e" : "var(--paper)",
|
||
borderRight: `1px solid var(--line)`,
|
||
zIndex: 201, display: "flex", flexDirection: "column",
|
||
alignItems: "center", paddingTop: "6px", paddingBottom: "8px", gap: "2px",
|
||
overflowY: "auto",
|
||
}}>
|
||
{/* Collapse — same icon as header toggle */}
|
||
<button className="mobile-rail-btn" onClick={() => { setShowMobileRail(false); setFlyoutSection(null); }} style={{ ...railIconBtnStyle }} title="Close"><PanelLeftOpen size={16} /></button>
|
||
{railSep}
|
||
{/* Navigation flyout */}
|
||
<button className="mobile-rail-btn" style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Navigation" : "Navigation"} onClick={(e) => { const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); setFlyoutY(rect.top); setFlyoutSection(flyoutSection === "nav" ? null : "nav"); }}><ArrowLeftRight size={15} /></button>
|
||
{railSep}
|
||
{/* View flyout */}
|
||
<button className="mobile-rail-btn" style={{ ...railIconBtnStyle, color: "var(--accent)" }} title={profile.language === "de" ? "Ansicht" : "View"} onClick={(e) => { const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); setFlyoutY(rect.top); setFlyoutSection(flyoutSection === "view" ? null : "view"); }}>
|
||
{profile.viewStyle === "simple" ? <Calendar size={15} /> : profile.viewStyle === "calendar" ? <CalendarDays size={15} /> : profile.viewStyle === "list" ? <ListTodo size={15} /> : profile.viewStyle === "kanban" ? <Kanban size={15} /> : <Target size={15} />}
|
||
</button>
|
||
{/* Columns flyout */}
|
||
<button className="mobile-rail-btn" style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Spalten" : "Columns"} onClick={(e) => { const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); setFlyoutY(rect.top); setFlyoutSection(flyoutSection === "columns" ? null : "columns"); }}><LayoutGrid size={15} /></button>
|
||
{/* Slot flyout */}
|
||
{profile.showTimeGrid && (<button className="mobile-rail-btn" style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Zeitfenster" : "Slot"} onClick={(e) => { const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); setFlyoutY(rect.top); setFlyoutSection(flyoutSection === "slot" ? null : "slot"); }}><Clock size={15} /></button>)}
|
||
{railSep}
|
||
{/* Quick Actions */}
|
||
<button className="mobile-rail-btn" onClick={() => { setIsSearchOpen(true); setShowMobileRail(false); setFlyoutSection(null); }} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Suche" : "Search"}><Search size={15} /></button>
|
||
<button className="mobile-rail-btn" onClick={() => { setShowDatePicker(true); setShowMobileRail(false); setFlyoutSection(null); }} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Datum wählen" : "Jump to date"}><CalendarDays size={15} /></button>
|
||
<button className="mobile-rail-btn" onClick={() => { const now = new Date(); setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` }); setShowMobileRail(false); setFlyoutSection(null); }} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Kalendereintrag" : "Calendar Event"}><Plus size={15} /></button>
|
||
<button className="mobile-rail-btn" onClick={() => { setShowProjectsSidebar(true); setShowMobileRail(false); setFlyoutSection(null); }} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Projekte" : "Projects"}><FolderOpen size={15} /></button>
|
||
<button className="mobile-rail-btn" onClick={() => { setIsRecurringTasksOpen(true); setShowMobileRail(false); setFlyoutSection(null); }} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Wiederkehrend" : "Recurring"}><Repeat size={15} /></button>
|
||
<button className="mobile-rail-btn" onClick={() => { const nv = !profile.showNextTask; setShowNextTask(nv); saveSetting("showNextTask", nv); }} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Ziel" : "Goal"}>{profile.showNextTask ? <Play size={15} /> : <Target size={15} />}</button>
|
||
<button className="mobile-rail-btn" onClick={() => { setShowFocusMode(true); setShowMobileRail(false); setFlyoutSection(null); }} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Fokus" : "Focus"}><Zap size={15} /></button>
|
||
{railSep}
|
||
{/* Visibility + Appearance merged flyout */}
|
||
<button className="mobile-rail-btn" style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Darstellung" : "Display"} onClick={(e) => { const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); setFlyoutY(rect.top); setFlyoutSection(flyoutSection === "vis-display" ? null : "vis-display"); }}><Eye size={15} /></button>
|
||
{railSep}
|
||
{/* History flyout (undo / redo) */}
|
||
<button className="mobile-rail-btn" style={{ ...railIconBtnStyle, opacity: undoCount === 0 && redoCount === 0 ? 0.5 : 1 }} title={profile.language === "de" ? "Verlauf" : "History"} onClick={(e) => { const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); setFlyoutY(rect.top); setFlyoutSection(flyoutSection === "history" ? null : "history"); }}><Undo2 size={15} /></button>
|
||
{/* Refresh */}
|
||
<button className="mobile-rail-btn" onClick={() => { fetchCalendarEvents(true); fetchTasks(); setShowMobileRail(false); setFlyoutSection(null); }} style={{ ...railIconBtnStyle }} title="Refresh"><RefreshCcw size={15} /></button>
|
||
<button className="mobile-rail-btn" onClick={() => setDarkMode(!darkMode)} style={{ ...railIconBtnStyle }} title={darkMode ? "Light Mode" : "Dark Mode"}>{darkMode ? <Sun size={15} /> : <Moon size={15} />}</button>
|
||
<div style={{ flex: 1 }} />
|
||
{railSep}
|
||
{/* Quick Settings + full Settings + User/logout */}
|
||
<button className="mobile-rail-btn" onClick={() => { setShowQuickSettings(true); setShowMobileRail(false); setFlyoutSection(null); }} style={{ ...railIconBtnStyle }} title="Quick Settings"><SlidersHorizontal size={15} /></button>
|
||
<button className="mobile-rail-btn" onClick={() => { setShowSettings(true); setShowMobileRail(false); setFlyoutSection(null); }} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Einstellungen" : "Settings"}><Settings size={15} /></button>
|
||
<button className="mobile-rail-btn" onClick={() => signOut()} style={{ ...railIconBtnStyle, color: "#ef4444" }} title={profile.language === "de" ? "Abmelden" : "Sign out"}><LogOut size={15} /></button>
|
||
</div>
|
||
{/* Mobile flyout panel — positioned right of the 60px rail */}
|
||
{flyoutSection && (
|
||
<>
|
||
<div onClick={() => setFlyoutSection(null)} style={{ position: "fixed", inset: 0, zIndex: 202 }} />
|
||
<div style={{ ...flyoutPanelStyle, left: "68px", zIndex: 203 }} onClick={(e) => e.stopPropagation()}>
|
||
{flyoutSection === "nav" && (
|
||
<>
|
||
<div style={flyoutLabelStyle}>{profile.language === "de" ? "Navigation" : "Navigation"}</div>
|
||
<div style={{ display: "flex", gap: "4px" }}>
|
||
<button onClick={goToPrevWeek} style={{ flex: 1, padding: "6px 0", borderRadius: "6px", border: "none", cursor: "pointer", background: darkMode ? "#1f2937" : "#e5e7eb", color: darkMode ? "#9ca3af" : "var(--ink-3)", display: "flex", alignItems: "center", justifyContent: "center" }} title={profile.language === "de" ? "Vorherige Woche" : "Prev Week"}><ChevronsLeft size={14} /></button>
|
||
<button onClick={goToPrevDay} style={{ flex: 1, padding: "6px 0", borderRadius: "6px", border: "none", cursor: "pointer", background: darkMode ? "#1f2937" : "#e5e7eb", color: darkMode ? "#9ca3af" : "var(--ink-3)", display: "flex", alignItems: "center", justifyContent: "center" }} title={profile.language === "de" ? "Vorheriger Tag" : "Prev Day"}><ChevronLeft size={14} /></button>
|
||
<button onClick={goToToday} style={{ flex: 2, padding: "6px 0", borderRadius: "6px", border: "none", cursor: "pointer", background: darkMode ? "#374151" : "var(--ink)", color: "#fff", fontSize: "0.55rem", fontWeight: 800, letterSpacing: "0.05em" }} title="Today">{profile.language === "de" ? "HEUTE" : "TODAY"}</button>
|
||
<button onClick={goToNextDay} style={{ flex: 1, padding: "6px 0", borderRadius: "6px", border: "none", cursor: "pointer", background: darkMode ? "#1f2937" : "#e5e7eb", color: darkMode ? "#9ca3af" : "var(--ink-3)", display: "flex", alignItems: "center", justifyContent: "center" }} title={profile.language === "de" ? "Nächster Tag" : "Next Day"}><ChevronRight size={14} /></button>
|
||
<button onClick={goToNextWeek} style={{ flex: 1, padding: "6px 0", borderRadius: "6px", border: "none", cursor: "pointer", background: darkMode ? "#1f2937" : "#e5e7eb", color: darkMode ? "#9ca3af" : "var(--ink-3)", display: "flex", alignItems: "center", justifyContent: "center" }} title={profile.language === "de" ? "Nächste Woche" : "Next Week"}><ChevronsRight size={14} /></button>
|
||
</div>
|
||
</>
|
||
)}
|
||
{flyoutSection === "history" && (
|
||
<>
|
||
<div style={flyoutLabelStyle}>{profile.language === "de" ? "Verlauf" : "History"}</div>
|
||
<div style={{ display: "flex", gap: "4px" }}>
|
||
<button onClick={handleUndo} disabled={undoCount === 0} style={{ flex: 1, padding: "6px 0", borderRadius: "6px", border: "none", cursor: undoCount === 0 ? "default" : "pointer", background: darkMode ? "#1f2937" : "#e5e7eb", color: darkMode ? "#9ca3af" : "var(--ink-3)", display: "flex", alignItems: "center", justifyContent: "center", opacity: undoCount === 0 ? 0.3 : 1 }} title="Undo"><Undo2 size={14} /></button>
|
||
<button onClick={handleRedo} disabled={redoCount === 0} style={{ flex: 1, padding: "6px 0", borderRadius: "6px", border: "none", cursor: redoCount === 0 ? "default" : "pointer", background: darkMode ? "#1f2937" : "#e5e7eb", color: darkMode ? "#9ca3af" : "var(--ink-3)", display: "flex", alignItems: "center", justifyContent: "center", opacity: redoCount === 0 ? 0.3 : 1 }} title="Redo"><Redo2 size={14} /></button>
|
||
</div>
|
||
</>
|
||
)}
|
||
{flyoutSection === "view" && (
|
||
<>
|
||
<div style={flyoutLabelStyle}>{profile.language === "de" ? "Ansicht" : "View"}</div>
|
||
<div style={{ display: "flex", gap: "4px" }}>{[{ key: "simple", icon: <Calendar size={13} />, label: profile.language === "de" ? "Einfach" : "Simple" }, { key: "calendar", icon: <CalendarDays size={13} />, label: profile.language === "de" ? "Kalender" : "Calendar" }, { key: "list", icon: <ListTodo size={13} />, label: profile.language === "de" ? "Liste" : "List" }, { key: "kanban", icon: <Kanban size={13} />, label: "Kanban" }, { key: "priority", icon: <Target size={13} />, label: profile.language === "de" ? "Priorität" : "Priority" }].map((v) => (<button key={v.key} onClick={() => { setViewStyle(v.key as any); saveSetting("viewStyle", v.key); if (v.key === "list") { setShowTimeGrid(false); saveSetting("showTimeGrid", false); } if (v.key === "simple" || v.key === "calendar") { setShowTimeGrid(true); saveSetting("showTimeGrid", true); } if (v.key === "priority" || v.key === "kanban") { setShowTimeGrid(false); saveSetting("showTimeGrid", false); } setFlyoutSection(null); }} title={v.label} style={{ flex: 1, padding: "6px 0", borderRadius: "6px", border: "none", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", background: profile.viewStyle === v.key ? "var(--accent)" : (darkMode ? "#1f2937" : "#e5e7eb"), color: profile.viewStyle === v.key ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)") }}>{v.icon}</button>))}</div>
|
||
</>
|
||
)}
|
||
{flyoutSection === "columns" && (
|
||
<>
|
||
<div style={flyoutLabelStyle}>{profile.language === "de" ? "Spalten" : "Days"}</div>
|
||
<div style={{ display: "flex", gap: "4px" }}>{[1, 2, 3, 5, 7].map((num) => (<button key={num} onClick={() => { setViewDays(num); savedViewDaysRef.current = num; saveSetting("viewDays", num); }} style={{ flex: 1, padding: "5px 0", fontSize: "0.8rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: viewDays === num ? 700 : 400, background: viewDays === num ? (darkMode ? "#374151" : "var(--ink)") : (darkMode ? "#1f2937" : "#e5e7eb"), color: viewDays === num ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)") }}>{num}</button>))}</div>
|
||
</>
|
||
)}
|
||
{flyoutSection === "slot" && (
|
||
<>
|
||
<div style={flyoutLabelStyle}>{profile.language === "de" ? "Zeitfenster" : "Slot Duration"}</div>
|
||
<div style={{ display: "flex", gap: "4px" }}>{(([15, 20, 30, 60] as CellDuration[])).map((d) => (<button key={d} onClick={() => { setCellDuration(d); saveSetting("cellDuration", d); saveViewSetting("cellDuration", d, true); }} style={{ flex: 1, padding: "5px 0", fontSize: "0.8rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: effectiveCellDuration === d ? 700 : 400, background: effectiveCellDuration === d ? (darkMode ? "#374151" : "var(--ink)") : (darkMode ? "#1f2937" : "#e5e7eb"), color: effectiveCellDuration === d ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)") }}>{d}m</button>))}</div>
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}><span style={{ fontSize: "0.75rem", color: darkMode ? "#9ca3af" : "#6b7280" }}>:15/:30/:45</span><button onClick={() => { saveViewSetting("showSubHourSlots", !effectiveShowSubHourSlots, true); }} style={{ background: "none", border: "none", cursor: "pointer", color: darkMode ? "#9ca3af" : "#6b7280" }}>{effectiveShowSubHourSlots ? <Eye size={15} /> : <EyeOff size={15} />}</button></div>
|
||
</>
|
||
)}
|
||
{flyoutSection === "vis-display" && (
|
||
<>
|
||
<div style={flyoutLabelStyle}>{profile.language === "de" ? "Sichtbarkeit" : "Visibility"}</div>
|
||
{[{ label: profile.language === "de" ? "Irgendwann" : "Someday", value: effectiveShowSomeday, toggle: () => saveViewSetting("showSomeday", !effectiveShowSomeday, true) }, { label: profile.language === "de" ? "Ganztägig" : "All-day", value: effectiveShowAllDay, toggle: () => saveViewSetting("showAllDayEvents", !effectiveShowAllDay, true) }, { label: profile.language === "de" ? "Checkboxen" : "Checkboxes", value: effectiveShowTaskCheckboxes, toggle: () => saveViewSetting("showTaskCheckboxes", !effectiveShowTaskCheckboxes, true) }, { label: profile.language === "de" ? "Projekt-Icons" : "Project Icons", value: effectiveShowProjectIcons, toggle: () => saveViewSetting("showProjectIcons", !effectiveShowProjectIcons, true) }, { label: profile.language === "de" ? "Prioritäts-Icons" : "Priority Icons", value: effectiveShowPriorityIcons, toggle: () => saveViewSetting("showPriorityIcons", !effectiveShowPriorityIcons, true) }, ...((profile.weatherEnabled || profile.weatherLat || profile.weatherLon) ? [{ label: profile.language === "de" ? "Wetter" : "Weather", value: effectiveWeatherEnabled, toggle: () => { const v = !effectiveWeatherEnabled; setProfile((p: any) => ({ ...p, weatherEnabled: v })); saveViewSetting("weatherEnabled", v, true); } }] : []) ].map(({ label, value, toggle }) => (<div key={label} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: "16px" }}><span style={{ fontSize: "0.8rem", color: darkMode ? "#d1d5db" : "#333", whiteSpace: "nowrap" }}>{label}</span><button onClick={toggle} style={{ background: "none", border: "none", cursor: "pointer", color: darkMode ? "#9ca3af" : "#6b7280", flexShrink: 0 }}>{value ? <Eye size={15} /> : <EyeOff size={15} />}</button></div>))}
|
||
<div style={{ borderTop: "1px solid var(--line)", margin: "6px 0" }} />
|
||
<div style={flyoutLabelStyle}>{profile.language === "de" ? "Darstellung" : "Appearance"}</div>
|
||
<div><div style={{ fontSize: "0.7rem", color: darkMode ? "#9ca3af" : "#6b7280", marginBottom: "4px" }}>{profile.language === "de" ? "Textgröße" : "Text size"}</div><div style={{ display: "flex", gap: "4px" }}>{(["S", "M", "L"] as const).map((size) => (<button key={size} onClick={() => { setFontSize(size); saveSetting("fontSize", size); }} style={{ flex: 1, padding: "4px 0", fontSize: "0.8rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: profile.fontSize === size ? 700 : 400, background: profile.fontSize === size ? (darkMode ? "#374151" : "#333") : (darkMode ? "#1f2937" : "#e5e7eb"), color: profile.fontSize === size ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280") }}>{size}</button>))}</div></div>
|
||
<div><div style={{ fontSize: "0.7rem", color: darkMode ? "#9ca3af" : "#6b7280", marginBottom: "4px" }}>{profile.language === "de" ? "Anzeige" : "Display"}</div><div style={{ display: "flex", gap: "4px" }}><button onClick={() => setDarkMode(false)} style={{ flex: 1, padding: "4px 0", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: "4px", fontWeight: !darkMode ? 700 : 400, background: !darkMode ? "#333" : (darkMode ? "#1f2937" : "#e5e7eb"), color: !darkMode ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280") }}><Sun size={12} /> {profile.language === "de" ? "Hell" : "Light"}</button><button onClick={() => setDarkMode(true)} style={{ flex: 1, padding: "4px 0", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: "4px", fontWeight: darkMode ? 700 : 400, background: darkMode ? "#374151" : "#e5e7eb", color: darkMode ? "#fff" : "#6b7280" }}><Moon size={12} /> {profile.language === "de" ? "Dunkel" : "Dark"}</button></div></div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* Desktop Header: Left, Center, Right */}
|
||
<header className="group relative flex items-center justify-between w-full px-4 py-1.5 border-b bg-white dark:bg-gray-900 dark:text-white transition-colors duration-200" style={isMobile ? { display: "none" } : { borderBottomColor: 'var(--line)', minHeight: useLeftRail ? (compactRail ? "40px" : "44px") : undefined }}>
|
||
{/* LEFT SECTION: View Switcher, Days, Hours, Slot Duration — only shown in top-toolbar mode */}
|
||
<div className={`weekly-header-controls flex items-center gap-4 transition-opacity duration-700 ${!useLeftRail ? "opacity-0 group-hover:opacity-100" : ""}`} style={{ zIndex: 1, display: useLeftRail ? "none" : undefined, ...(showIntroHints ? { opacity: 1 } : {}) }}>
|
||
{/* Quick-settings button (top mode only) */}
|
||
{!useLeftRail && (
|
||
<button
|
||
onClick={() => setShowQuickSettings(true)}
|
||
className="p-1.5 rounded text-gray-400 hover:text-gray-700 hover:bg-gray-100 dark:hover:text-white dark:hover:bg-gray-700 transition-colors"
|
||
title={profile.language === "de" ? "Einstellungen" : "Settings"}
|
||
>
|
||
<PanelLeftOpen size={16} />
|
||
</button>
|
||
)}
|
||
{/* View Switcher */}
|
||
<div className="flex items-center gap-0.5 bg-gray-100 dark:bg-gray-800 rounded p-0.5" title={t.viewStyle}>
|
||
<button
|
||
onClick={() => { setViewStyle("simple"); setShowTimeGrid(true); saveSetting("viewStyle", "simple"); saveSetting("showTimeGrid", true); }}
|
||
className={`p-1.5 rounded transition-colors ${profile.viewStyle ==="simple" ? "bg-white shadow-sm text-black dark:bg-gray-700 dark:text-white" : "text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700"}`}
|
||
title={t.simpleView}
|
||
>
|
||
<Calendar size={16} />
|
||
</button>
|
||
<button
|
||
onClick={() => { setViewStyle("calendar"); setShowTimeGrid(true); saveSetting("viewStyle", "calendar"); saveSetting("showTimeGrid", true); }}
|
||
className={`p-1.5 rounded transition-colors ${profile.viewStyle ==="calendar" ? "bg-white shadow-sm text-black dark:bg-gray-700 dark:text-white" : "text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700"}`}
|
||
title={t.calendarView}
|
||
>
|
||
<CalendarDays size={16} />
|
||
</button>
|
||
<button
|
||
onClick={() => { setViewStyle("list"); setShowTimeGrid(false); saveSetting("viewStyle", "list"); saveSetting("showTimeGrid", false); }}
|
||
className={`p-1.5 rounded transition-colors ${profile.viewStyle ==="list" ? "bg-white shadow-sm text-black dark:bg-gray-700 dark:text-white" : "text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700"}`}
|
||
title={t.listView}
|
||
>
|
||
<ListTodo size={16} />
|
||
</button>
|
||
<button
|
||
onClick={() => { setViewStyle("kanban"); saveSetting("viewStyle", "kanban"); setShowTimeGrid(false); saveSetting("showTimeGrid", false); }}
|
||
className={`p-1.5 rounded transition-colors ${profile.viewStyle ==="kanban" ? "bg-white shadow-sm text-black dark:bg-gray-700 dark:text-white" : "text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700"}`}
|
||
title={t.kanbanView}
|
||
>
|
||
<Kanban size={16} />
|
||
</button>
|
||
<button
|
||
onClick={() => { setViewStyle("priority"); saveSetting("viewStyle", "priority"); setShowTimeGrid(false); saveSetting("showTimeGrid", false); }}
|
||
className={`p-1.5 rounded transition-colors ${profile.viewStyle ==="priority" ? "bg-white shadow-sm text-black dark:bg-gray-700 dark:text-white" : "text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700"}`}
|
||
title={profile.language === "de" ? "Prioritäten" : "Priority View"}
|
||
>
|
||
<Target size={16} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Days to Show — desktop only (available in sidebar on tablet) */}
|
||
<div
|
||
className="header-desktop-only items-center gap-1 bg-gray-100 dark:bg-gray-800 rounded p-1"
|
||
title="Days to show"
|
||
>
|
||
<LayoutGrid size={16} className="text-gray-500 mr-1" />
|
||
{[1, 2, 3, 5, 7].map((num) => (
|
||
<button
|
||
key={num}
|
||
onClick={() => {
|
||
setViewDays(num);
|
||
savedViewDaysRef.current = num;
|
||
saveSetting("viewDays", num);
|
||
}}
|
||
className={`px-2 py-0.5 text-xs rounded transition-colors ${viewDays === num ? "bg-white shadow-sm font-bold text-black" : "text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700"}`}
|
||
>
|
||
{num}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Slot Duration — desktop only (available in sidebar on tablet) */}
|
||
{profile.showTimeGrid && (
|
||
<div
|
||
className="header-desktop-only items-center gap-1 bg-gray-100 dark:bg-gray-800 rounded p-1"
|
||
title="Slot Duration"
|
||
>
|
||
<Clock size={16} className="text-gray-500 mr-1" />
|
||
{(([15, 20, 30, 60] as CellDuration[])).map((duration) => (
|
||
<button
|
||
key={duration}
|
||
onClick={() => {
|
||
setCellDuration(duration);
|
||
saveSetting("cellDuration", duration);
|
||
saveViewSetting("cellDuration", duration, true);
|
||
}}
|
||
className={`px-2 py-0.5 text-xs rounded transition-colors ${effectiveCellDuration === duration ? "bg-white shadow-sm font-bold text-black" : "text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700"}`}
|
||
>
|
||
{duration}m
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Jump to Date — mirrored from nav cluster */}
|
||
<button
|
||
className="p-1 hover:bg-white dark:hover:bg-gray-700 hover:shadow-sm rounded text-gray-500 hover:text-black dark:hover:text-white transition-all"
|
||
onClick={() => setShowDatePicker(true)}
|
||
title={profile.language === "de" ? "Datum wählen" : "Jump to date"}
|
||
>
|
||
<CalendarDays size={15} />
|
||
</button>
|
||
|
||
</div>
|
||
|
||
{/* CENTER SECTION: Week/Year, Goal, Focus Mode - Reveal on Hover */}
|
||
<div className={`flex items-center justify-center gap-6 absolute left-1/2 transform -translate-x-1/2 z-0 transition-all duration-300 ${useLeftRail ? "" : "group-hover:opacity-20 group-hover:blur-[2px]"}`}>
|
||
{/* Week & Year */}
|
||
<div className="whitespace-nowrap flex items-center gap-2">
|
||
{/* Clickable Week & Year */}
|
||
<div
|
||
ref={datePickerBtnRef}
|
||
className="relative flex items-center gap-2 cursor-pointer hover:opacity-80"
|
||
onClick={() => setShowDatePicker(!showDatePicker)}
|
||
title="Jump to date"
|
||
>
|
||
{showDatePicker && !isMobile && (
|
||
<SimpleDatePicker
|
||
selected={currentWeekStart}
|
||
onSelect={(date) => {
|
||
setCurrentWeekStart(getStartOfWeek(date));
|
||
setShowDatePicker(false);
|
||
}}
|
||
onClose={() => setShowDatePicker(false)}
|
||
language={profile.language}
|
||
anchorRef={datePickerBtnRef}
|
||
/>
|
||
)}
|
||
<span style={{
|
||
fontFamily: profile.cwFontFamily || "Inter",
|
||
fontSize: profile.cwFontSize || "1.125rem",
|
||
fontWeight: Number(profile.cwFontWeight || "700"),
|
||
color: adjustColorForDarkMode(profile.cwColor || "#333333", darkMode),
|
||
filter: "brightness(var(--weekly-header-brightness, 1))"
|
||
}}>
|
||
{(() => {
|
||
const effectiveDisplay = (profile.viewStyle === "kanban" && !profile.headerDisplay) ? "current_day" : (profile.headerDisplay || "kw");
|
||
const visibleDays = getVisibleDays();
|
||
if (effectiveDisplay === "none") return "";
|
||
if (effectiveDisplay === "current_day") {
|
||
const fmt = profile.headerCurrentDayFormat || "DDD, DD. MMMM YYYY";
|
||
return formatCustomHeader(fmt, visibleDays, profile.language, t, getSelectedDay(visibleDays, selectedDay));
|
||
}
|
||
if (effectiveDisplay === "month") return getCWReferenceDate(visibleDays).toLocaleDateString(profile.language, { month: "long" });
|
||
if (effectiveDisplay === "month_year") return getCWReferenceDate(visibleDays).toLocaleDateString(profile.language, { month: "long", year: "numeric" });
|
||
if (effectiveDisplay === "date") return new Date().toLocaleDateString(profile.language, { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||
if (effectiveDisplay === "custom") return formatCustomHeader(profile.headerCustomFormat || "KW WW | YYYY", visibleDays, profile.language, t);
|
||
return `KW ${getWeekNumber(getCWReferenceDate(visibleDays)).toString().padStart(2, "0")}`;
|
||
})()}
|
||
</span>
|
||
{profile.headerDisplay !== "none" && profile.headerDisplay !== "current_day" && profile.headerDisplay !== "date" && profile.headerDisplay !== "month_year" && (profile.headerDisplay === "kw" || profile.headerDisplay === "month" || !profile.headerDisplay) && (
|
||
<>
|
||
<span className="text-gray-400">|</span>
|
||
<span
|
||
style={{
|
||
fontFamily: profile.yearFontFamily || "Inter",
|
||
fontSize: profile.yearFontSize || "1.125rem",
|
||
fontWeight: Number(profile.yearFontWeight || "700"),
|
||
color: adjustColorForDarkMode(profile.yearColor || "#333333", darkMode),
|
||
filter: "brightness(var(--weekly-header-brightness, 1))"
|
||
}}
|
||
>
|
||
{getCWReferenceDate(getVisibleDays()).getFullYear()}
|
||
</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
{/* Refresh / sync status — fixed-width slot so the date never shifts */}
|
||
<div style={{ width: 28, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
|
||
{syncError ? (
|
||
<div title={syncError} className="flex items-center text-red-500">
|
||
<AlertCircle size={14} />
|
||
</div>
|
||
) : (isLoading || isSyncing || syncStatus === "syncing") ? (
|
||
<div className="weekly-spinner" title="Syncing..." style={{ width: 14, height: 14 }}></div>
|
||
) : (
|
||
<button
|
||
onClick={() => { fetchCalendarEvents(true); fetchTasks(); }}
|
||
className="p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-all text-gray-400 hover:text-gray-600 opacity-0 group-hover:opacity-100"
|
||
title="Refresh Calendar & Tasks"
|
||
>
|
||
<RefreshCcw size={14} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Goal — hidden on tablet to save space */}
|
||
<div className="header-desktop-only items-center text-sm">
|
||
{isEditingGoal ? (
|
||
<input
|
||
type="text"
|
||
value={goal}
|
||
onChange={(e) => setGoal(e.target.value)}
|
||
onBlur={() => {
|
||
saveGoal(goal);
|
||
setIsEditingGoal(false);
|
||
}}
|
||
onKeyDown={(e) => e.key === "Enter" && e.currentTarget.blur()}
|
||
autoFocus
|
||
className="border-b border-gray-300 focus:outline-none focus:border-black px-1 text-center font-medium italic"
|
||
style={{
|
||
width: `${Math.max(10, goal.length)}ch`,
|
||
fontFamily: profile.goalFontFamily
|
||
? `"${profile.goalFontFamily}", sans-serif`
|
||
: undefined,
|
||
fontSize: profile.goalFontSize || undefined,
|
||
fontWeight: profile.goalFontWeight || undefined,
|
||
}}
|
||
/>
|
||
) : (
|
||
<span
|
||
onClick={() => !profile.showNextTask && setIsEditingGoal(true)}
|
||
className={`cursor-pointer font-medium italic transition-colors ${profile.showNextTask ? "cursor-default text-gray-600 dark:text-white hover:text-black dark:hover:text-gray-100" : "text-gray-600 dark:text-yellow-400 hover:text-black dark:hover:text-yellow-300"}`}
|
||
title={profile.showNextTask ? "Next task" : "Edit goal"}
|
||
style={{
|
||
fontFamily: profile.goalFontFamily
|
||
? `"${profile.goalFontFamily}", sans-serif`
|
||
: undefined,
|
||
fontSize: profile.goalFontSize || undefined,
|
||
fontWeight: profile.goalFontWeight || undefined,
|
||
color: adjustColorForDarkMode((profile.goalFallbackType === "quote" ? profile.taskColor : undefined) || "#333333", darkMode),
|
||
filter: "brightness(var(--weekly-goal-brightness, 1))",
|
||
maxWidth: "800px",
|
||
textAlign: "center" as const,
|
||
overflow: "hidden",
|
||
display: "-webkit-box",
|
||
WebkitLineClamp: 2,
|
||
WebkitBoxOrient: "vertical" as const,
|
||
}}
|
||
>
|
||
{profile.showNextTask
|
||
? (() => {
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
const todayStr = formatDateToISO(today);
|
||
const todayDay = today.getDay();
|
||
const todaysTasks = tasks
|
||
.filter((t) => {
|
||
if (t.completed || t.somedayListId) return false;
|
||
if (t.scheduledDate)
|
||
return (
|
||
formatDateToISO(new Date(t.scheduledDate)) ===
|
||
todayStr
|
||
);
|
||
if (t.dayOfWeek === todayDay && !t.scheduledDate)
|
||
return true;
|
||
return false;
|
||
})
|
||
.sort((a, b) => {
|
||
if (a.startTime && b.startTime)
|
||
return a.startTime.localeCompare(b.startTime);
|
||
if (a.startTime) return -1;
|
||
if (b.startTime) return 1;
|
||
return a.order - b.order;
|
||
});
|
||
const nextTask = todaysTasks[0];
|
||
const nextTaskText = nextTask ? `Do this now: ${nextTask.title}` : (goal || (profile.goalFallbackType === "quote" ? motivationalQuote : goal));
|
||
return nextTaskText;
|
||
})()
|
||
: (goal || (profile.goalFallbackType === "quote" ? motivationalQuote : goal))}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* RIGHT SECTION: Navigation & Tools */}
|
||
<div className={`weekly-header-controls flex-shrink-0 flex items-center gap-1.5 sm:gap-2 transition-opacity duration-700 ${useLeftRail ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`} style={{ zIndex: 10, marginLeft: useLeftRail ? "auto" : undefined, display: useLeftRail && !showHeaderControls ? "none" : undefined, ...(showIntroHints ? { opacity: 1 } : {}) }}>
|
||
{/* Secondary actions — hidden on tablet, visible on desktop */}
|
||
{!useLeftRail && (
|
||
<div className="header-desktop-only flex items-center gap-1.5">
|
||
<button onClick={handleUndo} disabled={undoCount === 0} className="weekly-btn-icon disabled:opacity-30 disabled:cursor-default" title="Undo (Ctrl+Z)">
|
||
<Undo2 size={17} className="text-gray-600 hover:text-black transition-colors" />
|
||
</button>
|
||
<button onClick={handleRedo} disabled={redoCount === 0} className="weekly-btn-icon disabled:opacity-30 disabled:cursor-default" title="Redo (Ctrl+Y)">
|
||
<Redo2 size={17} className="text-gray-600 hover:text-black transition-colors" />
|
||
</button>
|
||
<button onClick={() => { const now = new Date(); setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` }); }} className="weekly-btn-icon" title="Add Calendar Event">
|
||
<Plus size={17} className="text-gray-600 hover:text-black transition-colors" />
|
||
</button>
|
||
<button onClick={() => { const newVal = !profile.showNextTask; setShowNextTask(newVal); saveSetting("showNextTask", newVal); }} className={`weekly-btn-icon ${profile.showNextTask ? "active" : ""}`} title={profile.showNextTask ? "Showing Next Task" : "Showing Goal"}>
|
||
{profile.showNextTask ? <Play size={17} className="text-teal-600" /> : <Target size={17} className="text-gray-400" />}
|
||
</button>
|
||
<button onClick={() => setShowFocusMode(true)} className="weekly-btn-icon" title="Enter Focus Mode">
|
||
<Zap size={17} className="text-gray-600 hover:text-yellow-500 transition-colors" />
|
||
</button>
|
||
<button onClick={() => setDarkMode(!darkMode)} className="weekly-btn-icon" title={darkMode ? "Light Mode" : "Dark Mode"}>
|
||
{darkMode ? <Sun size={17} className="text-yellow-500" /> : <Moon size={17} className="text-gray-500" />}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Navigation Controls — always visible */}
|
||
<div className="flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg p-0.5">
|
||
<button className="p-1 hover:bg-white dark:hover:bg-gray-700 hover:shadow-sm rounded text-gray-500 hover:text-black dark:hover:text-white transition-all" onClick={goToPrevWeek} title="Previous Week"><ChevronsLeft size={15} /></button>
|
||
<button className="p-1 hover:bg-white dark:hover:bg-gray-700 hover:shadow-sm rounded text-gray-500 hover:text-black dark:hover:text-white transition-all" onClick={goToPrevDay} title="Previous Day"><ChevronLeft size={15} /></button>
|
||
<button className="px-2 py-1 text-xs font-bold text-gray-600 dark:text-gray-400 hover:text-black dark:hover:text-white hover:bg-white dark:hover:bg-gray-700 hover:shadow-sm rounded transition-all" onClick={goToToday} title="Go to Today">Today</button>
|
||
<button className="p-1 hover:bg-white dark:hover:bg-gray-700 hover:shadow-sm rounded text-gray-500 hover:text-black dark:hover:text-white transition-all" onClick={goToNextDay} title="Next Day"><ChevronLeft size={15} className="rotate-180" /></button>
|
||
<button className="p-1 hover:bg-white dark:hover:bg-gray-700 hover:shadow-sm rounded text-gray-500 hover:text-black dark:hover:text-white transition-all" onClick={goToNextWeek} title="Next Week"><ChevronsLeft size={15} className="rotate-180" /></button>
|
||
<div style={{ width: 1, height: 14, background: "var(--line, #e5e7eb)", margin: "0 2px", flexShrink: 0 }} />
|
||
<button className="p-1 hover:bg-white dark:hover:bg-gray-700 hover:shadow-sm rounded text-gray-500 hover:text-black dark:hover:text-white transition-all" onClick={() => setShowDatePicker(true)} title={profile.language === "de" ? "Datum wählen" : "Jump to date"}><Calendar size={15} /></button>
|
||
</div>
|
||
|
||
{/* Always visible: Search, Settings, User */}
|
||
<button className="weekly-btn-icon" onClick={() => setIsSearchOpen(true)} title="Search"><Search size={17} /></button>
|
||
<button className="weekly-btn-icon" onClick={() => setShowSettings(true)} title="Settings"><Settings size={17} /></button>
|
||
|
||
{/* Overflow menu — visible on tablet, hidden on desktop */}
|
||
<div className={useLeftRail ? "" : "header-tablet-only"} style={{ position: "relative" }}>
|
||
<button className="weekly-btn-icon" onClick={() => setShowHeaderMore(!showHeaderMore)} title="More">
|
||
<MoreVertical size={17} />
|
||
</button>
|
||
{showHeaderMore && (
|
||
<>
|
||
<div style={{ position: "fixed", inset: 0, zIndex: 99 }} onClick={() => setShowHeaderMore(false)} />
|
||
<div className="header-overflow-menu">
|
||
<button onClick={() => { handleUndo(); setShowHeaderMore(false); }} disabled={undoCount === 0}><Undo2 size={16} /> <span>Undo</span></button>
|
||
<button onClick={() => { handleRedo(); setShowHeaderMore(false); }} disabled={redoCount === 0}><Redo2 size={16} /> <span>Redo</span></button>
|
||
<button onClick={() => { const now = new Date(); setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` }); setShowHeaderMore(false); }}><Plus size={16} /> <span>{profile.language === "de" ? "Termin erstellen" : "Add Event"}</span></button>
|
||
<button onClick={() => { setIsRecurringTasksOpen(true); setShowHeaderMore(false); }}><Repeat size={16} /> <span>{profile.language === "de" ? "Wiederkehrend" : "Recurring"}</span></button>
|
||
<button onClick={() => { const nv = !showNextTask; setShowNextTask(nv); saveSetting("showNextTask", nv); setShowHeaderMore(false); }}>{showNextTask ? <Play size={16} /> : <Target size={16} />} <span>{showNextTask ? "Next Task" : "Goal"}</span></button>
|
||
<button onClick={() => { setShowFocusMode(true); setShowHeaderMore(false); }}><Zap size={16} /> <span>{profile.language === "de" ? "Fokus" : "Focus"}</span></button>
|
||
<button onClick={() => { setDarkMode(!darkMode); setShowHeaderMore(false); }}>{darkMode ? <Sun size={16} /> : <Moon size={16} />} <span>{darkMode ? "Light" : "Dark"}</span></button>
|
||
<button onClick={() => { setShowProjectsSidebar(true); setShowHeaderMore(false); }}><FolderPlus size={16} /> <span>{profile.language === "de" ? "Neues Projekt" : "New Project"}</span></button>
|
||
<div style={{ borderTop: "1px solid var(--border-color, #e5e7eb)", margin: "2px 0" }} />
|
||
<div style={{ padding: "6px 12px", display: "flex", alignItems: "center", gap: "6px", flexWrap: "wrap" }}>
|
||
<span style={{ fontSize: "12px", color: "#888", marginRight: "4px" }}>{profile.language === "de" ? "Tage" : "Days"}:</span>
|
||
{[1, 2, 3, 5, 7].map((num) => (
|
||
<button
|
||
key={num}
|
||
onClick={() => { setViewDays(num); savedViewDaysRef.current = num; saveSetting("viewDays", num); }}
|
||
style={{ padding: "2px 8px", fontSize: "12px", borderRadius: "4px", border: "none", cursor: "pointer", fontWeight: viewDays === num ? 700 : 400, background: viewDays === num ? "var(--bg-secondary, #e5e7eb)" : "transparent", color: "inherit" }}
|
||
>
|
||
{num}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{profile.showTimeGrid && (
|
||
<div style={{ padding: "6px 12px", display: "flex", alignItems: "center", gap: "6px" }}>
|
||
<span style={{ fontSize: "12px", color: "#888", marginRight: "4px" }}>{profile.language === "de" ? "Slot" : "Slot"}:</span>
|
||
{(([15, 20, 30, 60] as CellDuration[])).map((duration) => (
|
||
<button
|
||
key={duration}
|
||
onClick={() => { setCellDuration(duration); saveSetting("cellDuration", duration); saveViewSetting("cellDuration", duration, true); }}
|
||
style={{ padding: "2px 8px", fontSize: "12px", borderRadius: "4px", border: "none", cursor: "pointer", fontWeight: effectiveCellDuration === duration ? 700 : 400, background: effectiveCellDuration === duration ? "var(--bg-secondary, #e5e7eb)" : "transparent", color: "inherit" }}
|
||
>
|
||
{duration}m
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Desktop only: Recurring Tasks + New Project */}
|
||
{!useLeftRail && (
|
||
<>
|
||
<button className="weekly-btn-icon header-desktop-only" onClick={() => setIsRecurringTasksOpen(true)} title="Recurring Tasks"><Repeat size={17} /></button>
|
||
<button className="weekly-btn-icon header-desktop-only" onClick={() => setShowProjectsSidebar(true)} title="New Project"><FolderPlus size={17} /></button>
|
||
<button className="weekly-btn-icon header-desktop-only" onClick={() => setShowWeekPrintModal(true)} title={profile.language === "de" ? "Woche drucken / exportieren" : "Print / export week"}><Printer size={17} /></button>
|
||
</>
|
||
)}
|
||
|
||
{/* User Menu */}
|
||
<UserMenu
|
||
userEmail={session?.user?.email}
|
||
onOpenSettings={() => setShowSettings(true)}
|
||
language={profile.language}
|
||
trigger={<button className="weekly-btn-icon" title="User Menu"><User size={17} /></button>}
|
||
/>
|
||
</div>
|
||
</header>
|
||
|
||
{/* All-Day Events Section (above position) — hidden in kanban/priority */}
|
||
{profile.viewStyle !== "kanban" && profile.viewStyle !== "priority" && effectiveAllDayPosition === "above" && allDaySection}
|
||
|
||
{/* Priority View */}
|
||
{profile.viewStyle === "priority" && (
|
||
<>
|
||
<div
|
||
ref={priorityViewRef}
|
||
style={{ height: `${priorityViewHeight}px`, overflowY: "auto", flexShrink: 0 }}
|
||
>
|
||
<PriorityView
|
||
tasks={[...tasks, ...somedayLists.flatMap(l => l.tasks)].filter(t => !activeProjectFilter || t.projectId === activeProjectFilter)}
|
||
somedayLists={somedayLists.map(l => ({ id: l.id, title: l.title }))}
|
||
projects={projects}
|
||
darkMode={darkMode}
|
||
language={profile.language}
|
||
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
|
||
className="resize-handle"
|
||
onMouseDown={(e) => startResize(e, 'priority')}
|
||
onTouchStart={(e) => startResize(e, 'priority')}
|
||
>
|
||
<div className="resize-handle-bar" />
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Kanban Board View */}
|
||
{profile.viewStyle ==="kanban" && (() => {
|
||
// Combine all tasks (weekly + someday) for kanban
|
||
const allSomedayTasks = somedayLists.flatMap(l => l.tasks);
|
||
const allKanbanTasks = [...tasks, ...allSomedayTasks];
|
||
|
||
// Build week options for filter
|
||
const weekOptions = (() => {
|
||
const weeks = new Map<string, string>();
|
||
allKanbanTasks.forEach(t => {
|
||
if (t.scheduledDate) {
|
||
const d = new Date(t.scheduledDate);
|
||
const weekStart = getStartOfWeek(d);
|
||
const key = weekStart.toISOString().split("T")[0];
|
||
if (!weeks.has(key)) {
|
||
const weekEnd = new Date(weekStart);
|
||
weekEnd.setDate(weekEnd.getDate() + 6);
|
||
weeks.set(key, `${weekStart.toLocaleDateString(profile.language, { month: "short", day: "numeric" })} - ${weekEnd.toLocaleDateString(profile.language, { month: "short", day: "numeric" })}`);
|
||
}
|
||
}
|
||
});
|
||
return Array.from(weeks.entries()).sort((a, b) => a[0].localeCompare(b[0]));
|
||
})();
|
||
|
||
// Apply filters
|
||
const filteredKanbanTasks = allKanbanTasks.filter(t => {
|
||
if (!effectiveShowCompletedTasks && t.completed) return false;
|
||
if (kanbanSearch && !t.title.toLowerCase().includes(kanbanSearch.toLowerCase())) return false;
|
||
if (kanbanFilterProject && t.projectId !== kanbanFilterProject) return false;
|
||
if (activeProjectFilter && t.projectId !== activeProjectFilter) return false;
|
||
if (kanbanFilterList) {
|
||
if (kanbanFilterList === "__none__") {
|
||
if (t.somedayListId) return false;
|
||
} else {
|
||
if (t.somedayListId !== kanbanFilterList) return false;
|
||
}
|
||
}
|
||
if (kanbanFilterWeek && t.scheduledDate) {
|
||
const d = new Date(t.scheduledDate);
|
||
const weekStart = getStartOfWeek(d);
|
||
if (weekStart.toISOString().split("T")[0] !== kanbanFilterWeek) return false;
|
||
}
|
||
return true;
|
||
});
|
||
|
||
const renderKanbanCard = (task: Task) => {
|
||
const subTasks = task.subTasks || [];
|
||
const subCompleted = subTasks.filter(s => s.completed).length;
|
||
const subTotal = subTasks.length;
|
||
const subPct = subTotal > 0 ? (subCompleted / subTotal) * 100 : 0;
|
||
const allSubDone = subTotal > 0 && subCompleted === subTotal;
|
||
const isExpanded = kanbanExpandedCards.has(task.id);
|
||
|
||
return (
|
||
<div
|
||
key={task.id}
|
||
className={`kanban-card ${task.completed ? "kanban-card-done" : ""}`}
|
||
draggable
|
||
onDragStart={(e) => {
|
||
e.dataTransfer.setData("text/kanban-task", task.id);
|
||
e.dataTransfer.setData("text/plain", task.id);
|
||
e.dataTransfer.effectAllowed = "move";
|
||
setDraggedTask(task);
|
||
}}
|
||
onDragEnd={() => setDraggedTask(null)}
|
||
onClick={(e) => {
|
||
const target = e.target as HTMLElement;
|
||
if (target.tagName === "INPUT" || target.contentEditable === "true" || target.closest("button") || target.closest(".kanban-card-subtask-list")) return;
|
||
setKanbanDetailTask(task);
|
||
}}
|
||
style={{ cursor: "pointer" }}
|
||
>
|
||
<div className="kanban-card-header">
|
||
{effectiveShowTaskCheckboxes && (
|
||
<input
|
||
type="checkbox"
|
||
checked={task.completed}
|
||
onChange={() => toggleTask(task.id)}
|
||
className="kanban-card-checkbox"
|
||
/>
|
||
)}
|
||
{effectiveShowPriorityIcons && (() => {
|
||
const pb = getPriorityBadge(task, profile.priorityStyle || "eisenhower", 12);
|
||
return pb ? (
|
||
<span title={pb.label} style={{ flexShrink: 0, display: "flex", alignItems: "center", marginRight: "4px" }}>
|
||
{pb.node}
|
||
</span>
|
||
) : null;
|
||
})()}
|
||
{task.delegatedTo && (
|
||
<span
|
||
title={task.delegatedTo === "AI" ? `AI: ${task.delegationNote || ""}` : task.delegatedTo}
|
||
style={{ flexShrink: 0, display: "flex", alignItems: "center", marginRight: "4px" }}
|
||
>
|
||
{task.delegatedTo === "AI"
|
||
? <Bot size={12} color="#8b5cf6" />
|
||
: <User size={12} color="#8b5cf6" />}
|
||
</span>
|
||
)}
|
||
<span
|
||
className="kanban-card-title"
|
||
contentEditable
|
||
suppressContentEditableWarning
|
||
onBlur={(e) => {
|
||
const text = (e.target as HTMLElement).textContent || "";
|
||
if (text !== task.title) updateTask(task.id, text);
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") { e.preventDefault(); (e.target as HTMLElement).blur(); }
|
||
}}
|
||
>
|
||
{task.title}
|
||
</span>
|
||
</div>
|
||
{subTotal > 0 && (
|
||
<div className="kanban-card-subtask-toggle" onClick={(e) => e.stopPropagation()}>
|
||
<button
|
||
className="kanban-card-subtask-btn"
|
||
onClick={() => {
|
||
setKanbanExpandedCards(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(task.id)) next.delete(task.id);
|
||
else next.add(task.id);
|
||
return next;
|
||
});
|
||
}}
|
||
>
|
||
<ChevronDown size={12} style={{ transform: isExpanded ? "rotate(0deg)" : "rotate(-90deg)", transition: "transform 0.2s" }} />
|
||
<span className="kanban-card-subtask-bar">
|
||
<span className="kanban-card-subtask-bar-fill" style={{ width: `${subPct}%`, background: allSubDone ? "#0d9488" : "var(--weekly-accent, #6366f1)" }} />
|
||
</span>
|
||
<span className="kanban-card-subtask-count" style={{ color: allSubDone ? "#0d9488" : undefined }}>
|
||
{subCompleted}/{subTotal}
|
||
</span>
|
||
</button>
|
||
{isExpanded && (
|
||
<div className="kanban-card-subtask-list">
|
||
{subTasks.map(sub => (
|
||
<div key={sub.id} className={`kanban-card-subtask-item ${sub.completed ? "completed" : ""}`}>
|
||
<input
|
||
type="checkbox"
|
||
checked={sub.completed}
|
||
onChange={() => toggleSubTask(sub.id)}
|
||
style={{ accentColor: "var(--weekly-accent, #6366f1)" }}
|
||
/>
|
||
<span className={sub.completed ? "kanban-card-subtask-done" : ""}>{sub.title}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{task.markdownContent && (
|
||
<div className="kanban-card-has-notes" style={{ fontSize: "0.65rem", color: "#9ca3af", marginTop: "2px", display: "flex", alignItems: "center", gap: "3px" }}>
|
||
<FileText size={10} /> {profile.language === "de" ? "Notizen" : "Notes"}
|
||
</div>
|
||
)}
|
||
{(task.scheduledDate || task.startTime) && (
|
||
<div className="kanban-card-date">
|
||
{task.scheduledDate && new Date(task.scheduledDate).toLocaleDateString(profile.language, { weekday: "short", month: "short", day: "numeric" })}
|
||
{task.startTime && <span className="kanban-card-time"> {task.startTime}</span>}
|
||
</div>
|
||
)}
|
||
{task.project && (
|
||
<div className="kanban-card-project" style={{ color: task.project.color || "#888" }}>
|
||
<ProjectIcon icon={task.project.icon} size={12} color={task.project.color || "#888"} /> {task.project.name}
|
||
</div>
|
||
)}
|
||
{task.somedayListId && (() => {
|
||
const list = somedayLists.find(l => l.id === task.somedayListId);
|
||
return list ? <div className="kanban-card-list">{list.title}</div> : null;
|
||
})()}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const kanbanColumnDrop = (stageId: string | null) => ({
|
||
onDragOver: (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "move";
|
||
e.currentTarget.classList.add("kanban-column-drag-over");
|
||
},
|
||
onDragLeave: (e: React.DragEvent) => {
|
||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||
e.currentTarget.classList.remove("kanban-column-drag-over");
|
||
}
|
||
},
|
||
onDrop: async (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
e.currentTarget.classList.remove("kanban-column-drag-over");
|
||
const taskId = e.dataTransfer.getData("text/kanban-task");
|
||
if (taskId) {
|
||
await updateTaskFields(taskId, { kanbanStage: stageId });
|
||
}
|
||
},
|
||
});
|
||
|
||
const kanbanProjectDrop = (projectId: string | null) => ({
|
||
onDragOver: (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "move";
|
||
e.currentTarget.classList.add("kanban-project-chip-drag-over");
|
||
},
|
||
onDragLeave: (e: React.DragEvent) => {
|
||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||
e.currentTarget.classList.remove("kanban-project-chip-drag-over");
|
||
}
|
||
},
|
||
onDrop: async (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
e.currentTarget.classList.remove("kanban-project-chip-drag-over");
|
||
const taskId = e.dataTransfer.getData("text/kanban-task");
|
||
if (taskId) {
|
||
await updateTaskFields(taskId, { projectId });
|
||
}
|
||
},
|
||
});
|
||
|
||
const hasActiveFilters = kanbanSearch || kanbanFilterProject || kanbanFilterList || kanbanFilterWeek;
|
||
|
||
return (
|
||
<div className="kanban-wrapper">
|
||
{/* Unified toolbar: projects on left, filters on right */}
|
||
<div className="kanban-filter-bar">
|
||
{/* Left side: + button, project chips */}
|
||
<button
|
||
onClick={() => setShowProjectsSidebar(true)}
|
||
className="kanban-project-chip"
|
||
style={{ background: darkMode ? "#374151" : "#e5e7eb", color: darkMode ? "#9ca3af" : "#6b7280", cursor: "pointer", border: "2px dashed " + (darkMode ? "#4b5563" : "#d1d5db") }}
|
||
title={t.projects || "Projects"}
|
||
>
|
||
<Plus size={12} />
|
||
</button>
|
||
{projects.map(p => (
|
||
<div key={p.id} className="kanban-project-chip" style={{ background: (p.color || "#6366f1") + "18", color: p.color || "#6366f1" }} {...kanbanProjectDrop(p.id)}>
|
||
<ProjectIcon icon={p.icon} size={12} color={p.color || "#6366f1"} />
|
||
<span>{p.name}</span>
|
||
</div>
|
||
))}
|
||
{projects.length > 0 && (
|
||
<div className="kanban-project-chip" style={{ background: darkMode ? "#1f2937" : "#f3f4f6", color: "#9ca3af" }} {...kanbanProjectDrop(null)}>
|
||
<X size={12} />
|
||
<span>{profile.language === "de" ? "Kein Projekt" : "No project"}</span>
|
||
</div>
|
||
)}
|
||
{/* Spacer pushes filters to right */}
|
||
<div style={{ flex: 1 }} />
|
||
{/* Right side: search + filters */}
|
||
<div className="kanban-filter-search">
|
||
<Search size={14} className="kanban-filter-icon" />
|
||
<input
|
||
type="text"
|
||
value={kanbanSearch}
|
||
onChange={(e) => setKanbanSearch(e.target.value)}
|
||
placeholder="Search..."
|
||
className="kanban-filter-input"
|
||
/>
|
||
{kanbanSearch && (
|
||
<button onClick={() => setKanbanSearch("")} className="kanban-filter-clear"><X size={12} /></button>
|
||
)}
|
||
</div>
|
||
<select
|
||
value={kanbanFilterProject}
|
||
onChange={(e) => setKanbanFilterProject(e.target.value)}
|
||
className="kanban-filter-select"
|
||
>
|
||
<option value="">{t.filterByProject}</option>
|
||
{projects.map(p => (
|
||
<option key={p.id} value={p.id}>● {p.name}</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
value={kanbanFilterList}
|
||
onChange={(e) => setKanbanFilterList(e.target.value)}
|
||
className="kanban-filter-select"
|
||
>
|
||
<option value="">{t.filterByList}</option>
|
||
<option value="__none__">{t.weekView}</option>
|
||
{somedayLists.map(l => (
|
||
<option key={l.id} value={l.id}>{l.title}</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
value={kanbanFilterWeek}
|
||
onChange={(e) => setKanbanFilterWeek(e.target.value)}
|
||
className="kanban-filter-select"
|
||
>
|
||
<option value="">{t.filterByWeek}</option>
|
||
{weekOptions.map(([key, label]) => (
|
||
<option key={key} value={key}>{label}</option>
|
||
))}
|
||
</select>
|
||
{hasActiveFilters && (
|
||
<button
|
||
onClick={() => { setKanbanSearch(""); setKanbanFilterProject(""); setKanbanFilterList(""); setKanbanFilterWeek(""); }}
|
||
className="kanban-filter-reset"
|
||
title="Clear filters"
|
||
>
|
||
<X size={14} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="kanban-board">
|
||
{kanbanStages.map((stage, idx) => {
|
||
const stageTasks = filteredKanbanTasks.filter(t => (t.kanbanStage || null) === stage.id);
|
||
return (
|
||
<div key={stage.id} className="kanban-column" {...kanbanColumnDrop(stage.id)}>
|
||
<div className="kanban-column-header" style={{ borderBottomColor: stage.color }}>
|
||
<input
|
||
type="color"
|
||
value={stage.color}
|
||
onChange={(e) => {
|
||
const updated = kanbanStages.map((s, i) => i === idx ? { ...s, color: e.target.value } : s);
|
||
saveKanbanStages(updated);
|
||
}}
|
||
title={profile.language === "de" ? "Farbe ändern" : "Change color"}
|
||
style={{ width: "14px", height: "14px", border: "none", cursor: "pointer", padding: 0, borderRadius: "50%", flexShrink: 0 }}
|
||
/>
|
||
<span
|
||
className="kanban-column-title"
|
||
contentEditable
|
||
suppressContentEditableWarning
|
||
onBlur={(e) => {
|
||
const name = (e.target as HTMLElement).textContent || stage.name;
|
||
if (name !== stage.name) {
|
||
const updated = kanbanStages.map((s, i) => i === idx ? { ...s, name } : s);
|
||
saveKanbanStages(updated);
|
||
}
|
||
}}
|
||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); (e.target as HTMLElement).blur(); } }}
|
||
>
|
||
{stage.name}
|
||
</span>
|
||
<span className="kanban-column-count">{stageTasks.length}</span>
|
||
{kanbanDeleteStageId === stage.id ? (
|
||
<div style={{ display: "flex", alignItems: "center", gap: "3px", flexShrink: 0 }}>
|
||
<button
|
||
onClick={async () => {
|
||
// Move all tasks in this stage back to "no phase"
|
||
const tasksInStage = filteredKanbanTasks.filter(tk => tk.kanbanStage === stage.id);
|
||
for (const tk of tasksInStage) {
|
||
await updateTaskFields(tk.id, { kanbanStage: null });
|
||
}
|
||
saveKanbanStages(kanbanStages.filter((_, i) => i !== idx));
|
||
setKanbanDeleteStageId(null);
|
||
}}
|
||
style={{ padding: "2px 6px", fontSize: "0.65rem", borderRadius: "4px", border: "none", cursor: "pointer", background: "#ef4444", color: "#fff", fontWeight: 600, whiteSpace: "nowrap" }}
|
||
>
|
||
{profile.language === "de" ? "Ja, löschen" : "Confirm"}
|
||
</button>
|
||
<button
|
||
onClick={() => setKanbanDeleteStageId(null)}
|
||
style={{ padding: "2px", background: "none", border: "none", cursor: "pointer", color: "#9ca3af", display: "flex" }}
|
||
title={profile.language === "de" ? "Abbrechen" : "Cancel"}
|
||
>
|
||
<X size={12} />
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<button
|
||
onClick={() => setKanbanDeleteStageId(stage.id)}
|
||
className="kanban-column-delete"
|
||
title={profile.language === "de" ? "Phase löschen" : "Delete stage"}
|
||
>
|
||
<Trash2 size={12} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="kanban-column-body">
|
||
{stageTasks.map(renderKanbanCard)}
|
||
{kanbanAddingStageId === stage.id ? (
|
||
<div className="kanban-add-task-input">
|
||
<input
|
||
type="text"
|
||
autoFocus
|
||
placeholder={profile.language === "de" ? "Aufgabe eingeben..." : "Enter task..."}
|
||
value={kanbanNewTaskTitle}
|
||
onChange={(e) => setKanbanNewTaskTitle(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && kanbanNewTaskTitle.trim()) {
|
||
addKanbanTask(kanbanNewTaskTitle, stage.id);
|
||
} else if (e.key === "Escape") {
|
||
setKanbanAddingStageId(null);
|
||
setKanbanNewTaskTitle("");
|
||
}
|
||
}}
|
||
onBlur={() => {
|
||
if (kanbanNewTaskTitle.trim()) {
|
||
addKanbanTask(kanbanNewTaskTitle, stage.id);
|
||
} else {
|
||
setKanbanAddingStageId(null);
|
||
setKanbanNewTaskTitle("");
|
||
}
|
||
}}
|
||
style={{
|
||
width: "100%",
|
||
padding: "6px 8px",
|
||
fontSize: "0.8rem",
|
||
border: "1px solid var(--border-color, #d1d5db)",
|
||
borderRadius: "6px",
|
||
background: "var(--card-bg, #fff)",
|
||
color: "var(--text-color, #111)",
|
||
outline: "none",
|
||
}}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<button
|
||
className="kanban-add-task-btn"
|
||
onClick={() => {
|
||
setKanbanAddingStageId(stage.id);
|
||
setKanbanNewTaskTitle("");
|
||
}}
|
||
style={{
|
||
width: "100%",
|
||
padding: "6px 8px",
|
||
fontSize: "0.75rem",
|
||
color: "#9ca3af",
|
||
background: "none",
|
||
border: "1px dashed #d1d5db",
|
||
borderRadius: "6px",
|
||
cursor: "pointer",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: "4px",
|
||
justifyContent: "center",
|
||
marginTop: "4px",
|
||
}}
|
||
>
|
||
<Plus size={12} />
|
||
{profile.language === "de" ? "Aufgabe" : "Add task"}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
{/* Add stage column */}
|
||
<div
|
||
className="kanban-column kanban-column-add"
|
||
onClick={() => {
|
||
const id = `stage-${Date.now()}`;
|
||
saveKanbanStages([...kanbanStages, { id, name: t.stageName, color: "#6b7280" }]);
|
||
}}
|
||
>
|
||
<div className="kanban-column-header" style={{ borderBottomColor: "transparent", cursor: "pointer", justifyContent: "center", color: "#9ca3af" }}>
|
||
<Plus size={16} />
|
||
</div>
|
||
</div>
|
||
{/* Unassigned column */}
|
||
{(() => {
|
||
const unassigned = filteredKanbanTasks.filter(t => !t.kanbanStage && !t.completed);
|
||
if (unassigned.length === 0 && !hasActiveFilters) return null;
|
||
return (
|
||
<div className="kanban-column kanban-column-unassigned" {...kanbanColumnDrop(null)}>
|
||
<div className="kanban-column-header" style={{ borderBottomColor: "#d1d5db" }}>
|
||
<span className="kanban-column-dot" style={{ background: "#d1d5db" }} />
|
||
<span className="kanban-column-title">{t.noStage}</span>
|
||
<span className="kanban-column-count">{unassigned.length}</span>
|
||
</div>
|
||
<div className="kanban-column-body">
|
||
{unassigned.map(renderKanbanCard)}
|
||
{kanbanAddingStageId === "__unassigned__" ? (
|
||
<div className="kanban-add-task-input">
|
||
<input
|
||
type="text"
|
||
autoFocus
|
||
placeholder={profile.language === "de" ? "Aufgabe eingeben..." : "Enter task..."}
|
||
value={kanbanNewTaskTitle}
|
||
onChange={(e) => setKanbanNewTaskTitle(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && kanbanNewTaskTitle.trim()) {
|
||
addKanbanTask(kanbanNewTaskTitle, null);
|
||
} else if (e.key === "Escape") {
|
||
setKanbanAddingStageId(null);
|
||
setKanbanNewTaskTitle("");
|
||
}
|
||
}}
|
||
onBlur={() => {
|
||
if (kanbanNewTaskTitle.trim()) {
|
||
addKanbanTask(kanbanNewTaskTitle, null);
|
||
} else {
|
||
setKanbanAddingStageId(null);
|
||
setKanbanNewTaskTitle("");
|
||
}
|
||
}}
|
||
style={{
|
||
width: "100%",
|
||
padding: "6px 8px",
|
||
fontSize: "0.8rem",
|
||
border: "1px solid var(--border-color, #d1d5db)",
|
||
borderRadius: "6px",
|
||
background: "var(--card-bg, #fff)",
|
||
color: "var(--text-color, #111)",
|
||
outline: "none",
|
||
}}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<button
|
||
className="kanban-add-task-btn"
|
||
onClick={() => {
|
||
setKanbanAddingStageId("__unassigned__");
|
||
setKanbanNewTaskTitle("");
|
||
}}
|
||
style={{
|
||
width: "100%",
|
||
padding: "6px 8px",
|
||
fontSize: "0.75rem",
|
||
color: "#9ca3af",
|
||
background: "none",
|
||
border: "1px dashed #d1d5db",
|
||
borderRadius: "6px",
|
||
cursor: "pointer",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: "4px",
|
||
justifyContent: "center",
|
||
marginTop: "4px",
|
||
}}
|
||
>
|
||
<Plus size={12} />
|
||
{profile.language === "de" ? "Aufgabe" : "Add task"}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* Main Grid with Time Column */}
|
||
{profile.viewStyle !== "kanban" && <div style={{ position: "relative", flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||
<div className="time-grid-wrapper" ref={gridRef as React.Ref<HTMLDivElement>} onScroll={handleGridScroll} style={profile.showTimeGrid ? {
|
||
height: '100%',
|
||
overflowY: 'auto',
|
||
overflowX: 'hidden',
|
||
WebkitOverflowScrolling: 'touch' as any,
|
||
} : undefined}>
|
||
{/* Time Column */}
|
||
{profile.showTimeGrid && (
|
||
<div
|
||
className="time-column"
|
||
style={{
|
||
height: `${24 * (60 / effectiveCellDuration) * getSlotHeight(effectiveCellDuration) + measuredHeaderHeight}px`,
|
||
flex: 'none',
|
||
alignSelf: "flex-start",
|
||
position: 'relative'
|
||
}}
|
||
>
|
||
<div className="time-column-header" style={{
|
||
border: 'none',
|
||
background: 'var(--weekly-bg)',
|
||
position: 'sticky',
|
||
top: 0,
|
||
zIndex: 30,
|
||
minHeight: `${measuredHeaderHeight}px`,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center'
|
||
}}>
|
||
{/* Side Navigation Arrows (hover overlays) - now inside header */}
|
||
<div className="side-nav side-nav-left">
|
||
<button onClick={goToPrevDay} title="Previous Day" className="side-nav-btn">
|
||
<ChevronLeft size={16} />
|
||
</button>
|
||
<button onClick={goToPrevWeek} title="Previous Week" className="side-nav-btn">
|
||
<ChevronsLeft size={16} />
|
||
</button>
|
||
</div>
|
||
{/* Invisible structural match of day header to guarantee perfect height alignment */}
|
||
<div
|
||
style={{
|
||
visibility: "hidden", pointerEvents: "none",
|
||
display: "flex",
|
||
width: "100%",
|
||
alignItems:
|
||
activeDateLayout === "above" ||
|
||
activeDateLayout === "below"
|
||
? (profile.dateAlignment === "left" ? "flex-start" : profile.dateAlignment === "right" ? "flex-end" : "center")
|
||
: "center",
|
||
justifyContent:
|
||
profile.dateAlignment === "left"
|
||
? "flex-start"
|
||
: profile.dateAlignment === "right"
|
||
? "flex-end"
|
||
: "center",
|
||
flexDirection:
|
||
activeDateLayout === "above"
|
||
? "column-reverse"
|
||
: activeDateLayout === "below"
|
||
? "column"
|
||
: "row",
|
||
gap: profile.dateAlignment === "tight" ? "2px" : (profile.dayHeaderGap || "0.35em"),
|
||
}}
|
||
>
|
||
{activeDateLayout === "left" && (
|
||
<span className="weekly-day-date">W</span>
|
||
)}
|
||
<h3 className="weekly-day-name" style={{ marginBottom: 0 }}>
|
||
X
|
||
</h3>
|
||
{(activeDateLayout === "right" ||
|
||
activeDateLayout === "above" ||
|
||
activeDateLayout === "below" ||
|
||
activeDateLayout === undefined) && (
|
||
<span className="weekly-day-date">W</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div
|
||
className="time-column-slots"
|
||
style={{
|
||
flex: "none",
|
||
position: 'relative'
|
||
}}
|
||
>
|
||
{visibleSlots.map((slot, index) => {
|
||
const hour = getHourFromSlot(slot);
|
||
const minutes = slot.split(":")[1];
|
||
const isHourStart = minutes === "00";
|
||
if (!isHourStart && !effectiveShowSubHourSlots) return (
|
||
<div
|
||
key={slot}
|
||
className="time-slot-label"
|
||
style={{ height: `${getSlotHeight(effectiveCellDuration)}px` }}
|
||
/>
|
||
);
|
||
// Punkt 6: render extra timezone hours alongside the local label.
|
||
const extras: string[] = (profile.viewSettings?.extraTimezones as string[]) || [];
|
||
const localLabel = formatHour(hour, parseInt(minutes), (isHourStart ? effectiveHourLabelFormat : 'full') as "short" | "full", profile.timeFormat);
|
||
let extraLabels: string[] = [];
|
||
if (isHourStart && extras.length > 0) {
|
||
const userZone = profile.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||
const today = new Date();
|
||
today.setHours(hour, parseInt(minutes), 0, 0);
|
||
extraLabels = extras.map((zone) => {
|
||
try {
|
||
const opt = TIMEZONE_OPTIONS_LOCAL.find((o) => o.zone === zone);
|
||
const code = opt?.code || zone.split("/").pop() || zone;
|
||
const time = new Intl.DateTimeFormat("en-GB", {
|
||
timeZone: zone,
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
hour12: profile.timeFormat === "12h",
|
||
}).format(today);
|
||
return `${code} ${time}`;
|
||
} catch {
|
||
return "";
|
||
}
|
||
}).filter(Boolean);
|
||
}
|
||
return (
|
||
<div
|
||
key={slot}
|
||
className={`time-slot-label ${isHourStart ? "hour-start" : "sub-hour"}`}
|
||
style={{ height: `${getSlotHeight(effectiveCellDuration)}px`, cursor: isHourStart ? 'pointer' : 'default' }}
|
||
onClick={isHourStart ? () => jumpToHour(hour) : undefined}
|
||
title={isHourStart ? `Jump to ${hour}:00` : undefined}
|
||
>
|
||
{(isHourStart || effectiveShowSubHourSlots) && (
|
||
<span style={{ display: "inline-flex", flexDirection: "column", alignItems: "flex-end", lineHeight: 1.05 }}>
|
||
<span>{localLabel}</span>
|
||
{extraLabels.map((lbl, i) => (
|
||
<span key={i} style={{ fontSize: "0.55em", opacity: 0.65 }}>{lbl}</span>
|
||
))}
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
{/* End-of-day 24:00 label */}
|
||
<div className="time-slot-label hour-start" style={{ height: '0px', lineHeight: 0 }}>
|
||
<span>{profile.timeFormat === '24h' ? '24' : '12 AM'}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Day Columns */}
|
||
<main
|
||
className={`weekly-days-grid cols-${viewDays}`}
|
||
data-slide-direction={slideDirection}
|
||
data-nav-type={viewDays > 1 ? "week" : "day"}
|
||
style={profile.showTimeGrid ? {
|
||
height: `${24 * (60 / effectiveCellDuration) * getSlotHeight(effectiveCellDuration) + getHeaderHeight(effectiveCellDuration)}px`,
|
||
flex: 1,
|
||
alignSelf: "flex-start",
|
||
} : {
|
||
flex: 1,
|
||
}}
|
||
>
|
||
{getVisibleDays().map((date, colIndex) => {
|
||
const todayMidnight = new Date();
|
||
todayMidnight.setHours(0, 0, 0, 0);
|
||
const isToday = isSameDay(date, todayMidnight);
|
||
const isPast = date < todayMidnight && !isToday;
|
||
|
||
return (
|
||
<div
|
||
key={date.toISOString()}
|
||
className={`weekly-day-column ${date.getDay() === 6 ? "is-sat" : ""} ${date.getDay() === 0 ? "is-sun" : ""} ${isToday ? "is-today" : ""} ${isPast ? "is-past" : ""}`}
|
||
data-date={`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`}
|
||
onClick={() => setSelectedDay(date)}
|
||
>
|
||
{/* Day Header */}
|
||
<header className="weekly-day-header" ref={colIndex === 0 ? dayHeaderRef : undefined}>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
width: "100%",
|
||
alignItems:
|
||
activeDateLayout === "above" ||
|
||
activeDateLayout === "below"
|
||
? (profile.dateAlignment === "left" ? "flex-start" : profile.dateAlignment === "right" ? "flex-end" : "center")
|
||
: "baseline",
|
||
justifyContent:
|
||
profile.dateAlignment === "left"
|
||
? "flex-start"
|
||
: profile.dateAlignment === "right"
|
||
? "flex-end"
|
||
: "center",
|
||
flexDirection:
|
||
activeDateLayout === "above"
|
||
? "column-reverse"
|
||
: activeDateLayout === "below"
|
||
? "column"
|
||
: "row",
|
||
gap: profile.dateAlignment === "tight" ? "2px"
|
||
: (activeDateLayout === "above" || activeDateLayout === "below") ? "0px"
|
||
: (profile.dayHeaderGap || "0.35em"),
|
||
}}
|
||
>
|
||
{(() => {
|
||
// Compute date vertical offset relative to weekday font size
|
||
const isHorizontal = activeDateLayout === "left" || activeDateLayout === "right" || activeDateLayout === undefined;
|
||
const headlineSizePx = parseFloat(profile.headlineFontSize || "1.25rem") * (String(profile.headlineFontSize || "").includes("px") ? 1 : 16);
|
||
const dateOffset = !isHorizontal ? undefined
|
||
: profile.dateVerticalAlign === "top" ? `${-headlineSizePx * 0.55}px`
|
||
: profile.dateVerticalAlign === "bottom" ? "0px"
|
||
: `${-headlineSizePx * 0.25}px`; // center
|
||
const dateStyle = isHorizontal ? {
|
||
flexShrink: 0 as const, marginBottom: 0,
|
||
position: "relative" as const, top: dateOffset,
|
||
} : { flexShrink: 0 as const };
|
||
return (
|
||
<>
|
||
{activeDateLayout === "left" && (
|
||
<span className="weekly-day-date" style={dateStyle}>
|
||
{formatDateHeader(date, profile.language)}
|
||
</span>
|
||
)}
|
||
<h3
|
||
className={`weekly-day-name ${isSameDay(date, new Date()) ? "is-today" : ""}`}
|
||
style={{ marginBottom: 0, flexShrink: 0 }}
|
||
>
|
||
{getDayName(date, profile.language, profile.weekdayFormat, profile.customWeekdayNames, profile.weekStartDay, profile.weekdayCase)}
|
||
</h3>
|
||
{(activeDateLayout === "right" ||
|
||
activeDateLayout === "above" ||
|
||
activeDateLayout === "below" ||
|
||
activeDateLayout === undefined) && (
|
||
<span className="weekly-day-date" style={dateStyle}>
|
||
{formatDateHeader(date, profile.language)}
|
||
</span>
|
||
)}
|
||
</>
|
||
);
|
||
})()}
|
||
</div>
|
||
</header>
|
||
|
||
{/* Time Grid or Simple List */}
|
||
{profile.showTimeGrid ? (
|
||
<div
|
||
className="time-slots-container"
|
||
onDragLeave={handleDragLeave}
|
||
style={{ position: "relative" }}
|
||
>
|
||
{/* Calendar Fetching Indicator */}
|
||
{profile.showTimeGrid && colIndex === 0 && isFetchingCalendar && (
|
||
<div className="absolute top-2 left-2 z-[60] flex items-center gap-2 bg-white/90 dark:bg-zinc-800/90 px-3 py-1.5 rounded-full shadow-sm border border-zinc-200 dark:border-zinc-700 text-xs text-zinc-600 dark:text-zinc-300 pointer-events-none">
|
||
<div className="w-3 h-3 border-2 border-zinc-400 border-t-transparent rounded-full animate-spin"></div>
|
||
Syncing Calendar...
|
||
</div>
|
||
)}
|
||
|
||
{/* Now Line - only show on today's column */}
|
||
{isSameDay(date, new Date()) &&
|
||
(() => {
|
||
const now = currentTime;
|
||
const nowHour = now.getHours();
|
||
const nowMinute = now.getMinutes();
|
||
// Show if within 24h range
|
||
if (
|
||
nowHour >= 0 &&
|
||
nowHour < 24
|
||
) {
|
||
const minutesSinceStart =
|
||
nowHour * 60 + nowMinute;
|
||
const pixelsPerMinute =
|
||
getSlotHeight(effectiveCellDuration) / effectiveCellDuration;
|
||
const topPosition =
|
||
minutesSinceStart * pixelsPerMinute;
|
||
const timeString = formatHour(nowHour, nowMinute, "full", timeFormat);
|
||
return (
|
||
<div
|
||
className="now-line"
|
||
data-time={timeString}
|
||
style={{ top: `${topPosition}px` }}
|
||
/>
|
||
);
|
||
}
|
||
return null;
|
||
})()}
|
||
{/* Protection overlays - render at exact event positions */}
|
||
{protectEventTimes &&
|
||
getEventsForDate(date)
|
||
.filter((e) => !isAllDayEvent(e))
|
||
.map((event) => {
|
||
const eventStart = new Date(event.startTime);
|
||
const eventEnd = new Date(event.endTime);
|
||
const eventStartHour = eventStart.getHours();
|
||
const eventStartMinute = eventStart.getMinutes();
|
||
|
||
// Show if within 24h range
|
||
if (
|
||
eventStartHour < 0 ||
|
||
eventStartHour >= 24
|
||
)
|
||
return null;
|
||
|
||
const minutesSinceStart =
|
||
eventStartHour * 60 +
|
||
eventStartMinute;
|
||
const pixelsPerMinute =
|
||
getSlotHeight(effectiveCellDuration) / effectiveCellDuration;
|
||
const topPosition =
|
||
minutesSinceStart * pixelsPerMinute;
|
||
|
||
// Calculate height based on event duration
|
||
const durationMinutes =
|
||
(eventEnd.getTime() - eventStart.getTime()) /
|
||
(1000 * 60);
|
||
const calculatedHeight =
|
||
durationMinutes * pixelsPerMinute;
|
||
// Ensure minimum height of 15px for visibility
|
||
const height = Math.max(calculatedHeight, 15);
|
||
|
||
const isUnlocked = unlockedEvents.has(event.id);
|
||
|
||
return (
|
||
<div
|
||
key={`protection-${event.id}`}
|
||
className="event-protection-overlay"
|
||
style={{
|
||
position: "absolute",
|
||
top: `${topPosition}px`,
|
||
left: 0,
|
||
right: 0,
|
||
height: `${height}px`,
|
||
zIndex: 1,
|
||
pointerEvents: "none",
|
||
}}
|
||
>
|
||
<button
|
||
className="event-unlock-btn"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setUnlockedEvents((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(event.id)) {
|
||
next.delete(event.id);
|
||
} else {
|
||
next.add(event.id);
|
||
}
|
||
return next;
|
||
});
|
||
}}
|
||
title={
|
||
isUnlocked
|
||
? "Lock this time slot"
|
||
: "Unlock this time slot"
|
||
}
|
||
style={{ pointerEvents: "auto" }}
|
||
>
|
||
{isUnlocked ? "🔓" : "🔒"}
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
{/* GridTaskBlocks for timed tasks */}
|
||
{getTasksForDate(date)
|
||
.filter(t => !!t.startTime)
|
||
.map(task => (
|
||
<GridTaskBlock
|
||
key={task.id}
|
||
task={task}
|
||
date={date}
|
||
activeDate={currentWeekStart}
|
||
cellDuration={effectiveCellDuration}
|
||
darkMode={darkMode}
|
||
isProtected={false}
|
||
editingTaskId={editingTaskId}
|
||
setEditingTaskId={setEditingTaskId}
|
||
updateTask={updateTask}
|
||
updateTaskNotes={updateTaskNotes}
|
||
updateTaskUrl={updateTaskUrl}
|
||
updateTaskDuration={updateTaskDuration}
|
||
toggleTask={toggleTask}
|
||
deleteTask={deleteTask}
|
||
toggleTaskRolling={toggleTaskRolling}
|
||
setSelectedTaskForNotes={setSelectedTaskForNotes}
|
||
setSelectedTaskForRecurrence={setSelectedTaskForRecurrence}
|
||
handleDragStart={handleDragStart}
|
||
handleDragEnd={handleDragEnd}
|
||
getSlotHeight={getSlotHeight}
|
||
draggedTask={draggedTask as any}
|
||
addSubTask={addSubTask}
|
||
toggleSubTask={toggleSubTask}
|
||
updateSubTask={updateSubTask}
|
||
deleteSubTask={deleteSubTask}
|
||
onSetEditingTaskId={setEditingTaskId}
|
||
workingHoursStart={workingHoursStart}
|
||
showTaskCheckboxes={effectiveShowTaskCheckboxes}
|
||
showProjectIcons={effectiveShowProjectIcons}
|
||
showPriorityIcons={effectiveShowPriorityIcons}
|
||
priorityStyle={effectivePriorityStyle}
|
||
projects={projects}
|
||
onProjectAssign={assignProject}
|
||
kanbanStages={kanbanStages}
|
||
weatherEnabled={effectiveWeatherEnabled}
|
||
onMoveTask={(taskId, targetDate, targetTime) => {
|
||
moveTaskToSlot(taskId, targetDate.getDay(), targetTime, targetDate);
|
||
}}
|
||
/>
|
||
))}
|
||
{visibleSlots.map((slot) => {
|
||
const hour = getHourFromSlot(slot);
|
||
const minutes = slot.split(":")[1];
|
||
const isHourStart = minutes === "00";
|
||
const slotEvents = getEventsForSlot(date, slot);
|
||
const isActive =
|
||
activeSlot?.day === date.getDay() &&
|
||
activeSlot?.slot === slot;
|
||
const isProtected = isSlotProtected(date, slot);
|
||
const isOccupiedByTask = isSlotOccupiedByTask(date, slot, draggedTask?.id);
|
||
const isOccupiedByAnyTask = isSlotOccupiedByTask(date, slot);
|
||
|
||
const handleSlotClick = (e: React.MouseEvent) => {
|
||
if (slotDragJustEndedRef.current) return; // Suppress click after drag-to-create
|
||
if (isProtected || isOccupiedByAnyTask) return; // Don't allow adding tasks to protected or occupied slots
|
||
|
||
// Alt+Click to Create Calendar Event
|
||
if (e.altKey) {
|
||
e.stopPropagation();
|
||
setCalendarEventModal({
|
||
isOpen: true,
|
||
initialDate: date,
|
||
initialStartTime: slot,
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (!isActive) {
|
||
setActiveSlot({ day: date.getDay(), slot });
|
||
setNewSlotTask("");
|
||
}
|
||
};
|
||
|
||
const handleSlotSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const taskTitle = newSlotTask.trim();
|
||
// Clear state immediately to prevent double submit
|
||
setActiveSlot(null);
|
||
setNewSlotTask("");
|
||
if (taskTitle) {
|
||
await addTask(date, taskTitle, slot);
|
||
}
|
||
};
|
||
|
||
const handleSlotDrop = (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
if (isProtected || isOccupiedByTask) return; // Don't allow dropping on protected or occupied slots
|
||
handleDrop(e, date.getDay(), slot);
|
||
};
|
||
|
||
const isDropTarget =
|
||
dropPreview?.day === date.getDay() &&
|
||
dropPreview?.slot === slot;
|
||
|
||
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||
const isInDragSelection = slotDragSelection &&
|
||
slotDragSelection.dateStr === dateStr &&
|
||
slot >= slotDragSelection.startSlot &&
|
||
slot <= slotDragSelection.endSlot;
|
||
|
||
return (
|
||
<div
|
||
key={slot}
|
||
data-slot={slot}
|
||
data-slot-blocked={isProtected || isOccupiedByAnyTask ? "1" : undefined}
|
||
className={`time-slot ${isHourStart ? "hour-start" : ""} ${draggedTask && !isProtected && !isOccupiedByTask ? "drop-target" : ""} ${isActive ? "active" : ""} ${isInDragSelection ? "slot-drag-selected" : ""}`}
|
||
style={{
|
||
height: `${getSlotHeight(effectiveCellDuration)}px`,
|
||
position: "relative",
|
||
cursor: isProtected || isOccupiedByAnyTask ? "not-allowed" : "text",
|
||
}}
|
||
onClick={handleSlotClick}
|
||
onMouseDown={(e) => {
|
||
// Only start slot drag on empty slots, left button, no modifiers
|
||
if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey) return;
|
||
if (isProtected || isOccupiedByAnyTask) return;
|
||
// Don't start drag if clicking on an event or task
|
||
const target = e.target as HTMLElement;
|
||
if (target.closest('.calendar-event-block, .grid-task-block, .task-input-slot')) return;
|
||
slotDragRef.current = {
|
||
active: false,
|
||
date: new Date(date),
|
||
startSlot: slot,
|
||
currentSlot: slot,
|
||
startY: e.clientY,
|
||
};
|
||
}}
|
||
onDragOver={(e) =>
|
||
!isProtected && !isOccupiedByTask &&
|
||
handleDragOver(e, date.getDay(), slot)
|
||
}
|
||
onDrop={handleSlotDrop}
|
||
>
|
||
{/* Weather indicator for hour-start slots */}
|
||
{isHourStart && effectiveWeatherEnabled && (() => {
|
||
const h = parseInt(slot.split(":")[0]);
|
||
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}T${String(h).padStart(2, "0")}:00`;
|
||
const w = weatherData[dateStr];
|
||
if (!w) return null;
|
||
const d = effectiveWeatherDisplay;
|
||
const parts: string[] = [];
|
||
if (d.includes("temp")) parts.push(`${w.temp}°`);
|
||
if (d.includes("feelsLike") && w.feelsLike != null) parts.push(`(${w.feelsLike}°)`);
|
||
if (d.includes("wind") && w.wind != null) parts.push(`${w.wind}km/h`);
|
||
if (d.includes("gusts") && w.gusts != null) parts.push(`💨${w.gusts}`);
|
||
if (d.includes("precipProb") && w.precipProb != null) parts.push(`${w.precipProb}%`);
|
||
if (d.includes("precip") && w.precip != null && w.precip > 0) parts.push(`${w.precip}mm`);
|
||
if (d.includes("humidity") && w.humidity != null) parts.push(`💧${w.humidity}%`);
|
||
if (d.includes("uv") && w.uv != null && w.uv > 0) parts.push(`UV${w.uv}`);
|
||
return (
|
||
<div className="weather-chip" style={{ position: "absolute", top: "2px", right: "3px", display: "flex", alignItems: "center", gap: "2px", fontSize: "10px", pointerEvents: "none", zIndex: 20, lineHeight: 1, color: darkMode ? "#bbb" : "#555", background: darkMode ? "rgba(17,17,17,0.9)" : "rgba(255,255,255,0.92)", padding: "1px 5px", borderRadius: "6px", backdropFilter: "blur(4px)", boxShadow: "0 0 0 1px " + (darkMode ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.06)") }}>
|
||
{d.includes("icon") && <span>{getWeatherIcon(w.code)}</span>}
|
||
{parts.length > 0 && <span style={{ fontWeight: 600 }}>{parts.join(" ")}</span>}
|
||
</div>
|
||
);
|
||
})()}
|
||
{/* Drop preview indicator */}
|
||
{isDropTarget && !isProtected && !isOccupiedByTask && (
|
||
<div className="drop-preview" />
|
||
)}
|
||
{/* Calendar Events in time slot */}
|
||
{slotEvents.map((event) => {
|
||
// During drag, use the drag state's current times for position/height
|
||
const isDragging = eventDragState?.eventId === event.id && eventDragState?.hasMoved;
|
||
const displayStartTime = isDragging ? new Date(eventDragState!.currentStartTime) : new Date(event.startTime);
|
||
const displayEndTime = isDragging ? new Date(eventDragState!.currentEndTime) : new Date(event.endTime);
|
||
const durationMs = displayEndTime.getTime() - displayStartTime.getTime();
|
||
const durationMinutes = durationMs / (1000 * 60);
|
||
const pxPerMin = getSlotHeight(effectiveCellDuration) / effectiveCellDuration;
|
||
const eventHeight = Math.max(durationMinutes * pxPerMin, 15);
|
||
const timeStr = `${displayStartTime.getHours().toString().padStart(2, "0")}:${displayStartTime.getMinutes().toString().padStart(2, "0")} - ${displayEndTime.getHours().toString().padStart(2, "0")}:${displayEndTime.getMinutes().toString().padStart(2, "0")}`;
|
||
|
||
// Calculate offset within the slot based on event start time
|
||
const [slotHour, slotMinute] = slot
|
||
.split(":")
|
||
.map(Number);
|
||
const slotStartMinutes = slotHour * 60 + slotMinute;
|
||
const eventStartMinutes =
|
||
displayStartTime.getHours() * 60 +
|
||
displayStartTime.getMinutes();
|
||
const offsetMinutes =
|
||
eventStartMinutes - slotStartMinutes;
|
||
const topOffset = offsetMinutes * pxPerMin;
|
||
|
||
// Convert hex to rgba for background, or use default
|
||
const eventColor = event.calendarColor || "#009a9a";
|
||
const bgColor = eventColor.startsWith("#")
|
||
? `${eventColor}20` // Add alpha for transparency
|
||
: eventColor;
|
||
const borderColor = eventColor.startsWith("#")
|
||
? eventColor
|
||
: "var(--weekly-teal)";
|
||
|
||
const overlap = eventOverlapLayout[event.id] || { column: 0, totalColumns: 1 };
|
||
const colWidth = 100 / overlap.totalColumns;
|
||
const colLeft = overlap.column * colWidth;
|
||
|
||
return (
|
||
<div
|
||
key={event.id}
|
||
className={`time-slot-event${isDragging ? ' event-dragging' : ''}`}
|
||
title={`${event.calendarTitle}: ${event.title}\n${timeStr}`}
|
||
style={{
|
||
height: `${eventHeight}px`,
|
||
minHeight: `15px`,
|
||
position: "absolute",
|
||
top: `${topOffset}px`,
|
||
left: `calc(${colLeft}% + 1px)`,
|
||
width: `calc(${colWidth}% - 3px)`,
|
||
zIndex: isDragging ? 50 : 1,
|
||
flexDirection: "column",
|
||
alignItems: "flex-start",
|
||
justifyContent: "flex-start",
|
||
backgroundColor: bgColor,
|
||
borderLeftColor: borderColor,
|
||
color: borderColor,
|
||
cursor: event.editable
|
||
? (isDragging ? "grabbing" : "grab")
|
||
: "default",
|
||
opacity: isDragging && eventDragState?.mode === 'move' ? 0.7 : 1,
|
||
transition: isDragging ? 'none' : undefined,
|
||
userSelect: 'none',
|
||
}}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (dragJustEndedRef.current) return; // Don't open modal after drag
|
||
if (event.editable) {
|
||
setCalendarEventModal({
|
||
isOpen: true,
|
||
event: event,
|
||
});
|
||
}
|
||
}}
|
||
onMouseDown={(e) => {
|
||
if (event.editable && e.button === 0) {
|
||
handleEventDragStart(e, event, 'move');
|
||
}
|
||
}}
|
||
>
|
||
{/* Top resize handle */}
|
||
{event.editable && (
|
||
<div
|
||
className="event-resize-handle event-resize-handle-top"
|
||
onMouseDown={(e) => {
|
||
e.stopPropagation();
|
||
handleEventDragStart(e, event, 'resize-top');
|
||
}}
|
||
/>
|
||
)}
|
||
<div className="event-title-row">
|
||
<span className="event-indicator">📅</span>
|
||
<span className="event-title">
|
||
{event.title}
|
||
</span>
|
||
</div>
|
||
<div className="event-time-row">{timeStr}</div>
|
||
{event.location && (
|
||
<div className="event-location-row">{event.location}</div>
|
||
)}
|
||
<div className="event-icons-row">
|
||
{event.description && (
|
||
<span className="event-note-icon" data-note={stripHtml(event.description || '')}>
|
||
<FileText size={11} />
|
||
</span>
|
||
)}
|
||
{event.isRecurring && <Repeat size={11} />}
|
||
<FontAwesomeIcon
|
||
icon={event.source === "google" ? faGoogle : event.source === "apple" ? faApple : event.source === "outlook" ? faMicrosoft : faServer}
|
||
style={{ fontSize: '0.6rem' }}
|
||
/>
|
||
</div>
|
||
{/* Bottom resize handle */}
|
||
{event.editable && (
|
||
<div
|
||
className="event-resize-handle event-resize-handle-bottom"
|
||
onMouseDown={(e) => {
|
||
e.stopPropagation();
|
||
handleEventDragStart(e, event, 'resize-bottom');
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
{isActive && (
|
||
<form
|
||
onSubmit={handleSlotSubmit}
|
||
className="slot-input-form"
|
||
>
|
||
<input
|
||
type="text"
|
||
value={newSlotTask}
|
||
onChange={(e) => setNewSlotTask(e.target.value)}
|
||
onBlur={async (e) => {
|
||
// Prevent double submission if form was submitted
|
||
if (activeSlot && newSlotTask.trim()) {
|
||
// Delay slightly to let onSubmit fire if that was the cause
|
||
setTimeout(async () => {
|
||
if (activeSlot && newSlotTask.trim()) {
|
||
const taskTitle = newSlotTask.trim();
|
||
setActiveSlot(null);
|
||
setNewSlotTask("");
|
||
await addTask(date, taskTitle, slot);
|
||
}
|
||
}, 100);
|
||
} else {
|
||
setActiveSlot(null);
|
||
setNewSlotTask("");
|
||
}
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Escape") {
|
||
setActiveSlot(null);
|
||
setNewSlotTask("");
|
||
}
|
||
}}
|
||
autoFocus
|
||
className="weekly-task-input"
|
||
style={{
|
||
width: "100%",
|
||
background: "transparent",
|
||
outline: "none",
|
||
minHeight: "24px",
|
||
paddingLeft: "0",
|
||
}}
|
||
/>
|
||
|
||
</form>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
{/* End-of-day 24:00 line */}
|
||
<div className="time-slot hour-start" style={{ height: 0, position: 'relative' }} />
|
||
{/* All Day Events Section */}
|
||
|
||
|
||
</div>
|
||
) : (
|
||
<div
|
||
onDragOver={(e) => handleDragOver(e, date.getDay())}
|
||
onDrop={(e) => handleDrop(e, date.getDay())}
|
||
onDragLeave={handleDragLeave}
|
||
style={{ flex: 1 }}
|
||
>
|
||
{/* Calendar Events */}
|
||
{getEventsForDate(date).map((event) => {
|
||
const eventColor = event.calendarColor || "#009a9a";
|
||
const bgColor = eventColor.startsWith("#")
|
||
? `${eventColor}20`
|
||
: eventColor;
|
||
const borderColor = eventColor.startsWith("#")
|
||
? eventColor
|
||
: "var(--weekly-teal)";
|
||
|
||
return (
|
||
<div
|
||
key={event.id}
|
||
className="weekly-calendar-event"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (event.editable) {
|
||
setCalendarEventModal({
|
||
isOpen: true,
|
||
event: event,
|
||
});
|
||
}
|
||
}}
|
||
style={{
|
||
backgroundColor: bgColor,
|
||
borderLeftColor: borderColor,
|
||
color: borderColor,
|
||
cursor: event.editable ? "pointer" : "default",
|
||
}}
|
||
>
|
||
<div
|
||
className="weekly-calendar-event-time"
|
||
style={{ color: "inherit", opacity: 0.8 }}
|
||
>
|
||
{new Date(event.startTime).toLocaleTimeString(
|
||
"en-US",
|
||
{ hour: "numeric", minute: "2-digit" },
|
||
)}
|
||
</div>
|
||
<div
|
||
className="weekly-calendar-event-title"
|
||
style={{ color: "inherit" }}
|
||
>
|
||
{event.title}
|
||
</div>
|
||
{event.location && (
|
||
<div className="event-location-row" style={{ color: "inherit", opacity: 0.7 }}>{event.location}</div>
|
||
)}
|
||
{(event.isRecurring || event.description || profile.showCalendarProviderIcon) && (
|
||
<div className="event-icons-row">
|
||
{event.description && (
|
||
<span className="event-note-icon" data-note={stripHtml(event.description || '')}>
|
||
<FileText size={11} />
|
||
</span>
|
||
)}
|
||
{event.isRecurring && <Repeat size={11} />}
|
||
{profile.showCalendarProviderIcon && (
|
||
<img
|
||
src={
|
||
event.source === "google" ? "/icons/google-calendar.svg"
|
||
: event.source === "apple" ? "/icons/apple-calendar.svg"
|
||
: event.source === "outlook" ? "/icons/outlook-calendar.svg"
|
||
: event.source === "synology" ? "/icons/synology-calendar.svg"
|
||
: undefined
|
||
}
|
||
alt={event.source}
|
||
style={{ width: 13, height: 13 }}
|
||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* Tasks */}
|
||
<ol className="weekly-task-list">
|
||
{getTasksForDate(date).map((task) => (
|
||
<TaskItem
|
||
key={task.id}
|
||
task={task}
|
||
isEditing={editingTaskId === task.id}
|
||
onToggle={() => toggleTask(task.id)}
|
||
onEdit={() => setEditingTaskId(task.id)}
|
||
onUpdate={(newTitle) => updateTask(task.id, newTitle)}
|
||
onDelete={() => deleteTask(task.id)}
|
||
onNotes={(notes) => updateTaskNotes(task.id, notes)}
|
||
onUrl={(url) => updateTaskUrl(task.id, url)}
|
||
onRollToggle={() => toggleTaskRolling(task.id)}
|
||
onRecurrence={() =>
|
||
setSelectedTaskForRecurrence(task)
|
||
}
|
||
onDragStart={(e, t) => handleDragStart(e, t)}
|
||
onDragEnd={handleDragEnd}
|
||
onAddSubTask={addSubTask}
|
||
onToggleSubTask={toggleSubTask}
|
||
onDeleteSubTask={deleteSubTask}
|
||
onUpdateSubTask={updateSubTask}
|
||
editingTaskId={editingTaskId}
|
||
onSetEditingTaskId={setEditingTaskId}
|
||
showTaskCheckboxes={effectiveShowTaskCheckboxes}
|
||
showProjectIcons={effectiveShowProjectIcons}
|
||
showPriorityIcons={effectiveShowPriorityIcons}
|
||
priorityStyle={effectivePriorityStyle}
|
||
projects={projects}
|
||
onProjectAssign={assignProject}
|
||
kanbanStages={kanbanStages}
|
||
/>
|
||
))}
|
||
</ol>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</main>
|
||
|
||
</div>
|
||
{/* Right Navigation Arrows (outside scroll wrapper, fixed to right edge) */}
|
||
<div
|
||
className="side-nav side-nav-right"
|
||
style={{
|
||
height: `${measuredHeaderHeight}px`,
|
||
position: 'absolute',
|
||
top: 0,
|
||
right: 16,
|
||
width: '30px',
|
||
zIndex: 50
|
||
}}
|
||
>
|
||
<button onClick={goToNextDay} title="Next Day" className="side-nav-btn">
|
||
<ChevronRight size={16} />
|
||
</button>
|
||
<button onClick={goToNextWeek} title="Next Week" className="side-nav-btn">
|
||
<ChevronsRight size={16} />
|
||
</button>
|
||
</div>
|
||
</div>}
|
||
|
||
{/* All-Day Events Section (below position) — hidden in kanban */}
|
||
{profile.viewStyle !== "kanban" && profile.viewStyle !== "priority" && effectiveAllDayPosition === "below" && allDaySection}
|
||
|
||
{/* Someday Section */}
|
||
{effectiveShowSomeday && (<>
|
||
{/* Resize handle - on top border of someday section */}
|
||
{somedayExpanded && (
|
||
<div
|
||
className="resize-handle resize-handle-anyday"
|
||
onMouseDown={(e) => startResize(e, 'someday', true)}
|
||
onTouchStart={(e) => startResize(e, 'someday', true)}
|
||
>
|
||
<div className="resize-handle-bar" />
|
||
</div>
|
||
)}
|
||
<section
|
||
ref={somedaySectionRefCb}
|
||
className={`weekly-someday ${somedayExpanded ? "expanded" : "collapsed"} transition-colors duration-200`}
|
||
style={somedayExpanded && somedayHeight ? { height: `${somedayHeight}px`, overflowY: 'auto' } : undefined}
|
||
>
|
||
{/* Someday tabs bar */}
|
||
<div className="someday-tabs-bar">
|
||
<div
|
||
onClick={() => setSomedayExpanded(!somedayExpanded)}
|
||
style={{
|
||
cursor: "pointer",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: "4px",
|
||
marginRight: "4px",
|
||
marginBottom: "6px",
|
||
}}
|
||
title={somedayExpanded ? "Collapse" : "Expand"}
|
||
>
|
||
<ChevronRight
|
||
size={14}
|
||
style={{
|
||
transform: somedayExpanded ? "rotate(90deg)" : "rotate(0deg)",
|
||
transition: "transform 0.15s",
|
||
color: "var(--weekly-text-light)",
|
||
flexShrink: 0,
|
||
}}
|
||
/>
|
||
<span style={{
|
||
fontSize: "0.7rem",
|
||
fontWeight: 600,
|
||
color: "var(--weekly-text-light)",
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.05em",
|
||
whiteSpace: "nowrap",
|
||
}}>
|
||
any day
|
||
</span>
|
||
</div>
|
||
<button
|
||
className="someday-bar-icon-btn"
|
||
onClick={() => handleStartAddSomedayList()}
|
||
title={t.newList}
|
||
>
|
||
<ListPlus size={14} />
|
||
</button>
|
||
<button
|
||
className="someday-bar-icon-btn"
|
||
onClick={() => { setCreatingNewTab(true); setCreatingNewTabName(""); }}
|
||
title={t.newTab}
|
||
>
|
||
<FolderPlus size={14} />
|
||
</button>
|
||
{creatingNewTab && (
|
||
<input
|
||
className="someday-tab-rename-input"
|
||
value={creatingNewTabName}
|
||
onChange={(e) => setCreatingNewTabName(e.target.value)}
|
||
onBlur={() => {
|
||
const name = creatingNewTabName.trim();
|
||
if (name && !somedayTabs.includes(name)) {
|
||
saveCustomTabs([...customTabs, name]);
|
||
setSomedayTab(name);
|
||
} else if (name) {
|
||
setSomedayTab(name);
|
||
}
|
||
setCreatingNewTab(false);
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") e.currentTarget.blur();
|
||
if (e.key === "Escape") setCreatingNewTab(false);
|
||
}}
|
||
placeholder={t.newTab}
|
||
autoFocus
|
||
style={{
|
||
fontSize: "0.75rem",
|
||
border: "1px solid var(--weekly-border)",
|
||
borderRadius: "3px",
|
||
padding: "2px 6px",
|
||
background: "var(--weekly-bg)",
|
||
color: "var(--weekly-text)",
|
||
width: "80px",
|
||
}}
|
||
/>
|
||
)}
|
||
<div className="someday-tabs-bar-divider" />
|
||
<button
|
||
className={`someday-tab-btn-h ${activeSomedayTab === null ? "active" : ""} ${dragOverTab === "__all__" ? "drag-over" : ""}`}
|
||
onClick={() => setSomedayTab(null)}
|
||
onDragOver={(e) => {
|
||
if (e.dataTransfer.types.includes("text/list-id")) {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "move";
|
||
setDragOverTab("__all__");
|
||
}
|
||
}}
|
||
onDragLeave={() => setDragOverTab(null)}
|
||
onDrop={(e) => {
|
||
const listId = e.dataTransfer.getData("text/list-id");
|
||
if (listId) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
assignListToTab(listId, null);
|
||
setDraggingListId(null);
|
||
setDropTargetListIndex(null);
|
||
}
|
||
setDragOverTab(null);
|
||
}}
|
||
>{t.allTabs} <span className="someday-tab-count">{somedayLists.length}</span></button>
|
||
{somedayTabs.map(tab => (
|
||
editingTabName === tab ? (
|
||
<input
|
||
key={tab}
|
||
className="someday-tab-rename-input"
|
||
value={renamingTabValue}
|
||
onChange={(e) => setRenamingTabValue(e.target.value)}
|
||
onBlur={() => {
|
||
renameTab(tab, renamingTabValue);
|
||
setEditingTabName(null);
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") e.currentTarget.blur();
|
||
if (e.key === "Escape") setEditingTabName(null);
|
||
}}
|
||
autoFocus
|
||
style={{
|
||
fontSize: "0.75rem",
|
||
border: "1px solid var(--weekly-border)",
|
||
borderRadius: "3px",
|
||
padding: "2px 6px",
|
||
background: "var(--weekly-bg)",
|
||
color: "var(--weekly-text)",
|
||
width: "80px",
|
||
}}
|
||
/>
|
||
) : (
|
||
<div
|
||
key={tab}
|
||
className="someday-tab-wrapper-h"
|
||
style={{ position: "relative" }}
|
||
onDragOver={(e) => {
|
||
if (e.dataTransfer.types.includes("text/list-id")) {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "move";
|
||
setDragOverTab(tab);
|
||
}
|
||
}}
|
||
onDragLeave={() => setDragOverTab(null)}
|
||
onDrop={(e) => {
|
||
const listId = e.dataTransfer.getData("text/list-id");
|
||
if (listId) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
assignListToTab(listId, tab);
|
||
setDraggingListId(null);
|
||
setDropTargetListIndex(null);
|
||
}
|
||
setDragOverTab(null);
|
||
}}
|
||
>
|
||
{(() => {
|
||
const tv = getTabVisuals(tab);
|
||
return (
|
||
<button
|
||
className={`someday-tab-btn-h ${activeSomedayTab === tab ? "active" : ""} ${dragOverTab === tab ? "drag-over" : ""}`}
|
||
onClick={() => setSomedayTab(tab)}
|
||
onDoubleClick={(e) => { e.stopPropagation(); const newTab = editingTabVisualsName === tab ? null : tab; if (newTab) setEditingTabVisualsRect((e.currentTarget as HTMLElement).getBoundingClientRect()); setEditingTabVisualsName(newTab); }}
|
||
style={tv.color ? {
|
||
background: activeSomedayTab === tab ? tv.color : `${tv.color}22`,
|
||
color: activeSomedayTab === tab ? "#fff" : tv.color,
|
||
borderColor: tv.color,
|
||
borderBottomColor: activeSomedayTab === tab ? "transparent" : tv.color,
|
||
} : undefined}
|
||
title={profile.language === "de" ? "Doppelklick zum Bearbeiten" : "Double-click to edit"}
|
||
>
|
||
{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>
|
||
);
|
||
})()}
|
||
{editingTabVisualsName === tab && (
|
||
<TabVisualsPopover
|
||
tabName={tab}
|
||
visuals={getTabVisuals(tab)}
|
||
darkMode={darkMode}
|
||
language={profile.language}
|
||
onChange={(updates) => updateTabVisuals(tab, updates)}
|
||
onRename={(newName) => { renameTab(tab, newName); setEditingTabVisualsName(null); }}
|
||
onDissolve={() => dissolveTab(tab)}
|
||
onClose={() => setEditingTabVisualsName(null)}
|
||
anchorRect={editingTabVisualsRect}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
))}
|
||
</div>
|
||
<div style={{ display: "flex", flexDirection: "row", maxWidth: "100%", width: "100%" }}>
|
||
<div ref={somedayGridRef} style={{ flex: 1, minWidth: 0, overflowX: "auto" }}>
|
||
{somedayExpanded && (
|
||
<div
|
||
className={`weekly-someday-lists-grid cols-${Math.min(7, Math.max(1, viewDays))}`}
|
||
style={{ display: "flex", flexDirection: "row", flexWrap: "nowrap" }}
|
||
onDragLeave={(e) => {
|
||
// Clear indicator when leaving the someday grid entirely
|
||
if (draggingListId && !e.currentTarget.contains(e.relatedTarget as Node)) {
|
||
setDropTargetListIndex(null);
|
||
}
|
||
}}
|
||
>
|
||
{(() => {
|
||
const baseLists = filteredSomedayLists.length > 0
|
||
? filteredSomedayLists
|
||
: [{ id: "default", title: "LISTE", tasks: [] as Task[] }];
|
||
return baseLists.slice(0, Math.max(filteredSomedayLists.length, viewDays));
|
||
})()
|
||
.flatMap((list, listIdx, arr) => {
|
||
const indicator = draggingListId && dropTargetListIndex === listIdx && draggingListId !== list.id ? (
|
||
<div key={`drop-indicator-${listIdx}`} style={{
|
||
width: "3px",
|
||
flexShrink: 0,
|
||
background: "#6366f1",
|
||
borderRadius: "2px",
|
||
alignSelf: "stretch",
|
||
transition: "opacity 0.15s",
|
||
}} />
|
||
) : null;
|
||
// After last item, check for drop at end
|
||
const endIndicator = listIdx === arr.length - 1 && draggingListId && dropTargetListIndex === arr.length && draggingListId !== list.id ? (
|
||
<div key="drop-indicator-end" style={{
|
||
width: "3px",
|
||
flexShrink: 0,
|
||
background: "#6366f1",
|
||
borderRadius: "2px",
|
||
alignSelf: "stretch",
|
||
transition: "opacity 0.15s",
|
||
}} />
|
||
) : null;
|
||
const listEl = (
|
||
<div
|
||
key={list.id}
|
||
className={`weekly-someday-list ${draggingListId === list.id ? "is-dragging" : ""} transition-colors duration-200`}
|
||
style={{
|
||
minHeight: "200px",
|
||
cursor: "text",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
position: "relative",
|
||
...(list.color ? {
|
||
background: `${list.color}1f`,
|
||
borderLeft: `4px solid ${list.color}`,
|
||
borderRadius: "6px",
|
||
} : {}),
|
||
}}
|
||
onMouseDown={(e) => {
|
||
const target = e.target as HTMLElement;
|
||
if (
|
||
target.closest(".task-list-slot") ||
|
||
target.closest(".someday-list-header") ||
|
||
target.closest("button") ||
|
||
target.tagName === "INPUT"
|
||
) {
|
||
return;
|
||
}
|
||
|
||
const input = e.currentTarget.querySelector(
|
||
`[data-someday-add-input="${list.id}"]`,
|
||
) as HTMLInputElement;
|
||
if (input) {
|
||
input.focus();
|
||
}
|
||
}}
|
||
draggable
|
||
onDragStart={(e) => {
|
||
const target = e.target as HTMLElement;
|
||
if (target.closest(".weekly-task-item")) {
|
||
return;
|
||
}
|
||
if (!isDragFromHandle.current) {
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
isDragFromHandle.current = false;
|
||
setDraggingListId(list.id);
|
||
e.dataTransfer.setData("text/list-id", list.id);
|
||
e.dataTransfer.effectAllowed = "move";
|
||
}}
|
||
onDragEnd={() => { setDraggingListId(null); setDropTargetListIndex(null); setDragOverTab(null); }}
|
||
onDragOver={(e) => {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "move";
|
||
if (!draggingListId || draggingListId === list.id) return;
|
||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||
const midX = rect.left + rect.width / 2;
|
||
// Drop before or after this list
|
||
if (e.clientX < midX) {
|
||
setDropTargetListIndex(listIdx);
|
||
} else {
|
||
setDropTargetListIndex(listIdx + 1);
|
||
}
|
||
}}
|
||
onDrop={async (e) => {
|
||
e.preventDefault();
|
||
const droppedListId =
|
||
e.dataTransfer.getData("text/list-id");
|
||
const draggedTaskId =
|
||
e.dataTransfer.getData("text/plain");
|
||
|
||
if (droppedListId === list.id && !draggedTaskId) {
|
||
setDraggingListId(null);
|
||
setDropTargetListIndex(null);
|
||
return;
|
||
}
|
||
|
||
// If a task is dropped on the list generally (not on a specific slot),
|
||
// find the first free slot and place it there.
|
||
if (draggedTaskId && draggedTask && draggedTask.id === draggedTaskId) {
|
||
const occupiedSlots = list.tasks.map(t => t.somedaySlotIndex).filter(s => s !== null && s !== undefined) as number[];
|
||
let nextFreeSlot = 0;
|
||
while (occupiedSlots.includes(nextFreeSlot)) {
|
||
nextFreeSlot++;
|
||
}
|
||
handleSomedayDrop(e, list.id, nextFreeSlot);
|
||
return;
|
||
}
|
||
|
||
// Reorder logic (list drag) - apply the visual order
|
||
if (!droppedListId || dropTargetListIndex === null) {
|
||
setDraggingListId(null);
|
||
setDropTargetListIndex(null);
|
||
return;
|
||
}
|
||
const dragIdx = somedayLists.findIndex(
|
||
(l) => l.id === droppedListId,
|
||
);
|
||
if (dragIdx === -1) {
|
||
setDraggingListId(null);
|
||
setDropTargetListIndex(null);
|
||
return;
|
||
}
|
||
|
||
const newLists = [...somedayLists];
|
||
const [moved] = newLists.splice(dragIdx, 1);
|
||
// Adjust target index since we removed an item before it
|
||
const insertIdx = dragIdx < dropTargetListIndex
|
||
? dropTargetListIndex - 1
|
||
: dropTargetListIndex;
|
||
newLists.splice(insertIdx, 0, moved);
|
||
|
||
setSomedayLists(newLists);
|
||
setDraggingListId(null);
|
||
setDropTargetListIndex(null);
|
||
|
||
// Persist order
|
||
const orderUpdates = newLists.map((l, index) => ({
|
||
id: l.id,
|
||
order: index,
|
||
}));
|
||
try {
|
||
await fetch("/api/someday-lists", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(orderUpdates),
|
||
});
|
||
} catch (err) {
|
||
console.error("Failed to update list order", err);
|
||
}
|
||
}}
|
||
>
|
||
<div
|
||
className="weekly-someday-list-title-header"
|
||
style={{
|
||
display: "flex",
|
||
justifyContent: "flex-start",
|
||
alignItems: "center",
|
||
...(listToDelete === list.id ? { position: "relative", zIndex: 10 } : {}),
|
||
}}
|
||
>
|
||
{listToDelete === list.id ? (
|
||
<div style={{ display: "flex", flexDirection: "column", width: "100%", gap: "6px", padding: "4px" }}>
|
||
<span style={{ fontSize: "0.9rem", fontWeight: "bold" }}>
|
||
{profile.language === 'de' ? 'Diese Liste löschen?' : 'Delete this list?'}
|
||
</span>
|
||
{list.externalProvider ? (
|
||
<>
|
||
<span style={{ fontSize: "0.75rem", color: "#888" }}>
|
||
{profile.language === 'de'
|
||
? `Diese Liste ist mit ${list.externalProvider} verknüpft. Wie soll fortgefahren werden?`
|
||
: `This list is linked to ${list.externalProvider}. How would you like to proceed?`}
|
||
</span>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "6px", marginTop: "4px" }}>
|
||
<button onClick={(e) => {
|
||
e.stopPropagation();
|
||
setListToDelete(null);
|
||
}} style={{ padding: "6px 8px", borderRadius: "4px", backgroundColor: "#eee", color: "#333", border: "none", cursor: "pointer", fontSize: "0.8rem", textAlign: "left" }}>
|
||
{profile.language === 'de' ? 'Abbrechen' : 'Cancel'}
|
||
</button>
|
||
<button onClick={async (e) => {
|
||
e.stopPropagation();
|
||
try {
|
||
await fetch(`/api/someday-lists?id=${list.id}&tasksOnly=true`, { method: "DELETE" });
|
||
setSomedayLists((prev) => prev.filter((l) => l.id !== list.id));
|
||
setListToDelete(null);
|
||
} catch (err) { console.error(err); }
|
||
}} style={{ padding: "6px 8px", borderRadius: "4px", backgroundColor: "#3b82f6", color: "#fff", border: "none", cursor: "pointer", fontSize: "0.8rem", textAlign: "left" }}>
|
||
{profile.language === 'de'
|
||
? `Nur hier ausblenden (in ${list.externalProvider} bleibt sie)`
|
||
: `Hide here only (keep in ${list.externalProvider})`}
|
||
</button>
|
||
<button onClick={async (e) => {
|
||
e.stopPropagation();
|
||
try {
|
||
const res = await fetch(`/api/someday-lists?id=${list.id}&deleteExternal=true`, { method: "DELETE" });
|
||
const data = await res.json().catch(() => ({}));
|
||
if (data.externalDeleteFailed) {
|
||
alert(profile.language === 'de'
|
||
? `Hinweis: Die Liste wurde lokal gelöscht, aber nicht aus ${list.externalProvider} (Fehler beim Provider).`
|
||
: `Note: The list was deleted locally, but not from ${list.externalProvider} (provider error).`);
|
||
}
|
||
setSomedayLists((prev) => prev.filter((l) => l.id !== list.id));
|
||
setListToDelete(null);
|
||
} catch (err) { console.error(err); }
|
||
}} style={{ padding: "6px 8px", borderRadius: "4px", backgroundColor: "#dc2626", color: "#fff", border: "none", cursor: "pointer", fontSize: "0.8rem", textAlign: "left" }}>
|
||
{profile.language === 'de'
|
||
? `Hier UND in ${list.externalProvider} löschen`
|
||
: `Delete here AND in ${list.externalProvider}`}
|
||
</button>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div style={{ display: "flex", gap: "8px", marginTop: "4px" }}>
|
||
<button onClick={(e) => {
|
||
e.stopPropagation();
|
||
setListToDelete(null);
|
||
}} style={{ padding: "4px 8px", borderRadius: "4px", backgroundColor: "#eee", color: "#333", border: "none", cursor: "pointer", fontSize: "0.8rem" }}>
|
||
{profile.language === 'de' ? 'Abbrechen' : 'Cancel'}
|
||
</button>
|
||
<button onClick={async (e) => {
|
||
e.stopPropagation();
|
||
try {
|
||
await fetch(`/api/someday-lists?id=${list.id}`, { method: "DELETE" });
|
||
setSomedayLists((prev) => prev.filter((l) => l.id !== list.id));
|
||
setListToDelete(null);
|
||
} catch (err) { console.error(err); }
|
||
}} style={{ padding: "4px 8px", borderRadius: "4px", backgroundColor: "#dc2626", color: "#fff", border: "none", cursor: "pointer", fontSize: "0.8rem" }}>
|
||
{profile.language === 'de' ? 'Löschen' : 'Delete'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div
|
||
className="someday-drag-handle"
|
||
title="Drag to reorder"
|
||
onMouseDown={() => { isDragFromHandle.current = true; }}
|
||
onMouseUp={() => { isDragFromHandle.current = false; }}
|
||
>
|
||
<GripVertical size={14} />
|
||
</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
|
||
type="text"
|
||
defaultValue={list.title}
|
||
className="weekly-someday-list-title-input dark:bg-transparent dark:text-white"
|
||
onBlur={async (e) => {
|
||
const newTitle = e.target.value.trim();
|
||
if (newTitle && newTitle !== list.title) {
|
||
try {
|
||
await fetch("/api/someday-lists", {
|
||
method: "PATCH",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
id: list.id,
|
||
title: newTitle,
|
||
}),
|
||
});
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) =>
|
||
l.id === list.id
|
||
? { ...l, title: newTitle }
|
||
: l,
|
||
),
|
||
);
|
||
} catch (err) {
|
||
console.error(err);
|
||
e.target.value = list.title;
|
||
}
|
||
}
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") e.currentTarget.blur();
|
||
}}
|
||
/>
|
||
{/* Flyout menu: provider icon (or pencil) triggers all list actions */}
|
||
<div style={{ marginLeft: "auto", position: "relative", flexShrink: 0 }}>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); setMenuOpenListId(menuOpenListId === list.id ? null : list.id); }}
|
||
style={{ border: "none", background: "none", cursor: "pointer", padding: "4px", display: "flex", alignItems: "center", color: darkMode ? "#6b7280" : "#aaa", borderRadius: "4px" }}
|
||
title={profile.language === "de" ? "Listenoptionen" : "List options"}
|
||
>
|
||
{list.externalProvider === "google" ? <FontAwesomeIcon icon={faGoogle} style={{ fontSize: 13 }} /> :
|
||
list.externalProvider === "outlook" ? <FontAwesomeIcon icon={faMicrosoft} style={{ fontSize: 13 }} /> :
|
||
list.externalProvider === "apple" ? <FontAwesomeIcon icon={faApple} style={{ fontSize: 13 }} /> :
|
||
list.externalProvider === "synology" ? <FontAwesomeIcon icon={faServer} style={{ fontSize: 13 }} /> :
|
||
list.externalProvider === "notion" ? <FontAwesomeIcon icon={faNotion} style={{ fontSize: 13 }} /> :
|
||
<Pencil size={13} />}
|
||
</button>
|
||
{menuOpenListId === list.id && (
|
||
<>
|
||
<div style={{ position: "fixed", inset: 0, zIndex: 199 }} onClick={(e) => { e.stopPropagation(); setMenuOpenListId(null); }} />
|
||
<div style={{ position: "absolute", top: "calc(100% + 4px)", right: 0, zIndex: 200, background: darkMode ? "#1f2937" : "#fff", border: "1px solid var(--weekly-border)", borderRadius: "8px", boxShadow: "0 4px 16px rgba(0,0,0,0.12)", minWidth: "170px", overflow: "hidden" }}>
|
||
{/* Provider link */}
|
||
{list.externalProvider && (() => {
|
||
const providerUrl = list.externalProvider === "google" ? "https://tasks.google.com/tasks/" : list.externalProvider === "outlook" ? "https://to-do.live.com/tasks/" : list.externalProvider === "apple" ? "https://www.icloud.com/reminders/" : null;
|
||
const providerName = list.externalProvider === "outlook" ? "Microsoft" : list.externalProvider === "google" ? "Google" : list.externalProvider === "apple" ? "Apple" : list.externalProvider === "notion" ? "Notion" : list.externalProvider;
|
||
return providerUrl ? (
|
||
<a href={providerUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => { e.stopPropagation(); setMenuOpenListId(null); }} style={{ display: "flex", alignItems: "center", gap: "8px", padding: "8px 12px", fontSize: "0.8rem", color: darkMode ? "#e5e7eb" : "#374151", textDecoration: "none" }}>
|
||
{list.externalProvider === "google" ? <FontAwesomeIcon icon={faGoogle} style={{ fontSize: 13, opacity: 0.6 }} /> : list.externalProvider === "outlook" ? <FontAwesomeIcon icon={faMicrosoft} style={{ fontSize: 13, opacity: 0.6 }} /> : list.externalProvider === "apple" ? <FontAwesomeIcon icon={faApple} style={{ fontSize: 13, opacity: 0.6 }} /> : <RefreshCcw size={13} style={{ opacity: 0.6 }} />}
|
||
{profile.language === "de" ? `In ${providerName} öffnen` : `Open in ${providerName}`}
|
||
</a>
|
||
) : null;
|
||
})()}
|
||
{/* Assign to tab */}
|
||
<div style={{ position: "relative", display: "flex", alignItems: "center", gap: "8px", padding: "8px 12px", fontSize: "0.8rem", color: darkMode ? "#e5e7eb" : "#374151", cursor: "pointer" }}>
|
||
<FolderPlus size={13} style={{ opacity: 0.6, flexShrink: 0 }} />
|
||
<span>{profile.language === "de" ? "Tab zuweisen" : "Assign to tab"}{list.tab ? ` (${list.tab})` : ""}</span>
|
||
<select
|
||
value={list.tab || ""}
|
||
onClick={(e) => e.stopPropagation()}
|
||
onChange={(e) => {
|
||
const val = e.target.value;
|
||
if (val === "__new__") {
|
||
e.target.value = list.tab || "";
|
||
setNewTabForListId(list.id);
|
||
setNewTabNameValue("");
|
||
setMenuOpenListId(null);
|
||
} else {
|
||
assignListToTab(list.id, val || null);
|
||
setMenuOpenListId(null);
|
||
}
|
||
}}
|
||
style={{ position: "absolute", inset: 0, opacity: 0, cursor: "pointer", width: "100%" }}
|
||
>
|
||
<option value="">{t.noTab}</option>
|
||
{somedayTabs.map(tab => <option key={tab} value={tab}>{tab}</option>)}
|
||
<option value="__new__">+ {t.newTab || "New tab"}</option>
|
||
</select>
|
||
</div>
|
||
{/* Edit style */}
|
||
<button onClick={(e) => { e.stopPropagation(); setEditingListVisualsRect((e.currentTarget as HTMLElement).closest(".weekly-someday-list")?.getBoundingClientRect() ?? (e.currentTarget as HTMLElement).getBoundingClientRect()); setEditingListVisualsId(list.id); setMenuOpenListId(null); }} style={{ width: "100%", display: "flex", alignItems: "center", gap: "8px", padding: "8px 12px", fontSize: "0.8rem", color: darkMode ? "#e5e7eb" : "#374151", background: "none", border: "none", cursor: "pointer", textAlign: "left" }}>
|
||
<Pencil size={13} style={{ opacity: 0.6, flexShrink: 0 }} />
|
||
{profile.language === "de" ? "Stil bearbeiten" : "Edit style"}
|
||
</button>
|
||
<div style={{ height: "1px", background: "var(--weekly-border)", margin: "2px 0" }} />
|
||
{/* Delete */}
|
||
<button onClick={(e) => { e.stopPropagation(); setListToDelete(list.id); setMenuOpenListId(null); }} style={{ width: "100%", display: "flex", alignItems: "center", gap: "8px", padding: "8px 12px", fontSize: "0.8rem", color: "#ef4444", background: "none", border: "none", cursor: "pointer", textAlign: "left" }}>
|
||
<Trash2 size={13} style={{ flexShrink: 0 }} />
|
||
{profile.language === "de" ? "Löschen" : "Delete"}
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
{newTabForListId === list.id && (
|
||
<input
|
||
className="someday-tab-new-input"
|
||
value={newTabNameValue}
|
||
onChange={(e) => setNewTabNameValue(e.target.value)}
|
||
onBlur={() => {
|
||
if (newTabNameValue.trim()) assignListToTab(list.id, newTabNameValue.trim());
|
||
setNewTabForListId(null);
|
||
setNewTabNameValue("");
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") e.currentTarget.blur();
|
||
if (e.key === "Escape") { setNewTabForListId(null); setNewTabNameValue(""); }
|
||
}}
|
||
placeholder={t.newTabName || "Tab name..."}
|
||
autoFocus
|
||
onClick={(e) => e.stopPropagation()}
|
||
style={{ fontSize: "0.7rem", border: "1px solid var(--weekly-border)", borderRadius: "4px", padding: "2px 6px", background: "var(--weekly-bg)", color: "var(--weekly-text)", width: "80px", outline: "none" }}
|
||
/>
|
||
)}
|
||
</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)}
|
||
anchorRect={editingListVisualsRect}
|
||
/>
|
||
)}
|
||
|
||
<div
|
||
className="weekly-task-list"
|
||
style={{
|
||
flex: 1,
|
||
flexDirection: "column",
|
||
justifyContent: "flex-start",
|
||
position: "relative",
|
||
display: "flex",
|
||
}}
|
||
>
|
||
{(() => {
|
||
// Separate complete from active so done tasks always render at bottom
|
||
const activeTasks = list.tasks.filter(t => !t.completed);
|
||
const doneTasks = list.tasks.filter(t => t.completed);
|
||
|
||
const slotCount = getSomedaySlotCount(activeTasks);
|
||
const indexedTasks = activeTasks.filter(t => t.somedaySlotIndex !== null && t.somedaySlotIndex !== undefined && t.somedaySlotIndex < slotCount);
|
||
const unindexedTasks = activeTasks.filter(t => t.somedaySlotIndex === null || t.somedaySlotIndex === undefined || t.somedaySlotIndex >= slotCount);
|
||
|
||
// Fill indexed slots and put unindexed tasks in empty slots starting from top
|
||
const slots = Array.from({ length: slotCount }, (_, i) => ({ index: i, task: indexedTasks.find(t => t.somedaySlotIndex === i) || null }));
|
||
|
||
let unindexedIdx = 0;
|
||
const finalSlots = slots.map(slot => {
|
||
if (!slot.task && unindexedIdx < unindexedTasks.length) {
|
||
return { ...slot, task: unindexedTasks[unindexedIdx++] };
|
||
}
|
||
return slot;
|
||
});
|
||
|
||
// Any remaining unindexed active tasks that didn't fit in slots
|
||
const remainingTasks = unindexedTasks.slice(unindexedIdx);
|
||
|
||
return (
|
||
<>
|
||
{listToDelete !== list.id && <SomedayAddTask
|
||
listId={list.id}
|
||
darkMode={darkMode}
|
||
showProjectIcons={effectiveShowProjectIcons}
|
||
showTaskCheckboxes={effectiveShowTaskCheckboxes}
|
||
onAdd={async (title) => {
|
||
const occupiedSlots = list.tasks.map(t => t.somedaySlotIndex).filter(s => s !== null && s !== undefined) as number[];
|
||
let nextFreeSlot = 0;
|
||
while (occupiedSlots.includes(nextFreeSlot)) {
|
||
nextFreeSlot++;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch("/api/tasks", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
title,
|
||
somedayListId: list.id,
|
||
somedaySlotIndex: nextFreeSlot
|
||
}),
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
const newTask = {
|
||
...data.task,
|
||
createdAt: new Date(data.task.createdAt),
|
||
updatedAt: new Date(data.task.updatedAt),
|
||
};
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) =>
|
||
l.id === list.id
|
||
? { ...l, tasks: [...l.tasks, newTask] }
|
||
: l,
|
||
),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
}}
|
||
/>}
|
||
{finalSlots.map((slot) => {
|
||
const isTarget = dropPreview?.listId === list.id && dropPreview?.slotIdx === slot.index;
|
||
const task = slot.task;
|
||
return (
|
||
<div
|
||
key={slot.index}
|
||
className={`task-list-slot ${isTarget ? 'drop-target' : ''}`}
|
||
onDragOver={(e) => handleSomedayDragOver(e, list.id, slot.index)}
|
||
onDrop={(e) => handleSomedayDrop(e, list.id, slot.index)}
|
||
onDragLeave={() => setDropPreview(null)}
|
||
onMouseDown={(e) => {
|
||
e.stopPropagation();
|
||
if (!task) {
|
||
setActiveAddSlot({ listId: list.id, slotIdx: slot.index });
|
||
}
|
||
}}
|
||
style={{ cursor: task ? "default" : "text" }}
|
||
>
|
||
{task ? (
|
||
<TaskItem
|
||
key={task.id}
|
||
task={task}
|
||
isEditing={editingTaskId === task.id}
|
||
onToggle={() => toggleTask(task.id)}
|
||
onEdit={() => setEditingTaskId(task.id)}
|
||
onUpdate={(title) => updateTask(task.id, title)}
|
||
onDelete={() => deleteTask(task.id)}
|
||
onNotes={(notes) => updateTaskNotes(task.id, notes)}
|
||
onUrl={(url) => updateTaskUrl(task.id, url)}
|
||
onRollToggle={() => toggleTaskRolling(task.id)}
|
||
onRecurrence={() => setSelectedTaskForRecurrence(task)}
|
||
onDragStart={(e, t) => handleDragStart(e, t)}
|
||
onDragEnd={handleDragEnd}
|
||
variant="minimal"
|
||
isSomeday={true}
|
||
onAddSubTask={addSubTask}
|
||
onToggleSubTask={toggleSubTask}
|
||
onDeleteSubTask={deleteSubTask}
|
||
onUpdateSubTask={updateSubTask}
|
||
editingTaskId={editingTaskId}
|
||
onSetEditingTaskId={setEditingTaskId}
|
||
showTaskCheckboxes={effectiveShowTaskCheckboxes}
|
||
showProjectIcons={effectiveShowProjectIcons}
|
||
showPriorityIcons={effectiveShowPriorityIcons}
|
||
priorityStyle={effectivePriorityStyle}
|
||
projects={projects}
|
||
onProjectAssign={assignProject}
|
||
kanbanStages={kanbanStages}
|
||
/>
|
||
) : (
|
||
activeAddSlot?.listId === list.id && activeAddSlot?.slotIdx === slot.index && (
|
||
<SomedayAddTask
|
||
listId={list.id}
|
||
slotIdx={slot.index}
|
||
onAdd={async (title) => {
|
||
try {
|
||
const res = await fetch("/api/tasks", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
title,
|
||
somedayListId: list.id,
|
||
somedaySlotIndex: slot.index
|
||
}),
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
const newTask = {
|
||
...data.task,
|
||
createdAt: new Date(data.task.createdAt),
|
||
updatedAt: new Date(data.task.updatedAt),
|
||
};
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) =>
|
||
l.id === list.id
|
||
? { ...l, tasks: [...l.tasks, newTask] }
|
||
: l,
|
||
),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
setActiveAddSlot(null);
|
||
}}
|
||
onCancel={() => setActiveAddSlot(null)}
|
||
/>
|
||
)
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
{remainingTasks.map((task) => (
|
||
<div key={task.id} className="task-list-slot">
|
||
<TaskItem
|
||
key={task.id}
|
||
task={task}
|
||
isEditing={editingTaskId === task.id}
|
||
onToggle={() => toggleTask(task.id)}
|
||
onEdit={() => setEditingTaskId(task.id)}
|
||
onUpdate={(title) => updateTask(task.id, title)}
|
||
onDelete={() => deleteTask(task.id)}
|
||
onNotes={(notes) => updateTaskNotes(task.id, notes)}
|
||
onUrl={(url) => updateTaskUrl(task.id, url)}
|
||
onRollToggle={() => toggleTaskRolling(task.id)}
|
||
onRecurrence={() => setSelectedTaskForRecurrence(task)}
|
||
onDragStart={(e, t) => handleDragStart(e, t)}
|
||
onDragEnd={handleDragEnd}
|
||
variant="minimal"
|
||
isSomeday={true}
|
||
onAddSubTask={addSubTask}
|
||
onToggleSubTask={toggleSubTask}
|
||
onDeleteSubTask={deleteSubTask}
|
||
onUpdateSubTask={updateSubTask}
|
||
editingTaskId={editingTaskId}
|
||
onSetEditingTaskId={setEditingTaskId}
|
||
showTaskCheckboxes={effectiveShowTaskCheckboxes}
|
||
showProjectIcons={effectiveShowProjectIcons}
|
||
showPriorityIcons={effectiveShowPriorityIcons}
|
||
priorityStyle={effectivePriorityStyle}
|
||
projects={projects}
|
||
onProjectAssign={assignProject}
|
||
kanbanStages={kanbanStages}
|
||
/>
|
||
</div>
|
||
))}
|
||
{/* Completed tasks always rendered at bottom */}
|
||
{doneTasks.map((task) => (
|
||
<div key={task.id} className="task-list-slot">
|
||
<TaskItem
|
||
key={task.id}
|
||
task={task}
|
||
isEditing={editingTaskId === task.id}
|
||
onToggle={() => toggleTask(task.id)}
|
||
onEdit={() => setEditingTaskId(task.id)}
|
||
onUpdate={(title) => updateTask(task.id, title)}
|
||
onDelete={() => deleteTask(task.id)}
|
||
onNotes={(notes) => updateTaskNotes(task.id, notes)}
|
||
onUrl={(url) => updateTaskUrl(task.id, url)}
|
||
onRollToggle={() => toggleTaskRolling(task.id)}
|
||
onRecurrence={() => setSelectedTaskForRecurrence(task)}
|
||
onDragStart={(e, t) => handleDragStart(e, t)}
|
||
onDragEnd={handleDragEnd}
|
||
variant="minimal"
|
||
isSomeday={true}
|
||
onAddSubTask={addSubTask}
|
||
onToggleSubTask={toggleSubTask}
|
||
onDeleteSubTask={deleteSubTask}
|
||
onUpdateSubTask={updateSubTask}
|
||
editingTaskId={editingTaskId}
|
||
onSetEditingTaskId={setEditingTaskId}
|
||
showTaskCheckboxes={effectiveShowTaskCheckboxes}
|
||
showProjectIcons={effectiveShowProjectIcons}
|
||
showPriorityIcons={effectiveShowPriorityIcons}
|
||
priorityStyle={effectivePriorityStyle}
|
||
projects={projects}
|
||
onProjectAssign={assignProject}
|
||
kanbanStages={kanbanStages}
|
||
/>
|
||
</div>
|
||
))}
|
||
</>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
);
|
||
return [indicator, listEl, endIndicator].filter(Boolean);
|
||
})}
|
||
|
||
{/* Modal for adding lists if needed, or just rely on placeholders */}
|
||
{isAddingSomedayList && (
|
||
<div
|
||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 px-4"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setIsAddingSomedayList(false);
|
||
setSelectedSomedayProvider(null);
|
||
}}
|
||
>
|
||
<div
|
||
className="bg-white dark:bg-zinc-900 p-6 rounded-xl shadow-2xl w-full max-w-md"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<h2 className="text-xl font-black mb-1 flex items-center gap-2">
|
||
<Plus className="w-6 h-6" />
|
||
NEW LIST
|
||
</h2>
|
||
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-6 uppercase tracking-widest font-bold">
|
||
Create a new section for your tasks
|
||
</p>
|
||
|
||
<div className="mb-6">
|
||
<label className="block text-xs font-black text-zinc-400 dark:text-zinc-500 mb-2 uppercase tracking-tighter">
|
||
List Title
|
||
</label>
|
||
<input
|
||
autoFocus
|
||
type="text"
|
||
placeholder="NAME YOUR LIST..."
|
||
value={newSomedayListName}
|
||
onChange={(e) =>
|
||
setNewSomedayListName(e.target.value)
|
||
}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") saveSomedayList();
|
||
if (e.key === "Escape") {
|
||
setIsAddingSomedayList(false);
|
||
setNewSomedayListName("");
|
||
setSelectedSomedayProvider(null);
|
||
}
|
||
}}
|
||
className="w-full p-4 bg-zinc-50 dark:bg-zinc-800/50 border-2 border-zinc-100 dark:border-zinc-800 rounded-xl focus:border-zinc-900 dark:focus:border-zinc-100 outline-none transition-all font-bold text-lg"
|
||
style={{
|
||
fontFamily: profile.taskFontFamily
|
||
? `"${profile.taskFontFamily}"`
|
||
: "inherit",
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<div className="mb-8">
|
||
<label className="block text-xs font-black text-zinc-400 dark:text-zinc-500 mb-3 uppercase tracking-tighter">
|
||
Sync with External Provider (Optional)
|
||
</label>
|
||
<div
|
||
className="grid gap-2"
|
||
style={{ gridTemplateColumns: `repeat(${1 + (connections?.some(c => c.provider === "google") ? 1 : 0) + (connections?.some(c => c.provider === "outlook") ? 1 : 0) + (connections?.some(c => c.provider === "synology") ? 1 : 0)}, minmax(0, 1fr))` }}
|
||
>
|
||
<button
|
||
onClick={() => setSelectedSomedayProvider(null)}
|
||
className={`flex flex-col items-center justify-center p-3 rounded-xl border-2 transition-all ${!selectedSomedayProvider ? "border-zinc-900 bg-zinc-900 text-white" : "border-zinc-100 dark:border-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-600"}`}
|
||
>
|
||
<div className="w-6 h-6 flex items-center justify-center mb-1">
|
||
<Layout size={18} />
|
||
</div>
|
||
<span className="text-[10px] font-black uppercase">
|
||
Local
|
||
</span>
|
||
</button>
|
||
{connections?.some(c => c.provider === "google") && (
|
||
<button
|
||
onClick={() =>
|
||
setSelectedSomedayProvider("google")
|
||
}
|
||
className={`flex flex-col items-center justify-center p-3 rounded-xl border-2 transition-all ${selectedSomedayProvider === "google" ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20" : "border-zinc-100 dark:border-zinc-800 hover:border-blue-200 dark:hover:border-blue-800/40"}`}
|
||
>
|
||
<div className="w-6 h-6 flex items-center justify-center mb-1">
|
||
<FontAwesomeIcon icon={faGoogle} className="w-4 h-4 text-[#4285F4]" />
|
||
</div>
|
||
<span className="text-[10px] font-black uppercase">
|
||
Google
|
||
</span>
|
||
</button>
|
||
)}
|
||
{connections?.some(c => c.provider === "outlook") && (
|
||
<button
|
||
onClick={() =>
|
||
setSelectedSomedayProvider("outlook")
|
||
}
|
||
className={`flex flex-col items-center justify-center p-3 rounded-xl border-2 transition-all ${selectedSomedayProvider === "outlook" ? "border-blue-600 bg-blue-100 dark:bg-blue-900/30" : "border-zinc-100 dark:border-zinc-800 hover:border-blue-300 dark:hover:border-blue-800/50"}`}
|
||
>
|
||
<div className="w-6 h-6 flex items-center justify-center mb-1">
|
||
<FontAwesomeIcon icon={faMicrosoft} className="w-4 h-4 text-[#00A4EF]" />
|
||
</div>
|
||
<span className="text-[10px] font-black uppercase">
|
||
Outlook
|
||
</span>
|
||
</button>
|
||
)}
|
||
{connections?.some(c => c.provider === "synology") && (
|
||
<button
|
||
onClick={() =>
|
||
setSelectedSomedayProvider("synology")
|
||
}
|
||
className={`flex flex-col items-center justify-center p-3 rounded-xl border-2 transition-all ${selectedSomedayProvider === "synology" ? "border-zinc-900 bg-zinc-50 dark:bg-zinc-800" : "border-zinc-100 dark:border-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-600"}`}
|
||
>
|
||
<div className="w-6 h-6 flex items-center justify-center mb-1">
|
||
<FontAwesomeIcon icon={faServer} className="w-4 h-4 text-zinc-500" />
|
||
</div>
|
||
<span className="text-[10px] font-black uppercase">
|
||
Synology
|
||
</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-3">
|
||
<button
|
||
onClick={() => {
|
||
setIsAddingSomedayList(false);
|
||
setNewSomedayListName("");
|
||
setSelectedSomedayProvider(null);
|
||
}}
|
||
className="flex-1 px-4 py-4 text-sm font-black text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-xl transition-all uppercase tracking-widest"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={saveSomedayList}
|
||
disabled={!newSomedayListName.trim()}
|
||
className="flex-[2] px-4 py-4 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 font-black rounded-xl hover:opacity-90 transition-all uppercase tracking-widest shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
Create List
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{/* close flex row */}
|
||
</section>
|
||
</>
|
||
)
|
||
}
|
||
|
||
{/* Search Modal */}
|
||
<SearchModal
|
||
isOpen={isSearchOpen}
|
||
onClose={() => setIsSearchOpen(false)}
|
||
tasks={tasks}
|
||
events={calendarEvents}
|
||
somedayLists={somedayLists}
|
||
onSelectTask={(date) => {
|
||
setCurrentWeekStart(getStartOfWeek(date));
|
||
}}
|
||
/>
|
||
|
||
{/* Recurring Tasks Manager */}
|
||
<RecurringTasksManager
|
||
isOpen={isRecurringTasksOpen}
|
||
onClose={() => setIsRecurringTasksOpen(false)}
|
||
tasks={tasks}
|
||
onStopRecurring={async (task) => {
|
||
const idToUpdate = task.id.startsWith("virtual-")
|
||
? task.id.split("-")[1]
|
||
: task.id;
|
||
const dateStr = new Date().toISOString();
|
||
|
||
// Update locally
|
||
setTasks((prev) => {
|
||
const newTasks = [];
|
||
for (const t of prev) {
|
||
const isMatch =
|
||
t.title === task.title &&
|
||
t.userId === task.userId &&
|
||
t.recurrenceInterval === task.recurrenceInterval &&
|
||
t.recurrenceUnit === task.recurrenceUnit;
|
||
|
||
if (isMatch) {
|
||
// Remove future occurrences from the UI
|
||
if (t.scheduledDate && new Date(t.scheduledDate) > new Date(dateStr)) {
|
||
continue;
|
||
}
|
||
newTasks.push({ ...t, recurrenceEndDate: new Date(dateStr) });
|
||
} else {
|
||
newTasks.push(t);
|
||
}
|
||
}
|
||
return newTasks;
|
||
});
|
||
|
||
// Update DB
|
||
try {
|
||
await fetch("/api/tasks", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
id: idToUpdate,
|
||
recurrenceEndDate: dateStr,
|
||
}),
|
||
});
|
||
fetchTasks();
|
||
} catch (error) {
|
||
console.error("Failed to stop recurring series:", error);
|
||
}
|
||
}}
|
||
onSeriesUpdated={() => fetchTasks()}
|
||
/>
|
||
|
||
{/* Recurring Task Delete Confirmation Modal */}
|
||
{
|
||
recurringDeleteModal.isOpen && (
|
||
<div
|
||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-[100] px-4"
|
||
onClick={() =>
|
||
setRecurringDeleteModal({ isOpen: false, taskId: null })
|
||
}
|
||
>
|
||
<div
|
||
className="bg-white dark:bg-zinc-900 p-8 rounded-2xl shadow-2xl w-full max-w-md border border-zinc-100 dark:border-zinc-800"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="flex items-center gap-3 mb-6">
|
||
<div className="w-12 h-12 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center text-red-600 dark:text-red-400">
|
||
<Repeat size={24} />
|
||
</div>
|
||
<div>
|
||
<h2 className="text-xl font-black uppercase tracking-tight">
|
||
Recurring Task
|
||
</h2>
|
||
<p className="text-xs text-zinc-500 dark:text-zinc-400 font-bold uppercase tracking-widest">
|
||
Deletion Options
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-4 mb-8">
|
||
<div className="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-xl border border-zinc-100 dark:border-zinc-800">
|
||
<p className="text-sm font-medium text-zinc-600 dark:text-zinc-300 leading-relaxed">
|
||
How would you like to delete this task?
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-3">
|
||
<button
|
||
onClick={() =>
|
||
recurringDeleteModal.taskId &&
|
||
handleConfirmDeleteSeries(recurringDeleteModal.taskId)
|
||
}
|
||
className="group flex items-center gap-4 p-4 bg-red-600 hover:bg-red-700 text-white rounded-xl transition-all shadow-lg hover:shadow-red-500/20 text-left"
|
||
>
|
||
<div className="w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center group-hover:scale-110 transition-transform">
|
||
<Repeat size={20} />
|
||
</div>
|
||
<div>
|
||
<span className="block font-black uppercase text-xs tracking-widest">
|
||
Entire Series
|
||
</span>
|
||
<span className="text-[10px] opacity-80 font-bold">
|
||
Stop recurrence & remove all future
|
||
</span>
|
||
</div>
|
||
</button>
|
||
|
||
<button
|
||
onClick={() =>
|
||
recurringDeleteModal.taskId &&
|
||
handleConfirmDeleteOccurrence(recurringDeleteModal.taskId)
|
||
}
|
||
className="group flex items-center gap-4 p-4 bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 text-zinc-900 dark:text-zinc-100 rounded-xl transition-all text-left"
|
||
>
|
||
<div className="w-10 h-10 bg-zinc-200 dark:bg-zinc-700 rounded-lg flex items-center justify-center group-hover:scale-110 transition-transform">
|
||
<Calendar size={20} />
|
||
</div>
|
||
<div>
|
||
<span className="block font-black uppercase text-xs tracking-widest">
|
||
Only This One
|
||
</span>
|
||
<span className="text-[10px] text-zinc-500 dark:text-zinc-400 font-bold">
|
||
Only remove the selected instance
|
||
</span>
|
||
</div>
|
||
</button>
|
||
|
||
<button
|
||
onClick={() =>
|
||
setRecurringDeleteModal({ isOpen: false, taskId: null })
|
||
}
|
||
className="w-full mt-2 p-3 text-xs font-black text-zinc-400 dark:text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 uppercase tracking-widest transition-colors"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
{/* Calendar Event Modal */}
|
||
{
|
||
calendarEventModal.isOpen && (
|
||
<CalendarEventModal
|
||
event={calendarEventModal.event}
|
||
initialDate={calendarEventModal.initialDate}
|
||
initialStartTime={calendarEventModal.initialStartTime}
|
||
initialEndTime={calendarEventModal.initialEndTime}
|
||
connections={connections}
|
||
weekStartDay={profile.weekStartDay ?? 1}
|
||
language={profile.language}
|
||
savedLocations={(profile.viewSettings as any)?.savedLocations || []}
|
||
customReminderMinutes={(profile.viewSettings as any)?.customReminderMinutes || []}
|
||
onSaveLocation={(loc: string) => {
|
||
const current: string[] = (profile.viewSettings as any)?.savedLocations || [];
|
||
if (current.includes(loc)) return;
|
||
const updated = [loc, ...current].slice(0, 20);
|
||
const newVS = { ...(profile.viewSettings as any), savedLocations: updated };
|
||
setProfile((p: any) => ({ ...p, viewSettings: newVS }));
|
||
saveSetting('viewSettings', newVS);
|
||
}}
|
||
onSaveCustomReminder={(minutes: number) => {
|
||
const current: number[] = (profile.viewSettings as any)?.customReminderMinutes || [];
|
||
if (current.includes(minutes)) return;
|
||
const updated = [minutes, ...current].slice(0, 10);
|
||
const newVS = { ...(profile.viewSettings as any), customReminderMinutes: updated };
|
||
setProfile((p: any) => ({ ...p, viewSettings: newVS }));
|
||
saveSetting('viewSettings', newVS);
|
||
}}
|
||
onClose={() =>
|
||
setCalendarEventModal({ ...calendarEventModal, isOpen: false })
|
||
}
|
||
onSave={handleEventSave}
|
||
onDelete={handleEventDelete}
|
||
/>
|
||
)
|
||
}
|
||
{/* Recurring event drag/resize confirmation */}
|
||
{pendingRecurringDrag && (
|
||
<div style={{
|
||
position: 'fixed', inset: 0, zIndex: 2000,
|
||
display: 'flex', justifyContent: 'center', alignItems: 'center',
|
||
background: 'rgba(0,0,0,0.35)', backdropFilter: 'blur(2px)',
|
||
}} onClick={handleRecurringDragCancel}>
|
||
<div style={{
|
||
background: darkMode ? '#1e1e1e' : 'white',
|
||
color: darkMode ? '#eee' : '#333',
|
||
borderRadius: 12, width: '90%', maxWidth: 420,
|
||
boxShadow: '0 12px 40px rgba(0,0,0,0.25)',
|
||
overflow: 'hidden',
|
||
}} onClick={e => e.stopPropagation()}>
|
||
<div style={{
|
||
padding: '16px 20px 12px', fontWeight: 700, fontSize: '0.95rem',
|
||
borderBottom: '2px solid #3b82f6',
|
||
}}>
|
||
{profile.language === 'de' ? 'Wiederkehrendes Ereignis bearbeiten' : 'Edit recurring event'}
|
||
</div>
|
||
<div style={{ padding: '12px 20px' }}>
|
||
{([
|
||
{ value: 'this' as const, title: profile.language === 'de' ? 'Nur dieses Ereignis' : 'This event', desc: profile.language === 'de' ? 'Alle anderen Ereignisse der Serie bleiben unverändert.' : 'All other events in the series stay the same.' },
|
||
{ value: 'future' as const, title: profile.language === 'de' ? 'Dieses und folgende Ereignisse' : 'This and following events', desc: profile.language === 'de' ? 'Dieses und alle zukünftigen Ereignisse der Serie werden geändert.' : 'This and all future events in the series will be changed.' },
|
||
{ value: 'all' as const, title: profile.language === 'de' ? 'Alle Ereignisse' : 'All events', desc: profile.language === 'de' ? 'Alle Ereignisse der Serie werden geändert.' : 'All events in the series will be changed.' },
|
||
]).map(opt => (
|
||
<label key={opt.value} style={{
|
||
display: 'flex', gap: 12, padding: '10px 4px', cursor: 'pointer',
|
||
borderBottom: `1px solid ${darkMode ? '#333' : '#eee'}`,
|
||
alignItems: 'flex-start',
|
||
}} onClick={() => setRecurringDragEditMode(opt.value)}>
|
||
<input
|
||
type="radio" name="recurringDragEditMode"
|
||
checked={recurringDragEditMode === opt.value}
|
||
onChange={() => setRecurringDragEditMode(opt.value)}
|
||
style={{ marginTop: 3, accentColor: '#3b82f6' }}
|
||
/>
|
||
<div>
|
||
<div style={{ fontWeight: 600, fontSize: '0.88rem' }}>{opt.title}</div>
|
||
<div style={{ fontSize: '0.78rem', opacity: 0.6, marginTop: 2 }}>{opt.desc}</div>
|
||
</div>
|
||
</label>
|
||
))}
|
||
</div>
|
||
<div style={{
|
||
padding: '12px 20px', display: 'flex', justifyContent: 'flex-end', gap: 8,
|
||
}}>
|
||
<button onClick={handleRecurringDragCancel} style={{
|
||
padding: '8px 16px', fontSize: '0.85rem', fontWeight: 600,
|
||
background: 'none', border: 'none', cursor: 'pointer',
|
||
color: darkMode ? '#aaa' : '#666',
|
||
}}>{profile.language === 'de' ? 'Abbrechen' : 'Cancel'}</button>
|
||
<button onClick={() => handleRecurringDragConfirm(recurringDragEditMode)} style={{
|
||
padding: '8px 20px', fontSize: '0.85rem', fontWeight: 600,
|
||
background: '#3b82f6', color: 'white', border: 'none',
|
||
borderRadius: 8, cursor: 'pointer',
|
||
}}>{profile.language === 'de' ? 'Speichern' : 'Save'}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* Focus Mode Overlay */}
|
||
{
|
||
showFocusMode && (
|
||
<FocusModeOverlay
|
||
task={(() => {
|
||
// Logic to find the "Next Task"
|
||
// 1. Tasks for today with start time, sorted by time
|
||
// 2. Tasks for today without start time, sorted by order
|
||
// 3. Tasks rolling over from previous days
|
||
|
||
const now = new Date();
|
||
const todayStr = now.toISOString().split("T")[0];
|
||
|
||
// Get all tasks relevant for "Now"
|
||
const activeTasks = tasks.filter(
|
||
(t) =>
|
||
!t.completed &&
|
||
!t.somedayListId &&
|
||
// Scheduled for today
|
||
((t.scheduledDate &&
|
||
new Date(t.scheduledDate).toISOString().split("T")[0] ===
|
||
todayStr) ||
|
||
// Or rolling and overdue (simplified, assuming rolling means show on today if not done)
|
||
(t.isRolling &&
|
||
(!t.scheduledDate || new Date(t.scheduledDate) <= now)) ||
|
||
// Or implicitly today if within current view logic (e.g. dayOfWeek match in current week)
|
||
// But let's stick to explicit date or rolling for Focus Mode to be precise.
|
||
(!t.scheduledDate &&
|
||
t.dayOfWeek === now.getDay() &&
|
||
isSameDay(
|
||
currentWeekStart,
|
||
getStartOfWeek(now, profile.weekStartDay ?? 1),
|
||
))),
|
||
);
|
||
|
||
// Sort: Time-based first, then Order
|
||
activeTasks.sort((a, b) => {
|
||
if (a.startTime && b.startTime)
|
||
return a.startTime.localeCompare(b.startTime);
|
||
if (a.startTime) return -1;
|
||
if (b.startTime) return 1;
|
||
return a.order - b.order;
|
||
});
|
||
|
||
return activeTasks.length > 0 ? activeTasks[0] : null;
|
||
})()}
|
||
duration={focusTimerDuration}
|
||
onClose={() => setShowFocusMode(false)}
|
||
onComplete={(taskId) => toggleTask(taskId)}
|
||
/>
|
||
)
|
||
}
|
||
|
||
{/* Settings Sidebar */}
|
||
{
|
||
showSettings && (
|
||
<SettingsSidebar
|
||
initialTab={activeTab}
|
||
onRemoveConnection={handleRemoveConnection}
|
||
onClose={() => {
|
||
setShowSettings(false);
|
||
if (profile.hasCompletedOnboarding === false && localStorage.getItem("onboarding_step")) {
|
||
setShowOnboarding(true);
|
||
}
|
||
}}
|
||
onSettingsChanged={handleSettingsChanged}
|
||
showTimeGrid={profile.showTimeGrid ?? true}
|
||
setShowTimeGrid={setShowTimeGrid}
|
||
cellDuration={profile.cellDuration ?? 30}
|
||
setCellDuration={setCellDuration}
|
||
weekStartDay={profile.weekStartDay ?? 1}
|
||
setWeekStartDay={setWeekStartDay}
|
||
viewStyle={profile.viewStyle ?? "simple"}
|
||
setViewStyle={setViewStyle}
|
||
showSomeday={profile.showSomeday ?? true}
|
||
setShowSomeday={setShowSomeday}
|
||
showAllDay={profile.showAllDayEvents ?? true}
|
||
setShowAllDay={setShowAllDay}
|
||
showSchedule={profile.showSchedule ?? true}
|
||
setShowSchedule={setShowSchedule}
|
||
goal={goal}
|
||
setGoal={setGoal}
|
||
saveGoal={saveGoal}
|
||
connections={connections}
|
||
onUpdateConnections={setConnections}
|
||
focusTimerDuration={focusTimerDuration}
|
||
setFocusTimerDuration={setFocusTimerDuration}
|
||
focusBreakDuration={focusBreakDuration}
|
||
setFocusBreakDuration={setFocusBreakDuration}
|
||
fontSize={profile.fontSize ?? "M"}
|
||
setFontSize={setFontSize}
|
||
showNextTask={profile.showNextTask ?? false}
|
||
setShowNextTask={setShowNextTask}
|
||
headlineFont={profile.headlineFont ?? "Inter"}
|
||
headlineFontSize={profile.headlineFontSize ?? "1.25rem"}
|
||
headlineFontWeight={profile.headlineFontWeight ?? "900"}
|
||
dateFontFamily={profile.dateFontFamily ?? "Inter"}
|
||
dateFontSize={profile.dateFontSize ?? "0.65rem"}
|
||
dateFontWeight={profile.dateFontWeight ?? "400"}
|
||
timeTaskFontFamily={profile.timeTaskFontFamily ?? "Inter"}
|
||
timeTaskFontSize={profile.timeTaskFontSize ?? "0.75rem"}
|
||
timeTaskFontWeight={profile.timeTaskFontWeight ?? "500"}
|
||
bodyFont={profile.bodyFont ?? "Inter"}
|
||
taskFontFamily={profile.taskFontFamily ?? "Inter"}
|
||
taskFontSize={profile.taskFontSize ?? "0.9rem"}
|
||
taskFontWeight={profile.taskFontWeight ?? "400"}
|
||
fontWeight={profile.fontWeight ?? "400"}
|
||
weekendColorSat={profile.weekendColorSat ?? "#666666"}
|
||
weekendColorSun={profile.weekendColorSun ?? "#dc2626"}
|
||
protectEventTimes={protectEventTimes}
|
||
setProtectEventTimes={setProtectEventTimes}
|
||
goalFontWeight={profile.goalFontWeight || "500"}
|
||
goalFallbackType={profile.goalFallbackType}
|
||
goalDefaultSentence={profile.goalDefaultSentence}
|
||
importingTasksState={importingTasksState}
|
||
executeImport={executeImport}
|
||
onImportLists={(lists) => doImport("apple", lists)}
|
||
importStatusMsg={importStatusMsg}
|
||
hourLabelFormat={profile.hourLabelFormat ?? "short"}
|
||
setHourLabelFormat={setHourLabelFormat}
|
||
showSubHourSlots={profile.showSubHourSlots ?? true}
|
||
setShowSubHourSlots={setShowSubHourSlots}
|
||
allDayPosition={profile.allDayPosition ?? "above"}
|
||
setAllDayPosition={setAllDayPosition}
|
||
saveSetting={saveSetting}
|
||
availableTaskLists={availableTaskLists}
|
||
isFetchingProviderLists={isFetchingProviderLists}
|
||
somedayLists={somedayLists}
|
||
handleToggleTaskList={handleToggleTaskList}
|
||
unsyncConfirm={unsyncConfirm}
|
||
onConfirmUnsync={confirmUnsync}
|
||
onCancelUnsync={() => setUnsyncConfirm(null)}
|
||
handleSyncAll={handleSyncAll}
|
||
fetchAvailableTaskLists={fetchAvailableTaskLists}
|
||
setCurrentWeekStart={setCurrentWeekStart}
|
||
projects={projects}
|
||
onProjectsChanged={fetchProjects}
|
||
kanbanStages={kanbanStages}
|
||
saveKanbanStages={saveKanbanStages}
|
||
profile={profile}
|
||
setProfile={setProfile}
|
||
isMobile={isMobile}
|
||
mobileActions={{
|
||
goToPrevWeek,
|
||
goToPrevDay,
|
||
goToToday,
|
||
goToNextDay,
|
||
goToNextWeek,
|
||
onJumpToDate: () => setShowDatePicker(true),
|
||
onAddCalendarEvent: () => {
|
||
const now = new Date();
|
||
setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` });
|
||
},
|
||
onAddProject: () => { setShowProjectsSidebar(true); },
|
||
onRecurringTasks: () => setIsRecurringTasksOpen(true),
|
||
onToggleNextTask: () => { const newVal = !profile.showNextTask; setShowNextTask(newVal); saveSetting("showNextTask", newVal); },
|
||
onFocusMode: () => setShowFocusMode(true),
|
||
onToggleDarkMode: () => setDarkMode(!darkMode),
|
||
onSearch: () => setIsSearchOpen(true),
|
||
onUndo: handleUndo,
|
||
onRedo: handleRedo,
|
||
onRefresh: () => { fetchCalendarEvents(true); fetchTasks(); },
|
||
darkMode,
|
||
showNextTask: profile.showNextTask ?? false,
|
||
undoCount,
|
||
redoCount,
|
||
viewDays,
|
||
onViewDaysChange: (num: number) => { setViewDays(num); savedViewDaysRef.current = num; saveSetting("viewDays", num); },
|
||
showTimeGrid: profile.showTimeGrid ?? true,
|
||
cellDuration: profile.cellDuration ?? 30,
|
||
onCellDurationChange: (d: CellDuration) => { setCellDuration(d); saveSetting("cellDuration", d); },
|
||
viewStyle: profile.viewStyle ?? "simple",
|
||
onViewStyleChange: (s: string) => { setViewStyle(s as any); saveSetting("viewStyle", s); },
|
||
startHour: profile.startHour ?? 8,
|
||
endHour: profile.endHour ?? 18,
|
||
onStartHourChange: (h: number) => { setStartHour(h); saveSetting("startHour", h); },
|
||
onEndHourChange: (h: number) => { setEndHour(h); saveSetting("endHour", h); },
|
||
}}
|
||
perView={{
|
||
saveViewSetting: saveViewSetting as any,
|
||
getEffective: getEffective as any,
|
||
}}
|
||
onRunSetupAssistant={() => setShowOnboarding(true)}
|
||
/>
|
||
)
|
||
}
|
||
|
||
{
|
||
selectedTaskForRecurrence && (
|
||
<TaskRecurrenceModal
|
||
task={selectedTaskForRecurrence}
|
||
onClose={() => setSelectedTaskForRecurrence(null)}
|
||
onSave={handleRecurrenceSave}
|
||
language={profile.language}
|
||
/>
|
||
)
|
||
}
|
||
|
||
{/* Onboarding Wizard */}
|
||
{showOnboarding && (
|
||
<OnboardingWizard
|
||
profile={profile}
|
||
darkMode={darkMode}
|
||
language={profile.language}
|
||
connections={connections}
|
||
onComplete={async () => { await saveSetting("hasCompletedOnboarding", true); setShowOnboarding(false); await fetchProfile(); }}
|
||
onSkip={async () => { await saveSetting("hasCompletedOnboarding", true); setShowOnboarding(false); await fetchProfile(); }}
|
||
saveSetting={saveSetting}
|
||
onLanguageChange={(lang: string) => { setLanguage(lang); }}
|
||
onDarkModeToggle={() => setDarkMode(!darkMode)}
|
||
onConnectProvider={(provider: string) => {
|
||
setShowOnboarding(false);
|
||
setActiveTab("calendar");
|
||
setShowSettings(true);
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{
|
||
selectedTaskForNotes && (
|
||
<NotesSidebar
|
||
task={selectedTaskForNotes}
|
||
onClose={() => setSelectedTaskForNotes(null)}
|
||
updateTaskNotes={updateTaskNotes}
|
||
updateTaskUrl={updateTaskUrl}
|
||
/>
|
||
)
|
||
}
|
||
|
||
{/* Kanban Detail Modal */}
|
||
{kanbanDetailTask && (() => {
|
||
const task = kanbanDetailTask;
|
||
// Re-resolve the task from state to get latest data
|
||
const liveTask = findTaskAnywhere(task.id) || task;
|
||
return (
|
||
<div className="kanban-detail-overlay" onClick={() => setKanbanDetailTask(null)}>
|
||
<div className="kanban-detail-modal" onClick={(e) => e.stopPropagation()}>
|
||
{/* Header */}
|
||
<div className="kanban-detail-header">
|
||
<div style={{ display: "flex", alignItems: "center", gap: "8px", flex: 1 }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={liveTask.completed}
|
||
onChange={() => { toggleTask(liveTask.id); setKanbanDetailTask({ ...liveTask, completed: !liveTask.completed }); }}
|
||
style={{ width: "18px", height: "18px", cursor: "pointer", accentColor: "var(--weekly-accent, #6366f1)" }}
|
||
/>
|
||
<input
|
||
type="text"
|
||
defaultValue={liveTask.title}
|
||
onBlur={(e) => {
|
||
const val = e.target.value.trim();
|
||
if (val && val !== liveTask.title) {
|
||
updateTask(liveTask.id, val);
|
||
setKanbanDetailTask({ ...liveTask, title: val });
|
||
}
|
||
}}
|
||
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
|
||
className="kanban-detail-title-input"
|
||
/>
|
||
</div>
|
||
<button onClick={() => setKanbanDetailTask(null)} className="kanban-detail-close">
|
||
<X size={18} />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="kanban-detail-body">
|
||
{/* Meta row: project, stage, date */}
|
||
<div className="kanban-detail-meta">
|
||
{/* Project picker */}
|
||
<div className="kanban-detail-field">
|
||
<label><Tag size={13} /> {t.project || "Project"}</label>
|
||
<select
|
||
value={liveTask.projectId || ""}
|
||
onChange={(e) => {
|
||
const pid = e.target.value || null;
|
||
assignProject(liveTask.id, pid);
|
||
const proj = pid ? projects.find(p => p.id === pid) || null : null;
|
||
setKanbanDetailTask({ ...liveTask, projectId: pid, project: proj });
|
||
}}
|
||
className="kanban-detail-select"
|
||
>
|
||
<option value="">{t.noProject || "No project"}</option>
|
||
{projects.map(p => (
|
||
<option key={p.id} value={p.id}>{p.name}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Kanban stage picker */}
|
||
<div className="kanban-detail-field">
|
||
<label><Kanban size={13} /> {t.stage || "Stage"}</label>
|
||
<select
|
||
value={liveTask.kanbanStage || ""}
|
||
onChange={(e) => {
|
||
const stage = e.target.value || null;
|
||
updateTaskFields(liveTask.id, { kanbanStage: stage });
|
||
setKanbanDetailTask({ ...liveTask, kanbanStage: stage });
|
||
}}
|
||
className="kanban-detail-select"
|
||
>
|
||
<option value="">{t.noStage || "No stage"}</option>
|
||
{kanbanStages.map(s => (
|
||
<option key={s.id} value={s.id}>{s.name}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Due date */}
|
||
<div className="kanban-detail-field">
|
||
<label><CalendarDays size={13} /> {t.dueDate || "Due date"}</label>
|
||
<input
|
||
type="date"
|
||
value={liveTask.scheduledDate ? new Date(liveTask.scheduledDate).toISOString().split("T")[0] : ""}
|
||
onChange={(e) => {
|
||
const val = e.target.value || null;
|
||
updateTaskFields(liveTask.id, { scheduledDate: val });
|
||
setKanbanDetailTask({ ...liveTask, scheduledDate: val });
|
||
}}
|
||
className="kanban-detail-date"
|
||
/>
|
||
</div>
|
||
|
||
{/* Someday list */}
|
||
<div className="kanban-detail-field">
|
||
<label><ListTodo size={13} /> {t.list || "List"}</label>
|
||
<select
|
||
value={liveTask.somedayListId || ""}
|
||
onChange={(e) => {
|
||
const lid = e.target.value || null;
|
||
updateTaskFields(liveTask.id, { somedayListId: lid });
|
||
setKanbanDetailTask({ ...liveTask, somedayListId: lid });
|
||
fetchTasks();
|
||
}}
|
||
className="kanban-detail-select"
|
||
>
|
||
<option value="">—</option>
|
||
{somedayLists.map(sl => (
|
||
<option key={sl.id} value={sl.id}>{sl.title}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* URL */}
|
||
<div className="kanban-detail-section">
|
||
<label><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ display: "inline" }}><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> {t.link || "Link"}</label>
|
||
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||
<input
|
||
type="url"
|
||
defaultValue={liveTask.url || ""}
|
||
onBlur={(e) => {
|
||
const raw = e.target.value.trim();
|
||
const normalised = raw ? (raw.startsWith("http") ? raw : `https://${raw}`) : "";
|
||
if (normalised !== (liveTask.url || "")) {
|
||
updateTaskUrl(liveTask.id, normalised);
|
||
setKanbanDetailTask({ ...liveTask, url: normalised || null });
|
||
}
|
||
}}
|
||
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
|
||
placeholder="https://..."
|
||
className="kanban-detail-url"
|
||
style={{ flex: 1 }}
|
||
/>
|
||
{liveTask.url && (
|
||
<a href={liveTask.url} target="_blank" rel="noopener noreferrer" title="Open link" style={{ color: "#2563eb", display: "inline-flex", flexShrink: 0 }}>
|
||
<svg viewBox="0 0 24 24" width="14" height="14" 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>
|
||
</div>
|
||
|
||
{/* Notes */}
|
||
<div className="kanban-detail-section">
|
||
<label><FileText size={13} /> {t.notes || "Notes"}</label>
|
||
<RichTextEditor
|
||
value={liveTask.markdownContent || ""}
|
||
onChange={(html) => updateTaskNotes(liveTask.id, html)}
|
||
placeholder={profile.language === "de" ? "Notizen hinzufügen..." : "Add notes..."}
|
||
minHeight="120px"
|
||
/>
|
||
</div>
|
||
|
||
{/* Subtasks */}
|
||
<div className="kanban-detail-section">
|
||
<label><Check size={13} /> {t.subtasks || "Subtasks"}</label>
|
||
<div className="kanban-detail-subtasks">
|
||
{(liveTask.subTasks || []).map(sub => (
|
||
<div key={sub.id} className="kanban-detail-subtask">
|
||
<input
|
||
type="checkbox"
|
||
checked={sub.completed}
|
||
onChange={() => toggleSubTask(sub.id)}
|
||
style={{ accentColor: "var(--weekly-accent, #6366f1)" }}
|
||
/>
|
||
<input
|
||
type="text"
|
||
defaultValue={sub.title}
|
||
onBlur={(e) => {
|
||
const val = e.target.value.trim();
|
||
if (!val) deleteSubTask(sub.id);
|
||
else if (val !== sub.title) updateSubTask(sub.id, val);
|
||
}}
|
||
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
|
||
className={`kanban-detail-subtask-title ${sub.completed ? "completed" : ""}`}
|
||
/>
|
||
<button onClick={() => deleteSubTask(sub.id)} className="kanban-detail-subtask-delete">
|
||
<X size={12} />
|
||
</button>
|
||
</div>
|
||
))}
|
||
<form
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
const input = (e.target as HTMLFormElement).elements.namedItem("newSub") as HTMLInputElement;
|
||
const val = input.value.trim();
|
||
if (val) {
|
||
addSubTask(liveTask.id, val);
|
||
input.value = "";
|
||
// Refresh modal task after adding
|
||
setTimeout(() => {
|
||
const refreshed = findTaskAnywhere(liveTask.id);
|
||
if (refreshed) setKanbanDetailTask(refreshed);
|
||
}, 300);
|
||
}
|
||
}}
|
||
className="kanban-detail-subtask-add"
|
||
>
|
||
<Plus size={14} />
|
||
<input
|
||
name="newSub"
|
||
type="text"
|
||
placeholder={profile.language === "de" ? "Unteraufgabe hinzufügen..." : "Add subtask..."}
|
||
className="kanban-detail-subtask-input"
|
||
/>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="kanban-detail-footer">
|
||
<button
|
||
onClick={() => { deleteTask(liveTask.id); setKanbanDetailTask(null); }}
|
||
className="kanban-detail-delete"
|
||
title={profile.language === "de" ? "Löschen" : "Delete"}
|
||
>
|
||
<Trash2 size={16} />
|
||
</button>
|
||
<div className="kanban-detail-footer-right">
|
||
<button
|
||
onClick={() => setKanbanDetailTask(null)}
|
||
className="kanban-detail-btn kanban-detail-btn-cancel"
|
||
>
|
||
{profile.language === "de" ? "Abbrechen" : "Cancel"}
|
||
</button>
|
||
<button
|
||
onClick={() => setKanbanDetailTask(null)}
|
||
className="kanban-detail-btn kanban-detail-btn-ok"
|
||
>
|
||
OK
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
<ImportListModal
|
||
isOpen={isImportModalOpen}
|
||
onClose={() => setIsImportModalOpen(false)}
|
||
onImport={handleConfirmImport}
|
||
provider={importProvider}
|
||
lists={importLists}
|
||
isLoading={isFetchingLists}
|
||
/>
|
||
|
||
{/* Week Print / PDF Export Modal */}
|
||
<WeekPrintModal
|
||
isOpen={showWeekPrintModal}
|
||
onClose={() => setShowWeekPrintModal(false)}
|
||
currentWeekStart={currentWeekStart}
|
||
viewDays={viewDays}
|
||
startHour={startHour}
|
||
endHour={endHour}
|
||
language={profile.language || "de"}
|
||
weatherEnabled={!!(profile.weatherLat && profile.weatherLon)}
|
||
/>
|
||
|
||
{/* Mobile: Floating Action Button with quick menu */}
|
||
{isMobile && !showMobileFabSheet && !showSettings && !showFocusMode && (
|
||
<>
|
||
{showMobileFabMenu && (
|
||
<div className="mobile-fab-backdrop" onClick={() => setShowMobileFabMenu(false)} />
|
||
)}
|
||
{showMobileFabMenu && (
|
||
<div className="mobile-fab-menu">
|
||
<button className="mobile-fab-menu-item" onClick={() => { setShowMobileFabMenu(false); setIsSearchOpen(true); }}>
|
||
<div className="mobile-fab-menu-icon" style={{ background: "#10b981" }}><Search size={18} /></div>
|
||
<span>{profile.language === "de" ? "Suchen" : "Search"}</span>
|
||
</button>
|
||
<button className="mobile-fab-menu-item" onClick={() => { setShowMobileFabMenu(false); setShowFocusMode(true); }}>
|
||
<div className="mobile-fab-menu-icon" style={{ background: "#f59e0b" }}><Zap size={18} /></div>
|
||
<span>{profile.language === "de" ? "Fokus" : "Focus"}</span>
|
||
</button>
|
||
<button className="mobile-fab-menu-item" onClick={() => { setShowMobileFabMenu(false); setShowProjectsSidebar(true); }}>
|
||
<div className="mobile-fab-menu-icon" style={{ background: "#6366f1" }}><FolderPlus size={18} /></div>
|
||
<span>{profile.language === "de" ? "Projekt" : "Project"}</span>
|
||
</button>
|
||
<button className="mobile-fab-menu-item" onClick={() => {
|
||
setShowMobileFabMenu(false);
|
||
const now = new Date();
|
||
setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` });
|
||
}}>
|
||
<div className="mobile-fab-menu-icon" style={{ background: "#8b5cf6" }}><Calendar size={18} /></div>
|
||
<span>{profile.language === "de" ? "Termin" : "Event"}</span>
|
||
</button>
|
||
<button className="mobile-fab-menu-item" onClick={() => { setShowMobileFabMenu(false); setShowMobileFabSheet(true); }}>
|
||
<div className="mobile-fab-menu-icon" style={{ background: "#0ea5e9" }}><Plus size={18} /></div>
|
||
<span>{profile.language === "de" ? "Aufgabe" : "Task"}</span>
|
||
</button>
|
||
</div>
|
||
)}
|
||
<button
|
||
className={`mobile-fab ${showMobileFabMenu ? "active" : ""}`}
|
||
onClick={() => setShowMobileFabMenu(!showMobileFabMenu)}
|
||
>
|
||
<Plus size={28} style={{ transform: showMobileFabMenu ? "rotate(45deg)" : "rotate(0deg)", transition: "transform 0.2s ease" }} />
|
||
</button>
|
||
</>
|
||
)}
|
||
|
||
{/* Mobile: Bottom Sheet for task creation */}
|
||
{isMobile && showMobileFabSheet && (
|
||
<>
|
||
<div className="bottom-sheet-backdrop" onClick={() => { setShowMobileFabSheet(false); setFabTaskTitle(""); }} />
|
||
<div className="bottom-sheet">
|
||
<div className="bottom-sheet-handle" />
|
||
<textarea
|
||
ref={fabTextareaRef}
|
||
value={fabTaskTitle}
|
||
onChange={(e) => setFabTaskTitle(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
if (fabTaskTitle.trim()) {
|
||
// Find today's date and add task
|
||
const today = new Date();
|
||
const todayStr = formatDateToISO(today);
|
||
addTask(today, fabTaskTitle.trim());
|
||
setFabTaskTitle("");
|
||
setShowMobileFabSheet(false);
|
||
}
|
||
}
|
||
}}
|
||
placeholder="What do you need to do?"
|
||
rows={2}
|
||
style={{
|
||
width: "100%",
|
||
border: `1px solid ${darkMode ? "#374151" : "#e5e7eb"}`,
|
||
borderRadius: "12px",
|
||
padding: "12px 16px",
|
||
fontSize: "1rem",
|
||
background: darkMode ? "#111827" : "#f9fafb",
|
||
color: darkMode ? "#e5e7eb" : "#333",
|
||
outline: "none",
|
||
resize: "none",
|
||
fontFamily: "inherit",
|
||
}}
|
||
/>
|
||
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: "12px", gap: "8px" }}>
|
||
<button
|
||
onClick={() => { setShowMobileFabSheet(false); setFabTaskTitle(""); }}
|
||
style={{ padding: "8px 16px", borderRadius: "8px", border: "none", background: darkMode ? "#374151" : "#e5e7eb", color: darkMode ? "#e5e7eb" : "#333", fontSize: "0.85rem", cursor: "pointer" }}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
if (fabTaskTitle.trim()) {
|
||
const today = new Date();
|
||
addTask(today, fabTaskTitle.trim());
|
||
setFabTaskTitle("");
|
||
setShowMobileFabSheet(false);
|
||
}
|
||
}}
|
||
style={{ padding: "8px 16px", borderRadius: "8px", border: "none", background: "#0ea5e9", color: "white", fontSize: "0.85rem", fontWeight: 600, cursor: "pointer" }}
|
||
>
|
||
Add Task
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Mobile: Date Picker as centered modal overlay */}
|
||
{isMobile && showDatePicker && (
|
||
<div className="mobile-date-picker-overlay" onClick={() => setShowDatePicker(false)}>
|
||
<div onClick={(e) => e.stopPropagation()}>
|
||
<SimpleDatePicker
|
||
selected={currentWeekStart}
|
||
onSelect={(date) => {
|
||
setCurrentWeekStart(getStartOfWeek(date));
|
||
setShowDatePicker(false);
|
||
}}
|
||
onClose={() => setShowDatePicker(false)}
|
||
language={profile.language}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div >
|
||
);
|
||
}
|
||
|
||
// Task Input Component
|
||
interface TaskInputProps {
|
||
onAddTask: (title: string) => void;
|
||
onDragOver: (e: React.DragEvent) => void;
|
||
onDrop: (e: React.DragEvent) => void;
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
// 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[];
|
||
}
|
||
|
||
// ── 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;
|
||
}
|
||
|
||
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 >
|
||
);
|
||
}
|
||
|
||
// Punkt 7+8 — popover for editing a someday list's icon and color.
|
||
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).
|
||
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>
|
||
);
|
||
}
|
||
|
||
// Projects Sidebar Component
|
||
const projectIconsGlobal: { name: string; icon: IconDefinition }[] = [
|
||
{ name: "folder", icon: faFolder }, { name: "briefcase", icon: faBriefcase },
|
||
{ name: "bullseye", icon: faBullseye }, { name: "rocket", icon: faRocket },
|
||
{ name: "star", icon: faStar }, { name: "lightbulb", icon: faLightbulb },
|
||
{ name: "fire", icon: faFire }, { name: "palette", icon: faPalette },
|
||
{ name: "music", icon: faMusic }, { name: "mobile", icon: faMobileScreen },
|
||
{ name: "laptop", icon: faLaptop }, { name: "globe", icon: faGlobe },
|
||
{ name: "house", icon: faHouse }, { name: "building", icon: faBuilding },
|
||
{ name: "chart-bar", icon: faChartBar }, { name: "chart-line", icon: faChartLine },
|
||
{ name: "wrench", icon: faWrench }, { name: "bolt", icon: faBolt },
|
||
{ name: "gamepad", icon: faGamepad }, { name: "pen", icon: faPen },
|
||
{ name: "book", icon: faBook }, { name: "graduation-cap", icon: faGraduationCap },
|
||
{ name: "flask", icon: faFlask }, { name: "microscope", icon: faMicroscope },
|
||
{ name: "dumbbell", icon: faDumbbell }, { name: "utensils", icon: faUtensils },
|
||
{ name: "plane", icon: faPlane }, { name: "leaf", icon: faLeaf },
|
||
{ name: "heart", icon: faHeart }, { name: "cart-shopping", icon: faCartShopping },
|
||
{ name: "coins", icon: faCoins }, { name: "gift", icon: faGift },
|
||
{ name: "camera", icon: faCamera }, { name: "film", icon: faFilm },
|
||
{ name: "broom", icon: faBroom }, { name: "paw", icon: faPaw },
|
||
{ name: "earth", icon: faEarthAmericas }, { name: "lock", icon: faLock },
|
||
{ name: "check", icon: faCheck }, { name: "code", icon: faCode },
|
||
{ name: "cube", icon: faCube }, { name: "users", icon: faUsers },
|
||
{ name: "car", icon: faCar }, { name: "mountain", icon: faMountain },
|
||
{ name: "umbrella", icon: faUmbrella }, { name: "clock", icon: faClock },
|
||
{ name: "tag", icon: faTag },
|
||
];
|
||
|
||
// Helper to render a project icon (FA name, MDI name, or legacy emoji)
|
||
function ProjectIcon({ icon, size = 18, color }: { icon?: string | null; size?: number; color?: string }) {
|
||
if (!icon) return <FontAwesomeIcon icon={faFolder} style={{ fontSize: size, color }} />;
|
||
// Normalise legacy "faHeart" → "heart" prefix style
|
||
const normalised = icon.startsWith("fa") && icon.length > 2 && icon[2] === icon[2].toUpperCase()
|
||
? icon.slice(2, 3).toLowerCase() + icon.slice(3)
|
||
: icon;
|
||
// Check unified icon registry (FA + MDI)
|
||
const found = allIcons.find((i) => i.name === normalised || i.name === icon);
|
||
if (found) {
|
||
if (found.type === "fa") {
|
||
return <FontAwesomeIcon icon={found.icon as import("@fortawesome/free-solid-svg-icons").IconDefinition} style={{ fontSize: size, color }} />;
|
||
}
|
||
return <MdiIcon path={found.icon as string} size={size / 24} color={color} />;
|
||
}
|
||
// Legacy: check old projectIconsGlobal for backwards compat
|
||
const legacy = projectIconsGlobal.find((i) => i.name === normalised || i.name === icon);
|
||
if (legacy) return <FontAwesomeIcon icon={legacy.icon} style={{ fontSize: size, color }} />;
|
||
// Legacy emoji fallback
|
||
return <span style={{ fontSize: size, lineHeight: 1 }}>{icon}</span>;
|
||
}
|
||
|
||
function ProjectsSidebar({ darkMode, language, projects, onProjectsChanged, onClose, tasks, activeProjectFilter, onSetProjectFilter }: {
|
||
darkMode: boolean;
|
||
language: string;
|
||
projects: { id: string; name: string; icon?: string | null; color?: string | null }[];
|
||
onProjectsChanged: () => void;
|
||
onClose: () => void;
|
||
tasks: Task[];
|
||
activeProjectFilter: string | null;
|
||
onSetProjectFilter: (id: string | null) => void;
|
||
}) {
|
||
const [newProjectName, setNewProjectName] = useState("");
|
||
const [newProjectColor, setNewProjectColor] = useState("#3b82f6");
|
||
const [newProjectIcon, setNewProjectIcon] = useState("folder");
|
||
const [showNewIconPicker, setShowNewIconPicker] = useState(false);
|
||
const [editingId, setEditingId] = useState<string | null>(null);
|
||
const [editName, setEditName] = useState("");
|
||
const [editColor, setEditColor] = useState("");
|
||
const [editIcon, setEditIcon] = useState("folder");
|
||
const [showEditIconPicker, setShowEditIconPicker] = useState(false);
|
||
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(new Set());
|
||
|
||
const t = {
|
||
projects: language === "de" ? "Projekte" : "Projects",
|
||
desc: language === "de" ? "Aufgaben mit farbcodierten Projekten organisieren" : "Organize tasks with color-coded projects",
|
||
noProjects: language === "de" ? "Noch keine Projekte erstellt" : "No projects yet",
|
||
addProject: language === "de" ? "Projekt hinzufügen" : "Add Project",
|
||
name: language === "de" ? "Name" : "Name",
|
||
cancel: language === "de" ? "Abbrechen" : "Cancel",
|
||
save: language === "de" ? "Speichern" : "Save",
|
||
color: language === "de" ? "Farbe" : "Color",
|
||
deleteConfirm: (name: string) => language === "de" ? `Projekt "${name}" löschen?` : `Delete project "${name}"?`,
|
||
filterOnly: language === "de" ? "Nur dieses Projekt anzeigen" : "Show only this project",
|
||
clearFilter: language === "de" ? "Filter aufheben" : "Clear filter",
|
||
showTasks: language === "de" ? "Aufgaben einblenden" : "Show tasks",
|
||
hideTasks: language === "de" ? "Aufgaben ausblenden" : "Hide tasks",
|
||
noTasks: language === "de" ? "Keine Aufgaben" : "No tasks",
|
||
filteringBy: language === "de" ? "Filter aktiv:" : "Filtering by:",
|
||
};
|
||
|
||
const tasksByProject = useMemo(() => {
|
||
const map = new Map<string, Task[]>();
|
||
tasks.forEach((task) => {
|
||
if (!task.projectId || task.parentTaskId) return;
|
||
const arr = map.get(task.projectId) || [];
|
||
arr.push(task);
|
||
map.set(task.projectId, arr);
|
||
});
|
||
return map;
|
||
}, [tasks]);
|
||
|
||
const toggleExpanded = (id: string) => {
|
||
setExpandedProjects((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(id)) next.delete(id);
|
||
else next.add(id);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const createProject = () => {
|
||
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"); });
|
||
};
|
||
|
||
const updateProject = (id: string) => {
|
||
fetch("/api/projects", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, name: editName, color: editColor, icon: editIcon }) })
|
||
.then(() => { onProjectsChanged(); setEditingId(null); });
|
||
};
|
||
|
||
const deleteProject = (id: string, name: string) => {
|
||
if (confirm(t.deleteConfirm(name))) {
|
||
fetch(`/api/projects?id=${id}`, { method: "DELETE" }).then(() => onProjectsChanged());
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 999, background: "rgba(0,0,0,0.3)" }} />
|
||
<div style={{
|
||
position: "fixed", right: 0, top: 0, bottom: 0, width: "min(380px, 90vw)", zIndex: 1000,
|
||
background: darkMode ? "#1e1e2e" : "#fff",
|
||
borderLeft: `1px solid ${darkMode ? "#333" : "#e5e7eb"}`,
|
||
display: "flex", flexDirection: "column",
|
||
boxShadow: "-4px 0 24px rgba(0,0,0,0.12)",
|
||
}}>
|
||
{/* Header */}
|
||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "16px 20px", borderBottom: `1px solid ${darkMode ? "#333" : "#e5e7eb"}` }}>
|
||
<h2 style={{ fontSize: "1.1rem", fontWeight: 700, display: "flex", alignItems: "center", gap: "8px", color: darkMode ? "#f0f0f0" : "#333" }}>
|
||
<FolderOpen size={20} /> {t.projects}
|
||
</h2>
|
||
<button onClick={onClose} style={{ background: "none", border: "none", cursor: "pointer", padding: "6px", color: darkMode ? "#9ca3af" : "#666", borderRadius: "6px" }}>
|
||
<X size={20} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Add project form — at top */}
|
||
<div style={{ padding: "16px 20px", borderBottom: `1px solid ${darkMode ? "#333" : "#e5e7eb"}`, background: darkMode ? "#1e1e2e" : "#fff" }}>
|
||
<p style={{ fontSize: "0.8rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#888", marginBottom: "10px" }}>{t.addProject}</p>
|
||
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
||
<div style={{ position: "relative" }}>
|
||
<button onClick={() => setShowNewIconPicker(!showNewIconPicker)} style={{ width: "40px", height: "40px", borderRadius: "10px", border: `1px solid ${darkMode ? "#555" : "#e5e7eb"}`, background: darkMode ? "#2a2a3a" : "#fff", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||
<ProjectIcon icon={newProjectIcon} size={18} color={darkMode ? "#d1d5db" : "#555"} />
|
||
</button>
|
||
{showNewIconPicker && (
|
||
<div style={{ position: "absolute", top: "100%", left: 0, marginTop: "4px", zIndex: 50 }}>
|
||
<IconPicker selectedIcon={newProjectIcon} onSelect={(name) => { setNewProjectIcon(name); setShowNewIconPicker(false); }} darkMode={darkMode} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
<input type="color" value={newProjectColor} onChange={(e) => setNewProjectColor(e.target.value)} style={{ width: "32px", height: "32px", border: "none", cursor: "pointer", padding: 0, borderRadius: "8px" }} />
|
||
<input type="text" value={newProjectName} onChange={(e) => setNewProjectName(e.target.value)} placeholder={t.name} className="weekly-input" style={{ flex: 1, padding: "8px 12px", fontSize: "0.9rem" }} onKeyDown={(e) => { if (e.key === "Enter") createProject(); }} />
|
||
<button onClick={createProject} className="weekly-btn-primary" style={{ padding: "8px 16px", fontSize: "0.85rem", whiteSpace: "nowrap" }}>
|
||
<Plus size={14} /> {t.addProject}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Active filter banner */}
|
||
{activeProjectFilter && (
|
||
<div style={{ padding: "8px 20px", background: darkMode ? "#1e3a5f" : "#dbeafe", display: "flex", alignItems: "center", gap: "8px", fontSize: "0.8rem", color: darkMode ? "#bfdbfe" : "#1e40af" }}>
|
||
<Filter size={14} />
|
||
<span style={{ flex: 1 }}>
|
||
{t.filteringBy} {projects.find((p) => p.id === activeProjectFilter)?.name || ""}
|
||
</span>
|
||
<button onClick={() => onSetProjectFilter(null)} style={{ background: "none", border: "none", cursor: "pointer", color: darkMode ? "#bfdbfe" : "#1e40af", padding: "2px 6px", fontSize: "0.75rem", textDecoration: "underline" }}>
|
||
{t.clearFilter}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Scrollable content */}
|
||
<div style={{ flex: 1, overflowY: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: "12px" }}>
|
||
<p style={{ fontSize: "0.8rem", color: darkMode ? "#9ca3af" : "#888" }}>{t.desc}</p>
|
||
|
||
{projects.length === 0 ? (
|
||
<div style={{ textAlign: "center", padding: "32px 16px" }}>
|
||
<div style={{ width: "56px", height: "56px", borderRadius: "50%", background: darkMode ? "#2a2a3a" : "#f3f4f6", display: "flex", alignItems: "center", justifyContent: "center", margin: "0 auto 12px" }}><ProjectIcon icon="folder" size={24} color={darkMode ? "#9ca3af" : "#999"} /></div>
|
||
<p style={{ fontSize: "0.9rem", color: "#aaa", fontStyle: "italic" }}>{t.noProjects}</p>
|
||
</div>
|
||
) : (
|
||
projects.map((p) => {
|
||
const projectTasks = tasksByProject.get(p.id) || [];
|
||
const isExpanded = expandedProjects.has(p.id);
|
||
const isFiltered = activeProjectFilter === p.id;
|
||
return (
|
||
<div key={p.id} style={{ display: "flex", flexDirection: "column", borderRadius: "12px", background: darkMode ? "#2a2a3a" : "#f9fafb", borderLeft: `4px solid ${p.color || "#999"}`, outline: isFiltered ? `2px solid ${p.color || "#3b82f6"}` : "none" }}>
|
||
<div style={{ display: "flex", alignItems: "center", gap: "10px", padding: "10px 14px" }}>
|
||
{editingId === p.id ? (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "10px", width: "100%" }}>
|
||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||
<div style={{ position: "relative" }}>
|
||
<button onClick={() => setShowEditIconPicker(!showEditIconPicker)} style={{ width: "40px", height: "40px", borderRadius: "10px", border: `1px solid ${darkMode ? "#555" : "#e5e7eb"}`, background: darkMode ? "#1e1e2e" : "#fff", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||
<ProjectIcon icon={editIcon} size={18} color={darkMode ? "#d1d5db" : "#555"} />
|
||
</button>
|
||
{showEditIconPicker && (
|
||
<div style={{ position: "absolute", top: "100%", left: 0, marginTop: "4px", zIndex: 50 }}>
|
||
<IconPicker selectedIcon={editIcon} onSelect={(name) => { setEditIcon(name); setShowEditIconPicker(false); }} darkMode={darkMode} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
<input type="text" value={editName} onChange={(e) => setEditName(e.target.value)} className="weekly-input" style={{ flex: 1, padding: "8px 12px", fontSize: "0.9rem" }} onKeyDown={(e) => { if (e.key === "Enter") updateProject(p.id); if (e.key === "Escape") setEditingId(null); }} autoFocus />
|
||
</div>
|
||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||
<input type="color" value={editColor} onChange={(e) => setEditColor(e.target.value)} style={{ width: "32px", height: "32px", border: "none", cursor: "pointer", padding: 0, borderRadius: "8px" }} />
|
||
<span style={{ fontSize: "0.8rem", color: darkMode ? "#9ca3af" : "#888" }}>{t.color}</span>
|
||
<div style={{ flex: 1 }} />
|
||
<button onClick={() => setEditingId(null)} style={{ padding: "6px 14px", fontSize: "0.8rem", background: "none", border: `1px solid ${darkMode ? "#555" : "#ddd"}`, borderRadius: "8px", cursor: "pointer", color: darkMode ? "#9ca3af" : "#666" }}>{t.cancel}</button>
|
||
<button onClick={() => updateProject(p.id)} className="weekly-btn-primary" style={{ padding: "6px 14px", fontSize: "0.8rem" }}><Check size={14} /> {t.save}</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<button onClick={() => toggleExpanded(p.id)} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280", display: "flex", alignItems: "center" }} title={isExpanded ? t.hideTasks : t.showTasks}>
|
||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||
</button>
|
||
<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", color: darkMode ? "#f0f0f0" : "#333" }}>{p.name}</span>
|
||
<span style={{ fontSize: "0.7rem", color: darkMode ? "#9ca3af" : "#888" }}>{projectTasks.length}</span>
|
||
</div>
|
||
<button onClick={() => onSetProjectFilter(isFiltered ? null : p.id)} style={{ padding: "6px", opacity: isFiltered ? 1 : 0.5, cursor: "pointer", background: isFiltered ? (darkMode ? "#3b3b5f" : "#e0e7ff") : "none", border: "none", borderRadius: "6px", color: isFiltered ? (p.color || "#3b82f6") : (darkMode ? "#ccc" : "#333") }} title={isFiltered ? t.clearFilter : t.filterOnly}>
|
||
<Filter size={15} />
|
||
</button>
|
||
<button onClick={() => { setEditingId(p.id); setEditName(p.name); setEditColor(p.color || "#999"); setEditIcon(p.icon || "folder"); setShowEditIconPicker(false); }} style={{ padding: "6px", opacity: 0.5, cursor: "pointer", background: "none", border: "none", borderRadius: "6px", color: darkMode ? "#ccc" : "#333" }} title="Edit">
|
||
<Pencil size={15} />
|
||
</button>
|
||
<button onClick={() => deleteProject(p.id, p.name)} style={{ padding: "6px", opacity: 0.5, cursor: "pointer", color: "#ef4444", background: "none", border: "none", borderRadius: "6px" }} title="Delete">
|
||
<Trash2 size={15} />
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
{isExpanded && editingId !== p.id && (
|
||
<div style={{ padding: "0 14px 12px 38px", display: "flex", flexDirection: "column", gap: "4px" }}>
|
||
{projectTasks.length === 0 ? (
|
||
<span style={{ fontSize: "0.8rem", color: darkMode ? "#6b7280" : "#9ca3af", fontStyle: "italic" }}>{t.noTasks}</span>
|
||
) : (
|
||
projectTasks.map((task) => (
|
||
<div key={task.id} style={{ fontSize: "0.82rem", color: darkMode ? "#d1d5db" : "#374151", padding: "3px 6px", borderRadius: "4px", textDecoration: task.completed ? "line-through" : "none", opacity: task.completed ? 0.6 : 1, display: "flex", alignItems: "center", gap: "6px" }}>
|
||
<span style={{ width: "6px", height: "6px", borderRadius: "50%", background: p.color || "#999", flexShrink: 0 }} />
|
||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{task.title}</span>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// Notes Sidebar Component
|
||
interface NotesSidebarProps {
|
||
task: Task;
|
||
onClose: () => void;
|
||
updateTaskNotes: (id: string, notes: string) => void;
|
||
updateTaskUrl: (id: string, url: string) => void;
|
||
}
|
||
|
||
function NotesSidebar({ task, onClose, updateTaskNotes, updateTaskUrl }: NotesSidebarProps) {
|
||
const [isVisible, setIsVisible] = useState(false);
|
||
const [sidebarWidth, setSidebarWidth] = useState(500);
|
||
const [urlValue, setUrlValue] = useState(task.url || "");
|
||
const isResizing = useRef(false);
|
||
|
||
useEffect(() => { setUrlValue(task.url || ""); }, [task.url]);
|
||
|
||
useEffect(() => {
|
||
const handleMouseMove = (e: MouseEvent) => {
|
||
if (!isResizing.current) return;
|
||
const newWidth = window.innerWidth - e.clientX;
|
||
setSidebarWidth(Math.max(320, Math.min(newWidth, window.innerWidth * 0.9)));
|
||
};
|
||
const handleMouseUp = () => {
|
||
if (isResizing.current) {
|
||
isResizing.current = false;
|
||
document.body.style.cursor = '';
|
||
document.body.style.userSelect = '';
|
||
}
|
||
};
|
||
window.addEventListener('mousemove', handleMouseMove);
|
||
window.addEventListener('mouseup', handleMouseUp);
|
||
return () => {
|
||
window.removeEventListener('mousemove', handleMouseMove);
|
||
window.removeEventListener('mouseup', handleMouseUp);
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const timer = setTimeout(() => setIsVisible(true), 10);
|
||
return () => clearTimeout(timer);
|
||
}, []);
|
||
|
||
const handleClose = () => {
|
||
setIsVisible(false);
|
||
setTimeout(onClose, 300);
|
||
};
|
||
|
||
const handleUrlBlur = () => {
|
||
const normalised = urlValue.trim()
|
||
? urlValue.trim().startsWith("http") ? urlValue.trim() : `https://${urlValue.trim()}`
|
||
: "";
|
||
if (normalised !== (task.url || "")) {
|
||
updateTaskUrl(task.id, normalised);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<div
|
||
className={`weekly-modal-overlay ${isVisible ? "show" : ""}`}
|
||
onClick={handleClose}
|
||
style={{ zIndex: 1999 }}
|
||
/>
|
||
<div className={`weekly-notes-sidebar ${isVisible ? "open" : ""}`} style={{ width: `${sidebarWidth}px` }}>
|
||
{/* Resize handle */}
|
||
<div
|
||
onMouseDown={(e) => {
|
||
e.preventDefault();
|
||
isResizing.current = true;
|
||
document.body.style.cursor = 'col-resize';
|
||
document.body.style.userSelect = 'none';
|
||
}}
|
||
style={{
|
||
position: 'absolute',
|
||
left: 0,
|
||
top: 0,
|
||
bottom: 0,
|
||
width: '6px',
|
||
cursor: 'col-resize',
|
||
zIndex: 10,
|
||
}}
|
||
title="Drag to resize"
|
||
/>
|
||
<header className="weekly-notes-sidebar-header">
|
||
<h2 className="weekly-notes-sidebar-title">Notes: {task.title}</h2>
|
||
<button className="weekly-notes-sidebar-close" onClick={handleClose}>
|
||
×
|
||
</button>
|
||
</header>
|
||
<div className="weekly-notes-sidebar-content">
|
||
{/* URL field */}
|
||
<div style={{ marginBottom: "12px" }}>
|
||
<label style={{ display: "block", fontSize: "0.7rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em", color: "var(--weekly-text-muted, #999)", marginBottom: "4px" }}>
|
||
Link
|
||
</label>
|
||
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||
<input
|
||
type="url"
|
||
value={urlValue}
|
||
onChange={(e) => setUrlValue(e.target.value)}
|
||
onBlur={handleUrlBlur}
|
||
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
|
||
placeholder="https://..."
|
||
style={{ flex: 1, fontSize: "0.82rem", border: "1px solid var(--weekly-border, #ddd)", borderRadius: "4px", padding: "5px 8px", background: "var(--weekly-bg, white)", color: "var(--weekly-text, #333)", outline: "none" }}
|
||
/>
|
||
{urlValue && (
|
||
<button
|
||
onClick={() => { setUrlValue(""); updateTaskUrl(task.id, ""); }}
|
||
title="Remove link"
|
||
style={{ background: "none", border: "none", cursor: "pointer", color: "#999", padding: "4px" }}
|
||
>
|
||
<svg viewBox="0 0 24 24" width="13" height="13" 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"
|
||
title="Open link"
|
||
style={{ color: "#2563eb", display: "inline-flex", padding: "4px" }}
|
||
>
|
||
<svg viewBox="0 0 24 24" width="14" height="14" 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>
|
||
</div>
|
||
|
||
{/* Notes */}
|
||
<div>
|
||
<label style={{ display: "block", fontSize: "0.7rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em", color: "var(--weekly-text-muted, #999)", marginBottom: "4px" }}>
|
||
Notes
|
||
</label>
|
||
<RichTextEditor
|
||
value={task.markdownContent || ""}
|
||
onChange={(html) => updateTaskNotes(task.id, html)}
|
||
placeholder="Add details, notes, or links..."
|
||
minHeight="300px"
|
||
/>
|
||
</div>
|
||
|
||
<div className="weekly-modal-actions" style={{ marginTop: '24px' }}>
|
||
<button
|
||
className="weekly-btn weekly-btn-secondary"
|
||
onClick={handleClose}
|
||
style={{ width: '100%' }}
|
||
>
|
||
Close
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|