feat: assign events to calendars
Some checks are pending
Playwright Tests / test (push) Waiting to run
Test and SonarQube / quality (push) Waiting to run

This commit is contained in:
mARTin-B78 2026-09-05 12:34:46 +02:00
parent f5feffb4c0
commit 24a8185587
7 changed files with 78 additions and 36 deletions

View File

@ -2,6 +2,16 @@
All notable changes to this project are documented in this file.
## [1.116.3] - 2026-09-05
### Added
- Calendar events can now be reassigned to another editable calendar from the event editor.
- Moving an event updates the source and target calendar caches.
### Changed
- Calendar event backgrounds now use the calendar color with a lighter, readable contrast.
- Google event-specific colors no longer override the selected calendar's color.
## [1.116.2] - 2026-09-05
### Fixed

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "my-weekly-todo-list",
"version": "1.116.2",
"version": "1.116.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "my-weekly-todo-list",
"version": "1.116.2",
"version": "1.116.3",
"license": "MIT",
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",

View File

@ -1,6 +1,6 @@
{
"name": "my-weekly-todo-list",
"version": "1.116.2",
"version": "1.116.3",
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js",
"scripts": {

View File

@ -109,7 +109,7 @@ export async function PATCH(request: NextRequest) {
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await request.json();
const { calendarId, eventId, title, description, start, end, location, allDay, recurrence, recurrenceEndDate, recurrenceCount, recurrenceInterval, recurrenceDays, url,
const { calendarId, originalCalendarId, eventId, title, description, start, end, location, allDay, recurrence, recurrenceEndDate, recurrenceCount, recurrenceInterval, recurrenceDays, timezone, url,
reminders, busyStatus, visibility, attendees, attachments, editMode, recurringEventId } = body;
console.log('[API] Updating event:', { calendarId, eventId, title, editMode });
@ -128,6 +128,14 @@ export async function PATCH(request: NextRequest) {
const userId = dbUser2?.id;
if (!userId) return NextResponse.json({ error: 'User not found' }, { status: 404 });
const sourceCalendarId = originalCalendarId || calendarId;
const sourceConnection = sourceCalendarId === calendarId
? connection
: await findConnectionForCalendar(session.user.email, sourceCalendarId);
if (!sourceConnection) {
return NextResponse.json({ error: 'Original calendar connection not found' }, { status: 404 });
}
// For "all events" edit mode on recurring events, use the series master ID
let targetEventId = eventId;
if (editMode === 'all' && recurringEventId) {
@ -139,25 +147,35 @@ export async function PATCH(request: NextRequest) {
}
}
const event = await updateCalendarEvent(connection, calendarId, targetEventId, {
title,
description,
start,
end,
location,
const eventData = {
title, description, start, end, location,
allDay: allDay !== undefined ? !!allDay : undefined,
recurrence,
recurrenceEndDate,
recurrenceCount,
recurrenceInterval,
recurrenceDays,
url,
reminders,
busyStatus,
visibility,
attendees,
attachments,
} as any);
recurrence, recurrenceEndDate, recurrenceCount, recurrenceInterval, recurrenceDays, timezone,
url, reminders, busyStatus, visibility, attendees, attachments,
} as any;
// Providers do not share a portable move operation: create in the target,
// then remove the original event from its source calendar.
if (sourceCalendarId !== calendarId) {
if (editMode && editMode !== 'all') {
return NextResponse.json({ error: 'Recurring event instances must be moved as a whole series' }, { status: 400 });
}
const movedEvent = await createCalendarEvent(connection, calendarId, eventData);
if (!movedEvent.backgroundColor && connection.calendars) {
const cal = (connection.calendars as any[]).find((c: any) => c.id === calendarId);
if (cal?.backgroundColor || cal?.color) movedEvent.backgroundColor = cal.backgroundColor || cal.color;
}
await deleteCalendarEvent(sourceConnection, sourceCalendarId, targetEventId, 'all');
try {
await deleteCachedEvent(userId, targetEventId, sourceConnection.provider);
await upsertCachedEvent(userId, connection.id, connection.provider, movedEvent);
} catch (e) {
console.error('[CACHE] Failed to update cache after moving event:', e);
}
return NextResponse.json({ event: movedEvent });
}
const event = await updateCalendarEvent(connection, calendarId, targetEventId, eventData);
// Attach calendar color from connection metadata for cache + frontend
if (!event.backgroundColor && connection.calendars) {

View File

@ -364,6 +364,7 @@ export default function CalendarEventModal({
recurrenceEndDate: recurrence !== 'none' && recurrenceEndType === 'date' && recurrenceEndDateValue ? recurrenceEndDateValue : undefined,
recurrenceCount: recurrence !== 'none' && recurrenceEndType === 'count' && recurrenceCount > 0 ? recurrenceCount : undefined,
calendarId,
...(event?.calendarId ? { originalCalendarId: event.calendarId } : {}),
allDay,
start: { dateTime: startDate.toISOString() },
end: { dateTime: endDate.toISOString() },
@ -601,22 +602,22 @@ export default function CalendarEventModal({
<div ref={calendarSelectorRef} style={{ ...fieldCol, position: 'relative' }}>
<button
type="button"
onClick={() => !event && setIsCalendarSelectorOpen(!isCalendarSelectorOpen)}
disabled={!!event}
onClick={() => availableCalendars.length > 1 && setIsCalendarSelectorOpen(!isCalendarSelectorOpen)}
disabled={availableCalendars.length <= 1}
aria-label={language === 'de' ? 'Kalender auswählen' : 'Select calendar'}
aria-expanded={isCalendarSelectorOpen}
aria-haspopup="listbox"
style={{
display: 'flex', alignItems: 'center', gap: '4px',
background: 'transparent', border: 'none', fontWeight: 500,
cursor: event ? 'default' : 'pointer', outline: 'none',
cursor: availableCalendars.length > 1 ? 'pointer' : 'default', outline: 'none',
color: 'var(--weekly-text)', fontSize: '0.8rem', padding: '2px 0',
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{selectedCal?.summary || selectedCal?.title || (language === 'de' ? 'Kalender wählen' : 'Select Calendar')}
</span>
{!event && <ChevronDown size={12} style={{ opacity: 0.5 }} />}
{availableCalendars.length > 1 && <ChevronDown size={12} style={{ opacity: 0.5 }} />}
</button>
{isCalendarSelectorOpen && (

View File

@ -1838,7 +1838,7 @@ export default function WeeklyView() {
source: ev.source || calInfo?.provider || 'google',
calendarId: ev.calendarId || eventData.calendarId,
calendarTitle: ev.calendarTitle || calInfo?.summary || calInfo?.title || '',
calendarColor: ev.backgroundColor || ev.calendarColor || calInfo?.backgroundColor || calInfo?.color || '#3b82f6',
calendarColor: calInfo?.backgroundColor || calInfo?.color || ev.backgroundColor || ev.calendarColor || '#3b82f6',
};
setRawCalendarEvents(prev => {
if (eventData.id) {
@ -1860,13 +1860,17 @@ export default function WeeklyView() {
source: ev.source || calInfo?.provider || 'google',
calendarId: ev.calendarId || eventData.calendarId,
calendarTitle: ev.calendarTitle || calInfo?.summary || calInfo?.title || '',
calendarColor: ev.backgroundColor || ev.calendarColor || calInfo?.backgroundColor || calInfo?.color || '#3b82f6',
calendarColor: calInfo?.backgroundColor || calInfo?.color || ev.backgroundColor || ev.calendarColor || '#3b82f6',
};
setRawCalendarEvents(prev => prev.map(e => e.id === eventData.id ? frontendEvent : e));
}
// Force refresh only the affected provider
const connId = getConnectionIdForCalendar(eventData.calendarId);
await fetchCalendarEvents(true, connId);
const targetConnId = getConnectionIdForCalendar(eventData.calendarId);
const sourceConnId = getConnectionIdForCalendar(eventData.originalCalendarId);
await fetchCalendarEvents(true, targetConnId);
if (sourceConnId && sourceConnId !== targetConnId) {
await fetchCalendarEvents(true, sourceConnId);
}
} catch (error: any) {
console.error("Error saving event:", error);
if (error.name === "AbortError") {
@ -7803,11 +7807,14 @@ export default function WeeklyView() {
// Convert hex to rgba for background, or use default
const eventColor = event.calendarColor || "#009a9a";
const bgColor = eventColor.startsWith("#")
? `${eventColor}20` // Add alpha for transparency
? `color-mix(in srgb, ${eventColor} 14%, white)`
: eventColor;
const borderColor = eventColor.startsWith("#")
? eventColor
: "var(--weekly-teal)";
const textColor = eventColor.startsWith("#")
? `color-mix(in srgb, ${eventColor} 78%, black)`
: borderColor;
const overlap = eventOverlapLayout[event.id] || { column: 0, totalColumns: 1 };
const colWidth = 100 / overlap.totalColumns;
@ -7831,7 +7838,7 @@ export default function WeeklyView() {
justifyContent: "flex-start",
backgroundColor: bgColor,
borderLeftColor: borderColor,
color: borderColor,
color: textColor,
cursor: event.editable
? (isDragging ? "grabbing" : "grab")
: "default",
@ -7965,11 +7972,14 @@ export default function WeeklyView() {
{getEventsForDate(date).map((event) => {
const eventColor = event.calendarColor || "#009a9a";
const bgColor = eventColor.startsWith("#")
? `${eventColor}20`
? `color-mix(in srgb, ${eventColor} 14%, white)`
: eventColor;
const borderColor = eventColor.startsWith("#")
? eventColor
: "var(--weekly-teal)";
const textColor = eventColor.startsWith("#")
? `color-mix(in srgb, ${eventColor} 78%, black)`
: borderColor;
return (
<div
@ -7987,7 +7997,7 @@ export default function WeeklyView() {
style={{
backgroundColor: bgColor,
borderLeftColor: borderColor,
color: borderColor,
color: textColor,
cursor: event.editable ? "pointer" : "default",
}}
>

View File

@ -351,7 +351,10 @@ export const getCalendarEvents = async (
const calendarData = calendars.find(c => c.id === calendarId);
events = events.concat(calendarEvents.map((event: any) => {
const eventColor = event.colorId ? getGoogleEventColor(event.colorId) : calendarData?.backgroundColor;
// Keep the event tone consistent with the calendar selected by the user.
// Google can provide an event-specific color which otherwise makes one
// item look unrelated to the rest of its calendar.
const eventColor = calendarData?.backgroundColor || (event.colorId ? getGoogleEventColor(event.colorId) : undefined);
// Map Google reminders to our format
const reminders: EventReminder[] | undefined = event.reminders?.overrides?.map((r: any) => ({
method: r.method === 'email' ? 'email' : 'popup',
@ -1406,4 +1409,4 @@ export const deleteCalendarEvent = async (
}
throw new Error(`Provider ${connection.provider} does not support deleting events yet.`);
};
};