diff --git a/.env.local b/.env.local deleted file mode 100644 index cf7890b..0000000 --- a/.env.local +++ /dev/null @@ -1,26 +0,0 @@ -# Database Configuration -DATABASE_URL=postgresql://root:WKE7xeZohxdZit7eObjG@192.168.178.91:2665/My-Weekly-ToDo-List?schema=public - -# Email Service Configuration -SMTP_HOST=w00ff033.kasserver.com -SMTP_PORT=587 -SMTP_USERNAME=mail@carrylight.de -SMTP_PASSWORD=QijU8e2A8p3FE8WS8esR -SMTP_SECURE=true - -# NEXTAUTH_URL=https://todo.martin-bierschenk.de -NEXTAUTH_URL=http://localhost:3000 - -# Google OAuth (Replace with your credentials) -GOOGLE_CLIENT_ID=196368743757-1fn17q2ecg5n8rej4tu96khltno173he.apps.googleusercontent.com -GOOGLE_CLIENT_SECRET=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 -# GOOGLE_REDIRECT_URI=https://todo.martin-bierschenk.de/api/calendar/google/oauth -GOOGLE_REDIRECT_URI=http://localhost:3000/api/calendar/google/oauth - - -# Apple Sign In (Replace with your credentials when ready) -# APPLE_ID=your-apple-id -# APPLE_SECRET=your-apple-secret - -# Base URL for the application -NEXT_PUBLIC_BASE_URL=https://todo.martin-bierschenk.de diff --git a/src/app/globals.css b/src/app/globals.css index 1869280..cdc1110 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -635,7 +635,7 @@ h3 { min-height: 400px; max-height: calc(100vh - 200px); overflow-y: auto; - overflow-x: hidden; + overflow-x: visible; } .weekly-day-column:first-child { @@ -666,7 +666,7 @@ h3 { .weekly-day-header { padding: 1rem 1rem 0.75rem; text-align: left; - min-height: 60px; + min-height: 50px; box-sizing: border-box; } @@ -799,6 +799,45 @@ h3 { } /* Rolling Indicator */ +/* Note icon on tasks with notes */ +.task-note-icon { + display: inline-flex; + align-items: center; + flex-shrink: 0; + color: var(--weekly-teal, #009a9a); + opacity: 0.6; + margin-right: 2px; + position: relative; + cursor: default; +} + +.task-note-icon:hover { + opacity: 1; +} + +.task-note-icon:hover::after { + content: attr(data-note); + position: absolute; + left: 0; + top: 100%; + margin-top: 4px; + background: var(--weekly-bg, #fff); + color: var(--weekly-text, #333); + border: 1px solid var(--weekly-border, #ddd); + border-radius: 6px; + padding: 6px 10px; + font-size: 0.75rem; + font-weight: 400; + white-space: pre-wrap; + max-width: 250px; + max-height: 150px; + overflow: hidden; + z-index: 100; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + pointer-events: none; + line-height: 1.4; +} + .rolling-icon-indicator { position: absolute; top: 4px; @@ -896,6 +935,111 @@ h3 { color: var(--weekly-teal); } +/* Sub-tasks */ +.subtask-list { + list-style: none; + padding: 0.125rem 0 0.125rem 1.25rem; + margin: 0; +} + +.subtask-item { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.125rem 0; + font-size: 0.8125rem; + color: var(--weekly-text); + position: relative; +} + +.subtask-item.completed .subtask-title { + text-decoration: line-through; + opacity: 0.5; +} + +.subtask-checkbox { + background: none; + border: none; + padding: 0; + cursor: pointer; + color: var(--weekly-text-muted, #999); + display: flex; + align-items: center; + flex-shrink: 0; +} + +.subtask-checkbox:hover { + color: var(--weekly-teal); +} + +.subtask-item.completed .subtask-checkbox { + color: var(--weekly-teal); +} + +.subtask-title { + flex: 1; + cursor: pointer; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.subtask-title:hover { + color: var(--weekly-teal); +} + +.subtask-edit-input { + width: 100%; + border: none; + background: transparent; + outline: none; + font-size: 0.8125rem; + font-family: inherit; + color: var(--weekly-text); + padding: 0; +} + +.subtask-delete-btn { + background: none; + border: none; + padding: 0.125rem; + cursor: pointer; + color: var(--weekly-text-muted, #999); + opacity: 0; + transition: opacity 0.15s; + display: flex; + align-items: center; +} + +.subtask-item:hover .subtask-delete-btn { + opacity: 1; +} + +.subtask-delete-btn:hover { + color: #e53e3e; +} + +.subtask-add-row { + padding: 0.125rem 0 0.25rem 1.25rem; +} + +.subtask-add-input { + width: 100%; + border: none; + background: transparent; + outline: none; + font-size: 0.8125rem; + font-family: inherit; + color: var(--weekly-text); + padding: 0.125rem 0; +} + +.subtask-add-input::placeholder { + color: var(--weekly-text-muted, #999); + font-style: italic; +} + /* Task Input */ .weekly-task-input { padding: 0.5rem 1rem 0.5rem 2.5rem; @@ -918,8 +1062,7 @@ h3 { /* Someday Section */ .weekly-someday { - background: #faf8f5; - /* Light beige paper color */ + /* background color removed as per user request */ transition: max-height 0.3s ease; position: relative; } @@ -1468,14 +1611,33 @@ h3 { --weekly-settings-toggle-active-text: #ffffff; } +.weekly-container.dark-mode input, +.weekly-container.dark-mode textarea { + color: #ffffff; +} + +.weekly-container.dark-mode .weekly-task-input input { + color: #ffffff; +} + +.weekly-container.dark-mode .weekly-task-input input::placeholder { + color: #888888; +} + .weekly-container.dark-mode .weekly-task-item:hover { - background-color: #2a2a2a; + background-color: transparent; } .weekly-container.dark-mode .weekly-calendar-event { background: #2a3a3a; } +/* Bb: Remove someday list backgrounds in dark mode */ +.weekly-container.dark-mode .weekly-someday-list { + background-image: none; + background-color: transparent; +} + /* Responsive */ @media (max-width: 768px) { .weekly-days-grid { @@ -1529,20 +1691,38 @@ h3 { .time-column-header { padding: 1rem 0.5rem 0.75rem; - min-height: 60px; + min-height: 50px !important; box-sizing: border-box; } .time-column-slots { flex: 1; overflow-y: auto; + position: relative; +} + +/* Current time label in time column */ +.now-time-label { + position: absolute; + right: 2px; + transform: translateY(-50%); + font-size: 10px; + font-weight: 700; + color: #d50000; + background: var(--weekly-bg, #fff); + padding: 0 2px; + z-index: 12; + line-height: 1; + letter-spacing: -0.02em; + white-space: nowrap; + pointer-events: none; } .time-slot-label { display: flex; - align-items: flex-start; + align-items: center; /* Changed from flex-start to center */ justify-content: flex-end; - padding: 8px 0.5rem 0; + padding: 0 0.5rem; /* Removed top padding */ font-size: 0.65rem; color: var(--weekly-text-light); box-sizing: border-box; @@ -1554,13 +1734,27 @@ h3 { color: var(--weekly-text); } +.time-slot-label.sub-hour { + color: var(--weekly-text-light); + font-size: 0.55rem; +} + .time-slot-label span { - transform: translateY(50%); + transform: translateY(-50%); /* Changed from 50% to -50% for centering on the line */ background: var(--weekly-bg); padding: 0 2px; z-index: 1; } +.time-slot-label .sub-hour-label { + transform: translateY(-50%); + background: var(--weekly-bg); + padding: 0 2px; + z-index: 1; + opacity: 0.5; + font-size: 0.55rem; +} + /* Time Slots Container */ .time-slots-container { flex: 1; @@ -1837,6 +2031,10 @@ h3 { } .now-line::before { + content: none; +} + +.now-line::after { content: ''; position: absolute; left: -5px; @@ -1845,6 +2043,7 @@ h3 { height: 10px; background: #d50000; border-radius: 50%; + z-index: 12; } /* Drop Preview for Drag-n-Drop */ @@ -1928,7 +2127,7 @@ h3 { } .weekly-container.dark-mode .time-slot-task { - background: rgba(0, 154, 154, 0.2); + background: transparent; } .weekly-container.dark-mode .calendar-connect-btn { @@ -2508,6 +2707,16 @@ h3 { justify-content: space-between; } +.settings-tab-btn:hover { + opacity: 0.8 !important; + background: rgba(0, 0, 0, 0.04) !important; + border-radius: 6px 6px 0 0; +} + +.dark-mode .settings-tab-btn:hover { + background: rgba(255, 255, 255, 0.08) !important; +} + .dark-mode .weekly-settings-sidebar .weekly-settings-header { border-bottom-color: #333; } diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index 64a968b..d421ee1 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -1,11 +1,18 @@ -'use client'; +"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 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 FocusModeOverlay from './FocusModeOverlay'; +import FocusModeOverlay from "./FocusModeOverlay"; import { LayoutGrid, Calendar, @@ -25,17 +32,22 @@ import { GripVertical, Play, Zap, - Plus -} from 'lucide-react'; + Plus, + RefreshCcw, + Layout, + Palette, + Sparkles, + Info, +} from "lucide-react"; // Types -import UserMenu from './UserMenu'; -import SearchModal from './SearchModal'; -import SimpleDatePicker from './SimpleDatePicker'; -import RecurringTasksManager from './RecurringTasksManager'; -import { ImportListModal } from './ImportListModal'; +import UserMenu from "./UserMenu"; +import SearchModal from "./SearchModal"; +import SimpleDatePicker from "./SimpleDatePicker"; +import RecurringTasksManager from "./RecurringTasksManager"; +import { ImportListModal } from "./ImportListModal"; -export type ViewStyle = 'simple' | 'calendar' | 'list'; +export type ViewStyle = "simple" | "calendar" | "list" | "grid"; interface Task { id: string; @@ -61,6 +73,8 @@ interface Task { externalProvider?: string | null; externalListId?: string | null; lastSyncedAt?: Date | null; + parentTaskId?: string | null; + subTasks?: Task[]; } interface CalendarEvent { @@ -68,7 +82,7 @@ interface CalendarEvent { title: string; startTime: string; endTime: string; - source: 'google' | 'apple'; + source: "google" | "apple"; calendarId?: string; calendarTitle?: string; calendarColor?: string; @@ -86,44 +100,44 @@ type CellDuration = 15 | 30 | 60 | 120; // Font options const AVAILABLE_FONTS = [ - { name: 'Default (Inter)', value: 'Inter' }, - { name: 'Roboto', value: 'Roboto' }, - { name: 'Open Sans', value: 'Open Sans' }, - { name: 'Lato', value: 'Lato' }, - { name: 'Montserrat', value: 'Montserrat' }, - { name: 'Oswald', value: 'Oswald' }, - { name: 'Raleway', value: 'Raleway' }, - { name: 'Playfair Display', value: 'Playfair Display' }, - { name: 'Merriweather', value: 'Merriweather' }, - { name: 'Nunito', value: 'Nunito' }, - { name: 'Dancing Script', value: 'Dancing Script' }, - { name: 'Pacifico', value: 'Pacifico' }, + { name: "Default (Inter)", value: "Inter" }, + { name: "Roboto", value: "Roboto" }, + { name: "Open Sans", value: "Open Sans" }, + { name: "Lato", value: "Lato" }, + { name: "Montserrat", value: "Montserrat" }, + { name: "Oswald", value: "Oswald" }, + { name: "Raleway", value: "Raleway" }, + { name: "Playfair Display", value: "Playfair Display" }, + { name: "Merriweather", value: "Merriweather" }, + { name: "Nunito", value: "Nunito" }, + { name: "Dancing Script", value: "Dancing Script" }, + { name: "Pacifico", value: "Pacifico" }, ]; const FONT_WEIGHTS = [ - { name: 'Light', value: '300' }, - { name: 'Normal', value: '400' }, - { name: 'Medium', value: '500' }, - { name: 'Bold', value: '700' }, + { name: "Light", value: "300" }, + { name: "Normal", value: "400" }, + { name: "Medium", value: "500" }, + { name: "Bold", value: "700" }, ]; // Helper to load Google Fonts const useGoogleFonts = (fonts: string[]) => { useEffect(() => { - if (typeof window === 'undefined') return; - const fontsToLoad = fonts.filter(f => f && f !== 'Inter'); + if (typeof window === "undefined") return; + const fontsToLoad = fonts.filter((f) => f && f !== "Inter"); if (fontsToLoad.length === 0) return; - const linkId = 'google-fonts-link'; + 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=')}&display=swap`; + 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=")}&display=swap`; if (!link) { - link = document.createElement('link'); + link = document.createElement("link"); link.id = linkId; - link.rel = 'stylesheet'; + link.rel = "stylesheet"; document.head.appendChild(link); } link.href = href; @@ -133,113 +147,129 @@ const useGoogleFonts = (fonts: string[]) => { // Translations const translations: Record = { en: { - settings: 'Settings', - general: 'General', - calendar: 'Connections', - account: 'Account', - runningList: 'Running List (Auto-roll tasks to today)', - protectEventTimes: 'Protect Event Times', - showTimeGrid: 'Show Time Grid', - timeSlotDuration: 'Time Slot Duration', - viewStyle: 'View Style', - simpleView: 'Simple', - calendarView: 'Calendar', - listView: 'List', - language: 'Language', - dateFormat: 'Date Format', - timeFormat: 'Time Format', - saveChanges: 'Save Changes', - connectedCalendars: 'Connected Calendars', - connectMore: 'Connect More', - connectGoogle: 'Connect Google Calendar', - connectApple: 'Connect Apple Calendar', - noCalendars: 'No calendars connected yet.', - dataPrivacy: 'Data & Privacy', - downloadData: 'Download My Data', - deleteAccount: 'Delete Account', - name: 'Name', - email: 'Email', - timezone: 'Timezone', - changePassword: 'Change Password', - newPassword: 'New Password', - confirmPassword: 'Confirm Password', - someday: 'SOMEDAY', - lists: 'Lists', - loading: 'Loading your tasks...', - sycing: 'Syncing...', - synced: 'Synced', - localization: 'Localization', - allDayEvents: 'ALL-DAY EVENTS', - syncCalendar: 'Sync Calendar', - toggleDarkMode: 'Toggle Dark Mode', - signOut: 'Sign Out', - startHour: 'Start of Day', - endHour: 'End of Day', - weekAbbr: 'W', - goalOfWeek: 'Goal of the Week', - goalScope: 'Goal Scope', - goalScopeWeek: 'Per Week', - goalScopeDay: 'Per Day', - goalFallback: 'Goal Fallback Type', - defaultGoal: 'Custom Default Goal', - showSomeday: 'Show Someday Section', - showAllDay: 'Show All-Day Section', - newPasswordDesc: 'Leave blank to keep current password.' + settings: "Settings", + general: "General", + calendar: "Connections", + account: "Account", + runningList: "Running List (Auto-roll tasks to today)", + protectEventTimes: "Protect Event Times", + showTimeGrid: "Show Time Grid", + timeSlotDuration: "Time Slot Duration", + viewStyle: "View Style", + simpleView: "Simple", + calendarView: "Calendar", + listView: "List", + language: "Language", + dateFormat: "Date Format", + timeFormat: "Time Format", + saveChanges: "Save Changes", + connectedCalendars: "Connected Calendars", + connectMore: "Connect More", + connectGoogle: "Connect Google Calendar", + connectApple: "Connect Apple Calendar", + noCalendars: "No calendars connected yet.", + dataPrivacy: "Data & Privacy", + downloadData: "Download My Data", + deleteAccount: "Delete Account", + name: "Name", + email: "Email", + timezone: "Timezone", + changePassword: "Change Password", + newPassword: "New Password", + confirmPassword: "Confirm Password", + someday: "SOMEDAY", + lists: "Lists", + loading: "Loading your tasks...", + sycing: "Syncing...", + synced: "Synced", + localization: "Localization", + allDayEvents: "ALL-DAY EVENTS", + syncCalendar: "Sync Calendar", + toggleDarkMode: "Toggle Dark Mode", + signOut: "Sign Out", + startHour: "Start of Day", + endHour: "End of Day", + weekAbbr: "W", + goalOfWeek: "Goal of the Week", + goalScope: "Goal Scope", + goalScopeWeek: "Per Week", + goalScopeDay: "Per Day", + goalFallback: "Goal Fallback Type", + defaultGoal: "Custom Default Goal", + showSomeday: "Show Someday Section", + showAllDay: "Show All-Day Section", + allDayPosition: "All-Day Events Position", + allDayAbove: "Above", + allDayBelow: "Below", + newPasswordDesc: "Leave blank to keep current password.", + dateAlignment: "Date Alignment", + alignmentLeft: "Left", + alignmentCenter: "Center", + alignmentRight: "Right", + alignmentTight: "Tight", }, de: { - settings: 'Einstellungen', - general: 'Allgemein', - calendar: 'Verbindungen', - account: 'Konto', - runningList: 'Laufende Liste (Aufgaben automatisch auf heute verschieben)', - protectEventTimes: 'Ereigniszeiten schützen', - showTimeGrid: 'Zeitplan anzeigen', - timeSlotDuration: 'Zeitfensterdauer', - viewStyle: 'Ansichtsstil', - simpleView: 'Einfach', - calendarView: 'Kalender', - listView: 'Liste', - language: 'Sprache', - dateFormat: 'Datumsformat', - timeFormat: 'Zeitformat', - saveChanges: 'Änderungen speichern', - connectedCalendars: 'Verbundene Kalender', - connectMore: 'Mehr verbinden', - connectGoogle: 'Google Kalender verbinden', - connectApple: 'Apple Kalender verbinden', - noCalendars: 'Keine Kalender verbunden.', - dataPrivacy: 'Daten & Datenschutz', - downloadData: 'Meine Daten herunterladen', - deleteAccount: 'Konto löschen', - name: 'Name', - email: 'E-Mail', - timezone: 'Zeitzone', - changePassword: 'Passwort ändern', - newPassword: 'Neues Passwort', - confirmPassword: 'Passwort bestätigen', - someday: 'IRGENDWANN', - lists: 'Listen', - loading: 'Lade Aufgaben...', - syncing: 'Synchronisiere...', - synced: 'Synchronisiert', - localization: 'Lokalisierung', - allDayEvents: 'GANZTÄGIGE EREIGNISSE', - syncCalendar: 'Kalender synchronisieren', - toggleDarkMode: 'Dunkelmodus umschalten', - signOut: 'Abmelden', - startHour: 'Tagesbeginn', - endHour: 'Tagesende', - weekAbbr: 'KW', - goalOfWeek: 'Ziel der Woche', - goalScope: 'Ziel-Zeitraum', - goalScopeWeek: 'Pro Woche', - goalScopeDay: 'Pro Tag', - goalFallback: 'Ziel-Fallback-Typ', - defaultGoal: 'Benutzerdefiniertes Standardziel', - showSomeday: 'Irgendwann-Bereich anzeigen', - showAllDay: 'Ganztägige Ereignisse anzeigen', - newPasswordDesc: 'Leer lassen, um das aktuelle Passwort zu behalten.' - } + settings: "Einstellungen", + general: "Allgemein", + calendar: "Verbindungen", + account: "Konto", + runningList: "Laufende Liste (Aufgaben automatisch auf heute verschieben)", + protectEventTimes: "Ereigniszeiten schützen", + showTimeGrid: "Zeitplan anzeigen", + timeSlotDuration: "Zeitfensterdauer", + viewStyle: "Ansichtsstil", + simpleView: "Einfach", + calendarView: "Kalender", + listView: "Liste", + language: "Sprache", + dateFormat: "Datumsformat", + timeFormat: "Zeitformat", + saveChanges: "Änderungen speichern", + connectedCalendars: "Verbundene Kalender", + connectMore: "Mehr verbinden", + connectGoogle: "Google Kalender verbinden", + connectApple: "Apple Kalender verbinden", + noCalendars: "Keine Kalender verbunden.", + dataPrivacy: "Daten & Datenschutz", + downloadData: "Meine Daten herunterladen", + deleteAccount: "Konto löschen", + name: "Name", + email: "E-Mail", + timezone: "Zeitzone", + changePassword: "Passwort ändern", + newPassword: "Neues Passwort", + confirmPassword: "Passwort bestätigen", + someday: "IRGENDWANN", + lists: "Listen", + loading: "Lade Aufgaben...", + syncing: "Synchronisiere...", + synced: "Synchronisiert", + localization: "Lokalisierung", + allDayEvents: "GANZTÄGIGE EREIGNISSE", + syncCalendar: "Kalender synchronisieren", + toggleDarkMode: "Dunkelmodus umschalten", + signOut: "Abmelden", + startHour: "Tagesbeginn", + endHour: "Tagesende", + weekAbbr: "KW", + goalOfWeek: "Ziel der Woche", + goalScope: "Ziel-Zeitraum", + goalScopeWeek: "Pro Woche", + goalScopeDay: "Pro Tag", + goalFallback: "Ziel-Fallback-Typ", + defaultGoal: "Benutzerdefiniertes Standardziel", + showSomeday: "Irgendwann-Bereich anzeigen", + showAllDay: "Ganztägige Ereignisse anzeigen", + allDayPosition: "Position ganztägiger Ereignisse", + allDayAbove: "Oben", + allDayBelow: "Unten", + newPasswordDesc: "Leer lassen, um das aktuelle Passwort zu behalten.", + dateAlignment: "Datums-Ausrichtung", + alignmentLeft: "Links", + alignmentCenter: "Mitte", + alignmentRight: "Rechts", + alignmentTight: "Eng", + }, }; // Date utilities @@ -266,12 +296,12 @@ function getStartOfWeek(date: Date, startDay: number = 0): Date { return d; } -function formatDateHeader(date: Date, locale: string = 'en-US'): string { - return date.toLocaleDateString(locale, { day: 'numeric', month: 'short' }); // e.g. 12. Feb. +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'): string { - return date.toLocaleDateString(locale, { weekday: 'long' }).toUpperCase(); +function getDayName(date: Date, locale: string = "en-US"): string { + return date.toLocaleDateString(locale, { weekday: "long" }).toUpperCase(); } function isSameDay(d1: Date, d2: Date): boolean { @@ -280,37 +310,50 @@ function isSameDay(d1: Date, d2: Date): boolean { 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'); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } -function formatHour(hour: number): string { - return `${hour}`; +function formatHour(hour: number, format: "short" | "full" = "short", timeFormat: string = "24h"): string { + if (timeFormat === "12h") { + const h = hour % 12 || 12; + const ampm = hour >= 12 ? "PM" : "AM"; + return format === "full" ? `${h}:00 ${ampm}` : `${h} ${ampm}`; + } + return format === "full" ? `${hour}:00` : `${hour}`; } -function getTimeSlots(cellDuration: CellDuration, startHour: number, endHour: number): string[] { +function getTimeSlots( + cellDuration: CellDuration, + startHour: number, + endHour: number, +): string[] { const slots: string[] = []; const slotsPerHour = 60 / cellDuration; for (let hour = startHour; hour < endHour; hour++) { for (let slot = 0; slot < slotsPerHour; slot++) { const minutes = slot * cellDuration; - slots.push(`${hour.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`); + slots.push( + `${hour.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`, + ); } } return slots; } function getHourFromSlot(slot: string): number { - return parseInt(slot.split(':')[0], 10); + return parseInt(slot.split(":")[0], 10); } function getWeekNumber(date: Date): number { - const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + 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); + return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); } // Check if an event is an all-day event @@ -319,7 +362,7 @@ const isAllDayEvent = (event: CalendarEvent): boolean => { if (!event.startTime) return false; // Date-only format (YYYY-MM-DD) - if (!event.startTime.includes('T')) return true; + if (!event.startTime.includes("T")) return true; const start = new Date(event.startTime); const end = new Date(event.endTime); @@ -329,7 +372,8 @@ const isAllDayEvent = (event: CalendarEvent): boolean => { 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; + 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); @@ -338,16 +382,25 @@ const isAllDayEvent = (event: CalendarEvent): boolean => { // Helper to invert colors for dark mode function invertColor(hex: string): string { if (!hex) return hex; - let color = hex.startsWith('#') ? hex.slice(1) : hex; + let color = hex.startsWith("#") ? hex.slice(1) : hex; if (color.length === 3) { - color = color.split('').map(c => c + c).join(''); + 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'); + 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; @@ -359,16 +412,20 @@ export default function WeeklyView() { const { data: session } = useSession(); const [tasks, setTasks] = useState([]); const [connections, setConnections] = useState([]); // Lifted state - const [rawCalendarEvents, setRawCalendarEvents] = useState([]); + const [rawCalendarEvents, setRawCalendarEvents] = useState( + [], + ); // Extend events with editable flag from connections const calendarEvents = useMemo(() => { - return rawCalendarEvents.map(event => { + return rawCalendarEvents.map((event) => { let isEditable = false; if (event.calendarId) { for (const conn of connections) { if (conn.calendars && Array.isArray(conn.calendars)) { - const cal = conn.calendars.find((c: any) => c.id === event.calendarId); + const cal = conn.calendars.find( + (c: any) => c.id === event.calendarId, + ); if (cal && cal.editable) { isEditable = true; break; @@ -389,8 +446,11 @@ export default function WeeklyView() { const [isLoading, setIsLoading] = useState(true); const [isSyncing, setIsSyncing] = useState(false); const [darkMode, setDarkMode] = useState(false); - const [timeFormat, setTimeFormat] = useState('24h'); - const [dateFormat, setDateFormat] = useState('yyyy-MM-dd'); + 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); @@ -400,16 +460,26 @@ export default function WeeklyView() { // Moved state definitions to the top const [showSettings, setShowSettings] = useState(false); - const [activeTab, setActiveTab] = useState<'general' | 'calendar' | 'account'>('general'); - const [exportStartDate, setExportStartDate] = useState(''); - const [exportEndDate, setExportEndDate] = useState(''); - const [passwords, setPasswords] = useState({ new: '', confirm: '' }); - const [accountMsg, setAccountMsg] = useState(''); - const [importingTasksState, setImportingTasksState] = useState(false); - const [importStatusMsg, setImportStatusMsg] = useState<{ type: 'success' | 'error', text: string } | null>(null); + 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(""); + const [importingTasksState, setImportingTasksState] = + useState(false); + const [importStatusMsg, setImportStatusMsg] = useState<{ + type: "success" | "error"; + text: string; + } | null>(null); const [isImportModalOpen, setIsImportModalOpen] = useState(false); - const [importProvider, setImportProvider] = useState<'google' | 'apple' | 'outlook' | null>(null); - const [importLists, setImportLists] = useState<{ id: string, title: string }[]>([]); + const [importProvider, setImportProvider] = useState< + "google" | "apple" | "outlook" | null + >(null); + const [importLists, setImportLists] = useState< + { id: string; title: string }[] + >([]); const [isFetchingLists, setIsFetchingLists] = useState(false); const [isVisible, setIsVisible] = useState(false); const [profile, setProfile] = useState<{ @@ -428,7 +498,7 @@ export default function WeeklyView() { showTimeGrid?: boolean; cellDuration?: number; viewStyle?: string; - fontSize?: 'S' | 'M' | 'L'; + fontSize?: "S" | "M" | "L"; showNextTask?: boolean; showSomeday?: boolean; showAllDayEvents?: boolean; @@ -457,73 +527,99 @@ export default function WeeklyView() { taskColor?: string; todayHighlightColor?: string; pastDayColor?: string; - goalFallbackType?: 'quote' | 'next_todo' | 'default'; + goalFallbackType?: "quote" | "next_todo" | "default"; goalDefaultSentence?: string; goalFontFamily?: string; goalFontSize?: string; goalFontWeight?: string; - goalScope?: 'week' | 'day'; + goalScope?: "week" | "day"; + dateLayout?: "above" | "below" | "left" | "right" | "hidden"; + dateAlignment?: "left" | "center" | "right" | "tight"; + hourLabelFormat?: "short" | "full"; + showSubHourSlots?: boolean; + allDayPosition?: "above" | "below"; }>({ - name: session?.user?.name || '', - email: session?.user?.email || '', - timezone: 'UTC', - language: 'de', - dateFormat: 'yyyy-MM-dd', - timeFormat: '24h', + name: session?.user?.name || "", + email: session?.user?.email || "", + timezone: "UTC", + language: "de", + dateFormat: "yyyy-MM-dd", + timeFormat: "24h", startHour: 8, endHour: 22, autoRolling: true, protectEventTimes: true, showTimeGrid: true, cellDuration: 60, - viewStyle: 'list', - fontSize: 'M', + viewStyle: "list", + fontSize: "M", showNextTask: false, showSomeday: true, showAllDayEvents: true, showSchedule: true, - headlineFont: 'Inter', - headlineFontSize: '1.25rem', - headlineFontWeight: '900', - 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', - fontWeight: '400', - goalFontFamily: 'Inter', - goalFontSize: '0.9rem', - goalFontWeight: '500', - weekendColorSat: '#666666', - weekendColorSun: '#dc2626', + hourLabelFormat: "short", + showSubHourSlots: true, + allDayPosition: "below", focusTimerDuration: 25, focusBreakDuration: 5, - pastDayColor: '#a6a6a7' + headlineFont: "Inter", + headlineFontSize: "1.25rem", + headlineFontWeight: "900", + 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: "Inter", + goalFontSize: "0.9rem", + goalFontWeight: "500", + goalScope: "week", + dateLayout: "right", + dateAlignment: "center", + weekendColorSat: "#666666", + weekendColorSun: "#dc2626", + pastDayColor: "#a6a6a7", }); const [showSummary, setShowSummary] = useState(false); const [isAddingSomedayList, setIsAddingSomedayList] = useState(false); - const [newSomedayListName, setNewSomedayListName] = useState(''); - const [language, setLanguage] = useState('de'); - const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'synced'>('idle'); + 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(60); const [draggedTask, setDraggedTask] = useState(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(null); + 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( + null, + ); const [currentTime, setCurrentTime] = useState(new Date()); - const [dropPreview, setDropPreview] = useState<{ day: number; slot: string } | null>(null); - const [viewStyle, setViewStyle] = useState('simple'); + const [dropPreview, setDropPreview] = useState<{ + day: number; + slot: string; + } | null>(null); + const [viewStyle, setViewStyle] = useState("simple"); const [protectEventTimes, setProtectEventTimes] = useState(true); const [unlockedEvents, setUnlockedEvents] = useState>(new Set()); @@ -532,11 +628,12 @@ export default function WeeklyView() { const [weekStartDay, setWeekStartDay] = useState(1); // 1 = Monday, 0 = Sunday const [showSomeday, setShowSomeday] = useState(true); const [showAllDay, setShowAllDay] = useState(true); - const [goal, setGoal] = useState('your goal of this week'); + 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(null); + const [selectedTaskForRecurrence, setSelectedTaskForRecurrence] = + useState(null); const [showFocusMode, setShowFocusMode] = useState(false); const [showSchedule, setShowSchedule] = useState(true); const [focusBreakDuration, setFocusBreakDuration] = useState(5); @@ -547,26 +644,26 @@ export default function WeeklyView() { const [showDatePicker, setShowDatePicker] = 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'); + 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 @@ -575,9 +672,9 @@ export default function WeeklyView() { // regardless of whether the settings modal is open or closed, and for // real-time preview usage. useGoogleFonts([ - ...AVAILABLE_FONTS.map(f => f.value), - 'Dancing Script', - 'Pacifico' + ...AVAILABLE_FONTS.map((f) => f.value), + "Dancing Script", + "Pacifico", ]); // Dynamic font loading is handled by useGoogleFonts hook call above @@ -593,13 +690,18 @@ export default function WeeklyView() { // 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'); + const savedDarkMode = localStorage.getItem("weekly-dark-mode"); if (savedDarkMode) { setDarkMode(JSON.parse(savedDarkMode)); } - const savedWeekStart = localStorage.getItem('weekly-week-start'); + const savedWeekStart = localStorage.getItem("weekly-week-start"); if (savedWeekStart) { setWeekStartDay(Number(savedWeekStart)); } @@ -607,42 +709,49 @@ export default function WeeklyView() { useEffect(() => { if (!mounted) return; - localStorage.setItem('weekly-dark-mode', JSON.stringify(darkMode)); + localStorage.setItem("weekly-dark-mode", JSON.stringify(darkMode)); if (darkMode) { - document.documentElement.classList.add('dark'); + document.documentElement.classList.add("dark"); } else { - document.documentElement.classList.remove('dark'); + document.documentElement.classList.remove("dark"); } }, [darkMode, mounted]); useEffect(() => { if (!mounted) return; - localStorage.setItem('weekly-week-start', String(weekStartDay)); + localStorage.setItem("weekly-week-start", String(weekStartDay)); // 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)); }, [weekStartDay, mounted]); // Translation helper - const t = translations[language] || translations['en']; + const t = translations[language] || translations["en"]; // Refs for scroll synchronization const timeColumnRef = useRef(null); const dayColumnsRef = useRef([]); const isScrollSyncing = useRef(false); + const dayHeaderRef = useRef(null); + const [dayHeaderHeight, setDayHeaderHeight] = useState(null); // Scroll sync handler const handleTimeColumnScroll = (e: React.UIEvent) => { if (isScrollSyncing.current) return; isScrollSyncing.current = true; const scrollTop = e.currentTarget.scrollTop; - dayColumnsRef.current.forEach(col => { + dayColumnsRef.current.forEach((col) => { if (col) col.scrollTop = scrollTop; }); - setTimeout(() => { isScrollSyncing.current = false; }, 10); + setTimeout(() => { + isScrollSyncing.current = false; + }, 10); }; - const handleDayColumnScroll = (e: React.UIEvent, index: number) => { + const handleDayColumnScroll = ( + e: React.UIEvent, + index: number, + ) => { if (isScrollSyncing.current) return; isScrollSyncing.current = true; const scrollTop = e.currentTarget.scrollTop; @@ -650,28 +759,40 @@ export default function WeeklyView() { dayColumnsRef.current.forEach((col, i) => { if (col && i !== index) col.scrollTop = scrollTop; }); - setTimeout(() => { isScrollSyncing.current = false; }, 10); + setTimeout(() => { + isScrollSyncing.current = false; + }, 10); }; // Slot height based on cell duration const getSlotHeight = (duration: CellDuration) => { switch (duration) { - case 15: return 25; - case 30: return 35; - case 60: return 50; - case 120: return 80; - default: return 50; + case 15: + return 25; + case 30: + return 35; + case 60: + return 50; + case 120: + return 80; + default: + return 50; } }; // Header height based on cell duration for alignment const getHeaderHeight = (duration: CellDuration) => { switch (duration) { - case 15: return 65; - case 30: return 55; - case 60: return 40; - case 120: return 40; - default: return 40; + case 15: + return 65; + case 30: + return 55; + case 60: + return 50; + case 120: + return 50; + default: + return 50; } }; @@ -683,12 +804,14 @@ export default function WeeklyView() { const fetchCalendarEvents = useCallback(async () => { setIsSyncing(true); try { - const response = await fetch('/api/calendar/sync', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const response = await fetch("/api/calendar/sync", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timeMin: currentWeekStart.toISOString(), - timeMax: new Date(currentWeekStart.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(), + timeMax: new Date( + currentWeekStart.getTime() + 7 * 24 * 60 * 60 * 1000, + ).toISOString(), }), }); @@ -700,11 +823,14 @@ export default function WeeklyView() { setRawCalendarEvents(data.events); } } catch (e) { - console.error('Failed to parse calendar sync response:', text.substring(0, 100)); + console.error( + "Failed to parse calendar sync response:", + text.substring(0, 100), + ); } } } catch (error) { - console.error('Error fetching calendar events:', error); + console.error("Error fetching calendar events:", error); } finally { setIsSyncing(false); } @@ -716,30 +842,30 @@ export default function WeeklyView() { const timeoutId = setTimeout(() => controller.abort(), 15000); // 15s timeout try { - const method = eventData.id ? 'PATCH' : 'POST'; + const method = eventData.id ? "PATCH" : "POST"; const body = { ...eventData, - eventId: eventData.id // For PATCH + eventId: eventData.id, // For PATCH }; - const res = await fetch('/api/calendar/events', { + const res = await fetch("/api/calendar/events", { method, - headers: { 'Content-Type': 'application/json' }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), - signal: controller.signal + signal: controller.signal, }); if (!res.ok) { const err = await res.json(); - throw new Error(err.error || 'Failed to save event'); + throw new Error(err.error || "Failed to save event"); } // Refresh events await fetchCalendarEvents(); } catch (error: any) { - console.error('Error saving event:', error); - if (error.name === 'AbortError') { - throw new Error('Request timed out. Please try again.'); + console.error("Error saving event:", error); + if (error.name === "AbortError") { + throw new Error("Request timed out. Please try again."); } throw error; } finally { @@ -749,45 +875,50 @@ export default function WeeklyView() { const handleEventDelete = async (eventId: string, calendarId: string) => { try { - const res = await fetch(`/api/calendar/events?calendarId=${calendarId}&eventId=${eventId}`, { - method: 'DELETE' - }); + const res = await fetch( + `/api/calendar/events?calendarId=${calendarId}&eventId=${eventId}`, + { + method: "DELETE", + }, + ); if (!res.ok) { const err = await res.json(); - throw new Error(err.error || 'Failed to delete event'); + throw new Error(err.error || "Failed to delete event"); } // Refresh events await fetchCalendarEvents(); } catch (error) { - console.error('Error deleting event:', 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' }, + 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 - }) + ...recurrence, + }), }); if (!res.ok) { - throw new Error('Failed to update recurrence'); + throw new Error("Failed to update recurrence"); } const data = await res.json(); - // Update local state - setTasks(prev => prev.map(t => t.id === taskId ? data.task : t)); + // 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'); + alert("Failed to save recurrence settings"); } }; @@ -803,90 +934,98 @@ export default function WeeklyView() { // Periodic pull-sync from Google Tasks (every 2 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) { - console.log(`[SYNC] Pulled ${data.updated} updates, ${data.deleted} deletions from Google Tasks`); - fetchTasks(); // Reload to reflect changes + 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) { + console.log( + `[SYNC] Pulled ${data.updated} updates, ${data.deleted} deletions from Google Tasks`, + ); + fetchTasks(); // Reload to reflect changes + } } + } catch (e) { + // Silent fail for background sync } - } catch (e) { - // Silent fail for background sync - } - }, 2 * 60 * 1000); + }, + 2 * 60 * 1000, + ); return () => clearInterval(interval); }, [session]); // Periodic background calendar cache refresh (every 5 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 being refreshed; re-fetch events after delay - setTimeout(() => fetchCalendarEvents(), 8000); + 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 being refreshed; re-fetch events after delay + setTimeout(() => fetchCalendarEvents(), 8000); + } } + } catch (e) { + // Silent fail for background sync } - } catch (e) { - // Silent fail for background sync - } - }, 5 * 60 * 1000); + }, + 5 * 60 * 1000, + ); return () => clearInterval(interval); }, [session, fetchCalendarEvents]); async function fetchConnections() { try { setIsLoading(true); - const response = await fetch('/api/calendar/connections'); + 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); + console.error("Error fetching connections:", error); } finally { setIsLoading(false); } } const handleRemoveConnection = async (connectionId: string) => { - console.log('Disconnecting connection:', connectionId); + console.log("Disconnecting connection:", connectionId); const res = await fetch(`/api/calendar/connections?id=${connectionId}`, { - method: 'DELETE' + method: "DELETE", }); if (res.ok) { // Update state immediately - setConnections(prev => prev.filter(c => c.id !== connectionId)); + 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'); + console.error("Failed to disconnect calendar", err); + throw new Error(err.error || "Unknown error"); } }; - - - - // Refetch calendar events when week changes useEffect(() => { if (session) { @@ -894,32 +1033,52 @@ export default function WeeklyView() { } }, [currentWeekStart, session, fetchCalendarEvents]); - // Update current time every minute for the "Now" line + // Update current time every 30 seconds for the "Now" line and clock useEffect(() => { const interval = setInterval(() => { setCurrentTime(new Date()); - }, 60000); // Update every minute + }, 30000); return () => clearInterval(interval); }, []); + // Dynamically measure day header height and sync to time column header + useEffect(() => { + if (!dayHeaderRef.current) return; + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setDayHeaderHeight(entry.contentRect.height + /* padding */ 0); + } + }); + observer.observe(dayHeaderRef.current); + // Initial measurement + setDayHeaderHeight(dayHeaderRef.current.offsetHeight); + return () => observer.disconnect(); + }, []); + // 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 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(); - } - // 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]); + }, + [profile.goalScope], + ); - const goalDateKey = useMemo(() => getGoalDateKey(currentWeekStart), [currentWeekStart, getGoalDateKey]); + const goalDateKey = useMemo( + () => getGoalDateKey(currentWeekStart), + [currentWeekStart, getGoalDateKey], + ); // Fetch goal for current week/day useEffect(() => { @@ -931,7 +1090,7 @@ export default function WeeklyView() { setGoal(data.goal); } } catch (err) { - console.error('Failed to fetch goal:', err); + console.error("Failed to fetch goal:", err); } }; fetchGoal(); @@ -940,16 +1099,16 @@ export default function WeeklyView() { const saveGoal = async (newGoal: string) => { setGoal(newGoal); try { - await fetch('/api/goal', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + await fetch("/api/goal", { + method: "PUT", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ weekStart: goalDateKey, text: newGoal, }), }); } catch (err) { - console.error('Failed to save goal:', err); + console.error("Failed to save goal:", err); } }; @@ -960,10 +1119,10 @@ export default function WeeklyView() { }; const saveSetting = async (key: string, value: any) => { try { - await fetch('/api/user/profile', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ [key]: value }) + 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); @@ -997,12 +1156,16 @@ export default function WeeklyView() { setTaskFontFamily(newSettings.taskFontFamily); setTaskFontSize(newSettings.taskFontSize); setTaskFontWeight(newSettings.taskFontWeight); - if (newSettings.eventFontFamily) setEventFontFamily(newSettings.eventFontFamily); + if (newSettings.eventFontFamily) + setEventFontFamily(newSettings.eventFontFamily); if (newSettings.eventFontSize) setEventFontSize(newSettings.eventFontSize); - if (newSettings.eventFontWeight) setEventFontWeight(newSettings.eventFontWeight); + if (newSettings.eventFontWeight) + setEventFontWeight(newSettings.eventFontWeight); if (newSettings.fontWeight) setFontWeight(newSettings.fontWeight); - if (newSettings.weekendColorSat) setWeekendColorSat(newSettings.weekendColorSat); - if (newSettings.weekendColorSun) setWeekendColorSun(newSettings.weekendColorSun); + if (newSettings.weekendColorSat) + setWeekendColorSat(newSettings.weekendColorSat); + if (newSettings.weekendColorSun) + setWeekendColorSun(newSettings.weekendColorSun); setProfile((prev: any) => ({ ...prev, @@ -1010,10 +1173,11 @@ export default function WeeklyView() { weekdayColor: newSettings.weekdayColor || prev.weekdayColor, dateColor: newSettings.dateColor || prev.dateColor, taskColor: newSettings.taskColor || prev.taskColor, - todayHighlightColor: newSettings.todayHighlightColor || prev.todayHighlightColor, + todayHighlightColor: + newSettings.todayHighlightColor || prev.todayHighlightColor, eventFontFamily: newSettings.eventFontFamily || prev.eventFontFamily, eventFontSize: newSettings.eventFontSize || prev.eventFontSize, - eventFontWeight: newSettings.eventFontWeight || prev.eventFontWeight + eventFontWeight: newSettings.eventFontWeight || prev.eventFontWeight, })); // Custom start/end hours might affect task placement if we filter strictly @@ -1022,65 +1186,96 @@ export default function WeeklyView() { const fetchUserInfo = async () => { try { - const res = await fetch('/api/user/profile'); + 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); + 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.viewDays !== undefined) setViewDays(data.user.viewDays); - if (data.user.cellDuration !== undefined) setCellDuration(data.user.cellDuration as CellDuration); + if (data.user.cellDuration !== undefined) + setCellDuration(data.user.cellDuration as CellDuration); 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.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.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.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.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.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.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.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.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); + if (data.user.weekendColorSat) + setWeekendColorSat(data.user.weekendColorSat); + if (data.user.weekendColorSun) + setWeekendColorSun(data.user.weekendColorSun); - setProfile(prev => ({ + setProfile((prev) => ({ ...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', + weekdayColor: data.user.weekdayColor || "#888888", + dateColor: data.user.dateColor || "#888888", + taskColor: data.user.taskColor || "#333333", + todayHighlightColor: data.user.todayHighlightColor || "#f0fafa", })); - 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); + 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) { @@ -1094,22 +1289,24 @@ export default function WeeklyView() { async function fetchSomedayLists() { try { - const response = await fetch('/api/someday-lists'); + 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, - tasks: l.tasks || [] // Tasks will be overwritten/populated by fetchTasks - }))); + setSomedayLists( + data.lists.map((l: any) => ({ + id: l.id, + title: l.title, + tasks: l.tasks || [], // Tasks will be overwritten/populated by fetchTasks + })), + ); return data.lists; } } catch (error) { - console.error('Error fetching someday lists:', error); + console.error("Error fetching someday lists:", error); return []; } } @@ -1117,8 +1314,8 @@ export default function WeeklyView() { async function fetchTasks() { try { const [tasksResponse, listsResponse] = await Promise.all([ - fetch('/api/tasks'), - fetch('/api/someday-lists') // Fetch lists in parallel + fetch("/api/tasks"), + fetch("/api/someday-lists"), // Fetch lists in parallel ]); let fetchedLists: SomedayList[] = []; @@ -1127,12 +1324,12 @@ export default function WeeklyView() { fetchedLists = listData.lists.map((l: any) => ({ id: l.id, title: l.title, - tasks: [] + tasks: [], })); } - // If no lists exist, maybe create default 'Someday'? - // TeuxDeux usually starts with one. + // 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) { @@ -1156,29 +1353,39 @@ export default function WeeklyView() { // 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 orphanedSomedayTasks = somedayTasks.filter( + (t: Task) => !listIds.has(t.somedayListId || ""), + ); - const populatedLists = fetchedLists.map(list => ({ + const populatedLists = fetchedLists.map((list) => ({ ...list, - tasks: somedayTasks.filter((t: Task) => t.somedayListId === list.id) + tasks: somedayTasks.filter((t: Task) => t.somedayListId === list.id), })); // 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`); + 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]); + 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)); + 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), + ); } } @@ -1190,13 +1397,12 @@ export default function WeeklyView() { } } } catch (error) { - console.error('Error fetching data:', error); + console.error("Error fetching data:", error); } finally { setIsLoading(false); } } - // Get visible days based on current view setting const getVisibleDays = useCallback(() => { const days: Date[] = []; @@ -1207,79 +1413,97 @@ export default function WeeklyView() { }, [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; - // 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]); + 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; + // 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], + ); // 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; - // 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 && task.startTime === slot; - }); - }, [tasks]); + 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; + // 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 && task.startTime === slot; + }); + }, + [tasks], + ); // 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 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]); + 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; + 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; - if (event.title.includes('Valentinstag')) { - // Debug removed - } + if (event.title.includes("Valentinstag")) { + // Debug removed + } - const eventDate = new Date(event.startTime); - if (!isSameDay(eventDate, date)) return false; + const eventDate = new Date(event.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(); + // 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 + cellDuration; - const eventStart = eventHour * 60 + eventMinute; + // Match if event starts within this slot + const [slotHour, slotMinute] = slot.split(":").map(Number); + const slotStart = slotHour * 60 + slotMinute; + const slotEnd = slotStart + cellDuration; + const eventStart = eventHour * 60 + eventMinute; - return eventStart >= slotStart && eventStart < slotEnd; - }); - }, [calendarEvents, cellDuration]); + return eventStart >= slotStart && eventStart < slotEnd; + }); + }, + [calendarEvents, cellDuration], + ); // Calculate event duration in pixels for proper height display const getEventDuration = (event: CalendarEvent): number => { @@ -1291,49 +1515,63 @@ export default function WeeklyView() { // Calculate height based on duration and slot height const pixelsPerMinute = getSlotHeight(cellDuration) / cellDuration; - return Math.max(durationMinutes * pixelsPerMinute, getSlotHeight(cellDuration)); + return Math.max( + durationMinutes * pixelsPerMinute, + getSlotHeight(cellDuration), + ); }; // Get all-day events for a specific date - const getAllDayEventsForDate = useCallback((date: Date): CalendarEvent[] => { - return calendarEvents.filter(event => { - if (!isAllDayEvent(event)) return false; + const getAllDayEventsForDate = useCallback( + (date: Date): CalendarEvent[] => { + return calendarEvents.filter((event) => { + if (!isAllDayEvent(event)) return false; - // Parse date from startTime - const eventStart = new Date(event.startTime); - const eventEnd = event.endTime ? new Date(event.endTime) : new Date(eventStart); + // Parse date from startTime + const eventStart = new Date(event.startTime); + const eventEnd = event.endTime + ? new Date(event.endTime) + : new Date(eventStart); - // Normalize dates to start of day for comparison - const targetDate = new Date(date); - targetDate.setHours(0, 0, 0, 0); + // Normalize dates to start of day for comparison + const targetDate = new Date(date); + targetDate.setHours(0, 0, 0, 0); - const start = new Date(eventStart); - start.setHours(0, 0, 0, 0); + const start = new Date(eventStart); + start.setHours(0, 0, 0, 0); - const end = new Date(eventEnd); - end.setHours(0, 0, 0, 0); + const end = new Date(eventEnd); + end.setHours(0, 0, 0, 0); - // If strictly dates, often end date is exclusive or same day? - // Google Calendar all-day events: end date is exclusive (e.g. starts 2023-01-01, ends 2023-01-02 for 1 day). - // If start == end, it's 1 day (but usually GCal sends next day). - // Let's assume inclusive start, exclusive end logic or "overlaps" logic. - // Check if targetDate is >= start AND targetDate < end + // If strictly dates, often end date is exclusive or same day? + // Google Calendar all-day events: end date is exclusive (e.g. starts 2023-01-01, ends 2023-01-02 for 1 day). + // If start == end, it's 1 day (but usually GCal sends next day). + // Let's assume inclusive start, exclusive end logic or "overlaps" logic. + // Check if targetDate is >= start AND targetDate < end - // Handle single day case where start == end or end is not provided - if (!event.endTime || start.getTime() === end.getTime()) { - return start.getTime() === targetDate.getTime(); - } + // Handle single day case where start == end or end is not provided + if (!event.endTime || start.getTime() === end.getTime()) { + return start.getTime() === targetDate.getTime(); + } - return targetDate.getTime() >= start.getTime() && targetDate.getTime() < end.getTime(); - }); - }, [calendarEvents]); + return ( + targetDate.getTime() >= start.getTime() && + targetDate.getTime() < end.getTime() + ); + }); + }, + [calendarEvents], + ); // Get all all-day events for the visible week - const getAllDayEventsForWeek = useCallback((): Map => { + const getAllDayEventsForWeek = useCallback((): Map< + string, + CalendarEvent[] + > => { const eventsByDay = new Map(); const visibleDays = getVisibleDays(); - visibleDays.forEach(date => { + visibleDays.forEach((date) => { const dateKey = formatDateToISO(date); eventsByDay.set(dateKey, getAllDayEventsForDate(date)); }); @@ -1341,157 +1579,198 @@ export default function WeeklyView() { return eventsByDay; }, [calendarEvents, currentWeekStart, viewDays]); - const rollOverdueTasks = useCallback(async (currentTasks: Task[]) => { - const autoRolling = profile.autoRolling ?? false; - if (!autoRolling) return; + const rollOverdueTasks = useCallback( + async (currentTasks: Task[]) => { + const autoRolling = profile.autoRolling ?? false; + if (!autoRolling) return; - const now = new Date(); - const todayStr = formatDateToISO(now); - const today = new Date(todayStr); + const now = new Date(); + const todayStr = formatDateToISO(now); + const today = new Date(todayStr); - const overdue = currentTasks.filter(t => - !t.completed && - t.isRolling && - 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 - - // Collision detection - const isBlocked = (date: Date, slot: string, tasksToCheck: Task[]) => { - // Check other tasks in the updated list - const taskConflict = tasksToCheck.find(t => - t.id !== task.id && + const overdue = currentTasks.filter( + (t) => + !t.completed && + t.isRolling && t.scheduledDate && - formatDateToISO(new Date(t.scheduledDate)) === formatDateToISO(date) && - t.startTime === slot - ); - if (taskConflict) return true; + formatDateToISO(new Date(t.scheduledDate)) < todayStr, + ); - // Check calendar events - const [h, m] = slot.split(':').map(Number); - const slotStart = new Date(date); - slotStart.setHours(h, m, 0, 0); - const slotEnd = new Date(slotStart); - slotEnd.setMinutes(slotEnd.getMinutes() + cellDuration); + if (overdue.length === 0) return; - return dailyEvents.some(event => { - const eventStart = new Date(event.startTime); - const eventEnd = new Date(event.endTime); - return slotStart < eventEnd && slotEnd > eventStart; - }); - }; + console.log( + `[ROLLING] Found ${overdue.length} overdue tasks to roll to today. autoRolling=${autoRolling}`, + ); - const findFreeSlot = (date: Date, preferred: string, tasksToCheck: Task[]) => { - let current = preferred; - let [h, m] = current.split(':').map(Number); + const updatedTasks = [...currentTasks]; + let hasChanges = false; - while (isBlocked(date, current, tasksToCheck)) { - m += cellDuration; - if (m >= 60) { - h += 1; - m = 0; + const dailyEvents = getEventsForDate(today); + + for (const task of overdue) { + const targetSlot = task.startTime || "09:00"; // Default to 9am if no time + + // Collision detection + const isBlocked = (date: Date, slot: string, tasksToCheck: Task[]) => { + // Check other tasks in the updated list + const taskConflict = tasksToCheck.find( + (t) => + t.id !== task.id && + t.scheduledDate && + formatDateToISO(new Date(t.scheduledDate)) === + formatDateToISO(date) && + t.startTime === slot, + ); + if (taskConflict) return true; + + // Check calendar events + const [h, m] = slot.split(":").map(Number); + const slotStart = new Date(date); + slotStart.setHours(h, m, 0, 0); + const slotEnd = new Date(slotStart); + slotEnd.setMinutes(slotEnd.getMinutes() + cellDuration); + + return dailyEvents.some((event) => { + const eventStart = new Date(event.startTime); + const eventEnd = new Date(event.endTime); + return slotStart < eventEnd && slotEnd > 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 += cellDuration; + if (m >= 60) { + h += 1; + m = 0; + } + if (h >= endHour) break; + current = `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`; } - if (h >= endHour) break; - current = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`; - } - return current; - }; + return current; + }; - const nextSlot = findFreeSlot(today, targetSlot, updatedTasks); + 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 - }) - }); + // 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; + 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); } - } catch (err) { - console.error(`Failed to roll task ${task.id}:`, err); } - } - if (hasChanges) { - setTasks(updatedTasks.filter(t => !t.somedayListId)); - } - }, [profile.autoRolling, cellDuration, endHour, getEventsForDate]); + if (hasChanges) { + setTasks(updatedTasks.filter((t) => !t.somedayListId)); + } + }, + [profile.autoRolling, cellDuration, endHour, getEventsForDate], + ); // 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 isSlotProtected = useCallback( + (date: Date, slot: string): boolean => { + if (!protectEventTimes) return false; - const [slotHour, slotMinute] = slot.split(':').map(Number); - const slotStart = slotHour * 60 + slotMinute; + 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; + 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 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(); + 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]); + // 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], + ); // Navigation handlers with proper slide animation // Simplified: Immediate state update with slide-in animation to prevent blank flash - const navigate = (newDate: Date, direction: 'left' | 'right', type: 'day' | 'week') => { - if (typeof document !== 'undefined' && 'startViewTransition' in document) { + const navigate = ( + newDate: Date, + direction: "left" | "right", + type: "day" | "week", + ) => { + if (typeof document !== "undefined" && "startViewTransition" in document) { const doc = document as any; - doc.documentElement.dataset.slideDirection = direction === 'left' ? 'next' : 'prev'; - doc.documentElement.dataset.navType = 'week'; // Always use full-grid slide for both day and week + doc.documentElement.dataset.slideDirection = + direction === "left" ? "next" : "prev"; + doc.documentElement.dataset.navType = "week"; // Always use full-grid slide for both day and week doc.startViewTransition(() => { setCurrentWeekStart(newDate); // setSlideDirection state is not strictly needed for global transition but keeping it clean - setSlideDirection(direction === 'left' ? 'next' : 'prev'); + setSlideDirection(direction === "left" ? "next" : "prev"); }); } else { setCurrentWeekStart(newDate); } }; - 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 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); @@ -1499,8 +1778,7 @@ export default function WeeklyView() { setCurrentWeekStart(d); }; - - const executeImport = async (provider: 'google' | 'apple' | 'outlook') => { + const executeImport = async (provider: "google" | "apple" | "outlook") => { setImportProvider(provider); setIsImportModalOpen(true); setIsFetchingLists(true); @@ -1514,49 +1792,66 @@ export default function WeeklyView() { setImportLists(data.lists || []); } else { const errData = await res.json(); - console.error('Failed to fetch lists', errData); + console.error("Failed to fetch lists", errData); setIsImportModalOpen(false); - setImportStatusMsg({ type: 'error', text: errData.error || 'Failed to fetch task lists.' }); + setImportStatusMsg({ + type: "error", + text: errData.error || "Failed to fetch task lists.", + }); } } catch (e) { - console.error('Error fetching lists:', e); + console.error("Error fetching lists:", e); setIsImportModalOpen(false); - setImportStatusMsg({ type: 'error', text: 'Error fetching task lists.' }); + setImportStatusMsg({ type: "error", text: "Error fetching task lists." }); } finally { setIsFetchingLists(false); } }; // Core import logic — accepts provider directly so it works both from modal and sidebar - const doImport = async (provider: 'google' | 'apple' | 'outlook', selectedLists: { id: string, title: string }[]) => { + const doImport = async ( + provider: "google" | "apple" | "outlook", + 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 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: `Imported ${data.count} new tasks into ${data.listsCreated || 1} list(s).` }); + setImportStatusMsg({ + type: "success", + text: `Imported ${data.count} new tasks into ${data.listsCreated || 1} list(s).`, + }); await fetchTasks(); } else { - setImportStatusMsg({ type: 'error', text: data.error || 'Import failed.' }); + setImportStatusMsg({ + type: "error", + text: data.error || "Import failed.", + }); } } catch (error) { - console.error('Import error:', error); - setImportStatusMsg({ type: 'error', text: 'An error occurred during import.' }); + console.error("Import error:", error); + setImportStatusMsg({ + type: "error", + text: "An error occurred during import.", + }); } finally { setImportingTasksState(false); } }; // Called from the Google Tasks modal - const handleConfirmImport = async (selectedLists: { id: string, title: string }[]) => { + const handleConfirmImport = async ( + selectedLists: { id: string; title: string }[], + ) => { if (!importProvider) return; setIsImportModalOpen(false); await doImport(importProvider, selectedLists); @@ -1572,55 +1867,62 @@ export default function WeeklyView() { 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(), - }]); + 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' }, + 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 + 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), - }]); + 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()); + console.error("Failed to add task:", await response.text()); } } catch (error) { - console.error('Error adding task:', error); + console.error("Error adding task:", error); } }; // 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); + 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); + const found = list.tasks.find((t) => t.id === taskId); if (found) return found; } return undefined; @@ -1634,35 +1936,42 @@ export default function WeeklyView() { 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) - }))); + 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 - )); + 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' }, + 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' }, + 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((e) => console.error("Sync error:", e)); } - } catch (error) { - console.error('Error toggling task:', error); + console.error("Error toggling task:", error); } }; @@ -1676,69 +1985,231 @@ export default function WeeklyView() { 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) - }))); + 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 - )); + 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' }, + 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' }, + 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((e) => console.error("Sync error:", e)); } } catch (error) { - console.error('Error updating task:', error); + console.error("Error updating task:", error); } }; const updateTaskFields = async (taskId: string, fields: Partial) => { - setTasks(tasks.map(t => - t.id === taskId - ? { ...t, ...fields, updatedAt: new Date() } - : t - )); + setTasks( + tasks.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) - }))); + setSomedayLists((lists) => + lists.map((list) => ({ + ...list, + tasks: list.tasks.map((t) => + t.id === taskId ? { ...t, ...fields, updatedAt: new Date() } : t, + ), + })), + ); try { - await fetch('/api/tasks', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, + await fetch("/api/tasks", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: taskId, ...fields }), }); } catch (error) { - console.error('Error updating task fields:', error); + console.error("Error updating task fields:", error); } }; - const updateTaskDuration = async (taskId: string, durationMinutes: number) => { - const task = tasks.find(t => t.id === taskId); + // 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); + const [startHour, startMinute] = task.startTime.split(":").map(Number); // Calculate end time const totalStartMinutes = startHour * 60 + startMinute; @@ -1747,20 +2218,24 @@ export default function WeeklyView() { 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')}`; + 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 - )); + 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' }, + 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); + console.error("Error updating task duration:", error); } }; @@ -1769,32 +2244,42 @@ export default function WeeklyView() { 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) - }))); + 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 - )); + 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' }, + 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' }, + fetch("/api/tasks/sync", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ taskId, notes }), - }).catch(e => console.error('Sync error:', e)); + }).catch((e) => console.error("Sync error:", e)); } } catch (error) { - console.error('Error updating task notes:', error); + console.error("Error updating task notes:", error); } }; @@ -1806,70 +2291,106 @@ export default function WeeklyView() { 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) - }))); + 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 - )); + 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' }, + 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); + 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) - }))); + 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 - )); + 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, updatedAt: new Date() } - : 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, + 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 }), + await fetch("/api/tasks", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: taskId, + dayOfWeek, + startTime, + scheduledDate: newScheduledDate, + }), }); // Sync due date change to external provider if (task?.externalId && task?.externalProvider && newScheduledDate) { - fetch('/api/tasks/sync', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, + 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((e) => console.error("Sync error:", e)); } } catch (error) { - console.error('Error moving task:', error); + console.error("Error moving task:", error); } }; const deleteTask = async (taskId: string) => { const taskToDelete = findTaskAnywhere(taskId); const isSomeday = !!taskToDelete?.somedayListId; - const isVirtual = taskId.startsWith('virtual-'); + const isVirtual = taskId.startsWith("virtual-"); let originalId = taskId; if (isVirtual) { @@ -1883,96 +2404,133 @@ export default function WeeklyView() { const isSeries = isVirtual || (taskToDelete && taskToDelete.isRecurring); if (isSeries) { - const deleteSeries = window.confirm("This is a recurring task.\n\nPress OK to delete the ENTIRE SERIES (stop recurrence and remove all future tasks).\nPress Cancel to delete ONLY THIS OCCURRENCE."); - - if (deleteSeries) { - setTasks(prev => prev.filter(t => { - if (t.id === originalId) return false; - if (t.id.startsWith(`virtual-${originalId}-`)) return false; - if (t.id === taskId) return false; - return true; - })); - setEditingTaskId(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); - } - return; - } + 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) - }))); + setSomedayLists((prev) => + prev.map((l) => ({ + ...l, + tasks: l.tasks.filter((t) => t.id !== taskId), + })), + ); } else { - setTasks(prev => prev.filter(t => t.id !== taskId)); + 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)); + 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' }); + await fetch(`/api/tasks?id=${taskId}`, { method: "DELETE" }); } catch (error) { - console.error('Error deleting task:', 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]; + } + + setTasks((prev) => + prev.filter((t) => { + 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); + 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 - )); + 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' }, + 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); + 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); + 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(); + const currentDate = task.scheduledDate + ? new Date(task.scheduledDate) + : new Date(); // Calculate new date const newDate = new Date(currentDate); - if (rollType === 'tomorrow') { + if (rollType === "tomorrow") { newDate.setDate(newDate.getDate() + 1); } else { newDate.setDate(newDate.getDate() + 7); @@ -1983,19 +2541,23 @@ export default function WeeklyView() { // Preserve startTime — if the preferred slot is taken, find next free one let resolvedStartTime = task.startTime || undefined; if (resolvedStartTime) { - const targetSlotTasks = tasks.filter(t => { + 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(cellDuration, workingHoursStart, workingHoursEnd); + const allSlots = getTimeSlots( + cellDuration, + workingHoursStart, + workingHoursEnd, + ); 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 => { + 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; @@ -2009,34 +2571,42 @@ export default function WeeklyView() { } } - setTasks(tasks.map(t => - t.id === taskId - ? { ...t, scheduledDate: newScheduledDate, dayOfWeek: newDate.getDay(), startTime: resolvedStartTime || t.startTime, updatedAt: new Date() } - : t - )); + 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' }, + await fetch("/api/tasks", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: taskId, scheduledDate: newScheduledDate, dayOfWeek: newDate.getDay(), - startTime: resolvedStartTime + 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' }, + 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((e) => console.error("Sync error:", e)); } } catch (error) { - console.error('Error rolling task:', error); + console.error("Error rolling task:", error); } }; @@ -2044,19 +2614,23 @@ export default function WeeklyView() { const handleDragStart = (e: DragEvent, task: Task) => { setDraggedTask(task); if (e.dataTransfer) { - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/plain', task.id); + 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'); + e.currentTarget.classList.add("drag-source"); } }; - const handleDragOver = (e: DragEvent | React.DragEvent, dayOfWeek?: number, slot?: string) => { + const handleDragOver = ( + e: DragEvent | React.DragEvent, + dayOfWeek?: number, + slot?: string, + ) => { e.preventDefault(); if (e.dataTransfer) { - e.dataTransfer.dropEffect = 'move'; + e.dataTransfer.dropEffect = "move"; } // Update drop preview if we have day and slot info if (dayOfWeek !== undefined && slot) { @@ -2068,7 +2642,8 @@ export default function WeeklyView() { e.preventDefault(); if (draggedTask) { const visibleDays = getVisibleDays(); - const targetDateObj = visibleDays.find(d => d.getDay() === dayOfWeek) || new Date(); + const targetDateObj = + visibleDays.find((d) => d.getDay() === dayOfWeek) || new Date(); let targetSlot = slot; @@ -2080,8 +2655,15 @@ export default function WeeklyView() { // Collision detection / Find next free slot if (targetSlot) { const targetSlotTasks = getTasksForSlot(targetDateObj, targetSlot); - if (targetSlotTasks.length > 0 && !targetSlotTasks.some(t => t.id === draggedTask.id)) { - const allSlots = getTimeSlots(cellDuration, workingHoursStart, workingHoursEnd); + if ( + targetSlotTasks.length > 0 && + !targetSlotTasks.some((t) => t.id === draggedTask.id) + ) { + const allSlots = getTimeSlots( + cellDuration, + workingHoursStart, + workingHoursEnd, + ); const startIndex = allSlots.indexOf(targetSlot); if (startIndex !== -1) { for (let i = startIndex + 1; i < allSlots.length; i++) { @@ -2100,39 +2682,58 @@ export default function WeeklyView() { if (draggedTask.somedayListId) { const newScheduledDate = formatDateToISO(targetDateObj); // Remove from someday list UI - setSomedayLists(prev => prev.map(l => ({ - ...l, - tasks: l.tasks.filter(t => t.id !== draggedTask.id) - }))); + 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, scheduledDate: newScheduledDate, dayOfWeek, startTime: targetSlot || '' }]); + setTasks((prev) => [ + ...prev, + { + ...draggedTask, + somedayListId: null, + scheduledDate: newScheduledDate, + dayOfWeek, + startTime: targetSlot || "", + }, + ]); // Persist try { - await fetch('/api/tasks', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, + await fetch("/api/tasks", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: draggedTask.id, somedayListId: null, scheduledDate: newScheduledDate, dayOfWeek, - startTime: targetSlot || '' + startTime: targetSlot || "", }), }); // 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)); + 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); + console.error("Error moving task from someday to calendar:", error); } } else { - moveTaskToSlot(draggedTask.id, dayOfWeek, targetSlot || '', targetDateObj); + moveTaskToSlot( + draggedTask.id, + dayOfWeek, + targetSlot || "", + targetDateObj, + ); } setDraggedTask(null); } @@ -2143,7 +2744,9 @@ export default function WeeklyView() { setDraggedTask(null); setDropPreview(null); // Remove drag-source class from all elements - document.querySelectorAll('.drag-source').forEach(el => el.classList.remove('drag-source')); + document + .querySelectorAll(".drag-source") + .forEach((el) => el.classList.remove("drag-source")); }; const handleDragLeave = () => { @@ -2152,17 +2755,21 @@ export default function WeeklyView() { // Sync calendar const handleSync = async () => { - setSyncStatus('syncing'); + 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)); + 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' }, + const syncRes = await fetch("/api/calendar/sync", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timeMin: currentWeekStart.toISOString(), - timeMax: new Date(currentWeekStart.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(), + timeMax: new Date( + currentWeekStart.getTime() + 7 * 24 * 60 * 60 * 1000, + ).toISOString(), forceRefresh: true, }), }); @@ -2175,11 +2782,11 @@ export default function WeeklyView() { if (true) { setTimeout(() => fetchCalendarEvents(), 8000); } - setSyncStatus('synced'); - setTimeout(() => setSyncStatus('idle'), 3000); + setSyncStatus("synced"); + setTimeout(() => setSyncStatus("idle"), 3000); } catch (error) { - console.error('Error syncing:', error); - setSyncStatus('idle'); + console.error("Error syncing:", error); + setSyncStatus("idle"); } }; @@ -2192,94 +2799,301 @@ export default function WeeklyView() { const saveSomedayList = async () => { if (!newSomedayListName.trim()) { setIsAddingSomedayList(false); - setNewSomedayListName(''); + setNewSomedayListName(""); + setSelectedSomedayProvider(null); return; } try { - const response = await fetch('/api/someday-lists', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title: newSomedayListName.trim() }), + 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.list, - tasks: [] // Initially empty - }]); - setNewSomedayListName(''); + 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); + console.error("Error adding someday list:", error); + alert("An error occurred while creating the list"); } }; // Get time slots to display // Get time slots to display - const visibleSlots = getTimeSlots(cellDuration, workingHoursStart, workingHoursEnd); + const visibleSlots = getTimeSlots( + cellDuration, + workingHoursStart, + workingHoursEnd, + ); const containerStyle = { - '--weekly-font-headline': (profile.headlineFont || headlineFont) ? `"${profile.headlineFont || headlineFont}", sans-serif` : 'var(--font-headline)', - '--weekly-headline-size': profile.headlineFontSize || '1.25rem', - '--weekly-headline-weight': profile.headlineFontWeight || '900', - '--weekly-date-font': (profile.dateFontFamily) ? `"${profile.dateFontFamily}", sans-serif` : 'var(--weekly-font-headline)', - '--weekly-date-size': profile.dateFontSize || '0.65rem', - '--weekly-date-weight': profile.dateFontWeight || '400', - '--weekly-time-task-font': (profile.timeTaskFontFamily) ? `"${profile.timeTaskFontFamily}", sans-serif` : 'var(--weekly-font)', - '--weekly-time-task-size': profile.timeTaskFontSize || '0.75rem', - '--weekly-time-task-weight': profile.timeTaskFontWeight || '500', - '--weekly-font': 'var(--font-body)', /* Force default body font as requested */ - '--weekly-task-font': (profile.taskFontFamily) ? `"${profile.taskFontFamily}", sans-serif` : 'var(--weekly-font)', - '--weekly-task-size': profile.taskFontSize || '0.9rem', - '--weekly-task-weight': profile.taskFontWeight || '400', - '--weekly-event-font': (profile.eventFontFamily || eventFontFamily) ? `"${profile.eventFontFamily || eventFontFamily}", sans-serif` : 'var(--weekly-font)', - '--weekly-event-size': profile.eventFontSize || eventFontSize || '0.85rem', - '--weekly-event-weight': profile.eventFontWeight || eventFontWeight || '400', - '--font-weight-body': profile.fontWeight || fontWeight || '400', - '--weekly-weekend-sat': darkMode ? invertColor(profile.weekendColorSat || '#666666') : (profile.weekendColorSat || '#666666'), - '--weekly-weekend-sun': darkMode ? invertColor(profile.weekendColorSun || '#dc2626') : (profile.weekendColorSun || '#dc2626'), - '--weekly-weekday-color': darkMode ? invertColor(profile.weekdayColor || '#888888') : (profile.weekdayColor || '#888888'), - '--weekly-date-color': darkMode ? invertColor(profile.dateColor || '#888888') : (profile.dateColor || '#888888'), - '--weekly-task-color': darkMode ? invertColor(profile.taskColor || '#333333') : (profile.taskColor || '#333333'), - '--weekly-today-highlight': darkMode ? invertColor(profile.todayHighlightColor || '#f0fafa') : (profile.todayHighlightColor || '#f0fafa'), - '--weekly-past-color': darkMode ? invertColor(profile.pastDayColor || '#a6a6a7') : (profile.pastDayColor || '#a6a6a7'), + "--weekly-font-headline": + profile.headlineFont || headlineFont + ? `"${profile.headlineFont || headlineFont}", sans-serif` + : "var(--font-headline)", + "--weekly-headline-size": profile.headlineFontSize || "1.25rem", + "--weekly-headline-weight": profile.headlineFontWeight || "900", + "--weekly-date-font": profile.dateFontFamily + ? `"${profile.dateFontFamily}", sans-serif` + : "var(--weekly-font-headline)", + "--weekly-date-size": profile.dateFontSize || "0.65rem", + "--weekly-date-weight": profile.dateFontWeight || "400", + "--weekly-time-task-font": profile.timeTaskFontFamily + ? `"${profile.timeTaskFontFamily}", sans-serif` + : "var(--weekly-font)", + "--weekly-time-task-size": profile.timeTaskFontSize || "0.75rem", + "--weekly-time-task-weight": profile.timeTaskFontWeight || "500", + "--weekly-font": + "var(--font-body)" /* Force default body font as requested */, + "--weekly-task-font": profile.taskFontFamily + ? `"${profile.taskFontFamily}", sans-serif` + : "var(--weekly-font)", + "--weekly-task-size": profile.taskFontSize || "0.9rem", + "--weekly-task-weight": profile.taskFontWeight || "400", + "--weekly-event-font": + profile.eventFontFamily || eventFontFamily + ? `"${profile.eventFontFamily || eventFontFamily}", sans-serif` + : "var(--weekly-font)", + "--weekly-event-size": profile.eventFontSize || eventFontSize || "0.85rem", + "--weekly-event-weight": + profile.eventFontWeight || eventFontWeight || "400", + "--font-weight-body": profile.fontWeight || fontWeight || "400", + "--weekly-weekend-sat": darkMode + ? invertColor(profile.weekendColorSat || "#666666") + : profile.weekendColorSat || "#666666", + "--weekly-weekend-sun": darkMode + ? invertColor(profile.weekendColorSun || "#dc2626") + : profile.weekendColorSun || "#dc2626", + "--weekly-weekday-color": darkMode + ? invertColor(profile.weekdayColor || "#888888") + : profile.weekdayColor || "#888888", + "--weekly-date-color": darkMode + ? invertColor(profile.dateColor || "#888888") + : profile.dateColor || "#888888", + "--weekly-task-color": darkMode + ? invertColor(profile.taskColor || "#333333") + : profile.taskColor || "#333333", + "--weekly-today-highlight": darkMode + ? invertColor(profile.todayHighlightColor || "#f0fafa") + : profile.todayHighlightColor || "#f0fafa", + "--weekly-past-color": darkMode + ? invertColor(profile.pastDayColor || "#a6a6a7") + : profile.pastDayColor || "#a6a6a7", } as React.CSSProperties; if (isLoading) { return ( -
-
{translations[language]?.loading || translations['en'].loading}
+
+
+ {translations[language]?.loading || translations["en"].loading} +
); } - return ( -
- {/* View Transitions Style Block */} -