feat: improve calendar sync, event modal, and task list management
- Fix All Day checkbox positioning in CalendarEventModal (own row) - Add provider name to calendar dropdown (Google/Apple/Outlook) - Optimistic UI updates after event save/delete (no reload needed) - Force-refresh calendar cache after event mutations - Reduce background sync interval from 5min to 2min - Support forceRefresh in background-sync API - Use shared Prisma singleton in tasks sync route - Add per-provider task list fetching and sync checkboxes - Add allDay support to event creation and editing v1.4.0
This commit is contained in:
parent
210a142f61
commit
8842123caf
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "my-weekly-todo-list",
|
||||
"version": "1.3.1",
|
||||
"version": "1.4.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": {
|
||||
|
||||
@ -12,7 +12,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const { timeMin, timeMax } = body;
|
||||
const { timeMin, timeMax, forceRefresh } = body;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
@ -30,7 +30,7 @@ export async function POST(request: NextRequest) {
|
||||
const staleChecks = await Promise.all(
|
||||
user.calendarConnections.map(async conn => ({
|
||||
conn,
|
||||
stale: await isCacheStale(conn.id, tMin),
|
||||
stale: forceRefresh || await isCacheStale(conn.id, tMin),
|
||||
}))
|
||||
);
|
||||
|
||||
|
||||
@ -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 } = body;
|
||||
const { calendarId, title, description, start, end, location, allDay } = body;
|
||||
|
||||
console.log('[API] Creating event:', { calendarId, title, start, end });
|
||||
|
||||
@ -59,7 +59,8 @@ export async function POST(request: NextRequest) {
|
||||
description,
|
||||
start,
|
||||
end,
|
||||
location
|
||||
location,
|
||||
allDay: !!allDay
|
||||
});
|
||||
|
||||
// Update cache
|
||||
@ -80,7 +81,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 } = body;
|
||||
const { calendarId, eventId, title, description, start, end, location, allDay } = body;
|
||||
|
||||
console.log('[API] Updating event:', { calendarId, eventId, title });
|
||||
|
||||
@ -100,7 +101,8 @@ export async function PATCH(request: NextRequest) {
|
||||
description,
|
||||
start,
|
||||
end,
|
||||
location
|
||||
location,
|
||||
allDay: allDay !== undefined ? !!allDay : undefined
|
||||
});
|
||||
|
||||
// Update cache
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { createGoogleClient, 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';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// GET - Pull changes from Google Tasks into local DB
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
@ -478,6 +476,7 @@ export async function POST(req: NextRequest) {
|
||||
const created = await createMsTodoTask(outlookToken, listExternalId, {
|
||||
title: task.title,
|
||||
body: task.description || undefined,
|
||||
dueDateTime: task.scheduledDate ? task.scheduledDate.toISOString() : undefined,
|
||||
});
|
||||
|
||||
const updatedTask = await prisma.task.update({
|
||||
|
||||
@ -800,6 +800,80 @@ h3 {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Floating Notes Popup */
|
||||
.weekly-notes-popup {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
min-width: 250px;
|
||||
background: var(--weekly-bg);
|
||||
border: 1px solid var(--weekly-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
padding: 8px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Arrow default (pointing up, when popup is below) */
|
||||
.weekly-notes-popup::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: -8px;
|
||||
border-left: 8px solid transparent;
|
||||
border-right: 8px solid transparent;
|
||||
border-bottom: 8px solid var(--weekly-border);
|
||||
}
|
||||
|
||||
.weekly-notes-popup::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: -7px;
|
||||
border-left: 8px solid transparent;
|
||||
border-right: 8px solid transparent;
|
||||
border-bottom: 8px solid var(--weekly-bg);
|
||||
}
|
||||
|
||||
/* Arrow when popup is above (pointing down) */
|
||||
.weekly-notes-popup.on-top::before {
|
||||
top: auto !important;
|
||||
bottom: -8px !important;
|
||||
border-bottom: none !important;
|
||||
border-top: 8px solid var(--weekly-border) !important;
|
||||
}
|
||||
|
||||
.weekly-notes-popup.on-top::after {
|
||||
top: auto !important;
|
||||
bottom: -7px !important;
|
||||
border-bottom: none !important;
|
||||
border-top: 8px solid var(--weekly-bg) !important;
|
||||
}
|
||||
|
||||
.weekly-container.dark-mode .weekly-notes-popup {
|
||||
background: #2a2a2a;
|
||||
border-color: #444;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.weekly-container.dark-mode .weekly-notes-popup::before {
|
||||
border-bottom-color: #444;
|
||||
}
|
||||
|
||||
.weekly-container.dark-mode .weekly-notes-popup::after {
|
||||
border-bottom-color: #2a2a2a;
|
||||
}
|
||||
|
||||
.weekly-container.dark-mode .weekly-notes-popup.on-top::before {
|
||||
border-top-color: #444 !important;
|
||||
}
|
||||
|
||||
.weekly-container.dark-mode .weekly-notes-popup.on-top::after {
|
||||
border-top-color: #2a2a2a !important;
|
||||
}
|
||||
|
||||
.weekly-notes-editor-inline:focus {
|
||||
border-color: var(--weekly-teal);
|
||||
background: #fff;
|
||||
@ -1753,6 +1827,7 @@ h3 {
|
||||
.time-slots-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: visible;
|
||||
}
|
||||
|
||||
.time-slot {
|
||||
@ -2709,18 +2784,129 @@ h3 {
|
||||
|
||||
.weekly-settings-sidebar .weekly-settings-header {
|
||||
padding: 24px;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
/* Notes Sidebar */
|
||||
.weekly-notes-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 500px;
|
||||
max-width: 95vw;
|
||||
height: 100vh;
|
||||
background: white;
|
||||
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
z-index: 2005;
|
||||
overflow: hidden;
|
||||
border-left: 1px solid #eee;
|
||||
}
|
||||
|
||||
.weekly-notes-sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.dark-mode .weekly-notes-sidebar {
|
||||
background: #111;
|
||||
border-left: 1px solid #333;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.weekly-notes-sidebar-header {
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.dark-mode .weekly-notes-sidebar-header {
|
||||
border-bottom-color: #333;
|
||||
}
|
||||
|
||||
.weekly-notes-sidebar-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.weekly-notes-sidebar-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: #888;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.weekly-notes-sidebar-close:hover {
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.dark-mode .weekly-notes-sidebar-close:hover {
|
||||
background: #222;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.weekly-notes-sidebar-content {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.weekly-notes-sidebar .weekly-notes-editor {
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
border: 1px solid #eee;
|
||||
padding: 16px;
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.dark-mode .weekly-notes-sidebar .weekly-notes-editor {
|
||||
background: #1a1a1a;
|
||||
border-color: #333;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.weekly-notes-sidebar .notes-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dark-mode .weekly-notes-sidebar .notes-toolbar {
|
||||
background: #222;
|
||||
}
|
||||
.settings-tab-btn:hover {
|
||||
opacity: 0.8 !important;
|
||||
background: rgba(0, 0, 0, 0.04) !important;
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
|
||||
.dark-mode .settings-tab-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08) !important;
|
||||
}
|
||||
|
||||
@ -21,7 +21,12 @@ export default function CalendarEventModal({
|
||||
}: CalendarEventModalProps) {
|
||||
// Flatten calendars from connections to get selectable options
|
||||
const availableCalendars = connections
|
||||
.flatMap(conn => conn.calendars || [])
|
||||
.flatMap(conn => (conn.calendars || []).map((cal: any) => ({
|
||||
...cal,
|
||||
providerName: conn.provider === 'google' ? 'Google Calendar' :
|
||||
conn.provider === 'apple' ? 'Apple Calendar' :
|
||||
'Outlook Calendar'
|
||||
})))
|
||||
.filter((cal: any) => cal.editable); // Only editable calendars
|
||||
|
||||
const [title, setTitle] = useState(event?.title || '');
|
||||
@ -59,6 +64,7 @@ export default function CalendarEventModal({
|
||||
|
||||
const [startDate, setStartDate] = useState(getInitialStart());
|
||||
const [endDate, setEndDate] = useState(getInitialEnd());
|
||||
const [allDay, setAllDay] = useState(!!event?.allDay);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
@ -85,6 +91,7 @@ export default function CalendarEventModal({
|
||||
description,
|
||||
location,
|
||||
calendarId,
|
||||
allDay,
|
||||
start: { dateTime: startDate.toISOString() },
|
||||
end: { dateTime: endDate.toISOString() }
|
||||
});
|
||||
@ -170,18 +177,32 @@ export default function CalendarEventModal({
|
||||
>
|
||||
{availableCalendars.length === 0 && <option value="">No editable calendars</option>}
|
||||
{availableCalendars.map((cal: any) => (
|
||||
<option key={cal.id} value={cal.id}>{cal.summary || cal.title}</option>
|
||||
<option key={cal.id} value={cal.id}>
|
||||
{cal.summary || cal.title} ({cal.providerName})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* All Day Toggle */}
|
||||
<div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer', fontSize: '0.9rem', color: '#666' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allDay}
|
||||
onChange={e => setAllDay(e.target.checked)}
|
||||
/>
|
||||
All Day
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Date/Time */}
|
||||
<div style={{ display: 'flex', gap: '15px' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>Start</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={toLocalISOString(startDate)}
|
||||
type={allDay ? "date" : "datetime-local"}
|
||||
value={allDay ? startDate.toISOString().split('T')[0] : toLocalISOString(startDate)}
|
||||
onChange={e => handleStartDateChange(e.target.value)}
|
||||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
/>
|
||||
@ -189,8 +210,8 @@ export default function CalendarEventModal({
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>End</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={toLocalISOString(endDate)}
|
||||
type={allDay ? "date" : "datetime-local"}
|
||||
value={allDay ? endDate.toISOString().split('T')[0] : toLocalISOString(endDate)}
|
||||
onChange={e => setEndDate(new Date(e.target.value))}
|
||||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
/>
|
||||
|
||||
@ -171,7 +171,7 @@ export function GridTaskBlock({
|
||||
left: 0,
|
||||
right: 0,
|
||||
minHeight: `${Math.max(currentHeight, 20)}px`,
|
||||
height: isNotesOpen || isSubTasksOpen ? "auto" : `${currentHeight}px`,
|
||||
height: isSubTasksOpen ? "auto" : `${currentHeight}px`,
|
||||
zIndex: isResizing || isNotesOpen || isSubTasksOpen ? 10 : 5,
|
||||
background: (isNotesOpen || isSubTasksOpen || isResizing) ? (darkMode ? "#2a2a2a" : "#ffffff") : "transparent",
|
||||
border: (isNotesOpen || isSubTasksOpen || isResizing) ? `1px solid ${darkMode ? "#404040" : "#e0e0e0"}` : "none",
|
||||
@ -342,7 +342,19 @@ export function GridTaskBlock({
|
||||
{/* Inline Expanders Container */}
|
||||
<div style={{ paddingLeft: "4px", paddingRight: "4px", paddingBottom: "10px", marginTop: "4px" }}>
|
||||
{isNotesOpen && (
|
||||
<div className="weekly-notes-inline mt-1" onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className={`weekly-notes-popup ${topOffset > 180 ? "on-top" : ""}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
top: topOffset > 180 ? "auto" : "100%",
|
||||
bottom: topOffset > 180 ? "100%" : "auto",
|
||||
marginTop: topOffset > 180 ? "0" : "10px",
|
||||
marginBottom: topOffset > 180 ? "10px" : "0",
|
||||
left: "-10px",
|
||||
right: "-10px",
|
||||
width: "auto",
|
||||
}}
|
||||
>
|
||||
<div className="notes-toolbar">
|
||||
<button className="notes-toolbar-btn" onClick={() => insertMarkdown("**", "**")} title="Bold">B</button>
|
||||
<button className="notes-toolbar-btn" onClick={() => insertMarkdown("*", "*")} title="Italic">i</button>
|
||||
@ -356,7 +368,7 @@ export function GridTaskBlock({
|
||||
onChange={(e) => setNotesValue(e.target.value)}
|
||||
onBlur={handleNotesBlur}
|
||||
placeholder="Add notes..."
|
||||
style={{ minHeight: "60px", padding: "4px" }}
|
||||
style={{ minHeight: "120px", padding: "4px" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -108,6 +108,8 @@ interface SomedayList {
|
||||
title: string;
|
||||
tasks: Task[];
|
||||
externalProvider?: string | null;
|
||||
externalId?: string | null;
|
||||
externalListId?: string | null;
|
||||
}
|
||||
|
||||
// Time grid configuration options
|
||||
@ -236,6 +238,8 @@ const translations: Record<string, any> = {
|
||||
simpleView: "Einfach",
|
||||
calendarView: "Kalender",
|
||||
listView: "Liste",
|
||||
notes: "Notizen",
|
||||
notesSidebar: "Notizen-Seitenleiste",
|
||||
language: "Sprache",
|
||||
dateFormat: "Datumsformat",
|
||||
timeFormat: "Zeitformat",
|
||||
@ -529,6 +533,12 @@ export default function WeeklyView() {
|
||||
{ id: string; title: string }[]
|
||||
>([]);
|
||||
const [isFetchingLists, setIsFetchingLists] = useState(false);
|
||||
const [availableTaskLists, setAvailableTaskLists] = useState<{
|
||||
[key in "google" | "apple" | "outlook"]?: { id: string; title: string }[];
|
||||
}>({});
|
||||
const [isFetchingProviderLists, setIsFetchingProviderLists] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [profile, setProfile] = useState<{
|
||||
name: string;
|
||||
@ -921,8 +931,20 @@ export default function WeeklyView() {
|
||||
throw new Error(err.error || "Failed to save event");
|
||||
}
|
||||
|
||||
// Refresh events
|
||||
await fetchCalendarEvents();
|
||||
// Optimistically add/update from API response, then force refresh cache
|
||||
const data = await res.json();
|
||||
if (data.event) {
|
||||
setRawCalendarEvents(prev => {
|
||||
if (eventData.id) {
|
||||
// Update existing
|
||||
return prev.map(e => e.id === eventData.id ? data.event : e);
|
||||
}
|
||||
// Add new
|
||||
return [...prev, data.event];
|
||||
});
|
||||
}
|
||||
// Also force-refresh from provider to ensure full sync
|
||||
fetchCalendarEvents(true);
|
||||
} catch (error: any) {
|
||||
console.error("Error saving event:", error);
|
||||
if (error.name === "AbortError") {
|
||||
@ -948,8 +970,9 @@ export default function WeeklyView() {
|
||||
throw new Error(err.error || "Failed to delete event");
|
||||
}
|
||||
|
||||
// Refresh events
|
||||
await fetchCalendarEvents();
|
||||
// Optimistically remove, then force refresh
|
||||
setRawCalendarEvents(prev => prev.filter(e => e.id !== eventId));
|
||||
fetchCalendarEvents(true);
|
||||
} catch (error) {
|
||||
console.error("Error deleting event:", error);
|
||||
throw error;
|
||||
@ -1057,7 +1080,7 @@ export default function WeeklyView() {
|
||||
return () => clearInterval(interval);
|
||||
}, [session]);
|
||||
|
||||
// Periodic background calendar cache refresh (every 5 minutes)
|
||||
// Periodic background calendar cache refresh (every 2 minutes)
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
const interval = setInterval(
|
||||
@ -1074,12 +1097,13 @@ export default function WeeklyView() {
|
||||
timeMax: new Date(
|
||||
now.getTime() + 14 * 24 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
forceRefresh: true,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.queued > 0) {
|
||||
// Stale connections are being refreshed; re-fetch events after delay
|
||||
if (data.queued > 0 || data.refreshed > 0) {
|
||||
// Cache was refreshed; re-fetch events after delay
|
||||
setTimeout(() => fetchCalendarEvents(), 8000);
|
||||
}
|
||||
}
|
||||
@ -1087,7 +1111,7 @@ export default function WeeklyView() {
|
||||
// Silent fail for background sync
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
2 * 60 * 1000,
|
||||
);
|
||||
return () => clearInterval(interval);
|
||||
}, [session, fetchCalendarEvents]);
|
||||
@ -1946,6 +1970,75 @@ export default function WeeklyView() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAvailableTaskLists = useCallback(
|
||||
async (provider: "google" | "apple" | "outlook") => {
|
||||
setIsFetchingProviderLists((prev) => ({
|
||||
...prev,
|
||||
[provider]: true,
|
||||
}));
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/lists?provider=${provider}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAvailableTaskLists((prev) => ({
|
||||
...prev,
|
||||
[provider]: data.lists || [],
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch lists for ${provider}`, error);
|
||||
} finally {
|
||||
setIsFetchingProviderLists((prev) => ({
|
||||
...prev,
|
||||
[provider]: false,
|
||||
}));
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
|
||||
const handleToggleTaskList = async (
|
||||
provider: "google" | "apple" | "outlook",
|
||||
list: { id: string; title: string },
|
||||
) => {
|
||||
const existing = somedayLists.find(
|
||||
(l) => l.externalId === list.id && l.externalProvider === provider,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
// Unsync/Remove
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to stop syncing the list "${list.title}"? This will move its tasks to the trash.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/someday-lists?id=${existing.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (res.ok) {
|
||||
setSomedayLists((prev) => prev.filter((l) => l.id !== existing.id));
|
||||
setImportStatusMsg({
|
||||
type: "success",
|
||||
text: `Stopped syncing "${list.title}".`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete list", error);
|
||||
setImportStatusMsg({
|
||||
type: "error",
|
||||
text: "Failed to stop syncing list.",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Sync/Import
|
||||
await doImport(provider, [list]);
|
||||
}
|
||||
};
|
||||
|
||||
// Core import logic — accepts provider directly so it works both from modal and sidebar
|
||||
const doImport = async (
|
||||
provider: "google" | "apple" | "outlook",
|
||||
@ -5350,6 +5443,11 @@ export default function WeeklyView() {
|
||||
allDayPosition={allDayPosition}
|
||||
setAllDayPosition={setAllDayPosition}
|
||||
saveSetting={saveSetting}
|
||||
availableTaskLists={availableTaskLists}
|
||||
isFetchingProviderLists={isFetchingProviderLists}
|
||||
somedayLists={somedayLists}
|
||||
handleToggleTaskList={handleToggleTaskList}
|
||||
fetchAvailableTaskLists={fetchAvailableTaskLists}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -5362,166 +5460,11 @@ export default function WeeklyView() {
|
||||
)}
|
||||
|
||||
{selectedTaskForNotes && (
|
||||
<div
|
||||
className="weekly-modal-overlay"
|
||||
onClick={() => setSelectedTaskForNotes(null)}
|
||||
>
|
||||
<div
|
||||
className="weekly-modal-content"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3>Notes: {selectedTaskForNotes.title}</h3>
|
||||
{/* Toolbar for Modal */}
|
||||
<div className="notes-toolbar" style={{ marginTop: "1rem" }}>
|
||||
<button
|
||||
className="notes-toolbar-btn"
|
||||
onClick={() => {
|
||||
const textarea = document.querySelector(
|
||||
".weekly-notes-editor",
|
||||
) as HTMLTextAreaElement;
|
||||
if (!textarea) return;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const text = textarea.value;
|
||||
const before = text.substring(0, start);
|
||||
const selection = text.substring(start, end);
|
||||
const after = text.substring(end);
|
||||
const newText = `${before}**${selection}**${after}`;
|
||||
updateTaskNotes(selectedTaskForNotes.id, newText);
|
||||
// Hacky re-focus and update value visually since it's uncontrolled-ish/onBlur driven
|
||||
textarea.value = newText;
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(
|
||||
start + 2,
|
||||
start + 2 + selection.length,
|
||||
);
|
||||
}}
|
||||
title="Bold"
|
||||
>
|
||||
B
|
||||
</button>
|
||||
<button
|
||||
className="notes-toolbar-btn"
|
||||
onClick={() => {
|
||||
const textarea = document.querySelector(
|
||||
".weekly-notes-editor",
|
||||
) as HTMLTextAreaElement;
|
||||
if (!textarea) return;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const text = textarea.value;
|
||||
const before = text.substring(0, start);
|
||||
const selection = text.substring(start, end);
|
||||
const after = text.substring(end);
|
||||
const newText = `${before}*${selection}*${after}`;
|
||||
updateTaskNotes(selectedTaskForNotes.id, newText);
|
||||
textarea.value = newText;
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(
|
||||
start + 1,
|
||||
start + 1 + selection.length,
|
||||
);
|
||||
}}
|
||||
title="Italic"
|
||||
>
|
||||
i
|
||||
</button>
|
||||
<button
|
||||
className="notes-toolbar-btn"
|
||||
onClick={() => {
|
||||
const textarea = document.querySelector(
|
||||
".weekly-notes-editor",
|
||||
) as HTMLTextAreaElement;
|
||||
if (!textarea) return;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const text = textarea.value;
|
||||
const before = text.substring(0, start);
|
||||
const selection = text.substring(start, end);
|
||||
const after = text.substring(end);
|
||||
const newText = `${before}[${selection}](url)${after}`;
|
||||
updateTaskNotes(selectedTaskForNotes.id, newText);
|
||||
textarea.value = newText;
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(
|
||||
start + 1,
|
||||
start + 1 + selection.length,
|
||||
);
|
||||
}}
|
||||
title="Link"
|
||||
>
|
||||
🔗
|
||||
</button>
|
||||
<button
|
||||
className="notes-toolbar-btn"
|
||||
onClick={() => {
|
||||
const textarea = document.querySelector(
|
||||
".weekly-notes-editor",
|
||||
) as HTMLTextAreaElement;
|
||||
if (!textarea) return;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const text = textarea.value;
|
||||
const before = text.substring(0, start);
|
||||
const selection = text.substring(start, end);
|
||||
const after = text.substring(end);
|
||||
const newText = `${before}- ${selection}${after}`;
|
||||
updateTaskNotes(selectedTaskForNotes.id, newText);
|
||||
textarea.value = newText;
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(
|
||||
start + 2,
|
||||
start + 2 + selection.length,
|
||||
);
|
||||
}}
|
||||
title="List"
|
||||
>
|
||||
☑
|
||||
</button>
|
||||
<button
|
||||
className="notes-toolbar-btn"
|
||||
onClick={() => {
|
||||
const textarea = document.querySelector(
|
||||
".weekly-notes-editor",
|
||||
) as HTMLTextAreaElement;
|
||||
if (!textarea) return;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const text = textarea.value;
|
||||
const before = text.substring(0, start);
|
||||
const selection = text.substring(start, end);
|
||||
const after = text.substring(end);
|
||||
const newText = `${before}${after}`;
|
||||
updateTaskNotes(selectedTaskForNotes.id, newText);
|
||||
textarea.value = newText;
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(start + 2, start + 10); // select "alt text"
|
||||
}}
|
||||
title="Image"
|
||||
>
|
||||
🖼️
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
className="weekly-notes-editor"
|
||||
defaultValue={selectedTaskForNotes.markdownContent || ""}
|
||||
autoFocus
|
||||
placeholder="Add details, notes, or links..."
|
||||
onBlur={(e) =>
|
||||
updateTaskNotes(selectedTaskForNotes.id, e.target.value)
|
||||
}
|
||||
<NotesSidebar
|
||||
task={selectedTaskForNotes}
|
||||
onClose={() => setSelectedTaskForNotes(null)}
|
||||
updateTaskNotes={updateTaskNotes}
|
||||
/>
|
||||
<div className="weekly-modal-actions">
|
||||
<button
|
||||
className="weekly-btn weekly-btn-secondary"
|
||||
onClick={() => setSelectedTaskForNotes(null)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ImportListModal
|
||||
isOpen={isImportModalOpen}
|
||||
@ -6372,7 +6315,117 @@ interface SettingsSidebarProps {
|
||||
allDayPosition: "above" | "below";
|
||||
setAllDayPosition: (pos: "above" | "below") => void;
|
||||
saveSetting: (key: string, value: any) => void;
|
||||
availableTaskLists: {
|
||||
[key in "google" | "apple" | "outlook"]?: { id: string; title: string }[];
|
||||
};
|
||||
isFetchingProviderLists: Record<string, boolean>;
|
||||
somedayLists: SomedayList[];
|
||||
handleToggleTaskList: (
|
||||
provider: "google" | "apple" | "outlook",
|
||||
list: { id: string; title: string },
|
||||
) => Promise<void>;
|
||||
fetchAvailableTaskLists: (
|
||||
provider: "google" | "apple" | "outlook",
|
||||
) => Promise<void>;
|
||||
}
|
||||
// Notes Sidebar Component
|
||||
interface NotesSidebarProps {
|
||||
task: Task;
|
||||
onClose: () => void;
|
||||
updateTaskNotes: (id: string, notes: string) => void;
|
||||
}
|
||||
|
||||
function NotesSidebar({ task, onClose, updateTaskNotes }: NotesSidebarProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), 10);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(onClose, 300);
|
||||
};
|
||||
|
||||
const handleToolbarClick = (before: string, after: string, selectOffsetStart?: number, selectOffsetEnd?: number) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const text = textarea.value;
|
||||
const beforeText = text.substring(0, start);
|
||||
const selection = text.substring(start, end);
|
||||
const afterText = text.substring(end);
|
||||
|
||||
let newText = `${beforeText}${before}${selection}${after}${afterText}`;
|
||||
if (before === "") {
|
||||
// Special case for image to match original logic precisely
|
||||
newText = `${beforeText}${afterText}`;
|
||||
}
|
||||
|
||||
updateTaskNotes(task.id, newText);
|
||||
textarea.value = newText;
|
||||
textarea.focus();
|
||||
|
||||
if (before === "") {
|
||||
textarea.setSelectionRange(start + 2, start + 10);
|
||||
} else {
|
||||
textarea.setSelectionRange(
|
||||
start + before.length,
|
||||
start + before.length + selection.length
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`weekly-modal-overlay ${isVisible ? "show" : ""}`}
|
||||
onClick={handleClose}
|
||||
style={{ zIndex: 1999 }}
|
||||
/>
|
||||
<div className={`weekly-notes-sidebar ${isVisible ? "open" : ""}`}>
|
||||
<header className="weekly-notes-sidebar-header">
|
||||
<h2 className="weekly-notes-sidebar-title">Notes: {task.title}</h2>
|
||||
<button className="weekly-notes-sidebar-close" onClick={handleClose}>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className="weekly-notes-sidebar-content">
|
||||
<div className="notes-toolbar">
|
||||
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("**", "**")} title="Bold">B</button>
|
||||
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("*", "*")} title="Italic">i</button>
|
||||
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("[", "](url)")} title="Link">🔗</button>
|
||||
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("- ", "")} title="List">☑</button>
|
||||
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("")} title="Image">🖼️</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="weekly-notes-editor"
|
||||
defaultValue={task.markdownContent || ""}
|
||||
autoFocus
|
||||
placeholder="Add details, notes, or links..."
|
||||
onBlur={(e) => updateTaskNotes(task.id, e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="weekly-modal-actions" style={{ marginTop: '24px' }}>
|
||||
<button
|
||||
className="weekly-btn weekly-btn-secondary"
|
||||
onClick={handleClose}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function SettingsSidebar({
|
||||
onClose,
|
||||
@ -6437,6 +6490,11 @@ function SettingsSidebar({
|
||||
allDayPosition,
|
||||
setAllDayPosition,
|
||||
saveSetting,
|
||||
availableTaskLists,
|
||||
isFetchingProviderLists,
|
||||
somedayLists,
|
||||
handleToggleTaskList,
|
||||
fetchAvailableTaskLists,
|
||||
}: SettingsSidebarProps) {
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"calendar" | "general" | "account" | "styling" | "motivation" | "about"
|
||||
@ -6460,6 +6518,17 @@ function SettingsSidebar({
|
||||
const [confirmDisconnectId, setConfirmDisconnectId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Fetch lists when the calendar tab is selected
|
||||
useEffect(() => {
|
||||
if (activeTab === "calendar") {
|
||||
const providersWithAccounts = connections.map((c) => c.provider);
|
||||
if (providersWithAccounts.includes("google"))
|
||||
fetchAvailableTaskLists("google");
|
||||
if (providersWithAccounts.includes("outlook"))
|
||||
fetchAvailableTaskLists("outlook");
|
||||
}
|
||||
}, [activeTab, connections]);
|
||||
const [connMsg, setConnMsg] = useState<{
|
||||
type: "success" | "error";
|
||||
text: string;
|
||||
@ -7839,6 +7908,9 @@ function SettingsSidebar({
|
||||
}}
|
||||
>
|
||||
{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"
|
||||
@ -7974,57 +8046,132 @@ function SettingsSidebar({
|
||||
Sync tasks with Google Tasks or Microsoft To-Do. Selected
|
||||
lists will be kept in sync automatically.
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
gap: "1.5rem",
|
||||
}}
|
||||
>
|
||||
{connections
|
||||
.filter((c) => ["google", "outlook"].includes(c.provider))
|
||||
.map((conn) => {
|
||||
const providerLists =
|
||||
availableTaskLists[
|
||||
conn.provider as "google" | "outlook"
|
||||
] || [];
|
||||
const isFetching =
|
||||
isFetchingProviderLists[conn.provider];
|
||||
|
||||
return (
|
||||
<div key={conn.id}>
|
||||
<div
|
||||
style={{ display: "flex", gap: "1rem", flexWrap: "wrap" }}
|
||||
>
|
||||
<button
|
||||
onClick={() => executeImport("google")}
|
||||
className="calendar-connect-btn"
|
||||
disabled={
|
||||
!connections.some((c) => c.provider === "google") ||
|
||||
importingTasksState
|
||||
}
|
||||
style={{
|
||||
opacity:
|
||||
!connections.some((c) => c.provider === "google") ||
|
||||
importingTasksState
|
||||
? 0.5
|
||||
: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
marginBottom: "0.5rem",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
<span>📅</span>{" "}
|
||||
{importingTasksState
|
||||
? "Syncing..."
|
||||
: "Sync with Google Tasks"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => executeImport("outlook")}
|
||||
className="calendar-connect-btn"
|
||||
disabled={
|
||||
!connections.some((c) => c.provider === "outlook") ||
|
||||
importingTasksState
|
||||
}
|
||||
<span>
|
||||
{conn.provider === "google" ? "📅" : "📧"}
|
||||
</span>
|
||||
{conn.provider === "google"
|
||||
? "Google Tasks"
|
||||
: "Microsoft To-Do"}
|
||||
{isFetching && (
|
||||
<span
|
||||
style={{
|
||||
opacity:
|
||||
!connections.some((c) => c.provider === "outlook") ||
|
||||
importingTasksState
|
||||
? 0.5
|
||||
: 1,
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 400,
|
||||
color: "#888",
|
||||
}}
|
||||
>
|
||||
<span>📧</span>{" "}
|
||||
{importingTasksState
|
||||
? "Syncing..."
|
||||
: "Sync with Microsoft To-Do"}
|
||||
</button>
|
||||
(fetching lists...)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul
|
||||
style={{
|
||||
listStyle: "none",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "4px",
|
||||
}}
|
||||
>
|
||||
{providerLists.map((list: { id: string; title: string }) => {
|
||||
const isSynced = somedayLists.some(
|
||||
(sl: SomedayList) =>
|
||||
sl.externalId === list.id &&
|
||||
sl.externalProvider === conn.provider,
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={list.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
padding: "4px 8px",
|
||||
borderRadius: "4px",
|
||||
background: "rgba(0,0,0,0.02)",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSynced}
|
||||
onChange={() =>
|
||||
handleToggleTaskList(
|
||||
conn.provider as
|
||||
| "google"
|
||||
| "outlook",
|
||||
list,
|
||||
)
|
||||
}
|
||||
disabled={importingTasksState}
|
||||
/>
|
||||
<span style={{ fontSize: "0.9rem" }}>
|
||||
{list.title}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{!isFetching && providerLists.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.85rem",
|
||||
color: "#888",
|
||||
paddingLeft: "24px",
|
||||
}}
|
||||
>
|
||||
No task lists found.
|
||||
</div>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{connections.filter((c) =>
|
||||
["google", "outlook"].includes(c.provider),
|
||||
).length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.9rem",
|
||||
color: "#888",
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
>
|
||||
Connect a provider above to sync task lists.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importStatusMsg && (
|
||||
<div
|
||||
style={{
|
||||
|
||||
@ -23,6 +23,7 @@ export interface CalendarEvent {
|
||||
calendarId: string;
|
||||
calendarTitle: string;
|
||||
backgroundColor?: string;
|
||||
allDay?: boolean;
|
||||
}
|
||||
|
||||
// Google Calendar event color mapping (colorId -> hex color)
|
||||
@ -322,7 +323,8 @@ export const getCalendarEvents = async (
|
||||
calendarId,
|
||||
calendarTitle: calendars.find(c => c.id === calendarId)?.title || 'Apple Calendar',
|
||||
backgroundColor: calendars.find(c => c.id === calendarId)?.color || '#FF3B30'
|
||||
};}));
|
||||
};
|
||||
}));
|
||||
}
|
||||
} else if (connection.provider === 'outlook') {
|
||||
console.log('[CALENDAR] Processing Outlook connection:', connection.id);
|
||||
@ -547,7 +549,8 @@ export const createCalendarEvent = async (
|
||||
description: event.description,
|
||||
start: event.start,
|
||||
end: event.end,
|
||||
location: event.location
|
||||
location: event.location,
|
||||
allDay: event.allDay
|
||||
});
|
||||
|
||||
return {
|
||||
@ -557,6 +560,7 @@ export const createCalendarEvent = async (
|
||||
start: createdEvent.start,
|
||||
end: createdEvent.end,
|
||||
location: createdEvent.location,
|
||||
allDay: createdEvent.allDay,
|
||||
source: 'outlook',
|
||||
calendarId,
|
||||
calendarTitle: '',
|
||||
@ -658,7 +662,8 @@ export const updateCalendarEvent = async (
|
||||
description: event.description,
|
||||
start: event.start,
|
||||
end: event.end,
|
||||
location: event.location
|
||||
location: event.location,
|
||||
allDay: event.allDay
|
||||
});
|
||||
|
||||
return {
|
||||
@ -668,6 +673,7 @@ export const updateCalendarEvent = async (
|
||||
start: updatedEvent.start,
|
||||
end: updatedEvent.end,
|
||||
location: updatedEvent.location,
|
||||
allDay: updatedEvent.allDay,
|
||||
source: 'outlook',
|
||||
calendarId,
|
||||
calendarTitle: '',
|
||||
|
||||
@ -101,7 +101,7 @@ export const fetchMsTodoTasks = async (
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`${GRAPH_ENDPOINT}/me/todo/lists/${listId}/tasks?${params.toString()}`,
|
||||
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks?${params.toString()}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
@ -139,7 +139,7 @@ export const fetchMsTodoTasksForSync = async (
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${GRAPH_ENDPOINT}/me/todo/lists/${listId}/tasks?${params.toString()}`,
|
||||
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks?${params.toString()}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
@ -183,7 +183,7 @@ export const createMsTodoTask = async (
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${GRAPH_ENDPOINT}/me/todo/lists/${listId}/tasks`,
|
||||
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@ -238,7 +238,7 @@ export const updateMsTodoTask = async (
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${GRAPH_ENDPOINT}/me/todo/lists/${listId}/tasks/${taskId}`,
|
||||
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
@ -266,7 +266,7 @@ export const deleteMsTodoTask = async (
|
||||
taskId: string
|
||||
): Promise<void> => {
|
||||
const response = await fetch(
|
||||
`${GRAPH_ENDPOINT}/me/todo/lists/${listId}/tasks/${taskId}`,
|
||||
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
@ -297,7 +297,7 @@ export const fetchMsChecklistItems = async (
|
||||
taskId: string
|
||||
): Promise<MicrosoftChecklistItem[]> => {
|
||||
const response = await fetch(
|
||||
`${GRAPH_ENDPOINT}/me/todo/lists/${listId}/tasks/${taskId}/checklistItems`,
|
||||
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}/checklistItems`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
@ -326,7 +326,7 @@ export const createMsChecklistItem = async (
|
||||
displayName: string
|
||||
): Promise<MicrosoftChecklistItem> => {
|
||||
const response = await fetch(
|
||||
`${GRAPH_ENDPOINT}/me/todo/lists/${listId}/tasks/${taskId}/checklistItems`,
|
||||
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}/checklistItems`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@ -356,7 +356,7 @@ export const updateMsChecklistItem = async (
|
||||
updates: { displayName?: string; isChecked?: boolean }
|
||||
): Promise<MicrosoftChecklistItem> => {
|
||||
const response = await fetch(
|
||||
`${GRAPH_ENDPOINT}/me/todo/lists/${listId}/tasks/${taskId}/checklistItems/${checklistItemId}`,
|
||||
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}/checklistItems/${encodeURIComponent(checklistItemId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
@ -385,7 +385,7 @@ export const deleteMsChecklistItem = async (
|
||||
checklistItemId: string
|
||||
): Promise<void> => {
|
||||
const response = await fetch(
|
||||
`${GRAPH_ENDPOINT}/me/todo/lists/${listId}/tasks/${taskId}/checklistItems/${checklistItemId}`,
|
||||
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}/checklistItems/${encodeURIComponent(checklistItemId)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
|
||||
@ -153,7 +153,7 @@ export const getUpcomingEvents = async (
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`${GRAPH_ENDPOINT}/me/calendars/${calendarId}/calendarView?${params.toString()}`,
|
||||
`${GRAPH_ENDPOINT}/me/calendars/${encodeURIComponent(calendarId)}/calendarView?${params.toString()}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
@ -170,7 +170,7 @@ export const getUpcomingEvents = async (
|
||||
return data.value.map((event: any) => ({
|
||||
id: event.id,
|
||||
summary: event.subject,
|
||||
description: event.bodyPreview,
|
||||
description: event.body?.content || event.bodyPreview,
|
||||
start: {
|
||||
dateTime: event.start.dateTime,
|
||||
timeZone: event.start.timeZone
|
||||
@ -185,6 +185,14 @@ export const getUpcomingEvents = async (
|
||||
}));
|
||||
};
|
||||
|
||||
const ensureTimeZone = (dateTimeObj: any) => {
|
||||
if (!dateTimeObj) return dateTimeObj;
|
||||
return {
|
||||
dateTime: dateTimeObj.dateTime,
|
||||
timeZone: dateTimeObj.timeZone || 'UTC'
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create Outlook Event
|
||||
*/
|
||||
@ -193,7 +201,7 @@ export const createEvent = async (
|
||||
calendarId: string,
|
||||
event: any
|
||||
) => {
|
||||
const response = await fetch(`${GRAPH_ENDPOINT}/me/calendars/${calendarId}/events`, {
|
||||
const response = await fetch(`${GRAPH_ENDPOINT}/me/calendars/${encodeURIComponent(calendarId)}/events`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
@ -205,8 +213,9 @@ export const createEvent = async (
|
||||
contentType: 'HTML',
|
||||
content: event.description || ''
|
||||
},
|
||||
start: event.start,
|
||||
end: event.end,
|
||||
start: ensureTimeZone(event.start),
|
||||
end: ensureTimeZone(event.end),
|
||||
isAllDay: !!event.allDay,
|
||||
location: {
|
||||
displayName: event.location || ''
|
||||
}
|
||||
@ -225,7 +234,8 @@ export const createEvent = async (
|
||||
description: created.bodyPreview,
|
||||
start: created.start,
|
||||
end: created.end,
|
||||
location: created.location?.displayName
|
||||
location: created.location?.displayName,
|
||||
allDay: created.isAllDay
|
||||
};
|
||||
};
|
||||
|
||||
@ -238,7 +248,7 @@ export const updateEvent = async (
|
||||
eventId: string,
|
||||
event: any
|
||||
) => {
|
||||
const response = await fetch(`${GRAPH_ENDPOINT}/me/events/${eventId}`, {
|
||||
const response = await fetch(`${GRAPH_ENDPOINT}/me/events/${encodeURIComponent(eventId)}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
@ -250,8 +260,9 @@ export const updateEvent = async (
|
||||
contentType: 'HTML',
|
||||
content: event.description || ''
|
||||
},
|
||||
start: event.start,
|
||||
end: event.end,
|
||||
start: ensureTimeZone(event.start),
|
||||
end: ensureTimeZone(event.end),
|
||||
isAllDay: event.allDay !== undefined ? !!event.allDay : undefined,
|
||||
location: {
|
||||
displayName: event.location || ''
|
||||
}
|
||||
@ -270,7 +281,8 @@ export const updateEvent = async (
|
||||
description: updated.bodyPreview,
|
||||
start: updated.start,
|
||||
end: updated.end,
|
||||
location: updated.location?.displayName
|
||||
location: updated.location?.displayName,
|
||||
allDay: updated.isAllDay
|
||||
};
|
||||
};
|
||||
|
||||
@ -282,7 +294,7 @@ export const deleteEvent = async (
|
||||
calendarId: string,
|
||||
eventId: string
|
||||
) => {
|
||||
const response = await fetch(`${GRAPH_ENDPOINT}/me/events/${eventId}`, {
|
||||
const response = await fetch(`${GRAPH_ENDPOINT}/me/events/${encodeURIComponent(eventId)}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
|
||||
Loading…
Reference in New Issue
Block a user