diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 427d9fe..a783c75 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -63,6 +63,8 @@ model User { eventFontFamily String? @default("Inter") eventFontSize String? @default("0.85rem") eventFontWeight String? @default("400") + lightTheme Json? + darkTheme Json? fontWeight String @default("400") weekdayColor String? @default("#0ea5e9") dateColor String? @default("#888888") diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index 1acc463..35fb5b4 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -92,6 +92,8 @@ export async function GET(request: NextRequest) { quoteLanguages: true, kanbanStages: true, accountNumber: true, + lightTheme: true, + darkTheme: true, createdAt: true } }); @@ -134,7 +136,7 @@ export async function PATCH(request: NextRequest) { yearFontFamily, yearFontSize, yearFontWeight, yearColor, showTaskCheckboxes, dayHeaderGap, showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, quoteSourceUrls, quoteLanguages, - kanbanStages + kanbanStages, lightTheme, darkTheme } = body; const updateData: any = { @@ -215,6 +217,8 @@ export async function PATCH(request: NextRequest) { ...(quoteSourceUrls !== undefined && { quoteSourceUrls }), ...(quoteLanguages !== undefined && { quoteLanguages }), ...(kanbanStages !== undefined && { kanbanStages }), + ...(lightTheme !== undefined && { lightTheme }), + ...(darkTheme !== undefined && { darkTheme }), }; if (password && password.trim() !== "") { updateData.passwordHash = await bcrypt.hash(password, 10); @@ -304,6 +308,8 @@ export async function PATCH(request: NextRequest) { quoteLanguages: true, kanbanStages: true, accountNumber: true, + lightTheme: true, + darkTheme: true, } }); diff --git a/src/app/globals.css b/src/app/globals.css index 327259f..094f5a3 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -498,8 +498,8 @@ h3 { justify-content: space-between; position: relative; } - .weekly-logo { + flex-shrink: 0; font-size: 1.25rem; font-weight: 600; letter-spacing: 0.05em; @@ -2317,14 +2317,16 @@ h3 { .time-grid-wrapper { display: flex; flex: 1; + min-width: 0; overflow: hidden; position: relative; } /* Side Navigation Arrows (hover overlays) */ .side-nav { - position: absolute; - top: 0; + position: sticky; + top: 50px; /* Below header height if needed, or 0 */ + height: 0; /* Let it be a point of origin */ display: flex; flex-direction: column; align-items: center; @@ -2332,6 +2334,11 @@ h3 { padding: 0.35rem 0.15rem; gap: 0.1rem; z-index: 30; + pointer-events: none; /* Let clicks pass through to grid except buttons */ +} + +.side-nav-btn { + pointer-events: auto; } .side-nav .side-nav-btn { @@ -2387,7 +2394,7 @@ h3 { .time-column-slots { flex: 1; - overflow-y: auto; + overflow-y: visible; /* Unify with parent scroll */ position: relative; padding-top: 0.4rem; } @@ -2415,10 +2422,12 @@ h3 { } .time-slot-label span { - transform: translateY(-50%); /* Changed from 50% to -50% for centering on the line */ + transform: translateY(-50%); /* Centers the label on the grid line */ background: var(--weekly-bg); - padding: 0 2px; + padding: 0 4px; z-index: 1; + display: inline-block; + line-height: 1; } .time-slot-label .sub-hour-label { @@ -2433,8 +2442,9 @@ h3 { /* Time Slots Container */ .time-slots-container { flex: 1; - overflow-y: auto; + overflow: visible; /* Changed from auto to unify scroll */ overflow-x: visible; + padding-top: 0.4rem; /* Aligned with .time-column-slots */ } .time-slot { @@ -2586,7 +2596,8 @@ h3 { /* Make day columns scrollable when using time grid */ .time-grid-wrapper .weekly-days-grid { flex: 1; - overflow: hidden; + overflow-y: auto; /* Unified scroll container */ + overflow-x: hidden; } .time-grid-wrapper .weekly-day-column { @@ -2601,8 +2612,7 @@ h3 { } .time-grid-wrapper .time-slots-container { - overflow-y: visible; - overflow-x: visible; + overflow: visible; } /* All-Day Events Section */ @@ -3956,3 +3966,78 @@ h3 { left: auto !important; right: auto !important; } + +/* ============================================ + UNIFIED SCROLLING FOR TIME GRID VIEWS + ============================================ */ +/* Active time grid container becomes the sole vertical scroll wrapper */ +.time-grid-on .time-grid-wrapper { + overflow-y: scroll !important; /* Force scrollbar space to always exist */ + overflow-x: hidden !important; + -webkit-overflow-scrolling: touch; + position: relative; +} + +/* Force the all-day section to align with the scrollbar-constrained grid below it */ +.time-grid-on .all-day-events-section { + overflow-y: scroll !important; +} + +/* Hide the actual scrollbar thumb in the all-day section so it is just transparent reserved space */ +.time-grid-on .all-day-events-section::-webkit-scrollbar { + width: auto; + background: transparent; +} +.time-grid-on .all-day-events-section::-webkit-scrollbar-thumb { + background: transparent; +} + +/* Disable independent scrolling on all inner grid containers */ +.time-grid-on .weekly-days-grid, +.time-grid-on .weekly-day-column, +.time-grid-on .time-slots-container, +.time-grid-on .time-column-slots, +.time-grid-on .time-column { + overflow-y: visible !important; + overflow-x: visible !important; + max-height: none !important; + min-height: 100% !important; +} + +/* Ensure no rogue scrollbars appear and hide webskit ones */ +.time-grid-on .weekly-days-grid::-webkit-scrollbar, +.time-grid-on .weekly-day-column::-webkit-scrollbar, +.time-grid-on .time-slots-container::-webkit-scrollbar, +.time-grid-on .time-column-slots::-webkit-scrollbar { + display: none !important; + width: 0 !important; + height: 0 !important; +} +.time-grid-on .weekly-days-grid, +.time-grid-on .weekly-day-column, +.time-grid-on .time-slots-container, +.time-grid-on .time-column-slots { + scrollbar-width: none !important; + -ms-overflow-style: none !important; +} + +/* Make Day and Time Headers Sticky to the top of the viewport */ +.time-grid-on .weekly-day-header, +.time-grid-on .time-column-header { + position: sticky !important; + top: 0 !important; + z-index: 40 !important; + background-color: var(--weekly-bg, white); + border-bottom: 1px solid var(--weekly-border, #e0e0e0); +} + +.dark-mode .time-grid-on .weekly-day-header, +.dark-mode .time-grid-on .time-column-header { + background-color: var(--weekly-bg, #111) !important; + border-bottom-color: var(--weekly-border, #333) !important; +} + +/* Provide a solid background for the top-left empty corner above the time column */ +.time-grid-on .time-column-header { + z-index: 45 !important; +} diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index 0cb83b2..e591b07 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -1337,13 +1337,35 @@ function formatDateToISO(date: Date): string { return `${year}-${month}-${day}`; } -function formatHour(hour: number, format: "short" | "full" = "short", timeFormat: string = "24h"): string { +/** + * Parses a date string from a calendar event. + * If strictly a date (YYYY-MM-DD), it's parsed as local mid-night. + * If an ISO string with time, it's parsed regularly. + */ +function parseCalendarDate(dateStr: string): Date { + if (!dateStr) return new Date(); + // If it's date-only (YYYY-MM-DD), parse as local midnight + if (!dateStr.includes("T")) { + const parts = dateStr.split("-").map(Number); + if (parts.length === 3) { + return new Date(parts[0], parts[1] - 1, parts[2], 0, 0, 0); + } + } + // If it's an ISO string but we want local midnight (e.g. from cache or older backend) + // we still parse it. The fix in the backend should reduce this. + return new Date(dateStr); +} + +function formatHour(hour: number, minutes: number = 0, format: "short" | "full" = "short", timeFormat: string = "24h"): string { if (timeFormat === "12h") { const h = hour % 12 || 12; const ampm = hour >= 12 ? "PM" : "AM"; - return format === "full" ? `${h}:00 ${ampm}` : `${h} ${ampm}`; + const m = minutes > 0 ? `:${minutes.toString().padStart(2, "0")}` : ""; + return format === "full" || minutes > 0 ? `${h}:${minutes.toString().padStart(2, "0")} ${ampm}` : `${h}${m} ${ampm}`; } - return format === "full" ? `${hour}:00` : `${hour}`; + // 24h format: always show minutes if non-zero, or if format is full. + // Standardize to always include :00 for the hour start to avoid ambiguity. + return `${hour.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; } function getTimeSlots( @@ -1400,8 +1422,8 @@ const isAllDayEvent = (event: CalendarEvent): boolean => { // Date-only format (YYYY-MM-DD) if (!event.startTime.includes("T")) return true; - const start = new Date(event.startTime); - const end = new Date(event.endTime); + const start = parseCalendarDate(event.startTime); + const end = parseCalendarDate(event.endTime); const durationHours = (end.getTime() - start.getTime()) / (1000 * 60 * 60); // Check if strictly midnight to midnight in local time @@ -1772,6 +1794,8 @@ export default function WeeklyView() { customWeekdayNames?: string; dateVerticalAlign?: "top" | "middle" | "bottom"; headerDisplay?: "kw" | "month"; + lightTheme?: any; + darkTheme?: any; }>({ name: session?.user?.name || "", email: session?.user?.email || "", @@ -2051,68 +2075,95 @@ export default function WeeklyView() { const timeColumnRef = useRef(null); const dayColumnsRef = useRef([]); const isScrollSyncing = useRef(false); + const isInitialScrollDone = useRef(false); + const intendedScrollTop = useRef(null); const dayHeaderRef = useRef(null); const somedayGridRef = useRef(null); const somedaySectionRef = useRef(null); - // Scroll sync handler + // Unified scroll sync handlers const handleTimeColumnScroll = (e: React.UIEvent) => { + const scrollTop = e.currentTarget.scrollTop; + + // Detection for unexpected midnight reset during initial stabilization + if (intendedScrollTop.current !== null && scrollTop === 0 && !isInitialScrollDone.current) { + console.log(`[SCROLL] Detected unexpected reset to 0. Correcting to ${intendedScrollTop.current}px...`); + e.currentTarget.scrollTop = intendedScrollTop.current; + return; + } + if (isScrollSyncing.current) return; isScrollSyncing.current = true; - const scrollTop = e.currentTarget.scrollTop; - dayColumnsRef.current.forEach((col) => { - if (col) col.scrollTop = scrollTop; - }); + + if (gridRef.current) { + gridRef.current.scrollTop = scrollTop; + } setTimeout(() => { isScrollSyncing.current = false; - }, 10); + }, 100); }; - const handleDayColumnScroll = ( - e: React.UIEvent, - index: number, - ) => { + const handleGridScroll = (e: React.UIEvent) => { + const scrollTop = e.currentTarget.scrollTop; + + // Detection for unexpected midnight reset during initial stabilization + if (intendedScrollTop.current !== null && scrollTop === 0 && !isInitialScrollDone.current) { + console.log(`[SCROLL] Detected unexpected reset to 0. Correcting to ${intendedScrollTop.current}px...`); + e.currentTarget.scrollTop = intendedScrollTop.current; + return; + } + if (isScrollSyncing.current) return; isScrollSyncing.current = true; - const scrollTop = e.currentTarget.scrollTop; - if (timeColumnRef.current) timeColumnRef.current.scrollTop = scrollTop; - dayColumnsRef.current.forEach((col, i) => { - if (col && i !== index) col.scrollTop = scrollTop; - }); + + if (timeColumnRef.current) { + timeColumnRef.current.scrollTop = scrollTop; + } setTimeout(() => { isScrollSyncing.current = false; - }, 10); + }, 100); }; - // Slot height based on cell duration + const jumpToHour = (hour: number) => { + const slotsPerHour = 60 / cellDuration; + const slotHeight = getSlotHeight(cellDuration); + // Offset relative to workingHoursStart because the grid now starts there + const relativeHour = Math.max(0, hour - workingHoursStart); + const scrollOffset = relativeHour * slotsPerHour * slotHeight; + + console.log(`[SCROLL] Jumping to hour ${hour} (relative ${relativeHour}, offset ${scrollOffset}px)`); + + isScrollSyncing.current = true; + intendedScrollTop.current = scrollOffset; + + requestAnimationFrame(() => { + if (gridRef.current) gridRef.current.scrollTo({ top: scrollOffset, behavior: 'instant' }); + if (timeColumnRef.current) timeColumnRef.current.scrollTo({ top: scrollOffset, behavior: 'instant' }); + + setTimeout(() => { + isScrollSyncing.current = false; + }, 500); + }); + }; + + // Slot and Header height based on cell duration const getSlotHeight = (duration: number) => { 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) => { + const getHeaderHeight = (duration: number) => { switch (duration) { - case 15: - return 65; - case 30: - return 55; - case 60: - return 50; - case 120: - return 50; - default: - return 50; + case 15: return 65; + case 30: return 55; + case 60: return 50; + case 120: return 50; + default: return 50; } }; @@ -2455,6 +2506,38 @@ export default function WeeklyView() { } }, [currentWeekStart, session, fetchCalendarEvents]); + // Initial scroll to top (grid now starts at workingHoursStart) + useEffect(() => { + if (!isLoading) { + const performScroll = () => { + // Since the grid restricted to [workingHoursStart, workingHoursEnd], + // the top is already the start of the working hours. + const scrollOffset = 0; + + isScrollSyncing.current = true; + intendedScrollTop.current = scrollOffset; + + requestAnimationFrame(() => { + const jump = (el: HTMLElement | null) => { + if (el) el.scrollTo({ top: scrollOffset, behavior: 'auto' }); + }; + + jump(gridRef.current); + jump(timeColumnRef.current); + + setTimeout(() => { + isScrollSyncing.current = false; + isInitialScrollDone.current = true; + }, 500); + }); + }; + + // Delay slightly to ensure layout is stable + const timer = setTimeout(performScroll, 300); + return () => clearTimeout(timer); + } + }, [isLoading, workingHoursStart, cellDuration]); + // Update current time every 30 seconds for the "Now" line and clock useEffect(() => { const interval = setInterval(() => { @@ -3064,33 +3147,25 @@ export default function WeeklyView() { 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 safely using our local-time helper + const start = parseCalendarDate(event.startTime); + const end = event.endTime + ? parseCalendarDate(event.endTime) + : new Date(start); // Normalize dates to start of day for comparison const targetDate = new Date(date); targetDate.setHours(0, 0, 0, 0); - const start = new Date(eventStart); start.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 - - // Handle single day case where start == end or end is not provided - if (!event.endTime || start.getTime() === end.getTime()) { + // Handle single day case where start == end + if (start.getTime() === end.getTime()) { return start.getTime() === targetDate.getTime(); } + // Standard range comparison (inclusive start, exclusive end) return ( targetDate.getTime() >= start.getTime() && targetDate.getTime() < end.getTime() @@ -4388,8 +4463,8 @@ export default function WeeklyView() { // Slot is taken — find next free slot const allSlots = getTimeSlots( cellDuration, - workingHoursStart, - workingHoursEnd, + 0, + 24, ); const startIndex = allSlots.indexOf(resolvedStartTime); if (startIndex !== -1) { @@ -4503,8 +4578,8 @@ export default function WeeklyView() { if (isSlotOccupiedByTask(targetDateObj, targetSlot, draggedTask.id)) { const allSlots = getTimeSlots( cellDuration, - workingHoursStart, - workingHoursEnd, + 0, + 24, ); const startIndex = allSlots.indexOf(targetSlot); if (startIndex !== -1) { @@ -4821,8 +4896,18 @@ export default function WeeklyView() { const num = parseFloat(base); return `${(num * fontSizeScale).toFixed(3)}rem`; }; + const activeTheme = (darkMode ? profile.darkTheme : profile.lightTheme) as Record | null; const containerStyle = { + ...(activeTheme ? { + "--weekly-bg": activeTheme.background, + "--weekly-text": activeTheme.foreground, + "--weekly-text-light": activeTheme.color8 || activeTheme.color7, + "--weekly-border": activeTheme.color0, + "--weekly-teal": activeTheme.color4 || activeTheme.color6, + "--weekly-settings-item-bg": activeTheme.color0, + "--weekly-item-hover": activeTheme.color0, + } : {}), "--weekly-font-headline": fontVal(profile.headlineFont) || headlineFont ? `"${fontVal(profile.headlineFont) || headlineFont}", sans-serif` @@ -4870,27 +4955,27 @@ export default function WeeklyView() { "--weekly-event-weight": profile.eventFontWeight || eventFontWeight || "400", "--font-weight-body": profile.fontWeight || fontWeight || "400", - "--weekly-weekend-sat": darkMode + "--weekly-weekend-sat": activeTheme?.color3 || (darkMode ? invertColor(profile.weekendColorSat || "#666666") - : profile.weekendColorSat || "#666666", - "--weekly-weekend-sun": darkMode + : profile.weekendColorSat || "#666666"), + "--weekly-weekend-sun": activeTheme?.color1 || (darkMode ? invertColor(profile.weekendColorSun || "#dc2626") - : profile.weekendColorSun || "#dc2626", - "--weekly-weekday-color": darkMode + : profile.weekendColorSun || "#dc2626"), + "--weekly-weekday-color": activeTheme?.foreground || (darkMode ? invertColor(profile.weekdayColor || "#888888") - : profile.weekdayColor || "#888888", - "--weekly-date-color": darkMode + : profile.weekdayColor || "#888888"), + "--weekly-date-color": activeTheme?.color8 || (darkMode ? invertColor(profile.dateColor || "#888888") - : profile.dateColor || "#888888", - "--weekly-task-color": darkMode + : profile.dateColor || "#888888"), + "--weekly-task-color": activeTheme?.color7 || (darkMode ? invertColor(profile.taskColor || "#333333") - : profile.taskColor || "#333333", - "--weekly-today-highlight": darkMode + : profile.taskColor || "#333333"), + "--weekly-today-highlight": activeTheme?.color0 || (darkMode ? invertColor(profile.todayHighlightColor || "#f0fafa") - : profile.todayHighlightColor || "#f0fafa", - "--weekly-past-color": darkMode + : profile.todayHighlightColor || "#f0fafa"), + "--weekly-past-color": activeTheme?.color8 || (darkMode ? invertColor(profile.pastDayColor || "#a6a6a7") - : profile.pastDayColor || "#a6a6a7", + : profile.pastDayColor || "#a6a6a7"), } as React.CSSProperties; if (isLoading) { @@ -5738,7 +5823,7 @@ export default function WeeklyView() { {/* RIGHT SECTION: Navigation & Tools */} -
+
{/* Undo/Redo */}
+ {/* Time Column */} {showTimeGrid && ( -
-
+
+
{/* Invisible structural match of day header to guarantee perfect height alignment */}
{activeDateLayout === "left" && ( @@ -6173,8 +6277,10 @@ export default function WeeklyView() {
{visibleSlots.map((slot, index) => { const hour = getHourFromSlot(slot); @@ -6191,10 +6297,13 @@ export default function WeeklyView() {
jumpToHour(hour) : undefined} + title={isHourStart ? `Jump to ${hour}:00` : undefined} > - {isHourStart && {formatHour(hour, hourLabelFormat, timeFormat)}} - {!isHourStart && showSubHourSlots && :{minutes}} + {(isHourStart || showSubHourSlots) && ( + {formatHour(hour, parseInt(minutes), (isHourStart ? 'short' : 'full') as "short" | "full", timeFormat)} + )}
); })} @@ -6208,6 +6317,14 @@ export default function WeeklyView() { className={`weekly-days-grid cols-${viewDays}`} data-slide-direction={slideDirection} data-nav-type={viewDays > 1 ? "week" : "day"} + onScroll={handleGridScroll} + style={{ + height: `${(workingHoursEnd - workingHoursStart) * (60 / cellDuration) * getSlotHeight(cellDuration) + getHeaderHeight(cellDuration)}px`, + maxHeight: `${(workingHoursEnd - workingHoursStart) * (60 / cellDuration) * getSlotHeight(cellDuration) + getHeaderHeight(cellDuration)}px`, + flex: 1, + alignSelf: "flex-start", + overflowY: 'auto' + }} > {getVisibleDays().map((date, colIndex) => { const todayMidnight = new Date(); @@ -6272,10 +6389,6 @@ export default function WeeklyView() { {showTimeGrid ? (
{ - if (el) dayColumnsRef.current[colIndex] = el; - }} - onScroll={(e) => handleDayColumnScroll(e, colIndex)} onDragLeave={handleDragLeave} style={{ position: "relative" }} > @@ -6293,10 +6406,10 @@ export default function WeeklyView() { const now = currentTime; const nowHour = now.getHours(); const nowMinute = now.getMinutes(); - // Only show if within visible time range + // Show if within 24h range if ( - nowHour >= workingHoursStart && - nowHour < workingHoursEnd + nowHour >= 0 && + nowHour < 24 ) { const minutesSinceStart = (nowHour - workingHoursStart) * 60 + nowMinute; @@ -6325,10 +6438,10 @@ export default function WeeklyView() { const eventStartHour = eventStart.getHours(); const eventStartMinute = eventStart.getMinutes(); - // Only show if event is within visible time range + // Show if within 24h range if ( - eventStartHour < workingHoursStart || - eventStartHour >= workingHoursEnd + eventStartHour < 0 || + eventStartHour >= 24 ) return null; @@ -12543,6 +12656,196 @@ function SettingsSidebar({
+ {/* Terminal Theme Import/Export */} +
+ +

+ Import or Export standard Terminal 16-color JSON themes (e.g. Gogh, terminal.sexy) to completely change the app colors. +

+ +
+ {/* Light Theme */} +
+ +
+ + +
+ {((profile as any).lightTheme) && ( + + )} + {/* Color 16-grid Preview */} + {(profile as any).lightTheme && ( +
+ {[...Array(16)].map((_, i) => ( +
+ ))} +
+ )} +
+ + {/* Dark Theme */} +
+ +
+ + +
+ {((profile as any).darkTheme) && ( + + )} + {/* Color 16-grid Preview */} + {(profile as any).darkTheme && ( +
+ {[...Array(16)].map((_, i) => ( +
+ ))} +
+ )} +
+
+
+ {/* Element Colors */}
, which matches well. // However, event.start and event.end might be undefined in Partial, so we need checks. - if (!event.title) throw new Error('Event title is required'); - if (!event.start || !event.end) throw new Error('Event start and end times are required'); + // Handle all-day event normalization for Apple (requires 'date' property, not 'dateTime') + // AND exclusive end date (add 1 day if needed) + const start = { ...event.start! }; + let end = { ...event.end! }; + + if (event.allDay) { + if (start.dateTime && !start.date) { + start.date = start.dateTime.split('T')[0]; + delete start.dateTime; + } + if (end.dateTime && !end.date) { + end.date = end.dateTime.split('T')[0]; + delete end.dateTime; + } + + // Apple/iCloud requires DTEND to be the day AFTER the last day of the event + if (start.date && end.date && start.date === end.date) { + const d = new Date(end.date); + d.setDate(d.getDate() + 1); + end.date = d.toISOString().split('T')[0]; + } + } const createdEvent = await import('./apple-calendar').then(m => m.createEvent(email, appPassword, calendarId, { @@ -709,8 +730,8 @@ export const createCalendarEvent = async ( location: event.location, url: event.url, recurrence: event.recurrence, - start: event.start!, - end: event.end! + start, + end }) ); @@ -851,6 +872,24 @@ export const updateCalendarEvent = async ( } else if (connection.provider === 'apple') { const [email, appPassword] = connection.accessToken.split(':'); + // Normalize for Apple updates too + if (event.allDay) { + if (event.start && event.start.dateTime && !event.start.date) { + event.start.date = event.start.dateTime.split('T')[0]; + delete event.start.dateTime; + } + if (event.end && event.end.dateTime && !event.end.date) { + event.end.date = event.end.dateTime.split('T')[0]; + delete event.end.dateTime; + } + + if (event.start && event.end && event.start.date && event.end.date && event.start.date === event.end.date) { + const d = new Date(event.end.date); + d.setDate(d.getDate() + 1); + event.end.date = d.toISOString().split('T')[0]; + } + } + const updatedEvent = await import('./apple-calendar').then(m => m.updateEvent(email, appPassword, calendarId, eventId, { title: event.title, diff --git a/src/lib/synology-calendar.ts b/src/lib/synology-calendar.ts index 11f9586..2431465 100644 --- a/src/lib/synology-calendar.ts +++ b/src/lib/synology-calendar.ts @@ -13,6 +13,16 @@ export interface SynologyCalendarEvent { isRecurring?: boolean; } +/** + * Format Date to local YYYY-MM-DD string without timezone shift. + */ +function formatDateToLocalISO(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + export interface SynologyCalendar { id: string; title: string; @@ -196,8 +206,8 @@ export const getUpcomingEvents = async ( parsedEvents.push({ id: `caldav::${eventObj.url}::${exEvent.uid}::${exStart.toISOString()}`, title: exEvent.summary || 'Untitled Event', - startDate: exIsAllDay ? exStart.toISOString().slice(0, 10) : exStart.toISOString(), - endDate: exIsAllDay ? exEnd.toISOString().slice(0, 10) : exEnd.toISOString(), + startDate: exIsAllDay ? formatDateToLocalISO(exStart) : exStart.toISOString(), + endDate: exIsAllDay ? formatDateToLocalISO(exEnd) : exEnd.toISOString(), description: exEvent.description, location: exEvent.location, url: exVevent.getFirstPropertyValue('url')?.toString() || undefined, @@ -225,8 +235,8 @@ export const getUpcomingEvents = async ( parsedEvents.push({ id: `caldav::${eventObj.url}::${event.uid}::${occStart.toISOString()}`, title: event.summary || 'Untitled Event', - startDate: isAllDayRecurring ? occStart.toISOString().slice(0, 10) : occStart.toISOString(), - endDate: isAllDayRecurring ? occEnd.toISOString().slice(0, 10) : occEnd.toISOString(), + startDate: isAllDayRecurring ? formatDateToLocalISO(occStart) : occStart.toISOString(), + endDate: isAllDayRecurring ? formatDateToLocalISO(occStart) : occEnd.toISOString(), description: event.description, location: event.location, url: vevent.getFirstPropertyValue('url')?.toString() || undefined, @@ -247,8 +257,8 @@ export const getUpcomingEvents = async ( parsedEvents.push({ id: `caldav::${eventObj.url}::${event.uid || 'unknown'}`, title: event.summary || 'Untitled Event', - startDate: isAllDay ? start.toISOString().slice(0, 10) : start.toISOString(), - endDate: isAllDay ? end.toISOString().slice(0, 10) : end.toISOString(), + startDate: isAllDay ? formatDateToLocalISO(start) : start.toISOString(), + endDate: isAllDay ? formatDateToLocalISO(end) : end.toISOString(), description: event.description, location: event.location, url: vevent.getFirstPropertyValue('url')?.toString() || undefined,