Merge branch 'dev' into main
This commit is contained in:
commit
9e32440f15
@ -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")
|
||||
|
||||
@ -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,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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<HTMLDivElement>(null);
|
||||
const dayColumnsRef = useRef<HTMLDivElement[]>([]);
|
||||
const isScrollSyncing = useRef(false);
|
||||
const isInitialScrollDone = useRef(false);
|
||||
const intendedScrollTop = useRef<number | null>(null);
|
||||
const dayHeaderRef = useRef<HTMLElement>(null);
|
||||
const somedayGridRef = useRef<HTMLDivElement>(null);
|
||||
const somedaySectionRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Scroll sync handler
|
||||
// Unified scroll sync handlers
|
||||
const handleTimeColumnScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>,
|
||||
index: number,
|
||||
) => {
|
||||
const handleGridScroll = (e: React.UIEvent<HTMLElement>) => {
|
||||
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<string, string> | null;
|
||||
|
||||
const containerStyle = {
|
||||
...(activeTheme ? {
|
||||
"--weekly-bg": activeTheme.background,
|
||||
"--weekly-text": activeTheme.foreground,
|
||||
"--weekly-text-light": activeTheme.color8 || activeTheme.color7,
|
||||
"--weekly-border": activeTheme.color0,
|
||||
"--weekly-teal": activeTheme.color4 || activeTheme.color6,
|
||||
"--weekly-settings-item-bg": activeTheme.color0,
|
||||
"--weekly-item-hover": activeTheme.color0,
|
||||
} : {}),
|
||||
"--weekly-font-headline":
|
||||
fontVal(profile.headlineFont) || 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() {
|
||||
</div>
|
||||
|
||||
{/* RIGHT SECTION: Navigation & Tools */}
|
||||
<div className="weekly-header-controls flex items-center gap-3 transition-opacity duration-300 opacity-0 group-hover:opacity-100" style={{ zIndex: 1 }}>
|
||||
<div className="weekly-header-controls flex-shrink-0 flex items-center gap-2 sm:gap-3 transition-opacity duration-300 opacity-0 group-hover:opacity-100" style={{ zIndex: 10 }}>
|
||||
{/* Undo/Redo */}
|
||||
<button
|
||||
onClick={handleUndo}
|
||||
@ -6127,10 +6212,29 @@ export default function WeeklyView() {
|
||||
<ChevronsLeft size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Time Column */}
|
||||
{showTimeGrid && (
|
||||
<div className="time-column">
|
||||
<div className="time-column-header" style={{ border: 'none', background: 'transparent' }}>
|
||||
<div
|
||||
className="time-column"
|
||||
ref={timeColumnRef}
|
||||
onScroll={handleTimeColumnScroll}
|
||||
style={{
|
||||
height: `${(workingHoursEnd - workingHoursStart) * (60 / cellDuration) * getSlotHeight(cellDuration) + getHeaderHeight(cellDuration)}px`,
|
||||
maxHeight: `${(workingHoursEnd - workingHoursStart) * (60 / cellDuration) * getSlotHeight(cellDuration) + getHeaderHeight(cellDuration)}px`,
|
||||
overflowY: 'auto',
|
||||
flex: 'none',
|
||||
alignSelf: "flex-start",
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<div className="time-column-header" style={{
|
||||
border: 'none',
|
||||
background: 'var(--weekly-bg)',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 30
|
||||
}}>
|
||||
{/* Invisible structural match of day header to guarantee perfect height alignment */}
|
||||
<div
|
||||
style={{
|
||||
@ -6154,7 +6258,7 @@ export default function WeeklyView() {
|
||||
: activeDateLayout === "below"
|
||||
? "column"
|
||||
: "row",
|
||||
gap: profile.dateAlignment === "tight" ? "2px" : "4px",
|
||||
gap: profile.dateAlignment === "tight" ? "2px" : (profile.dayHeaderGap || "0.35em"),
|
||||
}}
|
||||
>
|
||||
{activeDateLayout === "left" && (
|
||||
@ -6173,8 +6277,10 @@ export default function WeeklyView() {
|
||||
</div>
|
||||
<div
|
||||
className="time-column-slots"
|
||||
ref={timeColumnRef}
|
||||
onScroll={handleTimeColumnScroll}
|
||||
style={{
|
||||
flex: "none",
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
{visibleSlots.map((slot, index) => {
|
||||
const hour = getHourFromSlot(slot);
|
||||
@ -6191,10 +6297,13 @@ export default function WeeklyView() {
|
||||
<div
|
||||
key={slot}
|
||||
className={`time-slot-label ${isHourStart ? "hour-start" : "sub-hour"}`}
|
||||
style={{ height: `${getSlotHeight(cellDuration)}px` }}
|
||||
style={{ height: `${getSlotHeight(cellDuration)}px`, cursor: isHourStart ? 'pointer' : 'default' }}
|
||||
onClick={isHourStart ? () => jumpToHour(hour) : undefined}
|
||||
title={isHourStart ? `Jump to ${hour}:00` : undefined}
|
||||
>
|
||||
{isHourStart && <span>{formatHour(hour, hourLabelFormat, timeFormat)}</span>}
|
||||
{!isHourStart && showSubHourSlots && <span className="sub-hour-label">:{minutes}</span>}
|
||||
{(isHourStart || showSubHourSlots) && (
|
||||
<span>{formatHour(hour, parseInt(minutes), (isHourStart ? 'short' : 'full') as "short" | "full", timeFormat)}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -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 ? (
|
||||
<div
|
||||
className="time-slots-container"
|
||||
ref={(el) => {
|
||||
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({
|
||||
|
||||
</div>
|
||||
|
||||
{/* Terminal Theme Import/Export */}
|
||||
<div
|
||||
style={{
|
||||
background: "var(--weekly-settings-item-bg)",
|
||||
padding: "16px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "12px",
|
||||
border: "1px solid var(--weekly-border)",
|
||||
}}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "0.95rem",
|
||||
fontWeight: 700,
|
||||
color: "var(--weekly-settings-title)",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
Terminal Color Theme
|
||||
</label>
|
||||
<p style={{ fontSize: "0.8rem", color: "var(--weekly-settings-label)", marginBottom: "16px" }}>
|
||||
Import or Export standard Terminal 16-color JSON themes (e.g. Gogh, terminal.sexy) to completely change the app colors.
|
||||
</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "16px" }}>
|
||||
{/* Light Theme */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px", padding: "12px", background: "rgba(0,0,0,0.03)", borderRadius: "6px" }}>
|
||||
<label style={{ fontSize: "0.85rem", fontWeight: 600, color: "var(--weekly-settings-title)" }}>☀️ Light Mode Theme</label>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<label className="weekly-btn-secondary" style={{ flex: 1, textAlign: "center", cursor: "pointer", fontSize: "0.8rem", padding: "6px" }}>
|
||||
Import JSON
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (evt) => {
|
||||
try {
|
||||
const text = evt.target?.result as string;
|
||||
let json = JSON.parse(text);
|
||||
|
||||
// Normalizing standard terminal formats to a consistent internal format
|
||||
const normalized: any = {
|
||||
background: json.background,
|
||||
foreground: json.foreground,
|
||||
cursorColor: json.cursorColor || json.cursor,
|
||||
};
|
||||
|
||||
// Handle Tabby-style colors array
|
||||
if (Array.isArray(json.colors)) {
|
||||
json.colors.forEach((c: string, i: number) => {
|
||||
normalized[`color${i}`] = c;
|
||||
});
|
||||
} else {
|
||||
// Handle Gogh/terminal.sexy color0...color15 keys
|
||||
for (let i = 0; i < 16; i++) {
|
||||
if (json[`color${i}`]) normalized[`color${i}`] = json[`color${i}`];
|
||||
}
|
||||
}
|
||||
|
||||
setProfile((p: any) => ({ ...p, lightTheme: normalized }));
|
||||
alert("Light Theme imported! Save changes to apply.");
|
||||
} catch (err) {
|
||||
alert("Invalid JSON format.");
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="weekly-btn-outline"
|
||||
onClick={() => {
|
||||
// Fix: access profile from state properly
|
||||
const currentTheme = (profile as any).lightTheme || {};
|
||||
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(currentTheme, null, 2));
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = dataStr;
|
||||
anchor.download = "light-theme.json";
|
||||
anchor.click();
|
||||
}}
|
||||
style={{ flex: 1, fontSize: "0.8rem", padding: "6px" }}
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
{((profile as any).lightTheme) && (
|
||||
<button
|
||||
onClick={() => setProfile((p: any) => ({ ...p, lightTheme: null }))}
|
||||
style={{ fontSize: "0.75rem", color: "#dc2626", background: "none", border: "none", cursor: "pointer", textAlign: "left", marginTop: "4px" }}
|
||||
>
|
||||
Clear Light Theme
|
||||
</button>
|
||||
)}
|
||||
{/* Color 16-grid Preview */}
|
||||
{(profile as any).lightTheme && (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(8, 1fr)", gap: "2px", marginTop: "8px" }}>
|
||||
{[...Array(16)].map((_, i) => (
|
||||
<div key={i} title={`color${i}`} style={{ width: "100%", height: "12px", background: (profile as any).lightTheme[`color${i}`] || "#ccc", borderRadius: "2px" }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dark Theme */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px", padding: "12px", background: "rgba(0,0,0,0.2)", borderRadius: "6px" }}>
|
||||
<label style={{ fontSize: "0.85rem", fontWeight: 600, color: "var(--weekly-settings-title)" }}>🌙 Dark Mode Theme</label>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<label className="weekly-btn-secondary" style={{ flex: 1, textAlign: "center", cursor: "pointer", fontSize: "0.8rem", padding: "6px" }}>
|
||||
Import JSON
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (evt) => {
|
||||
try {
|
||||
const text = evt.target?.result as string;
|
||||
let json = JSON.parse(text);
|
||||
|
||||
const normalized: any = {
|
||||
background: json.background,
|
||||
foreground: json.foreground,
|
||||
cursorColor: json.cursorColor || json.cursor,
|
||||
};
|
||||
|
||||
if (Array.isArray(json.colors)) {
|
||||
json.colors.forEach((c: string, i: number) => {
|
||||
normalized[`color${i}`] = c;
|
||||
});
|
||||
} else {
|
||||
for (let i = 0; i < 16; i++) {
|
||||
if (json[`color${i}`]) normalized[`color${i}`] = json[`color${i}`];
|
||||
}
|
||||
}
|
||||
|
||||
setProfile((p: any) => ({ ...p, darkTheme: normalized }));
|
||||
alert("Dark Theme imported! Save changes to apply.");
|
||||
} catch (err) {
|
||||
alert("Invalid JSON format.");
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="weekly-btn-outline"
|
||||
onClick={() => {
|
||||
const currentTheme = (profile as any).darkTheme || {};
|
||||
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(currentTheme, null, 2));
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = dataStr;
|
||||
anchor.download = "dark-theme.json";
|
||||
anchor.click();
|
||||
}}
|
||||
style={{ flex: 1, fontSize: "0.8rem", padding: "6px" }}
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
{((profile as any).darkTheme) && (
|
||||
<button
|
||||
onClick={() => setProfile((p: any) => ({ ...p, darkTheme: null }))}
|
||||
style={{ fontSize: "0.75rem", color: "#ef4444", background: "none", border: "none", cursor: "pointer", textAlign: "left", marginTop: "4px" }}
|
||||
>
|
||||
Clear Dark Theme
|
||||
</button>
|
||||
)}
|
||||
{/* Color 16-grid Preview */}
|
||||
{(profile as any).darkTheme && (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(8, 1fr)", gap: "2px", marginTop: "8px" }}>
|
||||
{[...Array(16)].map((_, i) => (
|
||||
<div key={i} title={`color${i}`} style={{ width: "100%", height: "12px", background: (profile as any).darkTheme[`color${i}`] || "#ccc", borderRadius: "2px" }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Element Colors */}
|
||||
<div
|
||||
style={{
|
||||
|
||||
@ -16,6 +16,16 @@ export interface AppleCalendarEvent {
|
||||
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 AppleCalendar {
|
||||
id: string;
|
||||
title: string;
|
||||
@ -165,8 +175,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,
|
||||
@ -200,8 +210,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(occEnd) : occEnd.toISOString(),
|
||||
description: event.description,
|
||||
location: event.location,
|
||||
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
|
||||
@ -223,8 +233,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,
|
||||
|
||||
@ -639,6 +639,7 @@ export const createCalendarEvent = async (
|
||||
end: event.end,
|
||||
location: event.location,
|
||||
...(rrule ? { recurrence: [rrule] } : {}),
|
||||
...(event.allDay ? { allDay: true } : {}),
|
||||
...(event.url ? { source: { url: event.url, title: event.url } } : {}),
|
||||
};
|
||||
|
||||
@ -699,8 +700,28 @@ export const createCalendarEvent = async (
|
||||
// Our 'event' arg is Partial<CalendarEvent>, 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,
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user