Reposition notes popup to floating vertical container and various integration fixes
This commit is contained in:
parent
8842123caf
commit
368928c016
@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@ -39,7 +39,7 @@ export async function POST(request: NextRequest) {
|
||||
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
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 });
|
||||
|
||||
@ -60,8 +60,10 @@ export async function POST(request: NextRequest) {
|
||||
start,
|
||||
end,
|
||||
location,
|
||||
allDay: !!allDay
|
||||
});
|
||||
allDay: !!allDay,
|
||||
recurrence,
|
||||
url,
|
||||
} as any);
|
||||
|
||||
// Update cache
|
||||
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 });
|
||||
|
||||
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 });
|
||||
|
||||
@ -102,8 +104,10 @@ export async function PATCH(request: NextRequest) {
|
||||
start,
|
||||
end,
|
||||
location,
|
||||
allDay: allDay !== undefined ? !!allDay : undefined
|
||||
});
|
||||
allDay: allDay !== undefined ? !!allDay : undefined,
|
||||
recurrence,
|
||||
url,
|
||||
} as any);
|
||||
|
||||
// Update cache
|
||||
upsertCachedEvent(userId, connection.id, connection.provider, event)
|
||||
|
||||
@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from "@/lib/auth";
|
||||
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 { getOutlookAccessToken } from '@/lib/outlook-token';
|
||||
|
||||
@ -492,6 +492,35 @@ export async function POST(req: NextRequest) {
|
||||
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 });
|
||||
|
||||
} catch (error: unknown) {
|
||||
|
||||
@ -982,6 +982,27 @@ h3 {
|
||||
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 {
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
@ -32,6 +32,8 @@ export default function CalendarEventModal({
|
||||
const [title, setTitle] = useState(event?.title || '');
|
||||
const [description, setDescription] = useState(event?.description || '');
|
||||
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 : ''));
|
||||
|
||||
// Date/Time State
|
||||
@ -90,6 +92,8 @@ export default function CalendarEventModal({
|
||||
title,
|
||||
description,
|
||||
location,
|
||||
url: url || undefined,
|
||||
recurrence: recurrence !== 'none' ? recurrence : undefined,
|
||||
calendarId,
|
||||
allDay,
|
||||
start: { dateTime: startDate.toISOString() },
|
||||
@ -218,6 +222,23 @@ export default function CalendarEventModal({
|
||||
</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 */}
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>Location</label>
|
||||
@ -230,9 +251,21 @@ export default function CalendarEventModal({
|
||||
/>
|
||||
</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 */}
|
||||
<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
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
|
||||
@ -269,21 +269,12 @@ export function GridTaskBlock({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{(true) && (
|
||||
</div>
|
||||
|
||||
{/* Task Actions - shown below text on hover */}
|
||||
<div
|
||||
className="task-actions"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "2px",
|
||||
marginLeft: "4px",
|
||||
flexShrink: 0,
|
||||
flexWrap: "nowrap",
|
||||
background: darkMode ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.8)",
|
||||
padding: "1px 3px",
|
||||
borderRadius: "4px",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className={`task-action-btn ${task.completed ? "active text-green-600 dark:text-green-500" : ""}`}
|
||||
@ -329,6 +320,15 @@ export function GridTaskBlock({
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{!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" />
|
||||
@ -336,8 +336,6 @@ export function GridTaskBlock({
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inline Expanders Container */}
|
||||
<div style={{ paddingLeft: "4px", paddingRight: "4px", paddingBottom: "10px", marginTop: "4px" }}>
|
||||
|
||||
@ -42,6 +42,7 @@ import {
|
||||
Trash2,
|
||||
Undo2,
|
||||
Redo2,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
// Types
|
||||
@ -96,7 +97,7 @@ interface CalendarEvent {
|
||||
title: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
source: "google" | "apple";
|
||||
source: "google" | "apple" | "outlook";
|
||||
calendarId?: string;
|
||||
calendarTitle?: string;
|
||||
calendarColor?: string;
|
||||
@ -487,6 +488,7 @@ export default function WeeklyView() {
|
||||
const [viewDays, setViewDays] = useState(7);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [syncError, setSyncError] = useState<string | null>(null);
|
||||
const syncCountRef = useRef(0);
|
||||
const startSync = useCallback(() => { syncCountRef.current++; setIsSyncing(true); }, []);
|
||||
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
|
||||
const data = await res.json();
|
||||
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 => {
|
||||
if (eventData.id) {
|
||||
// Update existing
|
||||
return prev.map(e => e.id === eventData.id ? data.event : e);
|
||||
return prev.map(e => e.id === eventData.id ? frontendEvent : e);
|
||||
}
|
||||
// Add new
|
||||
return [...prev, data.event];
|
||||
return [...prev, frontendEvent];
|
||||
});
|
||||
}
|
||||
// Also force-refresh from provider to ensure full sync
|
||||
@ -1072,7 +1084,9 @@ export default function WeeklyView() {
|
||||
}
|
||||
}
|
||||
} 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,
|
||||
@ -1108,7 +1122,9 @@ export default function WeeklyView() {
|
||||
}
|
||||
}
|
||||
} 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,
|
||||
@ -3085,6 +3101,8 @@ export default function WeeklyView() {
|
||||
} catch (error) {
|
||||
console.error("Error syncing:", error);
|
||||
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">
|
||||
{/* Week & Year */}
|
||||
<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>
|
||||
) : (
|
||||
<button
|
||||
@ -7866,31 +7889,70 @@ function SettingsSidebar({
|
||||
{conn.calendars &&
|
||||
Array.isArray(conn.calendars) &&
|
||||
conn.calendars.length > 0 ? (
|
||||
<ul
|
||||
style={{ paddingLeft: "24px", listStyle: "none" }}
|
||||
<div style={{ paddingLeft: "8px" }}>
|
||||
{/* 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) => {
|
||||
const isShared = /⚠/.test(cal.title);
|
||||
const cleanTitle = cal.title
|
||||
.replace(/\s*⚠️?\s*/g, "")
|
||||
.trim();
|
||||
return (
|
||||
<li
|
||||
<div
|
||||
key={cal.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "16px",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "3px 0",
|
||||
}}
|
||||
>
|
||||
{/* Calendar Name */}
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: "0.9rem",
|
||||
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
|
||||
type="checkbox"
|
||||
checked={cal.selected !== false}
|
||||
@ -7901,51 +7963,10 @@ function SettingsSidebar({
|
||||
}
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
<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 */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
{/* Edit checkbox */}
|
||||
<span style={{ width: "50px", textAlign: "center" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cal.editable === true}
|
||||
@ -7954,27 +7975,14 @@ function SettingsSidebar({
|
||||
editable: e.target.checked,
|
||||
})
|
||||
}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
title="Allow adding/editing events"
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
color: "#888",
|
||||
}}
|
||||
title="Allow adding/editing events"
|
||||
>
|
||||
Editable
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
@ -8095,16 +8103,29 @@ function SettingsSidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul
|
||||
<div
|
||||
style={{
|
||||
listStyle: "none",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
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 }) => {
|
||||
const isSynced = somedayLists.some(
|
||||
(sl: SomedayList) =>
|
||||
@ -8112,17 +8133,20 @@ function SettingsSidebar({
|
||||
sl.externalProvider === conn.provider,
|
||||
);
|
||||
return (
|
||||
<li
|
||||
<div
|
||||
key={list.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
padding: "4px 8px",
|
||||
borderRadius: "4px",
|
||||
background: "rgba(0,0,0,0.02)",
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1, fontSize: "0.9rem" }}>
|
||||
{list.title}
|
||||
</span>
|
||||
<span style={{ width: "50px", textAlign: "center" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSynced}
|
||||
@ -8136,10 +8160,8 @@ function SettingsSidebar({
|
||||
}
|
||||
disabled={importingTasksState}
|
||||
/>
|
||||
<span style={{ fontSize: "0.9rem" }}>
|
||||
{list.title}
|
||||
</span>
|
||||
</li>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!isFetching && providerLists.length === 0 && (
|
||||
@ -8153,7 +8175,7 @@ function SettingsSidebar({
|
||||
No task lists found.
|
||||
</div>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -244,6 +244,8 @@ export const createEvent = async (
|
||||
title: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
url?: string;
|
||||
recurrence?: string;
|
||||
start: { 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 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
|
||||
VERSION:2.0
|
||||
@ -303,7 +319,7 @@ DTSTAMP:${dtStamp}
|
||||
DTSTART${dtStartParam}:${dtStart}
|
||||
DTEND${dtEndParam}:${dtEnd}
|
||||
SUMMARY:${eventData.title}
|
||||
${description}${location}END:VEVENT
|
||||
${description}${location}${url}${rruleLine}END:VEVENT
|
||||
END:VCALENDAR`;
|
||||
|
||||
console.log('[APPLE CALENDAR] Creating event with iCal:', iCalString);
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
import { prisma } from './prisma';
|
||||
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.
|
||||
|
||||
@ -19,6 +19,8 @@ export interface CalendarEvent {
|
||||
date?: string;
|
||||
};
|
||||
location?: string;
|
||||
url?: string;
|
||||
recurrence?: string;
|
||||
source: 'google' | 'apple' | 'outlook';
|
||||
calendarId: string;
|
||||
calendarTitle: string;
|
||||
@ -26,6 +28,45 @@ export interface CalendarEvent {
|
||||
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)
|
||||
const GOOGLE_EVENT_COLORS: Record<string, string> = {
|
||||
'1': '#7986cb', // Lavender
|
||||
@ -510,12 +551,15 @@ export const createCalendarEvent = async (
|
||||
);
|
||||
|
||||
// Map to Google format
|
||||
const rrule = toRRule(event.recurrence);
|
||||
const googleEvent: any = {
|
||||
summary: event.title,
|
||||
description: event.description,
|
||||
start: event.start,
|
||||
end: event.end,
|
||||
location: event.location,
|
||||
...(rrule ? { recurrence: [rrule] } : {}),
|
||||
...(event.url ? { source: { url: event.url, title: event.url } } : {}),
|
||||
};
|
||||
|
||||
const createdEvent = await import('./google-calendar').then(m =>
|
||||
@ -544,13 +588,15 @@ export const createCalendarEvent = async (
|
||||
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, {
|
||||
summary: event.title,
|
||||
description: event.description,
|
||||
start: event.start,
|
||||
end: event.end,
|
||||
location: event.location,
|
||||
allDay: event.allDay
|
||||
allDay: event.allDay,
|
||||
recurrence: toOutlookRecurrence(event.recurrence, startDate),
|
||||
});
|
||||
|
||||
return {
|
||||
@ -581,6 +627,8 @@ export const createCalendarEvent = async (
|
||||
title: event.title!,
|
||||
description: event.description,
|
||||
location: event.location,
|
||||
url: event.url,
|
||||
recurrence: event.recurrence,
|
||||
start: event.start!,
|
||||
end: event.end!
|
||||
})
|
||||
@ -627,12 +675,15 @@ export const updateCalendarEvent = async (
|
||||
);
|
||||
|
||||
// Map to Google format
|
||||
const rrule = toRRule(event.recurrence);
|
||||
const googleEvent: any = {};
|
||||
if (event.title !== undefined) googleEvent.summary = event.title;
|
||||
if (event.description !== undefined) googleEvent.description = event.description;
|
||||
if (event.start !== undefined) googleEvent.start = event.start;
|
||||
if (event.end !== undefined) googleEvent.end = event.end;
|
||||
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 =>
|
||||
m.updateEvent(oauth2Client, accessToken, calendarId, eventId, googleEvent)
|
||||
@ -657,13 +708,15 @@ export const updateCalendarEvent = async (
|
||||
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, {
|
||||
summary: event.title,
|
||||
description: event.description,
|
||||
start: event.start,
|
||||
end: event.end,
|
||||
location: event.location,
|
||||
allDay: event.allDay
|
||||
allDay: event.allDay,
|
||||
recurrence: toOutlookRecurrence(event.recurrence, startDate),
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@ -118,15 +118,18 @@ export const createEvent = async (
|
||||
oauth2Client.setCredentials({ access_token: accessToken });
|
||||
try {
|
||||
const calendar = google.calendar({ version: 'v3', auth: oauth2Client });
|
||||
const response = await calendar.events.insert({
|
||||
calendarId,
|
||||
requestBody: {
|
||||
const requestBody: any = {
|
||||
summary: event.summary,
|
||||
description: event.description,
|
||||
start: event.start,
|
||||
end: event.end,
|
||||
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;
|
||||
} catch (error) {
|
||||
@ -148,16 +151,19 @@ export const updateEvent = async (
|
||||
oauth2Client.setCredentials({ access_token: accessToken });
|
||||
try {
|
||||
const calendar = google.calendar({ version: 'v3', auth: oauth2Client });
|
||||
const response = await calendar.events.patch({
|
||||
calendarId,
|
||||
eventId,
|
||||
requestBody: {
|
||||
const requestBody: any = {
|
||||
summary: event.summary,
|
||||
description: event.description,
|
||||
start: event.start,
|
||||
end: event.end,
|
||||
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;
|
||||
} catch (error) {
|
||||
|
||||
@ -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> => {
|
||||
const service = google.tasks({ version: 'v1', auth: client });
|
||||
try {
|
||||
|
||||
@ -218,7 +218,8 @@ export const createEvent = async (
|
||||
isAllDay: !!event.allDay,
|
||||
location: {
|
||||
displayName: event.location || ''
|
||||
}
|
||||
},
|
||||
...(event.recurrence ? { recurrence: event.recurrence } : {}),
|
||||
})
|
||||
});
|
||||
|
||||
@ -265,7 +266,8 @@ export const updateEvent = async (
|
||||
isAllDay: event.allDay !== undefined ? !!event.allDay : undefined,
|
||||
location: {
|
||||
displayName: event.location || ''
|
||||
}
|
||||
},
|
||||
...(event.recurrence ? { recurrence: event.recurrence } : {}),
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user