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:
mARTin 2026-02-24 11:01:15 +01:00
parent 210a142f61
commit 8842123caf
11 changed files with 659 additions and 274 deletions

View File

@ -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": {

View File

@ -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),
}))
);

View File

@ -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

View File

@ -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({

View File

@ -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;
}

View File

@ -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' }}
/>

View File

@ -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>
)}

View File

@ -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}![alt text](url)${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 === "![" && after === "](url)") {
// Special case for image to match original logic precisely
newText = `${beforeText}![alt text](url)${afterText}`;
}
updateTaskNotes(task.id, newText);
textarea.value = newText;
textarea.focus();
if (before === "![" && after === "](url)") {
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("![", "](url)")} 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={{

View File

@ -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: '',

View File

@ -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: {

View File

@ -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}`