Refactor Someday area to grid layout with dynamic slots, fixed scrolling, and border refinements
This commit is contained in:
parent
dea7c387ea
commit
fcaadc1ce9
@ -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",
|
||||
"main": "index.js",
|
||||
"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",
|
||||
"start": "next start",
|
||||
"start": "echo \"\\n ✦ My Weekly ToDo List v$(node -p \"require('./package.json').version\") — production\\n\" && next start",
|
||||
"lint": "next lint",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "jest"
|
||||
|
||||
@ -142,6 +142,7 @@ model Task {
|
||||
dayOfWeek Int?
|
||||
scheduledDate DateTime?
|
||||
somedayListId String?
|
||||
somedaySlotIndex Int?
|
||||
originalDate DateTime?
|
||||
startTime String?
|
||||
endTime String?
|
||||
|
||||
@ -195,7 +195,7 @@ export async function POST(request: NextRequest) {
|
||||
const userId = (session.user as any).id;
|
||||
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;
|
||||
const { isRecurring } = body;
|
||||
|
||||
@ -232,6 +232,7 @@ export async function POST(request: NextRequest) {
|
||||
recurrenceUnit,
|
||||
recurrenceTime,
|
||||
recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null,
|
||||
somedaySlotIndex: somedaySlotIndex !== undefined ? parseInt(somedaySlotIndex) : null,
|
||||
parentTaskId: parentTaskId || null,
|
||||
},
|
||||
});
|
||||
@ -265,7 +266,7 @@ export async function PATCH(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
|
||||
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) {
|
||||
return NextResponse.json(
|
||||
@ -349,6 +350,7 @@ export async function PATCH(request: NextRequest) {
|
||||
...(recurrenceTime !== undefined && { recurrenceTime }),
|
||||
...(recurrenceEndDate !== undefined && { recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null }),
|
||||
...(restore === true && { deletedAt: null }),
|
||||
...(somedaySlotIndex !== undefined && { somedaySlotIndex: somedaySlotIndex !== null ? parseInt(somedaySlotIndex) : null }),
|
||||
...(parentTaskId !== undefined && { parentTaskId: parentTaskId || null })
|
||||
},
|
||||
});
|
||||
|
||||
@ -104,6 +104,20 @@ function LoginContent() {
|
||||
Continue with Google
|
||||
</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 */}
|
||||
<div className="weekly-auth-links">
|
||||
<Link href="/auth/forgot-password" className="weekly-auth-link">
|
||||
|
||||
@ -169,6 +169,20 @@ export default function SignupPage() {
|
||||
Sign up with Google
|
||||
</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 */}
|
||||
<div className="weekly-auth-links">
|
||||
<span className="weekly-auth-text">Already have an account?</span>
|
||||
|
||||
@ -1158,7 +1158,7 @@ h3 {
|
||||
/* Task Input */
|
||||
.weekly-task-input {
|
||||
padding: 0.5rem 1rem 0.5rem 2.5rem;
|
||||
border-bottom: 1px solid var(--weekly-border);
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.weekly-task-input input {
|
||||
@ -1177,11 +1177,17 @@ h3 {
|
||||
|
||||
/* Someday Section */
|
||||
.weekly-someday {
|
||||
/* background color removed as per user request */
|
||||
background-color: #f9f9f9;
|
||||
width: 100%;
|
||||
padding: 0 1rem;
|
||||
transition: max-height 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.weekly-container.dark-mode .weekly-someday {
|
||||
background-color: #1a1a1b;
|
||||
}
|
||||
|
||||
.weekly-someday.collapsed {
|
||||
max-height: 30px;
|
||||
overflow: hidden;
|
||||
@ -1251,16 +1257,16 @@ h3 {
|
||||
|
||||
/* Someday Lists Container - Horizontal Scroll */
|
||||
.weekly-someday-lists-grid {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden; /* Prevent vertical Scrollbar on container */
|
||||
padding-bottom: 0.5rem; /* Space for scrollbar */
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
flex-wrap: nowrap !important;
|
||||
gap: 1.5rem;
|
||||
width: max-content; /* Allow grid to grow beyond parent width */
|
||||
min-width: 100%;
|
||||
padding-bottom: 1rem;
|
||||
align-items: flex-start;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling for Someday Container */
|
||||
@ -1308,25 +1314,43 @@ h3 {
|
||||
|
||||
/* Ruled paper lines for someday tasks */
|
||||
.weekly-someday-list {
|
||||
/* border-right: 1px solid var(--weekly-border); Removed for cleaner look */
|
||||
padding: 0;
|
||||
min-height: 200px;
|
||||
flex: 0 0 280px; /* Fixed width for horizontal scrolling */
|
||||
width: 280px;
|
||||
flex: 0 0 300px; /* Slightly wider lists */
|
||||
width: 300px;
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
max-height: 380px;
|
||||
background-image: repeating-linear-gradient(
|
||||
transparent,
|
||||
transparent 31px,
|
||||
var(--weekly-border) 31px,
|
||||
var(--weekly-border) 32px
|
||||
);
|
||||
background-attachment: local;
|
||||
background-position: 0 40px; /* Offset for the header */
|
||||
scrollbar-width: none; /* Hide scrollbar Firefox */
|
||||
-ms-overflow-style: none; /* Hide scrollbar IE/Edge */
|
||||
}
|
||||
|
||||
.weekly-someday-list::-webkit-scrollbar {
|
||||
display: none; /* Hide scrollbar Chrome/Safari/Webkit */
|
||||
}
|
||||
|
||||
.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 {
|
||||
@ -1363,9 +1387,18 @@ h3 {
|
||||
.weekly-someday-list.placeholder-list {
|
||||
background-image: repeating-linear-gradient(
|
||||
transparent,
|
||||
transparent 31px,
|
||||
#f5f5f5 31px,
|
||||
#f5f5f5 32px
|
||||
transparent 37px,
|
||||
#e5e5e5 37px,
|
||||
#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 {
|
||||
border-bottom: 1px solid transparent;
|
||||
min-height: 32px;
|
||||
border-bottom: none;
|
||||
min-height: 38px;
|
||||
height: auto;
|
||||
padding: 4px 1rem;
|
||||
padding: 6px 1rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.weekly-container.dark-mode .weekly-someday-list .weekly-task-item {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.weekly-someday-list .weekly-task-text {
|
||||
font-size: var(--base-font-size);
|
||||
line-height: 1.4;
|
||||
@ -1398,25 +1435,31 @@ h3 {
|
||||
}
|
||||
|
||||
.weekly-someday-list-title-header {
|
||||
padding: 0.75rem 1rem 0.25rem;
|
||||
height: 40px;
|
||||
padding: 0 1rem;
|
||||
margin-bottom: 0;
|
||||
height: 56px;
|
||||
min-height: 56px;
|
||||
display: flex;
|
||||
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 {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--weekly-text, #333);
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--weekly-text, #222);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
width: 100%;
|
||||
padding: 2px 0;
|
||||
padding: 4px 0;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.weekly-someday .weekly-task-item {
|
||||
@ -1763,32 +1806,10 @@ h3 {
|
||||
|
||||
/* === Tablet (≤ 1024px): 5-day view === */
|
||||
@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 === */
|
||||
@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 {
|
||||
grid-template-columns: 1fr;
|
||||
@ -1838,17 +1859,6 @@ h3 {
|
||||
|
||||
/* === Phone portrait (≤ 480px): 1-day view === */
|
||||
@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 */
|
||||
.time-column {
|
||||
|
||||
@ -3,5 +3,5 @@
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
return <SessionProvider refetchOnWindowFocus={false}>{children}</SessionProvider>;
|
||||
}
|
||||
|
||||
@ -72,6 +72,7 @@ export default function CalendarEventModal({
|
||||
const [endDate, setEndDate] = useState(getInitialEnd());
|
||||
const [allDay, setAllDay] = useState(!!event?.allDay);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@ -122,13 +123,13 @@ export default function CalendarEventModal({
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await onDelete(event.id, event.calendarId);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to delete event');
|
||||
setIsSaving(false);
|
||||
setIsDeleting(false);
|
||||
setIsDeleteConfirming(false);
|
||||
}
|
||||
};
|
||||
@ -286,7 +287,7 @@ export default function CalendarEventModal({
|
||||
{event && onDelete && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isSaving}
|
||||
disabled={isSaving || isDeleting}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
background: isDeleteConfirming ? '#d32f2f' : 'transparent',
|
||||
@ -299,13 +300,13 @@ export default function CalendarEventModal({
|
||||
width: isDeleteConfirming ? 'auto' : 'initial' // Expand if needed
|
||||
}}
|
||||
>
|
||||
{isDeleteConfirming ? 'Click again to confirm delete' : 'Delete'}
|
||||
{isDeleting ? 'Deleting...' : isDeleteConfirming ? 'Confirm Delete' : 'Delete'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<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-primary" onClick={handleSubmit} disabled={isSaving}>
|
||||
<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 || isDeleting}>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -75,6 +75,7 @@ export interface Task {
|
||||
isRolling?: boolean;
|
||||
isRecurring?: boolean;
|
||||
somedayListId?: string | null;
|
||||
somedaySlotIndex?: number | null;
|
||||
repeatPattern?: string | null;
|
||||
repeatEndDate?: string | null;
|
||||
repeatStartDate?: string | null;
|
||||
@ -115,6 +116,20 @@ interface SomedayList {
|
||||
|
||||
// Time grid configuration options
|
||||
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
|
||||
const AVAILABLE_FONTS = [
|
||||
@ -512,6 +527,7 @@ export default function WeeklyView() {
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []); // savedViewDaysRef is a ref, so no dependency needed
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [isFetchingCalendar, setIsFetchingCalendar] = useState(false);
|
||||
const [syncError, setSyncError] = useState<string | null>(null);
|
||||
const syncCountRef = useRef(0);
|
||||
const startSync = useCallback(() => { syncCountRef.current++; setIsSyncing(true); }, []);
|
||||
@ -717,8 +733,10 @@ export default function WeeklyView() {
|
||||
);
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [dropPreview, setDropPreview] = useState<{
|
||||
day: number;
|
||||
slot: string;
|
||||
day?: number;
|
||||
slot?: string;
|
||||
listId?: string;
|
||||
slotIdx?: number;
|
||||
} | null>(null);
|
||||
const [viewStyle, setViewStyle] = useState<ViewStyle>("simple");
|
||||
const [protectEventTimes, setProtectEventTimes] = useState(true);
|
||||
@ -904,6 +922,7 @@ export default function WeeklyView() {
|
||||
// Fetch calendar events
|
||||
const fetchCalendarEvents = useCallback(async (forceRefresh = false) => {
|
||||
startSync();
|
||||
setIsFetchingCalendar(true);
|
||||
try {
|
||||
const response = await fetch("/api/calendar/sync", {
|
||||
method: "POST",
|
||||
@ -934,9 +953,10 @@ export default function WeeklyView() {
|
||||
} catch (error) {
|
||||
console.error("Error fetching calendar events:", error);
|
||||
} finally {
|
||||
setIsFetchingCalendar(false);
|
||||
endSync();
|
||||
}
|
||||
}, [currentWeekStart]);
|
||||
}, [currentWeekStart, startSync, endSync]);
|
||||
|
||||
// Calendar Event Handlers
|
||||
const handleEventSave = async (eventData: any) => {
|
||||
@ -1291,7 +1311,29 @@ export default function WeeklyView() {
|
||||
const el = somedayGridRef.current;
|
||||
if (!el) return;
|
||||
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();
|
||||
el.scrollLeft += e.deltaY;
|
||||
}
|
||||
@ -2732,6 +2774,8 @@ export default function WeeklyView() {
|
||||
dayOfWeek,
|
||||
startTime,
|
||||
scheduledDate: newScheduledDate || t.scheduledDate,
|
||||
somedayListId: null,
|
||||
somedaySlotIndex: null,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
: t,
|
||||
@ -2747,6 +2791,8 @@ export default function WeeklyView() {
|
||||
dayOfWeek,
|
||||
startTime,
|
||||
scheduledDate: newScheduledDate,
|
||||
somedayListId: null,
|
||||
somedaySlotIndex: null,
|
||||
}),
|
||||
});
|
||||
|
||||
@ -2820,8 +2866,15 @@ export default function WeeklyView() {
|
||||
if (match) originalId = match[1];
|
||||
}
|
||||
|
||||
const taskToDelete = findTaskAnywhere(originalId);
|
||||
|
||||
setTasks((prev) =>
|
||||
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.startsWith(`virtual-${originalId}-`)) return false;
|
||||
if (t.id === taskId) return false;
|
||||
@ -3066,6 +3119,7 @@ export default function WeeklyView() {
|
||||
{
|
||||
...draggedTask,
|
||||
somedayListId: null,
|
||||
somedaySlotIndex: null,
|
||||
scheduledDate: newScheduledDate,
|
||||
dayOfWeek,
|
||||
startTime: targetSlot || "",
|
||||
@ -3079,6 +3133,7 @@ export default function WeeklyView() {
|
||||
body: JSON.stringify({
|
||||
id: draggedTask.id,
|
||||
somedayListId: null,
|
||||
somedaySlotIndex: null,
|
||||
scheduledDate: newScheduledDate,
|
||||
dayOfWeek,
|
||||
startTime: targetSlot || "",
|
||||
@ -3125,6 +3180,64 @@ export default function WeeklyView() {
|
||||
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
|
||||
const handleSync = async () => {
|
||||
setSyncStatus("syncing");
|
||||
@ -4032,6 +4145,14 @@ export default function WeeklyView() {
|
||||
onDragLeave={handleDragLeave}
|
||||
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 */}
|
||||
{isSameDay(date, new Date()) &&
|
||||
(() => {
|
||||
@ -4353,14 +4474,13 @@ export default function WeeklyView() {
|
||||
className="weekly-task-input"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
outline: "none",
|
||||
minHeight: "24px",
|
||||
paddingLeft: "0",
|
||||
}}
|
||||
/>
|
||||
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
@ -4368,47 +4488,7 @@ export default function WeeklyView() {
|
||||
})}
|
||||
{/* 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
|
||||
@ -4508,9 +4588,9 @@ export default function WeeklyView() {
|
||||
{/* Someday Section */}
|
||||
{showSomeday && (
|
||||
<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 && (
|
||||
<div
|
||||
className="someday-label-column"
|
||||
@ -4652,11 +4732,12 @@ export default function WeeklyView() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ flex: 1, minWidth: 0, overflowX: "auto" }}>
|
||||
{somedayExpanded && (
|
||||
<div
|
||||
ref={somedayGridRef}
|
||||
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
|
||||
@ -4678,7 +4759,7 @@ export default function WeeklyView() {
|
||||
.map((list) => (
|
||||
<div
|
||||
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={{
|
||||
minHeight: "200px",
|
||||
cursor: "text", // Indicate actionable area
|
||||
@ -4752,122 +4833,15 @@ export default function WeeklyView() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if a calendar task is being dropped into this someday list
|
||||
if (
|
||||
draggedTaskId &&
|
||||
draggedTask &&
|
||||
draggedTask.id === draggedTaskId
|
||||
) {
|
||||
// Move calendar task to this someday list
|
||||
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,
|
||||
);
|
||||
// If a task is dropped on the list generally (not on a specific slot),
|
||||
// find the first free slot and place it there.
|
||||
if (draggedTaskId && draggedTask && draggedTask.id === draggedTaskId) {
|
||||
const occupiedSlots = list.tasks.map(t => t.somedaySlotIndex).filter(s => s !== null && s !== undefined) as number[];
|
||||
let nextFreeSlot = 0;
|
||||
while (occupiedSlots.includes(nextFreeSlot)) {
|
||||
nextFreeSlot++;
|
||||
}
|
||||
handleSomedayDrop(e, list.id, nextFreeSlot);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -4926,7 +4900,6 @@ export default function WeeklyView() {
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
{/* Editable Title */}
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={list.title}
|
||||
@ -5012,28 +4985,41 @@ export default function WeeklyView() {
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<ol
|
||||
|
||||
<div
|
||||
className="weekly-task-list"
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
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
|
||||
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)
|
||||
}
|
||||
key={taskInSlot.id}
|
||||
task={taskInSlot}
|
||||
isEditing={editingTaskId === taskInSlot.id}
|
||||
onToggle={() => toggleTask(taskInSlot.id)}
|
||||
onEdit={() => setEditingTaskId(taskInSlot.id)}
|
||||
onUpdate={(title) => updateTask(taskInSlot.id, title)}
|
||||
onDelete={() => deleteTask(taskInSlot.id)}
|
||||
onNotes={() => setSelectedTaskForNotes(taskInSlot)}
|
||||
onRollToggle={() => toggleTaskRolling(taskInSlot.id)}
|
||||
onRecurrence={() => setSelectedTaskForRecurrence(taskInSlot)}
|
||||
onDragStart={(e, t) => handleDragStart(e, t)}
|
||||
onDragEnd={handleDragEnd}
|
||||
variant="minimal"
|
||||
@ -5046,10 +5032,49 @@ export default function WeeklyView() {
|
||||
onSetEditingTaskId={setEditingTaskId}
|
||||
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
|
||||
listId={list.id}
|
||||
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 {
|
||||
const res = await fetch("/api/tasks", {
|
||||
method: "POST",
|
||||
@ -5059,11 +5084,12 @@ export default function WeeklyView() {
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
somedayListId: list.id,
|
||||
somedaySlotIndex: nextFreeSlot
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
let newTask = {
|
||||
const newTask = {
|
||||
...data.task,
|
||||
createdAt: new Date(data.task.createdAt),
|
||||
updatedAt: new Date(data.task.updatedAt),
|
||||
@ -5075,53 +5101,13 @@ export default function WeeklyView() {
|
||||
: 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) {
|
||||
console.error(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{Array.from({
|
||||
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>
|
||||
{/* close flex row */}
|
||||
</section>
|
||||
)}
|
||||
)
|
||||
}
|
||||
|
||||
{/* Search Modal */}
|
||||
<SearchModal
|
||||
@ -5320,19 +5307,27 @@ export default function WeeklyView() {
|
||||
const dateStr = new Date().toISOString();
|
||||
|
||||
// Update locally
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => {
|
||||
setTasks((prev) => {
|
||||
const newTasks = [];
|
||||
for (const t of prev) {
|
||||
const isMatch =
|
||||
t.title === task.title &&
|
||||
t.userId === task.userId &&
|
||||
t.recurrenceInterval === task.recurrenceInterval &&
|
||||
t.recurrenceUnit === task.recurrenceUnit;
|
||||
|
||||
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
|
||||
try {
|
||||
@ -5352,7 +5347,8 @@ export default function WeeklyView() {
|
||||
/>
|
||||
|
||||
{/* Recurring Task Delete Confirmation Modal */}
|
||||
{recurringDeleteModal.isOpen && (
|
||||
{
|
||||
recurringDeleteModal.isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-[100] px-4"
|
||||
onClick={() =>
|
||||
@ -5437,10 +5433,12 @@ export default function WeeklyView() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
}
|
||||
|
||||
{/* Calendar Event Modal */}
|
||||
{calendarEventModal.isOpen && (
|
||||
{
|
||||
calendarEventModal.isOpen && (
|
||||
<CalendarEventModal
|
||||
event={calendarEventModal.event}
|
||||
initialDate={calendarEventModal.initialDate}
|
||||
@ -5452,9 +5450,11 @@ export default function WeeklyView() {
|
||||
onSave={handleEventSave}
|
||||
onDelete={handleEventDelete}
|
||||
/>
|
||||
)}
|
||||
)
|
||||
}
|
||||
{/* Focus Mode Overlay */}
|
||||
{showFocusMode && (
|
||||
{
|
||||
showFocusMode && (
|
||||
<FocusModeOverlay
|
||||
task={(() => {
|
||||
// Logic to find the "Next Task"
|
||||
@ -5502,10 +5502,12 @@ export default function WeeklyView() {
|
||||
onClose={() => setShowFocusMode(false)}
|
||||
onComplete={(taskId) => toggleTask(taskId)}
|
||||
/>
|
||||
)}
|
||||
)
|
||||
}
|
||||
|
||||
{/* Settings Sidebar */}
|
||||
{showSettings && (
|
||||
{
|
||||
showSettings && (
|
||||
<SettingsSidebar
|
||||
initialTab={activeTab}
|
||||
onRemoveConnection={handleRemoveConnection}
|
||||
@ -5576,23 +5578,28 @@ export default function WeeklyView() {
|
||||
handleToggleTaskList={handleToggleTaskList}
|
||||
fetchAvailableTaskLists={fetchAvailableTaskLists}
|
||||
/>
|
||||
)}
|
||||
)
|
||||
}
|
||||
|
||||
{selectedTaskForRecurrence && (
|
||||
{
|
||||
selectedTaskForRecurrence && (
|
||||
<TaskRecurrenceModal
|
||||
task={selectedTaskForRecurrence}
|
||||
onClose={() => setSelectedTaskForRecurrence(null)}
|
||||
onSave={handleRecurrenceSave}
|
||||
/>
|
||||
)}
|
||||
)
|
||||
}
|
||||
|
||||
{selectedTaskForNotes && (
|
||||
{
|
||||
selectedTaskForNotes && (
|
||||
<NotesSidebar
|
||||
task={selectedTaskForNotes}
|
||||
onClose={() => setSelectedTaskForNotes(null)}
|
||||
updateTaskNotes={updateTaskNotes}
|
||||
/>
|
||||
)}
|
||||
)
|
||||
}
|
||||
<ImportListModal
|
||||
isOpen={isImportModalOpen}
|
||||
onClose={() => setIsImportModalOpen(false)}
|
||||
@ -5601,7 +5608,7 @@ export default function WeeklyView() {
|
||||
lists={importLists}
|
||||
isLoading={isFetchingLists}
|
||||
/>
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
||||
@ -5661,7 +5668,6 @@ function SomedayAddTask({
|
||||
<li
|
||||
className="weekly-task-item minimal"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--weekly-border)",
|
||||
margin: "0 0.5rem",
|
||||
}}
|
||||
>
|
||||
|
||||
@ -363,6 +363,7 @@ export const updateEvent = async (
|
||||
title?: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
url?: string;
|
||||
start?: { 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.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.date) {
|
||||
event.startDate = ICAL.Time.fromJSDate(new Date(eventData.start.date), true);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { NextAuthOptions } from "next-auth";
|
||||
import GoogleProvider from "next-auth/providers/google";
|
||||
import AppleProvider from "next-auth/providers/apple";
|
||||
import { PrismaAdapter } from "@auth/prisma-adapter";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import { compare } from "bcryptjs";
|
||||
@ -19,6 +20,10 @@ export const authOptions: NextAuthOptions = {
|
||||
}
|
||||
}
|
||||
}),
|
||||
AppleProvider({
|
||||
clientId: process.env.APPLE_ID || "",
|
||||
clientSecret: process.env.APPLE_SECRET || ""
|
||||
}),
|
||||
CredentialsProvider({
|
||||
name: "credentials",
|
||||
credentials: {
|
||||
|
||||
@ -686,8 +686,11 @@ export const updateCalendarEvent = async (
|
||||
if (rrule) googleEvent.recurrence = [rrule];
|
||||
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 =>
|
||||
m.updateEvent(oauth2Client, accessToken, calendarId, eventId, googleEvent)
|
||||
m.updateEvent(oauth2Client, accessToken, calendarId, baseEventId, googleEvent)
|
||||
);
|
||||
|
||||
return {
|
||||
@ -709,8 +712,11 @@ export const updateCalendarEvent = async (
|
||||
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 updatedEvent = await updateOutlookEvent(accessToken, calendarId, eventId, {
|
||||
const updatedEvent = await updateOutlookEvent(accessToken, calendarId, baseEventId, {
|
||||
summary: event.title,
|
||||
description: event.description,
|
||||
start: event.start,
|
||||
@ -740,6 +746,7 @@ export const updateCalendarEvent = async (
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
location: event.location,
|
||||
url: event.url,
|
||||
start: event.start,
|
||||
end: event.end
|
||||
})
|
||||
@ -785,8 +792,11 @@ export const deleteCalendarEvent = async (
|
||||
process.env.GOOGLE_REDIRECT_URI || ''
|
||||
);
|
||||
|
||||
// Default to deleting the whole series if repeating
|
||||
const baseEventId = eventId.split('_')[0];
|
||||
|
||||
await import('./google-calendar').then(m =>
|
||||
m.deleteEvent(oauth2Client, accessToken, calendarId, eventId)
|
||||
m.deleteEvent(oauth2Client, accessToken, calendarId, baseEventId)
|
||||
);
|
||||
return;
|
||||
} else if (connection.provider === 'outlook') {
|
||||
@ -797,7 +807,10 @@ export const deleteCalendarEvent = async (
|
||||
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;
|
||||
} else if (connection.provider === 'apple') {
|
||||
const [email, appPassword] = connection.accessToken.split(':');
|
||||
|
||||
@ -108,15 +108,28 @@ export async function sendVerificationEmail(
|
||||
|
||||
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(`\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,
|
||||
to: email,
|
||||
subject: `${code} – Verify your email for My Weekly ToDo's`,
|
||||
html,
|
||||
});
|
||||
|
||||
const result = await Promise.race([mailPromise, timeoutPromise]) as any;
|
||||
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 {
|
||||
|
||||
@ -147,7 +147,7 @@ export const getUpcomingEvents = async (
|
||||
const params = new URLSearchParams({
|
||||
startDateTime: startDateTime,
|
||||
endDateTime: endDateTime,
|
||||
'$select': 'subject,bodyPreview,start,end,location,webLink,isAllDay',
|
||||
'$select': 'subject,bodyPreview,start,end,location,webLink,isAllDay,seriesMasterId,type',
|
||||
'$orderby': 'start/dateTime',
|
||||
'$top': '50'
|
||||
});
|
||||
@ -168,7 +168,7 @@ export const getUpcomingEvents = async (
|
||||
|
||||
const data = await response.json();
|
||||
return data.value.map((event: any) => ({
|
||||
id: event.id,
|
||||
id: event.seriesMasterId ? `${event.seriesMasterId}::${event.id}` : event.id,
|
||||
summary: event.subject,
|
||||
description: event.body?.content || event.bodyPreview,
|
||||
start: {
|
||||
|
||||
25
test-email.js
Normal file
25
test-email.js
Normal 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
Loading…
Reference in New Issue
Block a user