feat: assign events to calendars
This commit is contained in:
parent
f5feffb4c0
commit
24a8185587
10
CHANGELOG.md
10
CHANGELOG.md
@ -2,6 +2,16 @@
|
|||||||
|
|
||||||
All notable changes to this project are documented in this file.
|
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
|
## [1.116.2] - 2026-09-05
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.116.2",
|
"version": "1.116.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "my-weekly-todo-list",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.116.2",
|
"version": "1.116.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"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",
|
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -109,7 +109,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const body = await request.json();
|
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;
|
reminders, busyStatus, visibility, attendees, attachments, editMode, recurringEventId } = body;
|
||||||
|
|
||||||
console.log('[API] Updating event:', { calendarId, eventId, title, editMode });
|
console.log('[API] Updating event:', { calendarId, eventId, title, editMode });
|
||||||
@ -128,6 +128,14 @@ export async function PATCH(request: NextRequest) {
|
|||||||
const userId = dbUser2?.id;
|
const userId = dbUser2?.id;
|
||||||
if (!userId) return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
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
|
// For "all events" edit mode on recurring events, use the series master ID
|
||||||
let targetEventId = eventId;
|
let targetEventId = eventId;
|
||||||
if (editMode === 'all' && recurringEventId) {
|
if (editMode === 'all' && recurringEventId) {
|
||||||
@ -139,25 +147,35 @@ export async function PATCH(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = await updateCalendarEvent(connection, calendarId, targetEventId, {
|
const eventData = {
|
||||||
title,
|
title, description, start, end, location,
|
||||||
description,
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
location,
|
|
||||||
allDay: allDay !== undefined ? !!allDay : undefined,
|
allDay: allDay !== undefined ? !!allDay : undefined,
|
||||||
recurrence,
|
recurrence, recurrenceEndDate, recurrenceCount, recurrenceInterval, recurrenceDays, timezone,
|
||||||
recurrenceEndDate,
|
url, reminders, busyStatus, visibility, attendees, attachments,
|
||||||
recurrenceCount,
|
} as any;
|
||||||
recurrenceInterval,
|
|
||||||
recurrenceDays,
|
// Providers do not share a portable move operation: create in the target,
|
||||||
url,
|
// then remove the original event from its source calendar.
|
||||||
reminders,
|
if (sourceCalendarId !== calendarId) {
|
||||||
busyStatus,
|
if (editMode && editMode !== 'all') {
|
||||||
visibility,
|
return NextResponse.json({ error: 'Recurring event instances must be moved as a whole series' }, { status: 400 });
|
||||||
attendees,
|
}
|
||||||
attachments,
|
const movedEvent = await createCalendarEvent(connection, calendarId, eventData);
|
||||||
} as any);
|
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
|
// Attach calendar color from connection metadata for cache + frontend
|
||||||
if (!event.backgroundColor && connection.calendars) {
|
if (!event.backgroundColor && connection.calendars) {
|
||||||
|
|||||||
@ -364,6 +364,7 @@ export default function CalendarEventModal({
|
|||||||
recurrenceEndDate: recurrence !== 'none' && recurrenceEndType === 'date' && recurrenceEndDateValue ? recurrenceEndDateValue : undefined,
|
recurrenceEndDate: recurrence !== 'none' && recurrenceEndType === 'date' && recurrenceEndDateValue ? recurrenceEndDateValue : undefined,
|
||||||
recurrenceCount: recurrence !== 'none' && recurrenceEndType === 'count' && recurrenceCount > 0 ? recurrenceCount : undefined,
|
recurrenceCount: recurrence !== 'none' && recurrenceEndType === 'count' && recurrenceCount > 0 ? recurrenceCount : undefined,
|
||||||
calendarId,
|
calendarId,
|
||||||
|
...(event?.calendarId ? { originalCalendarId: event.calendarId } : {}),
|
||||||
allDay,
|
allDay,
|
||||||
start: { dateTime: startDate.toISOString() },
|
start: { dateTime: startDate.toISOString() },
|
||||||
end: { dateTime: endDate.toISOString() },
|
end: { dateTime: endDate.toISOString() },
|
||||||
@ -601,22 +602,22 @@ export default function CalendarEventModal({
|
|||||||
<div ref={calendarSelectorRef} style={{ ...fieldCol, position: 'relative' }}>
|
<div ref={calendarSelectorRef} style={{ ...fieldCol, position: 'relative' }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => !event && setIsCalendarSelectorOpen(!isCalendarSelectorOpen)}
|
onClick={() => availableCalendars.length > 1 && setIsCalendarSelectorOpen(!isCalendarSelectorOpen)}
|
||||||
disabled={!!event}
|
disabled={availableCalendars.length <= 1}
|
||||||
aria-label={language === 'de' ? 'Kalender auswählen' : 'Select calendar'}
|
aria-label={language === 'de' ? 'Kalender auswählen' : 'Select calendar'}
|
||||||
aria-expanded={isCalendarSelectorOpen}
|
aria-expanded={isCalendarSelectorOpen}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: '4px',
|
display: 'flex', alignItems: 'center', gap: '4px',
|
||||||
background: 'transparent', border: 'none', fontWeight: 500,
|
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',
|
color: 'var(--weekly-text)', fontSize: '0.8rem', padding: '2px 0',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{selectedCal?.summary || selectedCal?.title || (language === 'de' ? 'Kalender wählen' : 'Select Calendar')}
|
{selectedCal?.summary || selectedCal?.title || (language === 'de' ? 'Kalender wählen' : 'Select Calendar')}
|
||||||
</span>
|
</span>
|
||||||
{!event && <ChevronDown size={12} style={{ opacity: 0.5 }} />}
|
{availableCalendars.length > 1 && <ChevronDown size={12} style={{ opacity: 0.5 }} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isCalendarSelectorOpen && (
|
{isCalendarSelectorOpen && (
|
||||||
|
|||||||
@ -1838,7 +1838,7 @@ export default function WeeklyView() {
|
|||||||
source: ev.source || calInfo?.provider || 'google',
|
source: ev.source || calInfo?.provider || 'google',
|
||||||
calendarId: ev.calendarId || eventData.calendarId,
|
calendarId: ev.calendarId || eventData.calendarId,
|
||||||
calendarTitle: ev.calendarTitle || calInfo?.summary || calInfo?.title || '',
|
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 => {
|
setRawCalendarEvents(prev => {
|
||||||
if (eventData.id) {
|
if (eventData.id) {
|
||||||
@ -1860,13 +1860,17 @@ export default function WeeklyView() {
|
|||||||
source: ev.source || calInfo?.provider || 'google',
|
source: ev.source || calInfo?.provider || 'google',
|
||||||
calendarId: ev.calendarId || eventData.calendarId,
|
calendarId: ev.calendarId || eventData.calendarId,
|
||||||
calendarTitle: ev.calendarTitle || calInfo?.summary || calInfo?.title || '',
|
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));
|
setRawCalendarEvents(prev => prev.map(e => e.id === eventData.id ? frontendEvent : e));
|
||||||
}
|
}
|
||||||
// Force refresh only the affected provider
|
// Force refresh only the affected provider
|
||||||
const connId = getConnectionIdForCalendar(eventData.calendarId);
|
const targetConnId = getConnectionIdForCalendar(eventData.calendarId);
|
||||||
await fetchCalendarEvents(true, connId);
|
const sourceConnId = getConnectionIdForCalendar(eventData.originalCalendarId);
|
||||||
|
await fetchCalendarEvents(true, targetConnId);
|
||||||
|
if (sourceConnId && sourceConnId !== targetConnId) {
|
||||||
|
await fetchCalendarEvents(true, sourceConnId);
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Error saving event:", error);
|
console.error("Error saving event:", error);
|
||||||
if (error.name === "AbortError") {
|
if (error.name === "AbortError") {
|
||||||
@ -7803,11 +7807,14 @@ export default function WeeklyView() {
|
|||||||
// Convert hex to rgba for background, or use default
|
// Convert hex to rgba for background, or use default
|
||||||
const eventColor = event.calendarColor || "#009a9a";
|
const eventColor = event.calendarColor || "#009a9a";
|
||||||
const bgColor = eventColor.startsWith("#")
|
const bgColor = eventColor.startsWith("#")
|
||||||
? `${eventColor}20` // Add alpha for transparency
|
? `color-mix(in srgb, ${eventColor} 14%, white)`
|
||||||
: eventColor;
|
: eventColor;
|
||||||
const borderColor = eventColor.startsWith("#")
|
const borderColor = eventColor.startsWith("#")
|
||||||
? eventColor
|
? eventColor
|
||||||
: "var(--weekly-teal)";
|
: "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 overlap = eventOverlapLayout[event.id] || { column: 0, totalColumns: 1 };
|
||||||
const colWidth = 100 / overlap.totalColumns;
|
const colWidth = 100 / overlap.totalColumns;
|
||||||
@ -7831,7 +7838,7 @@ export default function WeeklyView() {
|
|||||||
justifyContent: "flex-start",
|
justifyContent: "flex-start",
|
||||||
backgroundColor: bgColor,
|
backgroundColor: bgColor,
|
||||||
borderLeftColor: borderColor,
|
borderLeftColor: borderColor,
|
||||||
color: borderColor,
|
color: textColor,
|
||||||
cursor: event.editable
|
cursor: event.editable
|
||||||
? (isDragging ? "grabbing" : "grab")
|
? (isDragging ? "grabbing" : "grab")
|
||||||
: "default",
|
: "default",
|
||||||
@ -7965,11 +7972,14 @@ export default function WeeklyView() {
|
|||||||
{getEventsForDate(date).map((event) => {
|
{getEventsForDate(date).map((event) => {
|
||||||
const eventColor = event.calendarColor || "#009a9a";
|
const eventColor = event.calendarColor || "#009a9a";
|
||||||
const bgColor = eventColor.startsWith("#")
|
const bgColor = eventColor.startsWith("#")
|
||||||
? `${eventColor}20`
|
? `color-mix(in srgb, ${eventColor} 14%, white)`
|
||||||
: eventColor;
|
: eventColor;
|
||||||
const borderColor = eventColor.startsWith("#")
|
const borderColor = eventColor.startsWith("#")
|
||||||
? eventColor
|
? eventColor
|
||||||
: "var(--weekly-teal)";
|
: "var(--weekly-teal)";
|
||||||
|
const textColor = eventColor.startsWith("#")
|
||||||
|
? `color-mix(in srgb, ${eventColor} 78%, black)`
|
||||||
|
: borderColor;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -7987,7 +7997,7 @@ export default function WeeklyView() {
|
|||||||
style={{
|
style={{
|
||||||
backgroundColor: bgColor,
|
backgroundColor: bgColor,
|
||||||
borderLeftColor: borderColor,
|
borderLeftColor: borderColor,
|
||||||
color: borderColor,
|
color: textColor,
|
||||||
cursor: event.editable ? "pointer" : "default",
|
cursor: event.editable ? "pointer" : "default",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -351,7 +351,10 @@ export const getCalendarEvents = async (
|
|||||||
const calendarData = calendars.find(c => c.id === calendarId);
|
const calendarData = calendars.find(c => c.id === calendarId);
|
||||||
|
|
||||||
events = events.concat(calendarEvents.map((event: any) => {
|
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
|
// Map Google reminders to our format
|
||||||
const reminders: EventReminder[] | undefined = event.reminders?.overrides?.map((r: any) => ({
|
const reminders: EventReminder[] | undefined = event.reminders?.overrides?.map((r: any) => ({
|
||||||
method: r.method === 'email' ? 'email' : 'popup',
|
method: r.method === 'email' ? 'email' : 'popup',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user