Reposition notes popup to floating vertical container and various integration fixes

This commit is contained in:
mARTin 2026-02-24 12:26:02 +01:00
parent 8842123caf
commit 368928c016
14 changed files with 410 additions and 192 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.4.0", "version": "1.5.0",
"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": {

View File

@ -39,7 +39,7 @@ export async function POST(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, title, description, start, end, location, allDay } = body; const { calendarId, title, description, start, end, location, allDay, recurrence, url } = body;
console.log('[API] Creating event:', { calendarId, title, start, end }); console.log('[API] Creating event:', { calendarId, title, start, end });
@ -60,8 +60,10 @@ export async function POST(request: NextRequest) {
start, start,
end, end,
location, location,
allDay: !!allDay allDay: !!allDay,
}); recurrence,
url,
} as any);
// Update cache // Update cache
upsertCachedEvent(userId, connection.id, connection.provider, event) upsertCachedEvent(userId, connection.id, connection.provider, event)
@ -81,7 +83,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 } = body; const { calendarId, eventId, title, description, start, end, location, allDay, recurrence, url } = body;
console.log('[API] Updating event:', { calendarId, eventId, title }); console.log('[API] Updating event:', { calendarId, eventId, title });
@ -102,8 +104,10 @@ export async function PATCH(request: NextRequest) {
start, start,
end, end,
location, location,
allDay: allDay !== undefined ? !!allDay : undefined allDay: allDay !== undefined ? !!allDay : undefined,
}); recurrence,
url,
} as any);
// Update cache // Update cache
upsertCachedEvent(userId, connection.id, connection.provider, event) upsertCachedEvent(userId, connection.id, connection.provider, event)

View File

@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { createGoogleClient, updateGoogleTaskStatus, updateGoogleTask, deleteGoogleTask, fetchGoogleTasksForSync, GoogleTask } from '@/lib/google-tasks'; import { createGoogleClient, createGoogleTask, updateGoogleTaskStatus, updateGoogleTask, deleteGoogleTask, fetchGoogleTasksForSync, GoogleTask } from '@/lib/google-tasks';
import { fetchMsTodoTasksForSync, updateMsTodoTask, deleteMsTodoTask, createMsTodoTask, isMsTodoTaskCompleted } from '@/lib/microsoft-todo'; import { fetchMsTodoTasksForSync, updateMsTodoTask, deleteMsTodoTask, createMsTodoTask, isMsTodoTaskCompleted } from '@/lib/microsoft-todo';
import { getOutlookAccessToken } from '@/lib/outlook-token'; import { getOutlookAccessToken } from '@/lib/outlook-token';
@ -492,6 +492,35 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: true, task: updatedTask }); return NextResponse.json({ success: true, task: updatedTask });
} }
if (provider === 'google') {
const account = await prisma.account.findFirst({
where: { userId: task.userId, provider: 'google' }
});
if (!account?.access_token) {
return NextResponse.json({ error: 'Google token not available' }, { status: 400 });
}
const client = createGoogleClient(account.access_token, account.refresh_token || undefined);
const created = await createGoogleTask(client, listExternalId, {
title: task.title,
notes: task.description || undefined,
due: task.scheduledDate ? task.scheduledDate.toISOString() : undefined,
});
const updatedTask = await prisma.task.update({
where: { id: taskId },
data: {
externalId: created.id,
externalProvider: 'google',
externalListId: listExternalId,
lastSyncedAt: new Date(),
}
});
return NextResponse.json({ success: true, task: updatedTask });
}
return NextResponse.json({ error: `Provider "${provider}" creation sync not supported yet` }, { status: 400 }); return NextResponse.json({ error: `Provider "${provider}" creation sync not supported yet` }, { status: 400 });
} catch (error: unknown) { } catch (error: unknown) {

View File

@ -982,6 +982,27 @@ h3 {
flex-wrap: nowrap; flex-wrap: nowrap;
} }
/* Time-grid tasks: show actions below task text as a dropdown toolbar */
.time-slot-task > .task-actions {
position: absolute;
top: auto;
bottom: auto;
left: 0;
right: auto;
margin-top: 0;
background: var(--weekly-bg, white);
border: 1px solid var(--weekly-border, #e0e0e0);
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
padding: 2px 4px;
z-index: 20;
}
.dark .time-slot-task > .task-actions {
background: #2a2a2a;
border-color: #404040;
}
.task-action-btn { .task-action-btn {
background: none; background: none;
border: none; border: none;

View File

@ -32,6 +32,8 @@ export default function CalendarEventModal({
const [title, setTitle] = useState(event?.title || ''); const [title, setTitle] = useState(event?.title || '');
const [description, setDescription] = useState(event?.description || ''); const [description, setDescription] = useState(event?.description || '');
const [location, setLocation] = useState(event?.location || ''); const [location, setLocation] = useState(event?.location || '');
const [url, setUrl] = useState(event?.url || '');
const [recurrence, setRecurrence] = useState(event?.recurrence || 'none');
const [calendarId, setCalendarId] = useState(event?.calendarId || (availableCalendars.length > 0 ? availableCalendars[0].id : '')); const [calendarId, setCalendarId] = useState(event?.calendarId || (availableCalendars.length > 0 ? availableCalendars[0].id : ''));
// Date/Time State // Date/Time State
@ -90,6 +92,8 @@ export default function CalendarEventModal({
title, title,
description, description,
location, location,
url: url || undefined,
recurrence: recurrence !== 'none' ? recurrence : undefined,
calendarId, calendarId,
allDay, allDay,
start: { dateTime: startDate.toISOString() }, start: { dateTime: startDate.toISOString() },
@ -218,6 +222,23 @@ export default function CalendarEventModal({
</div> </div>
</div> </div>
{/* Recurrence */}
<div>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>Repeat</label>
<select
value={recurrence}
onChange={e => setRecurrence(e.target.value)}
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
>
<option value="none">Never</option>
<option value="daily">Every Day</option>
<option value="weekly">Every Week</option>
<option value="biweekly">Every 2 Weeks</option>
<option value="monthly">Every Month</option>
<option value="yearly">Every Year</option>
</select>
</div>
{/* Location */} {/* Location */}
<div> <div>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>Location</label> <label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>Location</label>
@ -230,9 +251,21 @@ export default function CalendarEventModal({
/> />
</div> </div>
{/* URL */}
<div>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>URL</label>
<input
type="url"
value={url}
onChange={e => setUrl(e.target.value)}
placeholder="https://..."
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
</div>
{/* Description */} {/* Description */}
<div> <div>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>Description</label> <label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>Notes</label>
<textarea <textarea
value={description} value={description}
onChange={e => setDescription(e.target.value)} onChange={e => setDescription(e.target.value)}

View File

@ -269,74 +269,72 @@ export function GridTaskBlock({
</span> </span>
)} )}
{(true) && ( </div>
<div
className="task-actions" {/* Task Actions - shown below text on hover */}
style={{ <div
display: "flex", className="task-actions"
alignItems: "center", onClick={(e) => e.stopPropagation()}
gap: "2px", >
marginLeft: "4px", <button
flexShrink: 0, className={`task-action-btn ${task.completed ? "active text-green-600 dark:text-green-500" : ""}`}
flexWrap: "nowrap", onClick={(e) => { e.stopPropagation(); toggleTask(task.id); }}
background: darkMode ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.8)", title={task.completed ? "Mark incomplete" : "Mark complete"}
padding: "1px 3px", >
borderRadius: "4px", <svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="3" fill="none" strokeLinecap="round" strokeLinejoin="round">
boxShadow: "0 1px 3px rgba(0,0,0,0.1)", <line x1="5" y1="12" x2="19" y2="12" />
}} </svg>
</button>
<button
className="task-action-btn"
onClick={(e) => { e.stopPropagation(); setEditingTaskId(task.id); }}
title="Edit"
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button className={`task-action-btn ${isSubTaskInputOpen ? "active" : ""}`} onClick={(e) => { e.stopPropagation(); setIsSubTaskInputOpen(!isSubTaskInputOpen); if (!isSubTaskInputOpen) { setIsSubTasksOpen(true); setTimeout(() => subTaskInputRef.current?.focus(), 50); } }} title="Add sub-task">
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
<button className={`task-action-btn ${isNotesOpen ? "active" : ""}`} onClick={(e) => { e.stopPropagation(); setIsNotesOpen(!isNotesOpen); }} title="Notes">
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
{!task.completed && (
<button
className={`task-action-btn ${task.isRolling ? "active" : ""}`}
onClick={(e) => { e.stopPropagation(); toggleTaskRolling(task.id); }}
title={task.isRolling ? "Disable rolling" : "Enable rolling"}
> >
<button <svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
className={`task-action-btn ${task.completed ? "active text-green-600 dark:text-green-500" : ""}`} <polyline points="23 4 23 10 17 10" />
onClick={(e) => { e.stopPropagation(); toggleTask(task.id); }} <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
title={task.completed ? "Mark incomplete" : "Mark complete"} </svg>
> </button>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="3" fill="none" strokeLinecap="round" strokeLinejoin="round">
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
<button
className="task-action-btn"
onClick={(e) => { e.stopPropagation(); setEditingTaskId(task.id); }}
title="Edit"
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button className={`task-action-btn ${isSubTaskInputOpen ? "active" : ""}`} onClick={(e) => { e.stopPropagation(); setIsSubTaskInputOpen(!isSubTaskInputOpen); if (!isSubTaskInputOpen) { setIsSubTasksOpen(true); setTimeout(() => subTaskInputRef.current?.focus(), 50); } }} title="Add sub-task">
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
<button className={`task-action-btn ${isNotesOpen ? "active" : ""}`} onClick={(e) => { e.stopPropagation(); setIsNotesOpen(!isNotesOpen); }} title="Notes">
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
{!task.completed && (
<button
className={`task-action-btn ${task.isRolling ? "active" : ""}`}
onClick={(e) => { e.stopPropagation(); toggleTaskRolling(task.id); }}
title={task.isRolling ? "Disable rolling" : "Enable rolling"}
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
<polyline points="23 4 23 10 17 10" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
</button>
)}
<button className="task-action-btn delete text-red-500 hover:text-red-700 hover:bg-red-100/50 dark:hover:bg-red-900/30 rounded" onClick={(e) => { e.stopPropagation(); deleteTask(task.id); }} title="Delete">
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
)} )}
{!task.completed && (
<button
className={`task-action-btn ${task.isRecurring ? "active" : ""}`}
onClick={(e) => { e.stopPropagation(); setSelectedTaskForRecurrence(task); }}
title={task.isRecurring ? "Edit recurrence" : "Make recurring"}
>
<Repeat size={12} />
</button>
)}
<button className="task-action-btn delete text-red-500 hover:text-red-700 hover:bg-red-100/50 dark:hover:bg-red-900/30 rounded" onClick={(e) => { e.stopPropagation(); deleteTask(task.id); }} title="Delete">
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div> </div>
{/* Inline Expanders Container */} {/* Inline Expanders Container */}

View File

@ -42,6 +42,7 @@ import {
Trash2, Trash2,
Undo2, Undo2,
Redo2, Redo2,
AlertCircle,
} from "lucide-react"; } from "lucide-react";
// Types // Types
@ -96,7 +97,7 @@ interface CalendarEvent {
title: string; title: string;
startTime: string; startTime: string;
endTime: string; endTime: string;
source: "google" | "apple"; source: "google" | "apple" | "outlook";
calendarId?: string; calendarId?: string;
calendarTitle?: string; calendarTitle?: string;
calendarColor?: string; calendarColor?: string;
@ -487,6 +488,7 @@ export default function WeeklyView() {
const [viewDays, setViewDays] = useState(7); const [viewDays, setViewDays] = useState(7);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
const [syncError, setSyncError] = useState<string | null>(null);
const syncCountRef = useRef(0); const syncCountRef = useRef(0);
const startSync = useCallback(() => { syncCountRef.current++; setIsSyncing(true); }, []); const startSync = useCallback(() => { syncCountRef.current++; setIsSyncing(true); }, []);
const endSync = useCallback(() => { syncCountRef.current = Math.max(0, syncCountRef.current - 1); if (syncCountRef.current === 0) setIsSyncing(false); }, []); const endSync = useCallback(() => { syncCountRef.current = Math.max(0, syncCountRef.current - 1); if (syncCountRef.current === 0) setIsSyncing(false); }, []);
@ -934,13 +936,23 @@ export default function WeeklyView() {
// Optimistically add/update from API response, then force refresh cache // Optimistically add/update from API response, then force refresh cache
const data = await res.json(); const data = await res.json();
if (data.event) { if (data.event) {
// Transform API shape (start.dateTime/end.dateTime) to frontend shape (startTime/endTime)
const ev = data.event;
const frontendEvent: CalendarEvent = {
id: ev.id,
title: ev.title,
startTime: ev.start?.dateTime || ev.start?.date || ev.startTime || '',
endTime: ev.end?.dateTime || ev.end?.date || ev.endTime || '',
source: ev.source,
calendarId: ev.calendarId,
calendarTitle: ev.calendarTitle,
calendarColor: ev.backgroundColor || ev.calendarColor,
};
setRawCalendarEvents(prev => { setRawCalendarEvents(prev => {
if (eventData.id) { if (eventData.id) {
// Update existing return prev.map(e => e.id === eventData.id ? frontendEvent : e);
return prev.map(e => e.id === eventData.id ? data.event : e);
} }
// Add new return [...prev, frontendEvent];
return [...prev, data.event];
}); });
} }
// Also force-refresh from provider to ensure full sync // Also force-refresh from provider to ensure full sync
@ -1072,7 +1084,9 @@ export default function WeeklyView() {
} }
} }
} catch (e) { } catch (e) {
// Silent fail for background sync console.error("[SYNC] Task sync error:", e);
setSyncError("Task sync failed");
setTimeout(() => setSyncError(null), 10000);
} }
}, },
2 * 60 * 1000, 2 * 60 * 1000,
@ -1108,7 +1122,9 @@ export default function WeeklyView() {
} }
} }
} catch (e) { } catch (e) {
// Silent fail for background sync console.error("[SYNC] Calendar sync error:", e);
setSyncError("Calendar sync failed");
setTimeout(() => setSyncError(null), 10000);
} }
}, },
2 * 60 * 1000, 2 * 60 * 1000,
@ -3085,6 +3101,8 @@ export default function WeeklyView() {
} catch (error) { } catch (error) {
console.error("Error syncing:", error); console.error("Error syncing:", error);
setSyncStatus("idle"); setSyncStatus("idle");
setSyncError("Sync failed");
setTimeout(() => setSyncError(null), 10000);
} }
}; };
@ -3489,7 +3507,12 @@ export default function WeeklyView() {
<div className="flex items-center justify-center gap-6 absolute left-1/2 transform -translate-x-1/2 group"> <div className="flex items-center justify-center gap-6 absolute left-1/2 transform -translate-x-1/2 group">
{/* Week & Year */} {/* Week & Year */}
<div className="whitespace-nowrap flex items-center gap-2"> <div className="whitespace-nowrap flex items-center gap-2">
{(isLoading || isSyncing || syncStatus === "syncing") ? ( {syncError ? (
<div className="flex items-center gap-1 text-red-500" title={syncError}>
<AlertCircle size={14} />
<span className="text-xs">{syncError}</span>
</div>
) : (isLoading || isSyncing || syncStatus === "syncing") ? (
<div className="weekly-spinner" title="Syncing..."></div> <div className="weekly-spinner" title="Syncing..."></div>
) : ( ) : (
<button <button
@ -7866,31 +7889,70 @@ function SettingsSidebar({
{conn.calendars && {conn.calendars &&
Array.isArray(conn.calendars) && Array.isArray(conn.calendars) &&
conn.calendars.length > 0 ? ( conn.calendars.length > 0 ? (
<ul <div style={{ paddingLeft: "8px" }}>
style={{ paddingLeft: "24px", listStyle: "none" }} {/* Column Headers */}
> <div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
marginBottom: "6px",
paddingBottom: "4px",
borderBottom: "1px solid var(--weekly-border, #eee)",
}}
>
<span style={{ flex: 1, fontSize: "0.75rem", color: "var(--weekly-text-light)", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.5px" }}>
Calendar
</span>
<span style={{ width: "50px", textAlign: "center", fontSize: "0.75rem", color: "var(--weekly-text-light)", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.5px" }}>
Display
</span>
<span style={{ width: "50px", textAlign: "center", fontSize: "0.75rem", color: "var(--weekly-text-light)", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.5px" }}>
Edit
</span>
</div>
{/* Calendar Rows */}
{conn.calendars.map((cal: any) => { {conn.calendars.map((cal: any) => {
const isShared = /⚠/.test(cal.title); const isShared = /⚠/.test(cal.title);
const cleanTitle = cal.title const cleanTitle = cal.title
.replace(/\s*⚠️?\s*/g, "") .replace(/\s*⚠️?\s*/g, "")
.trim(); .trim();
return ( return (
<li <div
key={cal.id} key={cal.id}
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: "16px", gap: "8px",
marginBottom: "8px", padding: "3px 0",
}} }}
> >
<div {/* Calendar Name */}
<span
style={{ style={{
display: "flex", flex: 1,
alignItems: "center", fontSize: "0.9rem",
gap: "8px", color: "var(--weekly-text)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}} }}
> >
{cleanTitle}
{isShared && (
<span title="Shared calendar" style={{ marginLeft: "4px", fontSize: "0.75rem", opacity: 0.5 }}>
🔗
</span>
)}
{cal.isPrimary && (
<span style={{ fontSize: "0.8em", color: "var(--weekly-text-light)", marginLeft: "4px" }}>
(Primary)
</span>
)}
</span>
{/* Display checkbox */}
<span style={{ width: "50px", textAlign: "center" }}>
<input <input
type="checkbox" type="checkbox"
checked={cal.selected !== false} checked={cal.selected !== false}
@ -7901,51 +7963,10 @@ function SettingsSidebar({
} }
style={{ cursor: "pointer" }} style={{ cursor: "pointer" }}
/> />
<span </span>
style={{
fontSize: "0.9rem",
color: "var(--weekly-text)",
}}
>
{cleanTitle}
<span style={{ fontSize: '0.85em', color: 'var(--weekly-text-light)', marginLeft: '4px' }}>
({conn.provider === 'google' ? 'Google Calendar' : conn.provider === 'apple' ? 'Apple Calendar' : 'Outlook Calendar'})
</span>
{isShared && (
<span
title="Shared calendar"
style={{
marginLeft: "5px",
fontSize: "0.75rem",
opacity: 0.5,
}}
>
🔗
</span>
)}
{cal.isPrimary && (
<span
style={{
fontSize: "0.8em",
color: "var(--weekly-text-light)",
marginLeft: "4px",
}}
>
(Primary)
</span>
)}
</span>
</div>
{/* Editable toggle */} {/* Edit checkbox */}
<div <span style={{ width: "50px", textAlign: "center" }}>
style={{
display: "flex",
alignItems: "center",
gap: "4px",
opacity: 0.8,
}}
>
<input <input
type="checkbox" type="checkbox"
checked={cal.editable === true} checked={cal.editable === true}
@ -7954,27 +7975,14 @@ function SettingsSidebar({
editable: e.target.checked, editable: e.target.checked,
}) })
} }
style={{ style={{ cursor: "pointer" }}
cursor: "pointer",
width: "12px",
height: "12px",
}}
title="Allow adding/editing events" title="Allow adding/editing events"
/> />
<span </span>
style={{ </div>
fontSize: "0.8rem",
color: "#888",
}}
title="Allow adding/editing events"
>
Editable
</span>
</div>
</li>
); );
})} })}
</ul> </div>
) : ( ) : (
<div <div
style={{ style={{
@ -8095,16 +8103,29 @@ function SettingsSidebar({
)} )}
</div> </div>
<ul <div
style={{ style={{
listStyle: "none",
padding: 0,
margin: 0,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: "4px", gap: "4px",
}} }}
> >
{/* Column header */}
{providerLists.length > 0 && (
<div style={{
display: "flex",
alignItems: "center",
padding: "2px 8px",
fontSize: "0.75rem",
fontWeight: 600,
color: "var(--weekly-text-light, #888)",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}>
<span style={{ flex: 1 }}>List</span>
<span style={{ width: "50px", textAlign: "center" }}>Sync</span>
</div>
)}
{providerLists.map((list: { id: string; title: string }) => { {providerLists.map((list: { id: string; title: string }) => {
const isSynced = somedayLists.some( const isSynced = somedayLists.some(
(sl: SomedayList) => (sl: SomedayList) =>
@ -8112,34 +8133,35 @@ function SettingsSidebar({
sl.externalProvider === conn.provider, sl.externalProvider === conn.provider,
); );
return ( return (
<li <div
key={list.id} key={list.id}
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: "10px",
padding: "4px 8px", padding: "4px 8px",
borderRadius: "4px", borderRadius: "4px",
background: "rgba(0,0,0,0.02)", background: "rgba(0,0,0,0.02)",
}} }}
> >
<input <span style={{ flex: 1, fontSize: "0.9rem" }}>
type="checkbox"
checked={isSynced}
onChange={() =>
handleToggleTaskList(
conn.provider as
| "google"
| "outlook",
list,
)
}
disabled={importingTasksState}
/>
<span style={{ fontSize: "0.9rem" }}>
{list.title} {list.title}
</span> </span>
</li> <span style={{ width: "50px", textAlign: "center" }}>
<input
type="checkbox"
checked={isSynced}
onChange={() =>
handleToggleTaskList(
conn.provider as
| "google"
| "outlook",
list,
)
}
disabled={importingTasksState}
/>
</span>
</div>
); );
})} })}
{!isFetching && providerLists.length === 0 && ( {!isFetching && providerLists.length === 0 && (
@ -8153,7 +8175,7 @@ function SettingsSidebar({
No task lists found. No task lists found.
</div> </div>
)} )}
</ul> </div>
</div> </div>
); );
})} })}

View File

@ -244,6 +244,8 @@ export const createEvent = async (
title: string; title: string;
description?: string; description?: string;
location?: string; location?: string;
url?: string;
recurrence?: string;
start: { dateTime?: string; date?: string }; start: { dateTime?: string; date?: string };
end: { dateTime?: string; date?: string }; end: { dateTime?: string; date?: string };
} }
@ -293,6 +295,20 @@ export const createEvent = async (
const description = eventData.description ? `DESCRIPTION:${eventData.description.replace(/\n/g, '\\n')}\r\n` : ''; 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 location = eventData.location ? `LOCATION:${eventData.location.replace(/,/g, '\\,')}\r\n` : '';
const url = eventData.url ? `URL:${eventData.url}\r\n` : '';
let rruleLine = '';
if (eventData.recurrence) {
const rruleMap: Record<string, string> = {
daily: 'RRULE:FREQ=DAILY',
weekly: 'RRULE:FREQ=WEEKLY',
biweekly: 'RRULE:FREQ=WEEKLY;INTERVAL=2',
monthly: 'RRULE:FREQ=MONTHLY',
yearly: 'RRULE:FREQ=YEARLY',
};
if (rruleMap[eventData.recurrence]) {
rruleLine = `${rruleMap[eventData.recurrence]}\r\n`;
}
}
const iCalString = `BEGIN:VCALENDAR const iCalString = `BEGIN:VCALENDAR
VERSION:2.0 VERSION:2.0
@ -303,7 +319,7 @@ DTSTAMP:${dtStamp}
DTSTART${dtStartParam}:${dtStart} DTSTART${dtStartParam}:${dtStart}
DTEND${dtEndParam}:${dtEnd} DTEND${dtEndParam}:${dtEnd}
SUMMARY:${eventData.title} SUMMARY:${eventData.title}
${description}${location}END:VEVENT ${description}${location}${url}${rruleLine}END:VEVENT
END:VCALENDAR`; END:VCALENDAR`;
console.log('[APPLE CALENDAR] Creating event with iCal:', iCalString); console.log('[APPLE CALENDAR] Creating event with iCal:', iCalString);

View File

@ -4,7 +4,7 @@
import { prisma } from './prisma'; import { prisma } from './prisma';
import { getCalendarEvents, CalendarEvent, CalendarConnection } from './calendar-events'; import { getCalendarEvents, CalendarEvent, CalendarConnection } from './calendar-events';
const STALE_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes const STALE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes — match background sync interval
/** /**
* Get the Monday (start of ISO week) for a given date. * Get the Monday (start of ISO week) for a given date.

View File

@ -19,6 +19,8 @@ export interface CalendarEvent {
date?: string; date?: string;
}; };
location?: string; location?: string;
url?: string;
recurrence?: string;
source: 'google' | 'apple' | 'outlook'; source: 'google' | 'apple' | 'outlook';
calendarId: string; calendarId: string;
calendarTitle: string; calendarTitle: string;
@ -26,6 +28,45 @@ export interface CalendarEvent {
allDay?: boolean; allDay?: boolean;
} }
/**
* Convert friendly recurrence name to RRULE string
*/
function toRRule(recurrence?: string): string | null {
switch (recurrence) {
case 'daily': return 'RRULE:FREQ=DAILY';
case 'weekly': return 'RRULE:FREQ=WEEKLY';
case 'biweekly': return 'RRULE:FREQ=WEEKLY;INTERVAL=2';
case 'monthly': return 'RRULE:FREQ=MONTHLY';
case 'yearly': return 'RRULE:FREQ=YEARLY';
default: return null;
}
}
/**
* Convert friendly recurrence name to Outlook Graph recurrence object
*/
function toOutlookRecurrence(recurrence?: string, startDate?: Date): any {
if (!recurrence || recurrence === 'none') return undefined;
const start = startDate || new Date();
const range = {
type: 'noEnd',
startDate: start.toISOString().split('T')[0],
};
switch (recurrence) {
case 'daily':
return { pattern: { type: 'daily', interval: 1 }, range };
case 'weekly':
return { pattern: { type: 'weekly', interval: 1, daysOfWeek: [['sunday','monday','tuesday','wednesday','thursday','friday','saturday'][start.getDay()]] }, range };
case 'biweekly':
return { pattern: { type: 'weekly', interval: 2, daysOfWeek: [['sunday','monday','tuesday','wednesday','thursday','friday','saturday'][start.getDay()]] }, range };
case 'monthly':
return { pattern: { type: 'absoluteMonthly', interval: 1, dayOfMonth: start.getDate() }, range };
case 'yearly':
return { pattern: { type: 'absoluteYearly', interval: 1, dayOfMonth: start.getDate(), month: start.getMonth() + 1 }, range };
default: return undefined;
}
}
// Google Calendar event color mapping (colorId -> hex color) // Google Calendar event color mapping (colorId -> hex color)
const GOOGLE_EVENT_COLORS: Record<string, string> = { const GOOGLE_EVENT_COLORS: Record<string, string> = {
'1': '#7986cb', // Lavender '1': '#7986cb', // Lavender
@ -510,12 +551,15 @@ export const createCalendarEvent = async (
); );
// Map to Google format // Map to Google format
const rrule = toRRule(event.recurrence);
const googleEvent: any = { const googleEvent: any = {
summary: event.title, summary: event.title,
description: event.description, description: event.description,
start: event.start, start: event.start,
end: event.end, end: event.end,
location: event.location, location: event.location,
...(rrule ? { recurrence: [rrule] } : {}),
...(event.url ? { source: { url: event.url, title: event.url } } : {}),
}; };
const createdEvent = await import('./google-calendar').then(m => const createdEvent = await import('./google-calendar').then(m =>
@ -544,13 +588,15 @@ export const createCalendarEvent = async (
else throw new Error('Failed to refresh token'); else throw new Error('Failed to refresh token');
} }
const startDate = event.start?.dateTime ? new Date(event.start.dateTime) : new Date();
const createdEvent = await createOutlookEvent(accessToken, calendarId, { const createdEvent = await createOutlookEvent(accessToken, calendarId, {
summary: event.title, summary: event.title,
description: event.description, description: event.description,
start: event.start, start: event.start,
end: event.end, end: event.end,
location: event.location, location: event.location,
allDay: event.allDay allDay: event.allDay,
recurrence: toOutlookRecurrence(event.recurrence, startDate),
}); });
return { return {
@ -581,6 +627,8 @@ export const createCalendarEvent = async (
title: event.title!, title: event.title!,
description: event.description, description: event.description,
location: event.location, location: event.location,
url: event.url,
recurrence: event.recurrence,
start: event.start!, start: event.start!,
end: event.end! end: event.end!
}) })
@ -627,12 +675,15 @@ export const updateCalendarEvent = async (
); );
// Map to Google format // Map to Google format
const rrule = toRRule(event.recurrence);
const googleEvent: any = {}; const googleEvent: any = {};
if (event.title !== undefined) googleEvent.summary = event.title; if (event.title !== undefined) googleEvent.summary = event.title;
if (event.description !== undefined) googleEvent.description = event.description; if (event.description !== undefined) googleEvent.description = event.description;
if (event.start !== undefined) googleEvent.start = event.start; if (event.start !== undefined) googleEvent.start = event.start;
if (event.end !== undefined) googleEvent.end = event.end; if (event.end !== undefined) googleEvent.end = event.end;
if (event.location !== undefined) googleEvent.location = event.location; if (event.location !== undefined) googleEvent.location = event.location;
if (rrule) googleEvent.recurrence = [rrule];
if (event.url) googleEvent.source = { url: event.url, title: event.url };
const updatedEvent = await import('./google-calendar').then(m => const updatedEvent = await import('./google-calendar').then(m =>
m.updateEvent(oauth2Client, accessToken, calendarId, eventId, googleEvent) m.updateEvent(oauth2Client, accessToken, calendarId, eventId, googleEvent)
@ -657,13 +708,15 @@ export const updateCalendarEvent = async (
else throw new Error('Failed to refresh token'); else throw new Error('Failed to refresh token');
} }
const startDate = event.start?.dateTime ? new Date(event.start.dateTime) : new Date();
const updatedEvent = await updateOutlookEvent(accessToken, calendarId, eventId, { const updatedEvent = await updateOutlookEvent(accessToken, calendarId, eventId, {
summary: event.title, summary: event.title,
description: event.description, description: event.description,
start: event.start, start: event.start,
end: event.end, end: event.end,
location: event.location, location: event.location,
allDay: event.allDay allDay: event.allDay,
recurrence: toOutlookRecurrence(event.recurrence, startDate),
}); });
return { return {

View File

@ -118,15 +118,18 @@ export const createEvent = async (
oauth2Client.setCredentials({ access_token: accessToken }); oauth2Client.setCredentials({ access_token: accessToken });
try { try {
const calendar = google.calendar({ version: 'v3', auth: oauth2Client }); const calendar = google.calendar({ version: 'v3', auth: oauth2Client });
const response = await calendar.events.insert({ const requestBody: any = {
calendarId,
requestBody: {
summary: event.summary, summary: event.summary,
description: event.description, description: event.description,
start: event.start, start: event.start,
end: event.end, end: event.end,
location: event.location, location: event.location,
}, };
if ((event as any).recurrence) requestBody.recurrence = (event as any).recurrence;
if ((event as any).source) requestBody.source = (event as any).source;
const response = await calendar.events.insert({
calendarId,
requestBody,
}); });
return response.data as any; return response.data as any;
} catch (error) { } catch (error) {
@ -148,16 +151,19 @@ export const updateEvent = async (
oauth2Client.setCredentials({ access_token: accessToken }); oauth2Client.setCredentials({ access_token: accessToken });
try { try {
const calendar = google.calendar({ version: 'v3', auth: oauth2Client }); const calendar = google.calendar({ version: 'v3', auth: oauth2Client });
const response = await calendar.events.patch({ const requestBody: any = {
calendarId,
eventId,
requestBody: {
summary: event.summary, summary: event.summary,
description: event.description, description: event.description,
start: event.start, start: event.start,
end: event.end, end: event.end,
location: event.location, location: event.location,
}, };
if ((event as any).recurrence) requestBody.recurrence = (event as any).recurrence;
if ((event as any).source) requestBody.source = (event as any).source;
const response = await calendar.events.patch({
calendarId,
eventId,
requestBody,
}); });
return response.data as any; return response.data as any;
} catch (error) { } catch (error) {

View File

@ -180,6 +180,40 @@ export const fetchGoogleTasksForSync = async (client: OAuth2Client, taskListId:
} }
}; };
/**
* Create a new task in a task list
*/
export const createGoogleTask = async (
client: OAuth2Client,
taskListId: string,
taskData: { title: string; notes?: string; due?: string }
): Promise<GoogleTask> => {
const service = google.tasks({ version: 'v1', auth: client });
try {
const requestBody: any = { title: taskData.title };
if (taskData.notes) requestBody.notes = taskData.notes;
if (taskData.due) requestBody.due = taskData.due;
const response = await service.tasks.insert({
tasklist: taskListId,
requestBody,
});
const item = response.data;
return {
id: item.id!,
title: item.title!,
notes: item.notes || undefined,
status: item.status!,
due: item.due || undefined,
updated: item.updated!,
parent: (item as any).parent || undefined,
};
} catch (error) {
console.error('Error creating Google Task:', error);
throw error;
}
};
export const updateGoogleTaskStatus = async (client: OAuth2Client, taskListId: string, taskId: string, status: 'needsAction' | 'completed'): Promise<GoogleTask> => { export const updateGoogleTaskStatus = async (client: OAuth2Client, taskListId: string, taskId: string, status: 'needsAction' | 'completed'): Promise<GoogleTask> => {
const service = google.tasks({ version: 'v1', auth: client }); const service = google.tasks({ version: 'v1', auth: client });
try { try {

View File

@ -218,7 +218,8 @@ export const createEvent = async (
isAllDay: !!event.allDay, isAllDay: !!event.allDay,
location: { location: {
displayName: event.location || '' displayName: event.location || ''
} },
...(event.recurrence ? { recurrence: event.recurrence } : {}),
}) })
}); });
@ -265,7 +266,8 @@ export const updateEvent = async (
isAllDay: event.allDay !== undefined ? !!event.allDay : undefined, isAllDay: event.allDay !== undefined ? !!event.allDay : undefined,
location: { location: {
displayName: event.location || '' displayName: event.location || ''
} },
...(event.recurrence ? { recurrence: event.recurrence } : {}),
}) })
}); });

File diff suppressed because one or more lines are too long