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>

File diff suppressed because it is too large Load Diff

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({
from,
to: email,
subject: `${code} Verify your email for My Weekly ToDo's`,
html,
});
try {
// 5 second timeout for SMTP
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('SMTP timeout')), 5000)
);
console.log(`[EMAIL] Email sent successfully: ${result.messageId}`);
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