Refactor Someday area to grid layout with dynamic slots, fixed scrolling, and border refinements

This commit is contained in:
mARTin 2026-03-01 11:58:48 +01:00
parent dea7c387ea
commit fcaadc1ce9
16 changed files with 673 additions and 560 deletions

View File

@ -4,9 +4,9 @@
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "next dev -H 0.0.0.0 -p 3001", "dev": "echo \"\\n ✦ My Weekly ToDo List v$(node -p \"require('./package.json').version\") — dev mode\\n\" && next dev -H 0.0.0.0 -p 3002",
"build": "next build", "build": "next build",
"start": "next start", "start": "echo \"\\n ✦ My Weekly ToDo List v$(node -p \"require('./package.json').version\") — production\\n\" && next start",
"lint": "next lint", "lint": "next lint",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
"test": "jest" "test": "jest"

View File

@ -142,6 +142,7 @@ model Task {
dayOfWeek Int? dayOfWeek Int?
scheduledDate DateTime? scheduledDate DateTime?
somedayListId String? somedayListId String?
somedaySlotIndex Int?
originalDate DateTime? originalDate DateTime?
startTime String? startTime String?
endTime String? endTime String?

View File

@ -195,7 +195,7 @@ export async function POST(request: NextRequest) {
const userId = (session.user as any).id; const userId = (session.user as any).id;
const body = await request.json(); const body = await request.json();
const { title, description, dayOfWeek, order, markdownContent, somedayListId, startTime, scheduledDate, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, parentTaskId } = body; const { title, description, dayOfWeek, order, markdownContent, somedayListId, somedaySlotIndex, startTime, scheduledDate, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, parentTaskId } = body;
let { isRolling } = body; let { isRolling } = body;
const { isRecurring } = body; const { isRecurring } = body;
@ -232,6 +232,7 @@ export async function POST(request: NextRequest) {
recurrenceUnit, recurrenceUnit,
recurrenceTime, recurrenceTime,
recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null, recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null,
somedaySlotIndex: somedaySlotIndex !== undefined ? parseInt(somedaySlotIndex) : null,
parentTaskId: parentTaskId || null, parentTaskId: parentTaskId || null,
}, },
}); });
@ -265,7 +266,7 @@ export async function PATCH(request: NextRequest) {
const body = await request.json(); const body = await request.json();
const { id } = body; const { id } = body;
const { title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime, somedayListId, isRecurring, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, restore, parentTaskId } = body; const { title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime, somedayListId, somedaySlotIndex, isRecurring, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, restore, parentTaskId } = body;
if (!id) { if (!id) {
return NextResponse.json( return NextResponse.json(
@ -349,6 +350,7 @@ export async function PATCH(request: NextRequest) {
...(recurrenceTime !== undefined && { recurrenceTime }), ...(recurrenceTime !== undefined && { recurrenceTime }),
...(recurrenceEndDate !== undefined && { recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null }), ...(recurrenceEndDate !== undefined && { recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null }),
...(restore === true && { deletedAt: null }), ...(restore === true && { deletedAt: null }),
...(somedaySlotIndex !== undefined && { somedaySlotIndex: somedaySlotIndex !== null ? parseInt(somedaySlotIndex) : null }),
...(parentTaskId !== undefined && { parentTaskId: parentTaskId || null }) ...(parentTaskId !== undefined && { parentTaskId: parentTaskId || null })
}, },
}); });

View File

@ -104,6 +104,20 @@ function LoginContent() {
Continue with Google Continue with Google
</button> </button>
{/* Apple Sign In */}
<button
type="button"
onClick={() => { setIsLoading(true); signIn('apple', { callbackUrl }); }}
disabled={isLoading}
className="weekly-auth-button apple"
style={{ marginTop: '0.75rem', backgroundColor: '#000', color: '#fff' }}
>
<svg className="apple-icon" viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M16.365 21.492c-1.385 1.144-2.825 1.114-4.237.491-1.384-.622-2.735-.622-4.116 0-1.472.684-2.825.684-4.116-.279-5.1-4.144-6.31-11.458-2.67-15.002 1.442-1.383 3.08-1.745 4.503-1.745 1.471 0 2.883.682 3.86.682.95 0 2.61-.741 4.364-.741 1.748 0 3.35.532 4.417 1.838-3.647 1.956-3.08 6.906.653 8.358-.89 2.134-1.928 4.624-2.66 6.398zm-4.745-16.79c-.208-2.223 1.72-4.301 3.974-4.702.355 2.37-2.016 4.331-3.974 4.702z" />
</svg>
Continue with Apple
</button>
{/* Links */} {/* Links */}
<div className="weekly-auth-links"> <div className="weekly-auth-links">
<Link href="/auth/forgot-password" className="weekly-auth-link"> <Link href="/auth/forgot-password" className="weekly-auth-link">

View File

@ -169,6 +169,20 @@ export default function SignupPage() {
Sign up with Google Sign up with Google
</button> </button>
{/* Apple Sign Up */}
<button
type="button"
onClick={() => { setIsLoading(true); signIn('apple', { callbackUrl: '/tasks' }); }}
disabled={isLoading}
className="weekly-auth-button apple"
style={{ marginTop: '0.75rem', backgroundColor: '#000', color: '#fff' }}
>
<svg className="apple-icon" viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M16.365 21.492c-1.385 1.144-2.825 1.114-4.237.491-1.384-.622-2.735-.622-4.116 0-1.472.684-2.825.684-4.116-.279-5.1-4.144-6.31-11.458-2.67-15.002 1.442-1.383 3.08-1.745 4.503-1.745 1.471 0 2.883.682 3.86.682.95 0 2.61-.741 4.364-.741 1.748 0 3.35.532 4.417 1.838-3.647 1.956-3.08 6.906.653 8.358-.89 2.134-1.928 4.624-2.66 6.398zm-4.745-16.79c-.208-2.223 1.72-4.301 3.974-4.702.355 2.37-2.016 4.331-3.974 4.702z" />
</svg>
Sign up with Apple
</button>
{/* Links */} {/* Links */}
<div className="weekly-auth-links"> <div className="weekly-auth-links">
<span className="weekly-auth-text">Already have an account?</span> <span className="weekly-auth-text">Already have an account?</span>

View File

@ -1158,7 +1158,7 @@ h3 {
/* Task Input */ /* Task Input */
.weekly-task-input { .weekly-task-input {
padding: 0.5rem 1rem 0.5rem 2.5rem; padding: 0.5rem 1rem 0.5rem 2.5rem;
border-bottom: 1px solid var(--weekly-border); border-bottom: none;
} }
.weekly-task-input input { .weekly-task-input input {
@ -1177,11 +1177,17 @@ h3 {
/* Someday Section */ /* Someday Section */
.weekly-someday { .weekly-someday {
/* background color removed as per user request */ background-color: #f9f9f9;
width: 100%;
padding: 0 1rem;
transition: max-height 0.3s ease; transition: max-height 0.3s ease;
position: relative; position: relative;
} }
.weekly-container.dark-mode .weekly-someday {
background-color: #1a1a1b;
}
.weekly-someday.collapsed { .weekly-someday.collapsed {
max-height: 30px; max-height: 30px;
overflow: hidden; overflow: hidden;
@ -1251,16 +1257,16 @@ h3 {
/* Someday Lists Container - Horizontal Scroll */ /* Someday Lists Container - Horizontal Scroll */
.weekly-someday-lists-grid { .weekly-someday-lists-grid {
display: flex; display: flex !important;
flex-direction: row; flex-direction: row !important;
flex-wrap: nowrap; flex-wrap: nowrap !important;
gap: 1rem; gap: 1.5rem;
width: 100%; width: max-content; /* Allow grid to grow beyond parent width */
overflow-x: auto; min-width: 100%;
overflow-y: hidden; /* Prevent vertical Scrollbar on container */ padding-bottom: 1rem;
padding-bottom: 0.5rem; /* Space for scrollbar */
align-items: flex-start; align-items: flex-start;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
} }
/* Scrollbar Styling for Someday Container */ /* Scrollbar Styling for Someday Container */
@ -1308,25 +1314,43 @@ h3 {
/* Ruled paper lines for someday tasks */ /* Ruled paper lines for someday tasks */
.weekly-someday-list { .weekly-someday-list {
/* border-right: 1px solid var(--weekly-border); Removed for cleaner look */
padding: 0; padding: 0;
min-height: 200px; min-height: 200px;
flex: 0 0 280px; /* Fixed width for horizontal scrolling */ flex: 0 0 300px; /* Slightly wider lists */
width: 280px; width: 300px;
transition: transform 0.2s ease, opacity 0.2s ease; transition: transform 0.2s ease, opacity 0.2s ease;
max-width: 100%; max-width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: auto; overflow-y: auto;
max-height: 380px; scrollbar-width: none; /* Hide scrollbar Firefox */
background-image: repeating-linear-gradient( -ms-overflow-style: none; /* Hide scrollbar IE/Edge */
transparent, }
transparent 31px,
var(--weekly-border) 31px, .weekly-someday-list::-webkit-scrollbar {
var(--weekly-border) 32px display: none; /* Hide scrollbar Chrome/Safari/Webkit */
); }
background-attachment: local;
background-position: 0 40px; /* Offset for the header */ .weekly-container.dark-mode .weekly-someday-list {
background-image: none;
}
.task-list-slot {
height: 38px;
width: 100%;
position: relative;
display: flex;
align-items: center;
border-bottom: 1px solid var(--weekly-border);
box-sizing: border-box;
}
.task-list-slot.drop-target {
background-color: rgba(0, 128, 128, 0.05); /* Subtle teal background for drop target */
}
.weekly-container.dark-mode .task-list-slot.drop-target {
background-color: rgba(0, 128, 128, 0.15);
} }
.someday-drag-handle { .someday-drag-handle {
@ -1363,9 +1387,18 @@ h3 {
.weekly-someday-list.placeholder-list { .weekly-someday-list.placeholder-list {
background-image: repeating-linear-gradient( background-image: repeating-linear-gradient(
transparent, transparent,
transparent 31px, transparent 37px,
#f5f5f5 31px, #e5e5e5 37px,
#f5f5f5 32px #e5e5e5 38px
);
}
.weekly-container.dark-mode .weekly-someday-list.placeholder-list {
background-image: repeating-linear-gradient(
transparent,
transparent 37px,
#4a4a4a 37px,
#4a4a4a 38px
); );
} }
@ -1379,14 +1412,18 @@ h3 {
} }
.weekly-someday-list .weekly-task-item { .weekly-someday-list .weekly-task-item {
border-bottom: 1px solid transparent; border-bottom: none;
min-height: 32px; min-height: 38px;
height: auto; height: auto;
padding: 4px 1rem; padding: 6px 1rem;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
} }
.weekly-container.dark-mode .weekly-someday-list .weekly-task-item {
border-bottom: none;
}
.weekly-someday-list .weekly-task-text { .weekly-someday-list .weekly-task-text {
font-size: var(--base-font-size); font-size: var(--base-font-size);
line-height: 1.4; line-height: 1.4;
@ -1398,25 +1435,31 @@ h3 {
} }
.weekly-someday-list-title-header { .weekly-someday-list-title-header {
padding: 0.75rem 1rem 0.25rem; padding: 0 1rem;
height: 40px; margin-bottom: 0;
height: 56px;
min-height: 56px;
display: flex; display: flex;
align-items: center; align-items: center;
border-bottom: 1px solid var(--weekly-border);
}
.weekly-container.dark-mode .weekly-someday-list-title-header {
border-bottom-color: var(--weekly-border);
} }
.weekly-someday-list-title-input { .weekly-someday-list-title-input {
font-size: 1rem; font-size: 1.15rem;
font-weight: 700; font-weight: 800;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.08em;
color: var(--weekly-text, #333); color: var(--weekly-text, #222);
background: transparent; background: transparent;
border: none; border: none;
border-bottom: 1px solid transparent;
width: 100%; width: 100%;
padding: 2px 0; padding: 4px 0;
outline: none; outline: none;
transition: border-color 0.15s ease; transition: opacity 0.15s ease;
} }
.weekly-someday .weekly-task-item { .weekly-someday .weekly-task-item {
@ -1763,32 +1806,10 @@ h3 {
/* === Tablet (≤ 1024px): 5-day view === */ /* === Tablet (≤ 1024px): 5-day view === */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.weekly-days-grid {
grid-template-columns: repeat(5, 1fr) !important;
}
.all-day-events-grid {
grid-template-columns: repeat(5, 1fr) !important;
}
.weekly-someday-lists-grid {
grid-template-columns: repeat(5, 1fr) !important;
}
} }
/* === Phone landscape / small tablet (≤ 768px): 3-day view === */ /* === Phone landscape / small tablet (≤ 768px): 3-day view === */
@media (max-width: 768px) { @media (max-width: 768px) {
.weekly-days-grid {
grid-template-columns: repeat(3, 1fr) !important;
}
.all-day-events-grid {
grid-template-columns: repeat(3, 1fr) !important;
}
.weekly-someday-lists-grid {
grid-template-columns: repeat(3, 1fr) !important;
}
.weekly-someday-lists { .weekly-someday-lists {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@ -1838,17 +1859,6 @@ h3 {
/* === Phone portrait (≤ 480px): 1-day view === */ /* === Phone portrait (≤ 480px): 1-day view === */
@media (max-width: 480px) { @media (max-width: 480px) {
.weekly-days-grid {
grid-template-columns: 1fr !important;
}
.all-day-events-grid {
grid-template-columns: 1fr !important;
}
.weekly-someday-lists-grid {
grid-template-columns: 1fr !important;
}
/* Narrower time column on phone */ /* Narrower time column on phone */
.time-column { .time-column {

View File

@ -3,5 +3,5 @@
import { SessionProvider } from 'next-auth/react'; import { SessionProvider } from 'next-auth/react';
export function Providers({ children }: { children: React.ReactNode }) { export function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>; return <SessionProvider refetchOnWindowFocus={false}>{children}</SessionProvider>;
} }

View File

@ -72,6 +72,7 @@ export default function CalendarEventModal({
const [endDate, setEndDate] = useState(getInitialEnd()); const [endDate, setEndDate] = useState(getInitialEnd());
const [allDay, setAllDay] = useState(!!event?.allDay); const [allDay, setAllDay] = useState(!!event?.allDay);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const handleSubmit = async () => { const handleSubmit = async () => {
@ -122,13 +123,13 @@ export default function CalendarEventModal({
return; return;
} }
setIsSaving(true); setIsDeleting(true);
try { try {
await onDelete(event.id, event.calendarId); await onDelete(event.id, event.calendarId);
onClose(); onClose();
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Failed to delete event'); setError(err.message || 'Failed to delete event');
setIsSaving(false); setIsDeleting(false);
setIsDeleteConfirming(false); setIsDeleteConfirming(false);
} }
}; };
@ -286,7 +287,7 @@ export default function CalendarEventModal({
{event && onDelete && ( {event && onDelete && (
<button <button
onClick={handleDelete} onClick={handleDelete}
disabled={isSaving} disabled={isSaving || isDeleting}
style={{ style={{
padding: '10px 20px', padding: '10px 20px',
background: isDeleteConfirming ? '#d32f2f' : 'transparent', background: isDeleteConfirming ? '#d32f2f' : 'transparent',
@ -299,13 +300,13 @@ export default function CalendarEventModal({
width: isDeleteConfirming ? 'auto' : 'initial' // Expand if needed width: isDeleteConfirming ? 'auto' : 'initial' // Expand if needed
}} }}
> >
{isDeleteConfirming ? 'Click again to confirm delete' : 'Delete'} {isDeleting ? 'Deleting...' : isDeleteConfirming ? 'Confirm Delete' : 'Delete'}
</button> </button>
)} )}
</div> </div>
<div style={{ display: 'flex', gap: '10px' }}> <div style={{ display: 'flex', gap: '10px' }}>
<button className="weekly-btn weekly-btn-secondary" onClick={onClose} disabled={isSaving}>Cancel</button> <button className="weekly-btn weekly-btn-secondary" onClick={onClose} disabled={isSaving || isDeleting}>Cancel</button>
<button className="weekly-btn weekly-btn-primary" onClick={handleSubmit} disabled={isSaving}> <button className="weekly-btn weekly-btn-primary" onClick={handleSubmit} disabled={isSaving || isDeleting}>
{isSaving ? 'Saving...' : 'Save'} {isSaving ? 'Saving...' : 'Save'}
</button> </button>
</div> </div>

View File

@ -75,6 +75,7 @@ export interface Task {
isRolling?: boolean; isRolling?: boolean;
isRecurring?: boolean; isRecurring?: boolean;
somedayListId?: string | null; somedayListId?: string | null;
somedaySlotIndex?: number | null;
repeatPattern?: string | null; repeatPattern?: string | null;
repeatEndDate?: string | null; repeatEndDate?: string | null;
repeatStartDate?: string | null; repeatStartDate?: string | null;
@ -115,6 +116,20 @@ interface SomedayList {
// Time grid configuration options // Time grid configuration options
type CellDuration = 15 | 30 | 60 | 120; type CellDuration = 15 | 30 | 60 | 120;
const DEFAULT_SOMEDAY_SLOT_COUNT = 5;
const getSomedaySlotCount = (tasks: Task[]) => {
const maxIdx = tasks.reduce((max, t) => {
if (t.somedaySlotIndex !== null && t.somedaySlotIndex !== undefined) {
return Math.max(max, t.somedaySlotIndex);
}
return max;
}, -1);
// Add 1 extra slot if more than 4 tasks exist, or at least 5 slots total.
// "add 5 rows and then when 4 are taken add another row"
// Let's ensure there's always at least one empty slot at the bottom.
return Math.max(DEFAULT_SOMEDAY_SLOT_COUNT, maxIdx + 2);
};
// Font options // Font options
const AVAILABLE_FONTS = [ const AVAILABLE_FONTS = [
@ -512,6 +527,7 @@ export default function WeeklyView() {
return () => window.removeEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize);
}, []); // savedViewDaysRef is a ref, so no dependency needed }, []); // savedViewDaysRef is a ref, so no dependency needed
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
const [isFetchingCalendar, setIsFetchingCalendar] = useState(false);
const [syncError, setSyncError] = useState<string | null>(null); const [syncError, setSyncError] = useState<string | null>(null);
const syncCountRef = useRef(0); const syncCountRef = useRef(0);
const startSync = useCallback(() => { syncCountRef.current++; setIsSyncing(true); }, []); const startSync = useCallback(() => { syncCountRef.current++; setIsSyncing(true); }, []);
@ -717,8 +733,10 @@ export default function WeeklyView() {
); );
const [currentTime, setCurrentTime] = useState(new Date()); const [currentTime, setCurrentTime] = useState(new Date());
const [dropPreview, setDropPreview] = useState<{ const [dropPreview, setDropPreview] = useState<{
day: number; day?: number;
slot: string; slot?: string;
listId?: string;
slotIdx?: number;
} | null>(null); } | null>(null);
const [viewStyle, setViewStyle] = useState<ViewStyle>("simple"); const [viewStyle, setViewStyle] = useState<ViewStyle>("simple");
const [protectEventTimes, setProtectEventTimes] = useState(true); const [protectEventTimes, setProtectEventTimes] = useState(true);
@ -904,6 +922,7 @@ export default function WeeklyView() {
// Fetch calendar events // Fetch calendar events
const fetchCalendarEvents = useCallback(async (forceRefresh = false) => { const fetchCalendarEvents = useCallback(async (forceRefresh = false) => {
startSync(); startSync();
setIsFetchingCalendar(true);
try { try {
const response = await fetch("/api/calendar/sync", { const response = await fetch("/api/calendar/sync", {
method: "POST", method: "POST",
@ -934,9 +953,10 @@ export default function WeeklyView() {
} catch (error) { } catch (error) {
console.error("Error fetching calendar events:", error); console.error("Error fetching calendar events:", error);
} finally { } finally {
setIsFetchingCalendar(false);
endSync(); endSync();
} }
}, [currentWeekStart]); }, [currentWeekStart, startSync, endSync]);
// Calendar Event Handlers // Calendar Event Handlers
const handleEventSave = async (eventData: any) => { const handleEventSave = async (eventData: any) => {
@ -1291,7 +1311,29 @@ export default function WeeklyView() {
const el = somedayGridRef.current; const el = somedayGridRef.current;
if (!el) return; if (!el) return;
const handler = (e: WheelEvent) => { const handler = (e: WheelEvent) => {
if (e.deltaY !== 0) { // Ignore if scrolling horizontally natively (trackpad)
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
// Check if hovering over a vertically scrollable list that isn't at its boundary
let target = e.target as HTMLElement | null;
let canScrollVertically = false;
while (target && target !== el) {
if (target.scrollHeight > target.clientHeight) {
const style = window.getComputedStyle(target);
if (style.overflowY === 'auto' || style.overflowY === 'scroll') {
const atTop = target.scrollTop <= 0;
const atBottom = target.scrollTop + target.clientHeight >= target.scrollHeight - 1;
if (!(e.deltaY < 0 && atTop) && !(e.deltaY > 0 && atBottom)) {
canScrollVertically = true;
break;
}
}
}
target = target.parentElement;
}
if (!canScrollVertically && e.deltaY !== 0) {
e.preventDefault(); e.preventDefault();
el.scrollLeft += e.deltaY; el.scrollLeft += e.deltaY;
} }
@ -2732,6 +2774,8 @@ export default function WeeklyView() {
dayOfWeek, dayOfWeek,
startTime, startTime,
scheduledDate: newScheduledDate || t.scheduledDate, scheduledDate: newScheduledDate || t.scheduledDate,
somedayListId: null,
somedaySlotIndex: null,
updatedAt: new Date(), updatedAt: new Date(),
} }
: t, : t,
@ -2747,6 +2791,8 @@ export default function WeeklyView() {
dayOfWeek, dayOfWeek,
startTime, startTime,
scheduledDate: newScheduledDate, scheduledDate: newScheduledDate,
somedayListId: null,
somedaySlotIndex: null,
}), }),
}); });
@ -2820,8 +2866,15 @@ export default function WeeklyView() {
if (match) originalId = match[1]; if (match) originalId = match[1];
} }
const taskToDelete = findTaskAnywhere(originalId);
setTasks((prev) => setTasks((prev) =>
prev.filter((t) => { prev.filter((t) => {
if (taskToDelete && t.title === taskToDelete.title &&
t.recurrenceInterval === taskToDelete.recurrenceInterval &&
t.recurrenceUnit === taskToDelete.recurrenceUnit) {
return false;
}
if (t.id === originalId) return false; if (t.id === originalId) return false;
if (t.id.startsWith(`virtual-${originalId}-`)) return false; if (t.id.startsWith(`virtual-${originalId}-`)) return false;
if (t.id === taskId) return false; if (t.id === taskId) return false;
@ -3066,6 +3119,7 @@ export default function WeeklyView() {
{ {
...draggedTask, ...draggedTask,
somedayListId: null, somedayListId: null,
somedaySlotIndex: null,
scheduledDate: newScheduledDate, scheduledDate: newScheduledDate,
dayOfWeek, dayOfWeek,
startTime: targetSlot || "", startTime: targetSlot || "",
@ -3079,6 +3133,7 @@ export default function WeeklyView() {
body: JSON.stringify({ body: JSON.stringify({
id: draggedTask.id, id: draggedTask.id,
somedayListId: null, somedayListId: null,
somedaySlotIndex: null,
scheduledDate: newScheduledDate, scheduledDate: newScheduledDate,
dayOfWeek, dayOfWeek,
startTime: targetSlot || "", startTime: targetSlot || "",
@ -3125,6 +3180,64 @@ export default function WeeklyView() {
setDropPreview(null); setDropPreview(null);
}; };
const handleSomedayDragOver = (e: React.DragEvent, listId: string, slotIdx: number) => {
e.preventDefault();
setDropPreview({ listId, slotIdx });
};
const handleSomedayDrop = async (e: React.DragEvent, listId: string, slotIndex: number) => {
e.preventDefault();
if (draggedTask) {
// Update local state for someday lists
setSomedayLists((prev) =>
prev.map((l) => {
// Remove the task from its current position in all lists
const filteredTasks = l.tasks.filter((t) => t.id !== draggedTask.id);
if (l.id === listId) {
const movedTask = {
...draggedTask,
somedayListId: listId,
somedaySlotIndex: slotIndex,
scheduledDate: null as any,
dayOfWeek: null as any,
startTime: null as any,
};
return {
...l,
tasks: [...filteredTasks, movedTask],
};
}
return { ...l, tasks: filteredTasks };
}),
);
// If it was a calendar task, remove it from the calendar tasks array
if (!draggedTask.somedayListId) {
setTasks((prev) => prev.filter((t) => t.id !== draggedTask.id));
}
// Persist the change
try {
await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: draggedTask.id,
somedayListId: listId,
somedaySlotIndex: slotIndex,
scheduledDate: null,
dayOfWeek: null,
startTime: null,
}),
});
} catch (error) {
console.error("Error moving task to someday slot:", error);
}
setDraggedTask(null);
setDropPreview(null);
}
};
// Sync calendar // Sync calendar
const handleSync = async () => { const handleSync = async () => {
setSyncStatus("syncing"); setSyncStatus("syncing");
@ -4032,6 +4145,14 @@ export default function WeeklyView() {
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
style={{ position: "relative" }} style={{ position: "relative" }}
> >
{/* Calendar Fetching Indicator */}
{showTimeGrid && colIndex === 0 && isFetchingCalendar && (
<div className="absolute top-2 left-2 z-[60] flex items-center gap-2 bg-white/90 dark:bg-zinc-800/90 px-3 py-1.5 rounded-full shadow-sm border border-zinc-200 dark:border-zinc-700 text-xs text-zinc-600 dark:text-zinc-300 pointer-events-none">
<div className="w-3 h-3 border-2 border-zinc-400 border-t-transparent rounded-full animate-spin"></div>
Syncing Calendar...
</div>
)}
{/* Now Line - only show on today's column */} {/* Now Line - only show on today's column */}
{isSameDay(date, new Date()) && {isSameDay(date, new Date()) &&
(() => { (() => {
@ -4353,14 +4474,13 @@ export default function WeeklyView() {
className="weekly-task-input" className="weekly-task-input"
style={{ style={{
width: "100%", width: "100%",
height: "100%",
border: "none",
background: "transparent", background: "transparent",
outline: "none", outline: "none",
minHeight: "24px", minHeight: "24px",
paddingLeft: "0", paddingLeft: "0",
}} }}
/> />
</form> </form>
)} )}
</div> </div>
@ -4368,47 +4488,7 @@ export default function WeeklyView() {
})} })}
{/* All Day Events Section */} {/* All Day Events Section */}
{/* Untimed Tasks List below grid */}
<div
className="weekly-task-list"
style={{
marginTop: "1rem",
borderTop: "1px solid #eee",
paddingTop: "0.5rem",
}}
>
{/* Filter for untimed tasks */}
{getTasksForDate(date)
.filter((task) => !task.startTime)
.map((task) => (
<TaskItem
key={task.id}
task={task}
isEditing={editingTaskId === task.id}
onToggle={() => toggleTask(task.id)}
onEdit={() => setEditingTaskId(task.id)}
onUpdate={(newTitle) =>
updateTask(task.id, newTitle)
}
onDelete={() => deleteTask(task.id)}
onNotes={(notes) => updateTaskNotes(task.id, notes)}
onRollToggle={() => toggleTaskRolling(task.id)}
onRecurrence={() =>
setSelectedTaskForRecurrence(task)
}
onDragStart={(e, t) => handleDragStart(e, t)}
onDragEnd={handleDragEnd}
variant="minimal"
onAddSubTask={addSubTask}
onToggleSubTask={toggleSubTask}
onDeleteSubTask={deleteSubTask}
onUpdateSubTask={updateSubTask}
editingTaskId={editingTaskId}
onSetEditingTaskId={setEditingTaskId}
showTaskCheckboxes={profile.showTaskCheckboxes}
/>
))}
</div>
</div> </div>
) : ( ) : (
<div <div
@ -4508,9 +4588,9 @@ export default function WeeklyView() {
{/* Someday Section */} {/* Someday Section */}
{showSomeday && ( {showSomeday && (
<section <section
className={`weekly-someday ${somedayExpanded ? "expanded" : "collapsed"} dark:bg-gray-900 dark:text-white transition-colors duration-200`} className={`weekly-someday ${somedayExpanded ? "expanded" : "collapsed"} transition-colors duration-200`}
> >
<div style={{ display: "flex", flexDirection: "row" }}> <div style={{ display: "flex", flexDirection: "row", maxWidth: "100%", width: "100%" }}>
{showTimeGrid && ( {showTimeGrid && (
<div <div
className="someday-label-column" className="someday-label-column"
@ -4652,11 +4732,12 @@ export default function WeeklyView() {
</button> </button>
</div> </div>
)} )}
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0, overflowX: "auto" }}>
{somedayExpanded && ( {somedayExpanded && (
<div <div
ref={somedayGridRef} ref={somedayGridRef}
className={`weekly-someday-lists-grid cols-${Math.min(7, Math.max(1, viewDays))}`} className={`weekly-someday-lists-grid cols-${Math.min(7, Math.max(1, viewDays))}`}
style={{ display: "flex", flexDirection: "row", flexWrap: "nowrap" }}
> >
{(() => { {(() => {
const baseLists = somedayLists.length > 0 const baseLists = somedayLists.length > 0
@ -4678,7 +4759,7 @@ export default function WeeklyView() {
.map((list) => ( .map((list) => (
<div <div
key={list.id} key={list.id}
className={`weekly-someday-list ${draggingListId === list.id ? "is-dragging" : ""} dark:bg-gray-800 dark:border-gray-700 p-2 transition-colors duration-200`} className={`weekly-someday-list ${draggingListId === list.id ? "is-dragging" : ""} p-2 transition-colors duration-200`}
style={{ style={{
minHeight: "200px", minHeight: "200px",
cursor: "text", // Indicate actionable area cursor: "text", // Indicate actionable area
@ -4752,122 +4833,15 @@ export default function WeeklyView() {
return; return;
} }
// Check if a calendar task is being dropped into this someday list // If a task is dropped on the list generally (not on a specific slot),
if ( // find the first free slot and place it there.
draggedTaskId && if (draggedTaskId && draggedTask && draggedTask.id === draggedTaskId) {
draggedTask && const occupiedSlots = list.tasks.map(t => t.somedaySlotIndex).filter(s => s !== null && s !== undefined) as number[];
draggedTask.id === draggedTaskId let nextFreeSlot = 0;
) { while (occupiedSlots.includes(nextFreeSlot)) {
// Move calendar task to this someday list nextFreeSlot++;
const taskToMove = draggedTask;
// Remove from calendar tasks
setTasks((prev) =>
prev.filter((t) => t.id !== taskToMove.id),
);
// Add to target someday list and remove from source/other someday lists
const movedTask = {
...taskToMove,
somedayListId: list.id,
scheduledDate: undefined,
dayOfWeek: null,
startTime: "",
};
setSomedayLists((prev) =>
prev.map((l) => {
// Filter out the task from all lists first (handles source removal and prevents target duplicates)
const filteredTasks = l.tasks.filter(
(t) => t.id !== taskToMove.id,
);
if (l.id === list.id) {
return {
...l,
tasks: [...filteredTasks, movedTask],
};
}
return { ...l, tasks: filteredTasks };
}),
);
setDraggedTask(null);
// Persist
try {
await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: taskToMove.id,
somedayListId: list.id,
scheduledDate: null,
dayOfWeek: null,
startTime: null,
}),
});
// Clear due date in external provider when moving to someday
if (
taskToMove.externalId &&
taskToMove.externalProvider
) {
fetch("/api/tasks/sync", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
taskId: taskToMove.id,
scheduledDate: null,
}),
}).catch((e) =>
console.error("Sync error:", e),
);
} else if (list.externalProvider) {
// If target list is synced but task is not, push it
fetch("/api/tasks/sync", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
taskId: taskToMove.id,
}),
})
.then(async (r) => {
if (r.ok) {
const syncData = await r.json();
if (syncData.task) {
// Update local state with external IDs
setSomedayLists((prev) =>
prev.map((l) =>
l.id === list.id
? {
...l,
tasks: l.tasks.map(
(t) =>
t.id ===
taskToMove.id
? {
...t,
...syncData.task,
}
: t,
),
}
: l,
),
);
}
}
})
.catch((e) =>
console.error("Sync push error:", e),
);
}
} catch (error) {
console.error(
"Error moving task to someday list:",
error,
);
} }
handleSomedayDrop(e, list.id, nextFreeSlot);
return; return;
} }
@ -4926,7 +4900,6 @@ export default function WeeklyView() {
> >
<GripVertical size={14} /> <GripVertical size={14} />
</div> </div>
{/* Editable Title */}
<input <input
type="text" type="text"
defaultValue={list.title} defaultValue={list.title}
@ -5012,28 +4985,41 @@ export default function WeeklyView() {
× ×
</button> </button>
</div> </div>
<ol
<div
className="weekly-task-list" className="weekly-task-list"
style={{ style={{
flex: 1, flex: 1,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
justifyContent: "flex-start",
position: "relative",
}} }}
> >
{list.tasks.map((task) => ( {Array.from({ length: getSomedaySlotCount(list.tasks) }).map((_, slotIdx) => {
const taskInSlot = list.tasks.find(t => t.somedaySlotIndex === slotIdx);
const isTarget = dropPreview?.listId === list.id && dropPreview?.slotIdx === slotIdx;
return (
<div
key={slotIdx}
className={`task-list-slot ${isTarget ? 'drop-target' : ''}`}
onDragOver={(e) => handleSomedayDragOver(e, list.id, slotIdx)}
onDrop={(e) => handleSomedayDrop(e, list.id, slotIdx)}
onDragLeave={() => setDropPreview(null)}
>
{taskInSlot && (
<TaskItem <TaskItem
key={task.id} key={taskInSlot.id}
task={task} task={taskInSlot}
isEditing={editingTaskId === task.id} isEditing={editingTaskId === taskInSlot.id}
onToggle={() => toggleTask(task.id)} onToggle={() => toggleTask(taskInSlot.id)}
onEdit={() => setEditingTaskId(task.id)} onEdit={() => setEditingTaskId(taskInSlot.id)}
onUpdate={(title) => updateTask(task.id, title)} onUpdate={(title) => updateTask(taskInSlot.id, title)}
onDelete={() => deleteTask(task.id)} onDelete={() => deleteTask(taskInSlot.id)}
onNotes={() => setSelectedTaskForNotes(task)} onNotes={() => setSelectedTaskForNotes(taskInSlot)}
onRollToggle={() => toggleTaskRolling(task.id)} onRollToggle={() => toggleTaskRolling(taskInSlot.id)}
onRecurrence={() => onRecurrence={() => setSelectedTaskForRecurrence(taskInSlot)}
setSelectedTaskForRecurrence(task)
}
onDragStart={(e, t) => handleDragStart(e, t)} onDragStart={(e, t) => handleDragStart(e, t)}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
variant="minimal" variant="minimal"
@ -5046,10 +5032,49 @@ export default function WeeklyView() {
onSetEditingTaskId={setEditingTaskId} onSetEditingTaskId={setEditingTaskId}
showTaskCheckboxes={profile.showTaskCheckboxes} showTaskCheckboxes={profile.showTaskCheckboxes}
/> />
)}
</div>
);
})}
{/* Legacy / unindexed tasks */}
{list.tasks.filter(t => t.somedaySlotIndex === null || t.somedaySlotIndex === undefined || t.somedaySlotIndex >= getSomedaySlotCount(list.tasks)).map(task => (
<div key={task.id} className="task-list-slot">
<TaskItem
key={task.id}
task={task}
isEditing={editingTaskId === task.id}
onToggle={() => toggleTask(task.id)}
onEdit={() => setEditingTaskId(task.id)}
onUpdate={(title) => updateTask(task.id, title)}
onDelete={() => deleteTask(task.id)}
onNotes={() => setSelectedTaskForNotes(task)}
onRollToggle={() => toggleTaskRolling(task.id)}
onRecurrence={() => setSelectedTaskForRecurrence(task)}
onDragStart={(e, t) => handleDragStart(e, t)}
onDragEnd={handleDragEnd}
variant="minimal"
isSomeday={true}
onAddSubTask={addSubTask}
onToggleSubTask={toggleSubTask}
onDeleteSubTask={deleteSubTask}
onUpdateSubTask={updateSubTask}
editingTaskId={editingTaskId}
onSetEditingTaskId={setEditingTaskId}
showTaskCheckboxes={profile.showTaskCheckboxes}
/>
</div>
))} ))}
<SomedayAddTask <SomedayAddTask
listId={list.id} listId={list.id}
onAdd={async (title) => { onAdd={async (title) => {
const occupiedSlots = list.tasks.map(t => t.somedaySlotIndex).filter(s => s !== null && s !== undefined) as number[];
let nextFreeSlot = 0;
while (occupiedSlots.includes(nextFreeSlot)) {
nextFreeSlot++;
}
try { try {
const res = await fetch("/api/tasks", { const res = await fetch("/api/tasks", {
method: "POST", method: "POST",
@ -5059,11 +5084,12 @@ export default function WeeklyView() {
body: JSON.stringify({ body: JSON.stringify({
title, title,
somedayListId: list.id, somedayListId: list.id,
somedaySlotIndex: nextFreeSlot
}), }),
}); });
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
let newTask = { const newTask = {
...data.task, ...data.task,
createdAt: new Date(data.task.createdAt), createdAt: new Date(data.task.createdAt),
updatedAt: new Date(data.task.updatedAt), updatedAt: new Date(data.task.updatedAt),
@ -5075,53 +5101,13 @@ export default function WeeklyView() {
: l, : l,
), ),
); );
// Push to external provider if list is synced
if (list.externalProvider) {
try {
const syncRes = await fetch("/api/tasks/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ taskId: newTask.id }),
});
if (syncRes.ok) {
const syncData = await syncRes.json();
if (syncData.task) {
newTask = { ...newTask, ...syncData.task };
setSomedayLists((prev) =>
prev.map((l) =>
l.id === list.id
? { ...l, tasks: l.tasks.map(t => t.id === newTask.id ? newTask : t) }
: l,
),
);
}
}
} catch (syncErr) {
console.error("Failed to sync new task to external provider:", syncErr);
}
}
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
}} }}
/> />
{Array.from({ </div>
length: Math.max(0, 5 - list.tasks.length),
}).map((_, i) => (
<li
key={`filler-${i}`}
className="weekly-task-item minimal filler"
style={{
borderBottom: "1px solid var(--weekly-border)",
height: "32px",
margin: "0 0.5rem",
pointerEvents: "none",
}}
></li>
))}
</ol>
</div> </div>
))} ))}
@ -5295,7 +5281,8 @@ export default function WeeklyView() {
</div> </div>
{/* close flex row */} {/* close flex row */}
</section> </section>
)} )
}
{/* Search Modal */} {/* Search Modal */}
<SearchModal <SearchModal
@ -5320,19 +5307,27 @@ export default function WeeklyView() {
const dateStr = new Date().toISOString(); const dateStr = new Date().toISOString();
// Update locally // Update locally
setTasks((prev) => setTasks((prev) => {
prev.map((t) => { const newTasks = [];
for (const t of prev) {
const isMatch = const isMatch =
t.title === task.title && t.title === task.title &&
t.userId === task.userId && t.userId === task.userId &&
t.recurrenceInterval === task.recurrenceInterval && t.recurrenceInterval === task.recurrenceInterval &&
t.recurrenceUnit === task.recurrenceUnit; t.recurrenceUnit === task.recurrenceUnit;
if (isMatch) { if (isMatch) {
return { ...t, recurrenceEndDate: new Date(dateStr) }; // Remove future occurrences from the UI
if (t.scheduledDate && new Date(t.scheduledDate) > new Date(dateStr)) {
continue;
} }
return t; newTasks.push({ ...t, recurrenceEndDate: new Date(dateStr) });
}), } else {
); newTasks.push(t);
}
}
return newTasks;
});
// Update DB // Update DB
try { try {
@ -5352,7 +5347,8 @@ export default function WeeklyView() {
/> />
{/* Recurring Task Delete Confirmation Modal */} {/* Recurring Task Delete Confirmation Modal */}
{recurringDeleteModal.isOpen && ( {
recurringDeleteModal.isOpen && (
<div <div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-[100] px-4" className="fixed inset-0 bg-black/50 flex items-center justify-center z-[100] px-4"
onClick={() => onClick={() =>
@ -5437,10 +5433,12 @@ export default function WeeklyView() {
</div> </div>
</div> </div>
</div> </div>
)} )
}
{/* Calendar Event Modal */} {/* Calendar Event Modal */}
{calendarEventModal.isOpen && ( {
calendarEventModal.isOpen && (
<CalendarEventModal <CalendarEventModal
event={calendarEventModal.event} event={calendarEventModal.event}
initialDate={calendarEventModal.initialDate} initialDate={calendarEventModal.initialDate}
@ -5452,9 +5450,11 @@ export default function WeeklyView() {
onSave={handleEventSave} onSave={handleEventSave}
onDelete={handleEventDelete} onDelete={handleEventDelete}
/> />
)} )
}
{/* Focus Mode Overlay */} {/* Focus Mode Overlay */}
{showFocusMode && ( {
showFocusMode && (
<FocusModeOverlay <FocusModeOverlay
task={(() => { task={(() => {
// Logic to find the "Next Task" // Logic to find the "Next Task"
@ -5502,10 +5502,12 @@ export default function WeeklyView() {
onClose={() => setShowFocusMode(false)} onClose={() => setShowFocusMode(false)}
onComplete={(taskId) => toggleTask(taskId)} onComplete={(taskId) => toggleTask(taskId)}
/> />
)} )
}
{/* Settings Sidebar */} {/* Settings Sidebar */}
{showSettings && ( {
showSettings && (
<SettingsSidebar <SettingsSidebar
initialTab={activeTab} initialTab={activeTab}
onRemoveConnection={handleRemoveConnection} onRemoveConnection={handleRemoveConnection}
@ -5576,23 +5578,28 @@ export default function WeeklyView() {
handleToggleTaskList={handleToggleTaskList} handleToggleTaskList={handleToggleTaskList}
fetchAvailableTaskLists={fetchAvailableTaskLists} fetchAvailableTaskLists={fetchAvailableTaskLists}
/> />
)} )
}
{selectedTaskForRecurrence && ( {
selectedTaskForRecurrence && (
<TaskRecurrenceModal <TaskRecurrenceModal
task={selectedTaskForRecurrence} task={selectedTaskForRecurrence}
onClose={() => setSelectedTaskForRecurrence(null)} onClose={() => setSelectedTaskForRecurrence(null)}
onSave={handleRecurrenceSave} onSave={handleRecurrenceSave}
/> />
)} )
}
{selectedTaskForNotes && ( {
selectedTaskForNotes && (
<NotesSidebar <NotesSidebar
task={selectedTaskForNotes} task={selectedTaskForNotes}
onClose={() => setSelectedTaskForNotes(null)} onClose={() => setSelectedTaskForNotes(null)}
updateTaskNotes={updateTaskNotes} updateTaskNotes={updateTaskNotes}
/> />
)} )
}
<ImportListModal <ImportListModal
isOpen={isImportModalOpen} isOpen={isImportModalOpen}
onClose={() => setIsImportModalOpen(false)} onClose={() => setIsImportModalOpen(false)}
@ -5601,7 +5608,7 @@ export default function WeeklyView() {
lists={importLists} lists={importLists}
isLoading={isFetchingLists} isLoading={isFetchingLists}
/> />
</div> </div >
); );
} }
@ -5661,7 +5668,6 @@ function SomedayAddTask({
<li <li
className="weekly-task-item minimal" className="weekly-task-item minimal"
style={{ style={{
borderBottom: "1px solid var(--weekly-border)",
margin: "0 0.5rem", margin: "0 0.5rem",
}} }}
> >

View File

@ -363,6 +363,7 @@ export const updateEvent = async (
title?: string; title?: string;
description?: string; description?: string;
location?: string; location?: string;
url?: string;
start?: { dateTime?: string; date?: string }; start?: { dateTime?: string; date?: string };
end?: { dateTime?: string; date?: string }; end?: { dateTime?: string; date?: string };
} }
@ -499,6 +500,14 @@ export const updateEvent = async (
if (eventData.description) event.description = eventData.description; if (eventData.description) event.description = eventData.description;
if (eventData.location) event.location = eventData.location; if (eventData.location) event.location = eventData.location;
if (eventData.url !== undefined) {
if (eventData.url) {
vevent.updatePropertyWithValue('url', eventData.url);
} else {
vevent.removeProperty('url');
}
}
if (eventData.start) { if (eventData.start) {
if (eventData.start.date) { if (eventData.start.date) {
event.startDate = ICAL.Time.fromJSDate(new Date(eventData.start.date), true); event.startDate = ICAL.Time.fromJSDate(new Date(eventData.start.date), true);

View File

@ -1,5 +1,6 @@
import { NextAuthOptions } from "next-auth"; import { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google"; import GoogleProvider from "next-auth/providers/google";
import AppleProvider from "next-auth/providers/apple";
import { PrismaAdapter } from "@auth/prisma-adapter"; import { PrismaAdapter } from "@auth/prisma-adapter";
import CredentialsProvider from "next-auth/providers/credentials"; import CredentialsProvider from "next-auth/providers/credentials";
import { compare } from "bcryptjs"; import { compare } from "bcryptjs";
@ -19,6 +20,10 @@ export const authOptions: NextAuthOptions = {
} }
} }
}), }),
AppleProvider({
clientId: process.env.APPLE_ID || "",
clientSecret: process.env.APPLE_SECRET || ""
}),
CredentialsProvider({ CredentialsProvider({
name: "credentials", name: "credentials",
credentials: { credentials: {

View File

@ -686,8 +686,11 @@ export const updateCalendarEvent = async (
if (rrule) googleEvent.recurrence = [rrule]; if (rrule) googleEvent.recurrence = [rrule];
if (event.url) googleEvent.source = { url: event.url, title: event.url }; if (event.url) googleEvent.source = { url: event.url, title: event.url };
// Google adds _date suffix for instances. Editing base series only.
const baseEventId = eventId.split('_')[0];
const updatedEvent = await import('./google-calendar').then(m => const updatedEvent = await import('./google-calendar').then(m =>
m.updateEvent(oauth2Client, accessToken, calendarId, eventId, googleEvent) m.updateEvent(oauth2Client, accessToken, calendarId, baseEventId, googleEvent)
); );
return { return {
@ -709,8 +712,11 @@ export const updateCalendarEvent = async (
else throw new Error('Failed to refresh token'); else throw new Error('Failed to refresh token');
} }
// Extract base series ID for Outlook
const baseEventId = eventId.includes('::') ? eventId.split('::')[0] : eventId;
const startDate = event.start?.dateTime ? new Date(event.start.dateTime) : new Date(); const startDate = event.start?.dateTime ? new Date(event.start.dateTime) : new Date();
const updatedEvent = await updateOutlookEvent(accessToken, calendarId, eventId, { const updatedEvent = await updateOutlookEvent(accessToken, calendarId, baseEventId, {
summary: event.title, summary: event.title,
description: event.description, description: event.description,
start: event.start, start: event.start,
@ -740,6 +746,7 @@ export const updateCalendarEvent = async (
title: event.title, title: event.title,
description: event.description, description: event.description,
location: event.location, location: event.location,
url: event.url,
start: event.start, start: event.start,
end: event.end end: event.end
}) })
@ -785,8 +792,11 @@ export const deleteCalendarEvent = async (
process.env.GOOGLE_REDIRECT_URI || '' process.env.GOOGLE_REDIRECT_URI || ''
); );
// Default to deleting the whole series if repeating
const baseEventId = eventId.split('_')[0];
await import('./google-calendar').then(m => await import('./google-calendar').then(m =>
m.deleteEvent(oauth2Client, accessToken, calendarId, eventId) m.deleteEvent(oauth2Client, accessToken, calendarId, baseEventId)
); );
return; return;
} else if (connection.provider === 'outlook') { } else if (connection.provider === 'outlook') {
@ -797,7 +807,10 @@ export const deleteCalendarEvent = async (
else throw new Error('Failed to refresh token'); else throw new Error('Failed to refresh token');
} }
await deleteOutlookEvent(accessToken, calendarId, eventId); // Extract base series ID for Outlook
const baseEventId = eventId.includes('::') ? eventId.split('::')[0] : eventId;
await deleteOutlookEvent(accessToken, calendarId, baseEventId);
return; return;
} else if (connection.provider === 'apple') { } else if (connection.provider === 'apple') {
const [email, appPassword] = connection.accessToken.split(':'); const [email, appPassword] = connection.accessToken.split(':');

View File

@ -108,15 +108,28 @@ export async function sendVerificationEmail(
const from = process.env.SMTP_FROM || '"My Weekly ToDo\'s" <noreply@example.com>'; const from = process.env.SMTP_FROM || '"My Weekly ToDo\'s" <noreply@example.com>';
console.log(`[EMAIL] Sending verification email to ${email} from ${from}`); console.log(`[EMAIL] Sending verification email to ${email} from ${from}`);
console.log(`\n======================================================`);
console.log(`[DEV VERIFICATION LINK]:\n${verifyLink}`);
console.log(`======================================================\n`);
const result = await getTransporter().sendMail({ try {
// 5 second timeout for SMTP
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('SMTP timeout')), 5000)
);
const mailPromise = getTransporter().sendMail({
from, from,
to: email, to: email,
subject: `${code} Verify your email for My Weekly ToDo's`, subject: `${code} Verify your email for My Weekly ToDo's`,
html, html,
}); });
const result = await Promise.race([mailPromise, timeoutPromise]) as any;
console.log(`[EMAIL] Email sent successfully: ${result.messageId}`); console.log(`[EMAIL] Email sent successfully: ${result.messageId}`);
} catch (err: any) {
console.error(`[EMAIL] Failed to send email (Error: ${err.message}). The verification link is printed above for manual use.`);
}
} }
export function generateVerificationCode(): string { export function generateVerificationCode(): string {

View File

@ -147,7 +147,7 @@ export const getUpcomingEvents = async (
const params = new URLSearchParams({ const params = new URLSearchParams({
startDateTime: startDateTime, startDateTime: startDateTime,
endDateTime: endDateTime, endDateTime: endDateTime,
'$select': 'subject,bodyPreview,start,end,location,webLink,isAllDay', '$select': 'subject,bodyPreview,start,end,location,webLink,isAllDay,seriesMasterId,type',
'$orderby': 'start/dateTime', '$orderby': 'start/dateTime',
'$top': '50' '$top': '50'
}); });
@ -168,7 +168,7 @@ export const getUpcomingEvents = async (
const data = await response.json(); const data = await response.json();
return data.value.map((event: any) => ({ return data.value.map((event: any) => ({
id: event.id, id: event.seriesMasterId ? `${event.seriesMasterId}::${event.id}` : event.id,
summary: event.subject, summary: event.subject,
description: event.body?.content || event.bodyPreview, description: event.body?.content || event.bodyPreview,
start: { start: {

25
test-email.js Normal file
View File

@ -0,0 +1,25 @@
const nodemailer = require('nodemailer');
const host = process.env.SMTP_HOST;
const port = 465; // Force 465
const secure = true; // Use TLS
const user = process.env.SMTP_USERNAME;
const pass = process.env.SMTP_PASSWORD;
const from = process.env.SMTP_FROM || 'test@example.com';
const transporter = nodemailer.createTransport({
host,
port,
secure,
auth: { user, pass },
tls: { rejectUnauthorized: false },
});
transporter.verify(function (error, success) {
if (error) {
console.log('SMTP Verification Error:', error);
} else {
console.log('SMTP Server is ready on port 465');
process.exit(0);
}
});

File diff suppressed because one or more lines are too long