My-Weekly-ToDo-List/src/components/WeeklyView.tsx
mARTin 7bc43d3c98 feat: add hyperlink support to tasks
Option A — URL field per task:
- New url String? column in Task schema (migration: 20260403_add_task_url)
- Link icon button in GridTaskBlock action bar; click to open inline URL popup
  with input, clear (×) button, and open-in-new-tab shortcut
- Blue link indicator shown inline on the task card when a URL is set
- updateTaskUrl() in WeeklyView persists to DB via PATCH /api/tasks

Option B — Rich text notes with inline hyperlinks:
- GridTaskBlock notes popup upgraded from plain markdown textarea to
  RichTextEditor (Tiptap), which already has bold/italic/underline/
  bullet/blockquote/link toolbar and full link support
- Notes now save on every change via onChange (no blur required)

v1.82.0
2026-04-03 15:04:44 +02:00

10465 lines
573 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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,
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,
} 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";
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 { WeatherDisplayKey, WEATHER_DISPLAY_DEFAULTS } from "../lib/weeklyViewConstants";
const SettingsSidebar = dynamic(() => import("./SettingsSidebar"), { ssr: false });
// Cookie helpers for per-device settings
const DEVICE_SETTINGS_KEYS = ["viewDays", "cellDuration", "startHour", "endHour"];
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`;
}
export type ViewStyle = "simple" | "calendar" | "list" | "grid" | "kanban";
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;
}
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;
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(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 resizingRef = useRef<{ target: 'someday' | 'allday'; startY: number; startHeight: number; handleOnTop: boolean } | null>(null);
const startResize = useCallback((e: React.MouseEvent | React.TouchEvent, target: 'someday' | 'allday', handleOnTop = false) => {
e.preventDefault();
e.stopPropagation();
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
const section = target === 'someday' ? somedaySectionRef.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;
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 section2 = resizingRef.current.target === 'someday' ? somedaySectionRef.current : (document.querySelector('.all-day-events-section') as HTMLElement);
if (section2) {
const h = Math.round(section2.getBoundingClientRect().height);
document.cookie = `${resizingRef.current.target === 'someday' ? 'somedayHeight' : 'allDayHeight'}=${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 [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);
}
}
};
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);
// 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 [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 [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,
});
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 [cellDuration, setCellDuration] = useState<CellDuration>(30);
const [draggedTask, setDraggedTask] = useState<Task | null>(null);
const [showTimeGrid, setShowTimeGrid] = useState(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>("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;
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", "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;
const getEffective = <K extends keyof PerViewOverrides>(key: K, globalVal: PerViewOverrides[K]): 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) => {
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 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 [showProjectsSidebar, setShowProjectsSidebar] = useState(false);
const [focusTimerDuration, setFocusTimerDuration] = useState(25);
const [fontSize, setFontSize] = useState<"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);
};
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 profileData = data.user;
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);
});
}
}
// 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();
}
}, []);
// Periodic pull-sync from external task providers (every 15 minutes)
useEffect(() => {
if (!session) return;
const interval = setInterval(
async () => {
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] Pulled ${data.updated} updates, ${data.deleted} deletions, ${data.created || 0} new tasks`,
);
fetchTasks();
}
}
} catch (e) {
console.error("[SYNC] Task sync error:", e);
}
},
15 * 60 * 1000,
);
return () => 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);
}, []);
// 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) => {
// 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));
return; // Don't write to DB — that would overwrite other devices
}
// 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");
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);
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);
}
}
} 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,
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,
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;
// 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],
);
// 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;
// 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],
);
// 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 {
const res = await fetch(`/api/someday-lists?id=${existing.id}`, {
method: "DELETE",
});
if (res.ok) {
setSomedayLists((prev) => prev.filter((l) => l.id !== existing.id));
setImportStatusMsg({
type: "success",
text: `Stopped syncing "${list.title}".`,
});
}
} catch (error) {
console.error("Failed to delete 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 {
const res = await fetch(`/api/someday-lists?id=${existing.id}`, {
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();
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;
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);
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");
setTimeout(() => setSyncStatus("idle"), 3000);
} catch (error) {
console.error("Error syncing:", error);
setSyncStatus("idle");
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 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"),
} 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,
});
// 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"
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}
>
{/* 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>
)}
{/* Quick Settings Sidebar (TeuxDeux-style) */}
{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" : "#fafafa",
borderRight: `1px solid ${darkMode ? "#333" : "#e5e7eb"}`,
zIndex: 1000,
display: "flex",
flexDirection: "column",
padding: "16px 14px",
gap: "10px",
overflowY: "auto",
boxShadow: "2px 0 12px rgba(0,0,0,0.08)",
}}
>
<div style={{ fontSize: "0.85rem", fontWeight: 700, color: darkMode ? "#e5e7eb" : "#333", marginBottom: "2px" }}>
{profile.language === "de" ? "Einstellungen" : "Preferences"}
</div>
{/* Navigation Row */}
<div style={{ display: "flex", justifyContent: "center", gap: "4px", padding: "4px 0", borderBottom: `1px solid ${darkMode ? "#333" : "#e5e7eb"}`, 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>
{/* Quick Actions */}
<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>
{/* Separator */}
<div style={{ borderTop: `1px solid ${darkMode ? "#333" : "#e5e7eb"}`, paddingTop: "8px" }} />
{/* View Style */}
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
<span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>
{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} /> },
].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); } }}
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 ? "#0ea5e9" : (darkMode ? "#1f2937" : "#e5e7eb"), color: profile.viewStyle ===v.key ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280") }}>
{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" : "#6b7280" }}>
{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" : "#333") : (darkMode ? "#1f2937" : "#e5e7eb"), color: viewDays === num ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280") }}>
{num}
</button>
))}
</div>
</div>
{/* Slot Duration + sub-hour slots (only with time grid) */}
{profile.showTimeGrid && (
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
<span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>
{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" : "#333") : (darkMode ? "#1f2937" : "#e5e7eb"), color: effectiveCellDuration === d ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280") }}>
{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" }}>{profile.language === "de" ? ":15/:30/:45" : ":15/:30/:45"}</span>
<button onClick={() => { const v = !effectiveShowSubHourSlots; setShowSubHourSlots(v); saveSetting("showSubHourSlots", v); }} 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>
{/* Separator */}
<div style={{ borderTop: `1px solid ${darkMode ? "#333" : "#e5e7eb"}`, paddingTop: "4px" }} />
{/* Toggle switches */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>{profile.language === "de" ? "Irgendwann" : "Someday"}</span>
<button onClick={() => { const v = !effectiveShowSomeday; setShowSomeday(v); saveSetting("showSomeday", v); }} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280" }}>
{effectiveShowSomeday ? <Eye size={16} /> : <EyeOff size={16} />}
</button>
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>{profile.language === "de" ? "Ganztägig" : "All-day"}</span>
<button onClick={() => { const v = !effectiveShowAllDay; setShowAllDay(v); saveSetting("showAllDayEvents", v); }} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280" }}>
{effectiveShowAllDay ? <Eye size={16} /> : <EyeOff size={16} />}
</button>
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>{profile.language === "de" ? "Checkboxen" : "Checkboxes"}</span>
<button onClick={() => { const v = !effectiveShowTaskCheckboxes; saveSetting("showTaskCheckboxes", v); }} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280" }}>
{effectiveShowTaskCheckboxes ? <Eye size={16} /> : <EyeOff size={16} />}
</button>
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>{profile.language === "de" ? "Projekt-Icons" : "Project Icons"}</span>
<button onClick={() => { const v = !effectiveShowProjectIcons; saveSetting("showProjectIcons", v); }} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280" }}>
{effectiveShowProjectIcons ? <Eye size={16} /> : <EyeOff size={16} />}
</button>
</div>
{(profile.weatherLat || profile.weatherLon) && (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>{profile.language === "de" ? "Wetter" : "Weather"}</span>
<button onClick={() => { const v = !effectiveWeatherEnabled; saveSetting("weatherEnabled", v); }} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280" }}>
{effectiveWeatherEnabled ? <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 mode */}
<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>
{/* Separator */}
<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(); setShowQuickSettings(false); }} style={qsBtnStyle(darkMode)} title="Refresh"><RefreshCcw size={15} /></button>
</div>
{/* Spacer */}
<div style={{ flex: 1 }} />
{/* Close button at bottom */}
<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>
</>
)}
{/* Projects Sidebar */}
{showProjectsSidebar && (
<ProjectsSidebar
darkMode={darkMode}
language={profile.language}
projects={projects}
onProjectsChanged={fetchProjects}
onClose={() => setShowProjectsSidebar(false)}
/>
)}
{/* Quick Settings toggle button is now in the desktop header toolbar */}
{/* 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: Quick settings */}
<button className="mobile-header-btn" onClick={() => setShowQuickSettings(true)} title="Quick Settings" 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
? <AlertCircle size={14} className="text-red-500" title={syncError} />
: (isLoading || isSyncing || syncStatus === "syncing")
? <div className="weekly-spinner" title="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>
)}
{/* Desktop Header: Left, Center, Right */}
<header className="group relative flex items-center justify-between w-full px-4 py-2 border-b border-gray-200 bg-white dark:bg-gray-900 dark:border-gray-700 dark:text-white transition-colors duration-200" style={isMobile ? { display: "none" } : {}}>
{/* LEFT SECTION: View Switcher, Days, Hours, Slot Duration */}
<div className="weekly-header-controls flex items-center gap-4 transition-opacity duration-300 opacity-0 group-hover:opacity-100" style={{ zIndex: 1 }}>
{/* Quick Settings toggle */}
{!showQuickSettings && (
<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" ? "Schnelleinstellungen" : "Quick 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"); }}
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>
</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>
)}
</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 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-300 opacity-0 group-hover:opacity-100" style={{ zIndex: 10 }}>
{/* Secondary actions — hidden on tablet, visible on desktop */}
<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>
{/* 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="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 */}
<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>
{/* 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 */}
{profile.viewStyle !== "kanban" && effectiveAllDayPosition === "above" && allDaySection}
{/* 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 (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"
/>
)}
<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` }}
/>
);
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>{formatHour(hour, parseInt(minutes), (isHourStart ? effectiveHourLabelFormat : 'full') as "short" | "full", profile.timeFormat)}</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}
projects={projects}
onProjectAssign={assignProject}
kanbanStages={kanbanStages}
weatherEnabled={effectiveWeatherEnabled}
/>
))}
{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={() => setSelectedTaskForNotes(task)}
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}
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" && effectiveAllDayPosition === "below" && allDaySection}
{/* Someday Section */}
{effectiveShowSomeday && (<>
{/* Resize handle - on top border of someday section */}
{somedayExpanded && (
<div
className="resize-handle"
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",
}}
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"
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);
}}
>
<button
className={`someday-tab-btn-h ${activeSomedayTab === tab ? "active" : ""} ${dragOverTab === tab ? "drag-over" : ""}`}
onClick={() => setSomedayTab(tab)}
onDoubleClick={() => {
setEditingTabName(tab);
setRenamingTabValue(tab);
}}
title={t.renameTab}
>{tab} <span className="someday-tab-count">{somedayLists.filter(l => l.tab === tab).length}</span></button>
<button
className="someday-tab-dissolve-h"
onClick={(e) => { e.stopPropagation(); dissolveTab(tab); }}
title={t.dissolveTab}
><X size={10} /></button>
</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" : ""} p-2 transition-colors duration-200`}
style={{
minHeight: "200px",
cursor: "text",
display: "flex",
flexDirection: "column",
}}
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: "8px", padding: "4px" }}>
<span style={{ fontSize: "0.9rem", fontWeight: "bold" }}>Delete this list?</span>
{list.externalProvider && <span style={{ fontSize: "0.75rem", color: "#888" }}>Note: This list is not deleted from {list.externalProvider}, just from this view.</span>}
<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" }}>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" }}>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>
<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();
}}
/>
{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 iconContent = (
<span
title={`Synced with ${list.externalProvider === "outlook" ? "Microsoft" : list.externalProvider === "google" ? "Google" : list.externalProvider === "apple" ? "Apple" : list.externalProvider === "notion" ? "Notion" : list.externalProvider}`}
style={{ display: "inline-flex", alignItems: "center", marginLeft: "8px", opacity: 0.8, flexShrink: 0, cursor: providerUrl ? "pointer" : "default" }}
>
{list.externalProvider === "outlook" ? (
<FontAwesomeIcon icon={faMicrosoft} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#0078D4] dark:hover:text-[#00A4EF] transition-colors" />
) : list.externalProvider === "google" ? (
<FontAwesomeIcon icon={faGoogle} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#4285F4] dark:hover:text-[#8AB4F8] transition-colors" />
) : list.externalProvider === "apple" ? (
<FontAwesomeIcon icon={faApple} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#555] dark:hover:text-[#ccc] transition-colors" />
) : list.externalProvider === "synology" ? (
<FontAwesomeIcon icon={faServer} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#007AFF] dark:hover:text-[#3A9CFF] transition-colors" />
) : list.externalProvider === "notion" ? (
<FontAwesomeIcon icon={faNotion} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#000] dark:hover:text-[#fff] transition-colors" />
) : (
<RefreshCcw size={14} className="text-zinc-400" />
)}
</span>
);
return providerUrl ? (
<a href={providerUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
{iconContent}
</a>
) : iconContent;
})()}
<div className="someday-tab-assign" style={{ marginLeft: "auto", position: "relative", display: "flex", alignItems: "center" }}>
<FolderPlus size={13} style={{ color: list.tab ? "var(--weekly-accent, #6366f1)" : "#bbb", flexShrink: 0 }} />
<select
className="someday-tab-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("");
} else {
assignListToTab(list.id, val || null);
}
}}
style={{
position: "absolute",
inset: 0,
opacity: 0,
cursor: "pointer",
width: "100%",
}}
title={t.assignTab || "Assign to tab"}
>
<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>
{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",
}}
/>
)}
<button
className="someday-list-delete-btn"
onClick={(e) => {
e.stopPropagation();
setListToDelete(list.id);
}}
style={{
border: "none",
background: "none",
cursor: "pointer",
color: "#ccc",
marginLeft: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "4px"
}}
title="Delete List"
>
<Trash2 size={16} />
</button>
</>
)}
</div>
<div
className="weekly-task-list"
style={{
flex: 1,
flexDirection: "column",
justifyContent: "flex-start",
position: "relative",
display: "flex",
}}
>
{(() => {
const slotCount = getSomedaySlotCount(list.tasks);
const indexedTasks = list.tasks.filter(t => t.somedaySlotIndex !== null && t.somedaySlotIndex !== undefined && t.somedaySlotIndex < slotCount);
const unindexedTasks = list.tasks.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 tasks that didn't fit in slots
const remainingTasks = unindexedTasks.slice(unindexedIdx);
return (
<>
{listToDelete !== list.id && <SomedayAddTask
listId={list.id}
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={{ minHeight: "24px", 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={() => setSelectedTaskForNotes(task)}
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}
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={() => setSelectedTaskForNotes(task)}
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}
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()}
>
<h3 className="text-xl font-black mb-1 flex items-center gap-2">
<Plus className="w-6 h-6" />
NEW LIST
</h3>
<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>
<h3 className="text-xl font-black uppercase tracking-tight">
Recurring Task
</h3>
<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}
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}
/>
)
}
{/* 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>
{/* Notes */}
<div className="kanban-detail-section">
<label><FileText size={13} /> {t.notes || "Notes"}</label>
<textarea
defaultValue={liveTask.markdownContent || ""}
onBlur={(e) => {
const val = e.target.value;
if (val !== (liveTask.markdownContent || "")) {
updateTaskNotes(liveTask.id, val);
setKanbanDetailTask({ ...liveTask, markdownContent: val });
}
}}
placeholder={profile.language === "de" ? "Notizen hinzufügen..." : "Add notes..."}
className="kanban-detail-notes"
rows={4}
/>
</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}
/>
{/* 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,
}: {
listId: string;
onAdd: (title: string) => void;
onCancel?: () => void;
slotIdx?: number;
}) {
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]);
return (
<li
className="weekly-task-item minimal"
style={{
margin: slotIdx !== undefined ? "0" : "0 0.5rem",
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 0",
fontSize: "0.9375rem",
outline: "none",
height: slotIdx !== undefined ? "auto" : "24px",
display: "block",
}}
placeholder={slotIdx !== undefined ? "" : "Add task..."}
data-someday-add-input={slotIdx !== undefined ? undefined : listId}
/>
</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;
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;
projects?: { id: string; name: string; icon?: string | null; color?: string | null }[];
onProjectAssign?: (taskId: string, projectId: string | null) => void;
kanbanStages?: KanbanStage[];
}
function TaskItem({
task,
isEditing,
onToggle,
onEdit,
onUpdate,
onDelete,
onNotes,
onRollToggle,
onRecurrence,
onDragStart,
onDragEnd,
variant = "default",
isSomeday = false,
onAddSubTask,
onToggleSubTask,
onDeleteSubTask,
onUpdateSubTask,
editingTaskId,
onSetEditingTaskId,
isSubTask = false,
showTaskCheckboxes = false,
showProjectIcons = false,
projects = [],
onProjectAssign,
kanbanStages = [],
}: TaskItemProps) {
const [editValue, setEditValue] = useState(task.title);
const [isNotesOpen, setIsNotesOpen] = useState(false);
const [notesValue, setNotesValue] = useState(task.markdownContent || "");
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 notesRef = 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 notes when opened
useEffect(() => {
if (isNotesOpen && notesRef.current) {
notesRef.current.focus();
}
}, [isNotesOpen]);
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);
}
};
const handleNotesBlur = () => {
if (notesValue !== task.markdownContent) {
onNotes(notesValue);
}
};
// Markdown insertion helper
const insertMarkdown = (prefix: string, suffix: string = "") => {
if (!notesRef.current) return;
const start = notesRef.current.selectionStart;
const end = notesRef.current.selectionEnd;
const text = notesValue;
const before = text.substring(0, start);
const selection = text.substring(start, end);
const after = text.substring(end);
const newText = `${before}${prefix}${selection}${suffix}${after}`;
setNotesValue(newText);
setTimeout(() => {
if (notesRef.current) {
notesRef.current.focus();
const newCursorPos =
start + prefix.length + selection.length + suffix.length;
notesRef.current.setSelectionRange(newCursorPos, newCursorPos);
}
}, 0);
};
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: "flex-start",
gap: "0.5rem",
width: "100%",
}}
>
{isEditing ? (
<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>
) : (
<>
<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 } : {}) }
}
>
{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,
}}
>
{showProjectIcons && task.project && (
<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>
);
})()}
{/* 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>
)}
{/* 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>
{/* Inline Notes Editor with Toolbar */}
{
isNotesOpen && (
<div
className="weekly-notes-inline"
onClick={(e) => e.stopPropagation()}
>
<div className="notes-toolbar">
<button className="notes-toolbar-btn" onClick={() => insertMarkdown("**", "**")} title="Bold" style={{ fontWeight: 700 }}>B</button>
<button className="notes-toolbar-btn" onClick={() => insertMarkdown("*", "*")} title="Italic" style={{ fontStyle: "italic" }}>I</button>
<button className="notes-toolbar-btn" onClick={() => insertMarkdown("[", "](url)")} title="Link">
<svg viewBox="0 0 24 24" width="12" height="12" 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>
</button>
<button className="notes-toolbar-btn" onClick={() => insertMarkdown("- ")} title="List">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
</button>
<span style={{ marginLeft: "auto", fontSize: "0.65rem", color: "var(--weekly-text-muted, #999)", opacity: 0.6 }}>md</span>
</div>
<textarea
ref={notesRef}
className="weekly-notes-editor-inline"
value={notesValue}
onChange={(e) => setNotesValue(e.target.value)}
onBlur={handleNotesBlur}
placeholder="Add notes..."
/>
</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 >
);
}
// 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 }: {
darkMode: boolean;
language: string;
projects: { id: string; name: string; icon?: string | null; color?: string | null }[];
onProjectsChanged: () => void;
onClose: () => 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 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}"?`,
};
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>
{/* 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) => (
<div key={p.id} style={{ display: "flex", alignItems: "center", gap: "10px", padding: "10px 14px", borderRadius: "12px", background: darkMode ? "#2a2a3a" : "#f9fafb", borderLeft: `4px solid ${p.color || "#999"}` }}>
{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>
) : (
<>
<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>
</div>
<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>
))
)}
</div>
{/* Add project form — fixed at bottom */}
<div style={{ padding: "16px 20px", borderTop: `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", bottom: "100%", left: 0, marginBottom: "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>
</div>
</>
);
}
// Notes Sidebar Component
interface NotesSidebarProps {
task: Task;
onClose: () => void;
updateTaskNotes: (id: string, notes: string) => void;
}
function NotesSidebar({ task, onClose, updateTaskNotes }: NotesSidebarProps) {
const [isVisible, setIsVisible] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [sidebarWidth, setSidebarWidth] = useState(500);
const isResizing = useRef(false);
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 handleToolbarClick = (before: string, after: string, selectOffsetStart?: number, selectOffsetEnd?: number) => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const beforeText = text.substring(0, start);
const selection = text.substring(start, end);
const afterText = text.substring(end);
let newText = `${beforeText}${before}${selection}${after}${afterText}`;
if (before === "![" && after === "](url)") {
// Special case for image to match original logic precisely
newText = `${beforeText}![alt text](url)${afterText}`;
}
updateTaskNotes(task.id, newText);
textarea.value = newText;
textarea.focus();
if (before === "![" && after === "](url)") {
textarea.setSelectionRange(start + 2, start + 10);
} else {
textarea.setSelectionRange(
start + before.length,
start + before.length + selection.length
);
}
};
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">
<div className="notes-toolbar">
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("**", "**")} title="Bold">B</button>
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("*", "*")} title="Italic">i</button>
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("[", "](url)")} title="Link">🔗</button>
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("- ", "")} title="List"></button>
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("![", "](url)")} title="Image">🖼</button>
</div>
<textarea
ref={textareaRef}
className="weekly-notes-editor"
defaultValue={task.markdownContent || ""}
autoFocus
placeholder="Add details, notes, or links..."
onBlur={(e) => updateTaskNotes(task.id, e.target.value)}
/>
<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>
</>
);
}