Fix task creation and other updates

This commit is contained in:
mARTin 2026-02-18 14:36:34 +01:00
parent 46dbf2ac02
commit 192c418223
5 changed files with 475 additions and 14 deletions

View File

@ -8,14 +8,14 @@ SMTP_USERNAME=mail@carrylight.de
SMTP_PASSWORD=QijU8e2A8p3FE8WS8esR SMTP_PASSWORD=QijU8e2A8p3FE8WS8esR
SMTP_SECURE=true SMTP_SECURE=true
NEXTAUTH_URL=https://todo.martin-bierschenk.de # NEXTAUTH_URL=https://todo.martin-bierschenk.de
# NEXTAUTH_URL=http://localhost:3000 NEXTAUTH_URL=http://localhost:3000
# Google OAuth (Replace with your credentials) # Google OAuth (Replace with your credentials)
GOOGLE_CLIENT_ID=196368743757-1fn17q2ecg5n8rej4tu96khltno173he.apps.googleusercontent.com GOOGLE_CLIENT_ID=196368743757-1fn17q2ecg5n8rej4tu96khltno173he.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=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 GOOGLE_CLIENT_SECRET=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
GOOGLE_REDIRECT_URI=https://todo.martin-bierschenk.de/api/calendar/google/oauth # GOOGLE_REDIRECT_URI=https://todo.martin-bierschenk.de/api/calendar/google/oauth
# GOOGLE_REDIRECT_URI=http://localhost:3000/api/calendar/google/oauth GOOGLE_REDIRECT_URI=http://localhost:3000/api/calendar/google/oauth
# Apple Sign In (Replace with your credentials when ready) # Apple Sign In (Replace with your credentials when ready)

View File

@ -1053,7 +1053,10 @@ export default function WeeklyView() {
return tasks return tasks
.filter(task => { .filter(task => {
if (!task.scheduledDate) return false; if (!task.scheduledDate) return false;
const taskDateStr = formatDateToISO(new Date(task.scheduledDate)); // Use string comparison to avoid timezone shifts
const taskDateStr = typeof task.scheduledDate === 'string'
? task.scheduledDate.substring(0, 10)
: formatDateToISO(new Date(task.scheduledDate));
return taskDateStr === dateStr; return taskDateStr === dateStr;
}) })
.sort((a, b) => { .sort((a, b) => {
@ -1072,7 +1075,10 @@ export default function WeeklyView() {
const dateStr = formatDateToISO(date); const dateStr = formatDateToISO(date);
return tasks.filter(task => { return tasks.filter(task => {
if (!task.scheduledDate) return false; if (!task.scheduledDate) return false;
const taskDateStr = formatDateToISO(new Date(task.scheduledDate)); // Use string comparison to avoid timezone shifts
const taskDateStr = typeof task.scheduledDate === 'string'
? task.scheduledDate.substring(0, 10)
: formatDateToISO(new Date(task.scheduledDate));
return taskDateStr === dateStr && task.startTime === slot; return taskDateStr === dateStr && task.startTime === slot;
}); });
}, [tasks]); }, [tasks]);
@ -2529,13 +2535,18 @@ export default function WeeklyView() {
type="text" type="text"
value={newSlotTask} value={newSlotTask}
onChange={(e) => setNewSlotTask(e.target.value)} onChange={(e) => setNewSlotTask(e.target.value)}
onBlur={async () => { onBlur={async (e) => {
// Only save if still active (not already submitted) // Prevent double submission if form was submitted
if (activeSlot && newSlotTask.trim()) { if (activeSlot && newSlotTask.trim()) {
const taskTitle = newSlotTask.trim(); // Delay slightly to let onSubmit fire if that was the cause
setActiveSlot(null); setTimeout(async () => {
setNewSlotTask(''); if (activeSlot && newSlotTask.trim()) {
await addTask(date, taskTitle, slot); const taskTitle = newSlotTask.trim();
setActiveSlot(null);
setNewSlotTask('');
await addTask(date, taskTitle, slot);
}
}, 100);
} else { } else {
setActiveSlot(null); setActiveSlot(null);
setNewSlotTask(''); setNewSlotTask('');
@ -2548,7 +2559,15 @@ export default function WeeklyView() {
} }
}} }}
autoFocus autoFocus
className="slot-input" className="weekly-task-input"
style={{
width: '100%',
height: '100%',
border: 'none',
background: 'transparent',
outline: 'none',
minHeight: '24px'
}}
/> />
</form> </form>
)} )}

View File

@ -221,3 +221,371 @@ export const getUpcomingEvents = async (
return []; return [];
} }
}; };
/**
* Create a new event in the specified calendar
*/
export const createEvent = async (
email: string,
appSpecificPassword: string,
calendarUrl: string,
eventData: {
title: string;
description?: string;
location?: string;
start: { dateTime?: string; date?: string };
end: { dateTime?: string; date?: string };
}
): Promise<AppleCalendarEvent> => {
try {
const client = createClient(email, appSpecificPassword);
await client.login();
const calendars = await client.fetchCalendars();
const targetCalendar = calendars.find(c => c.url === calendarUrl);
if (!targetCalendar) {
throw new Error(`Calendar not found: ${calendarUrl}`);
}
// Generate iCal string
const now = new Date();
const uid = crypto.randomUUID();
// Construct VCALENDAR/VEVENT manually to ensure compatibility
// ical.js is great for parsing but sometimes verbose for creation
// A simple template works well for basic events
const dtStamp = now.toISOString().replace(/[-:.]/g, '').substring(0, 15) + 'Z';
let dtStart = '';
let dtEnd = '';
let dtStartParam = '';
let dtEndParam = '';
if (eventData.start.date) {
// All-day event
dtStart = eventData.start.date.replace(/-/g, '');
dtEnd = eventData.end.date ? eventData.end.date.replace(/-/g, '') : dtStart; // Fallback
dtStartParam = ';VALUE=DATE';
dtEndParam = ';VALUE=DATE';
// For all-day events, end date is exclusive, so if they are same, add 1 day
// But typically UI handles this. Let's assume input is correct.
} else if (eventData.start.dateTime) {
// Timed event
dtStart = new Date(eventData.start.dateTime).toISOString().replace(/[-:.]/g, '').substring(0, 15) + 'Z';
dtEnd = eventData.end.dateTime
? new Date(eventData.end.dateTime).toISOString().replace(/[-:.]/g, '').substring(0, 15) + 'Z'
: dtStart;
}
const description = eventData.description ? `DESCRIPTION:${eventData.description.replace(/\n/g, '\\n')}\r\n` : '';
const location = eventData.location ? `LOCATION:${eventData.location.replace(/,/g, '\\,')}\r\n` : '';
const iCalString = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//My Weekly ToDo List//EN
BEGIN:VEVENT
UID:${uid}
DTSTAMP:${dtStamp}
DTSTART${dtStartParam}:${dtStart}
DTEND${dtEndParam}:${dtEnd}
SUMMARY:${eventData.title}
${description}${location}END:VEVENT
END:VCALENDAR`;
console.log('[APPLE CALENDAR] Creating event with iCal:', iCalString);
const filename = `${uid}.ics`;
await client.createCalendarObject({
calendar: targetCalendar,
filename,
iCalString
});
return {
id: `${uid}-${filename}`, // Composite ID to help with updates later if needed, but usually UID is enough
title: eventData.title,
startDate: eventData.start.dateTime || eventData.start.date || '',
endDate: eventData.end.dateTime || eventData.end.date || '',
description: eventData.description,
location: eventData.location
};
} catch (error) {
console.error('[APPLE CALENDAR] Error creating event:', error);
throw error;
}
};
/**
* Update an existing event
*/
export const updateEvent = async (
email: string,
appSpecificPassword: string,
calendarUrl: string,
eventId: string, // This might be composite or just UID
eventData: {
title?: string;
description?: string;
location?: string;
start?: { dateTime?: string; date?: string };
end?: { dateTime?: string; date?: string };
}
): Promise<AppleCalendarEvent> => {
try {
const client = createClient(email, appSpecificPassword);
await client.login();
const calendars = await client.fetchCalendars();
const targetCalendar = calendars.find(c => c.url === calendarUrl);
if (!targetCalendar) {
throw new Error(`Calendar not found: ${calendarUrl}`);
}
// Parse IDs - implementation specific
// Our getUpcomingEvents returns ID as "UID-filename" or just UID if filename not avail?
// Actually getUpcomingEvents returns `${event.uid}-${occStart}` for recurring
// or `event.uid || eventObj.url` for simple.
// We need the original object URL (filename) to update via DAV.
// If we only have UID, we have to search for it.
// Strategy: Fetch all objects in range (expensive?) or try to find by UID?
// CALDAV allows query by UID.
// Let's assume eventId passed in is the UID for now, or we can extract it.
const uid = eventId.split('-')[0]; // Simple heuristic
// Use calendarQuery to find the object by UID
// Valid PROP query for getetag and calendar-data
// Tsdav doesn't expose a simple "findOneByUID".
// We'll traverse, assuming we can filter.
// Actually, `fetchCalendarObjects` allows filters.
/*
NOTE: tsdav filter support is XML based.
Constructing a filter for UID:
<filter>
<comp-filter name="VCALENDAR">
<comp-filter name="VEVENT">
<prop-filter name="UID">
<text-match collation="i;octet">${uid}</text-match>
</prop-filter>
</comp-filter>
</comp-filter>
</filter>
*/
// Since constructing that XML object via tsdav's types might be complex,
// let's try a simpler approach if possible, or build the object.
// For now, let's assume we can fetch objects and find the match in memory if the range isn't too huge?
// No, better to search.
// Let's try to pass a simpler time range around the event if we knew the time.
// If not, we scan.
// Given we are editing, we usually have the original time.
// But `eventData` only has NEW data. We might need valid old data.
// Let's rely on client logic to pass us enough info?
// Wait, `updateCalendarEvent` in `calendar-events.ts` calls us.
// Let's assume for MVP we fetch objects in a wide range? No.
// Let's use `fetchCalendarObjects` without timerange -> fetches all? Dangerous for large cals.
// Alternative: The `eventId` from our `getUpcomingEvents` was `event.uid` (or derived).
// Let's try to match by UID.
// Workaround: We will use a time range if provided in inputs (unlikely for existing?)
// Actually, we don't have the OLD time in the `updateEvent` signature here easily unless we fetch.
// Let's try to standard approach: Fetch all from now - 1 month to + 1 year?
// Or just valid `calendar-query` with UID filter.
// Since constructing the filter manually is hard in this context without xml-js helpers handy...
// I will try to fetch the object by its URL if the ID *was* the URL.
// In `getUpcomingEvents`, for simple events, we returned `id: event.uid || eventObj.url`.
// If it's a URL (ends in .ics), we can just use it.
let objectUrl = '';
let etag = '';
let existingIcal = '';
if (eventId.endsWith('.ics')) {
// It looks like a filename/url
objectUrl = eventId;
// But we need the full URL or relative?
// tsdav expects `calendarObject.url`.
}
// If we can't easily find it by ID, we might fail.
// Let's assume for this iteration we try to find it.
const allObjects = await client.fetchCalendarObjects({
calendar: targetCalendar,
// No time range = all? limit?
// Let's check if we can filter by UID in filter object
});
// This fetches ALL objects (headers only usually?).
// `fetchCalendarObjects` does report usually.
// Find matching UID
const targetObject = allObjects.find(obj => {
// obj.data contains iCal string if expanded?
// If not expanded, we might need to fetch data.
// By default `fetchCalendarObjects` usually fetches props specified.
if (obj.data) {
return obj.data.includes(`UID:${uid}`);
}
return obj.url.includes(uid); // Fallback assumption
});
if (!targetObject) {
throw new Error('Event not found on server');
}
// Now we have the object.
// Parse existing iCal to preserve other fields
const jcal = ICAL.parse(targetObject.data);
const comp = new ICAL.Component(jcal);
const vevent = comp.getFirstSubcomponent('vevent');
if (!vevent) {
throw new Error('No VEVENT found in calendar object');
}
const event = new ICAL.Event(vevent);
// Update fields
if (eventData.title) event.summary = eventData.title;
if (eventData.description) event.description = eventData.description;
if (eventData.location) event.location = eventData.location;
if (eventData.start) {
if (eventData.start.date) {
event.startDate = ICAL.Time.fromJSDate(new Date(eventData.start.date), true);
event.startDate.isDate = true;
} else if (eventData.start.dateTime) {
event.startDate = ICAL.Time.fromJSDate(new Date(eventData.start.dateTime), true);
event.startDate.isDate = false;
}
}
if (eventData.end) {
if (eventData.end.date) {
event.endDate = ICAL.Time.fromJSDate(new Date(eventData.end.date), true);
event.endDate.isDate = true;
} else if (eventData.end.dateTime) {
event.endDate = ICAL.Time.fromJSDate(new Date(eventData.end.dateTime), true);
event.endDate.isDate = false;
}
}
// Bump sequence
event.sequence = (event.sequence || 0) + 1;
if (vevent) {
vevent.updatePropertyWithValue('dtstamp', ICAL.Time.now());
}
const updatedIcalString = comp.toString();
console.log('[APPLE CALENDAR] Updating event with iCal:', updatedIcalString);
// tsdav types might be slightly off in the d.ts compared to usage or we need to check the actual signature
// In d.ts: updateCalendarObject(params: { calendarObject: DAVCalendarObject ... }) -> Promise<Response>
// But it doesn't seem to take 'data' in the d.ts signature shown earlier?
// Wait, let's look at d.ts again.
// updateCalendarObject: (params: { calendarObject: ..., headers?: ... })
// It DOES NOT show `data` or `etag` in the params in the d.ts signature shown earlier?
// Let's check line 117 of d.ts:
// updateCalendarObject: (params: { calendarObject: ... })
// This implies the data must be SET on the calendarObject before calling?
// OR the d.ts is incomplete/wrong.
// Let's assume we need to update the object locally then call update?
// Or maybe we use the `davRequest` or `updateObject` lower level if `updateCalendarObject` limits us.
// Actually, `updateObject` takes `url`, `data`, `etag`.
// Let's try using `client.updateObject` directly which is more raw but allows data.
// `targetObject.url` is what we need.
await client.updateObject({
url: targetObject.url,
data: updatedIcalString,
etag: targetObject.etag
} as any); // Cast to any to bypass type definition mismatch
return {
id: eventId,
title: event.summary,
startDate: event.startDate.toString(),
endDate: event.endDate.toString(),
description: event.description,
location: event.location
};
} catch (error) {
console.error('[APPLE CALENDAR] Error updating event:', error);
throw error;
}
};
/**
* Delete an event
*/
export const deleteEvent = async (
email: string,
appSpecificPassword: string,
calendarUrl: string,
eventId: string
): Promise<void> => {
try {
const client = createClient(email, appSpecificPassword);
await client.login();
const calendars = await client.fetchCalendars();
const targetCalendar = calendars.find(c => c.url === calendarUrl);
if (!targetCalendar) {
throw new Error(`Calendar not found: ${calendarUrl}`);
}
const uid = eventId.split('-')[0];
// Find object - similar logic to update
// Optimal: Pass the object URL in the ID in getUpcomingEvents to allow O(1) delete/update
const allObjects = await client.fetchCalendarObjects({
calendar: targetCalendar,
});
const targetObject = allObjects.find(obj => {
if (obj.data) {
return obj.data.includes(`UID:${uid}`);
}
return obj.url.includes(uid);
});
if (!targetObject) {
console.warn('[APPLE CALENDAR] Event to delete not found, maybe already deleted?');
return;
}
// Same issue as update - use deleteObject directly
await client.deleteObject({
url: targetObject.url,
etag: targetObject.etag
} as any); // Cast to any to bypass type definition mismatch
console.log('[APPLE CALENDAR] Event deleted successfully');
} catch (error) {
console.error('[APPLE CALENDAR] Error deleting event:', error);
throw error;
}
};

View File

@ -559,6 +559,49 @@ export const createCalendarEvent = async (
calendarId, calendarId,
calendarTitle: '', calendarTitle: '',
} as CalendarEvent; } as CalendarEvent;
return {
id: createdEvent.id,
title: createdEvent.summary,
description: createdEvent.description,
start: createdEvent.start,
end: createdEvent.end,
location: createdEvent.location,
source: 'outlook',
calendarId,
calendarTitle: '',
} as CalendarEvent;
} else if (connection.provider === 'apple') {
const [email, appPassword] = connection.accessToken.split(':');
// We need to map our event format to what createEvent expects
// createEvent expects: { title, description?, location?, start: {dateTime?, date?}, end: ... }
// 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');
const createdEvent = await import('./apple-calendar').then(m =>
m.createEvent(email, appPassword, calendarId, {
title: event.title!,
description: event.description,
location: event.location,
start: event.start!,
end: event.end!
})
);
return {
id: createdEvent.id,
title: createdEvent.title,
description: createdEvent.description,
start: { dateTime: createdEvent.startDate }, // Mapping back might need adjustment if date-only
end: { dateTime: createdEvent.endDate },
location: createdEvent.location,
source: 'apple',
calendarId,
calendarTitle: '', // Fetch if needed
} as CalendarEvent;
} }
throw new Error(`Provider ${connection.provider} does not support creating events yet.`); throw new Error(`Provider ${connection.provider} does not support creating events yet.`);
@ -638,6 +681,30 @@ export const updateCalendarEvent = async (
calendarId, calendarId,
calendarTitle: '', calendarTitle: '',
} as CalendarEvent; } as CalendarEvent;
} else if (connection.provider === 'apple') {
const [email, appPassword] = connection.accessToken.split(':');
const updatedEvent = await import('./apple-calendar').then(m =>
m.updateEvent(email, appPassword, calendarId, eventId, {
title: event.title,
description: event.description,
location: event.location,
start: event.start,
end: event.end
})
);
return {
id: updatedEvent.id,
title: updatedEvent.title,
description: updatedEvent.description,
start: { dateTime: updatedEvent.startDate },
end: { dateTime: updatedEvent.endDate },
location: updatedEvent.location,
source: 'apple',
calendarId,
calendarTitle: '',
} as CalendarEvent;
} }
throw new Error(`Provider ${connection.provider} does not support updating events yet.`); throw new Error(`Provider ${connection.provider} does not support updating events yet.`);
@ -680,6 +747,13 @@ export const deleteCalendarEvent = async (
await deleteOutlookEvent(accessToken, calendarId, eventId); await deleteOutlookEvent(accessToken, calendarId, eventId);
return; return;
} else if (connection.provider === 'apple') {
const [email, appPassword] = connection.accessToken.split(':');
await import('./apple-calendar').then(m =>
m.deleteEvent(email, appPassword, calendarId, eventId)
);
return;
} }
throw new Error(`Provider ${connection.provider} does not support deleting events yet.`); throw new Error(`Provider ${connection.provider} does not support deleting events yet.`);

File diff suppressed because one or more lines are too long