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",
"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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)}
@ -5661,7 +5668,6 @@ function SomedayAddTask({
<li
className="weekly-task-item minimal"
style={{
borderBottom: "1px solid var(--weekly-border)",
margin: "0 0.5rem",
}}
>

View File

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

View File

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

View File

@ -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(':');

View File

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

View File

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