Merge branch 'dev' into main

This commit is contained in:
mARTin 2026-03-13 12:11:37 +01:00
commit 9e32440f15
7 changed files with 579 additions and 124 deletions

View File

@ -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")

View File

@ -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,
}
});

View File

@ -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;
}

View File

@ -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={{

View File

@ -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,

View File

@ -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,

View File

@ -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,