From d054144939acb0bd5cba36987ab301692888c6f3 Mon Sep 17 00:00:00 2001 From: mARTin Date: Mon, 9 Feb 2026 12:11:52 +0100 Subject: [PATCH] feat: localize app to German and add configurable day hours --- package-lock.json | 7 + package.json | 1 + .../migration.sql | 11 + prisma/schema.prisma | 31 +- scripts/seed-connection.ts | 40 + scripts/verify-schema.ts | 37 + src/app/api/calendar/connections/route.ts | 50 +- src/app/api/calendar/google/oauth/route.ts | 43 + src/app/api/calendar/sync/route.ts | 110 + src/app/api/someday-lists/route.ts | 220 ++ src/app/api/tasks/route.ts | 56 +- src/app/api/user/export/route.ts | 51 + src/app/api/user/profile/route.ts | 107 + src/app/auth/login/page.tsx | 36 +- src/app/auth/signup/page.tsx | 42 +- src/app/globals.css | 859 +++-- src/app/tasks/page.tsx | 4 +- src/components/TaskItem.tsx | 22 +- src/components/TaskList.tsx | 6 +- src/components/TeuxDeuxView.tsx | 950 ------ src/components/WeeklyView.tsx | 2884 +++++++++++++++-- src/lib/calendar-events.ts | 203 +- src/lib/google-calendar.ts | 14 +- 23 files changed, 4205 insertions(+), 1579 deletions(-) create mode 100644 prisma/migrations/20260206172137_add_scheduled_date/migration.sql create mode 100644 scripts/seed-connection.ts create mode 100644 scripts/verify-schema.ts create mode 100644 src/app/api/calendar/sync/route.ts create mode 100644 src/app/api/someday-lists/route.ts create mode 100644 src/app/api/user/export/route.ts create mode 100644 src/app/api/user/profile/route.ts delete mode 100644 src/components/TeuxDeuxView.tsx diff --git a/package-lock.json b/package-lock.json index 0b9990f..c14f227 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@auth/prisma-adapter": "^2.11.1", "@prisma/client": "^5.22.0", + "@types/bcryptjs": "^2.4.6", "bcryptjs": "^3.0.3", "date-fns": "^2.30.0", "googleapis": "^170.1.0", @@ -1900,6 +1901,12 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "license": "MIT" + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", diff --git a/package.json b/package.json index 7943fef..eda71b4 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "dependencies": { "@auth/prisma-adapter": "^2.11.1", "@prisma/client": "^5.22.0", + "@types/bcryptjs": "^2.4.6", "bcryptjs": "^3.0.3", "date-fns": "^2.30.0", "googleapis": "^170.1.0", diff --git a/prisma/migrations/20260206172137_add_scheduled_date/migration.sql b/prisma/migrations/20260206172137_add_scheduled_date/migration.sql new file mode 100644 index 0000000..a3252b5 --- /dev/null +++ b/prisma/migrations/20260206172137_add_scheduled_date/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "CalendarConnection" ADD COLUMN "calendars" JSONB; + +-- AlterTable +ALTER TABLE "Task" ADD COLUMN "scheduledDate" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "timezone" TEXT NOT NULL DEFAULT 'UTC'; + +-- CreateIndex +CREATE INDEX "Task_userId_scheduledDate_idx" ON "Task"("userId", "scheduledDate"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b198496..a12e914 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -21,10 +21,19 @@ model User { passwordResetExpires DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + timezone String @default("UTC") + autoRolling Boolean @default(false) + protectEventTimes Boolean @default(false) + language String @default("en") + dateFormat String @default("MM/dd/yyyy") + timeFormat String @default("12h") + startHour Int @default(6) + endHour Int @default(22) accounts Account[] sessions Session[] tasks Task[] + somedayLists SomedayList[] calendarConnections CalendarConnection[] } @@ -70,8 +79,10 @@ model Task { description String? markdownContent String? @db.Text completed Boolean @default(false) + isRolling Boolean @default(false) order Int @default(0) - dayOfWeek Int? // 0-6 for Sunday-Saturday + dayOfWeek Int? // 0-6 for Sunday-Saturday (legacy/someday lists) + scheduledDate DateTime? // Actual date for the task somedayListId String? originalDate DateTime? startTime String? @@ -81,10 +92,27 @@ model Task { user User @relation(fields: [userId], references: [id], onDelete: Cascade) + somedayList SomedayList? @relation(fields: [somedayListId], references: [id]) + @@index([userId, dayOfWeek]) + @@index([userId, scheduledDate]) @@index([userId, somedayListId]) } +model SomedayList { + id String @id @default(cuid()) + userId String + title String + order Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + tasks Task[] + + @@index([userId]) +} + model CalendarConnection { id String @id @default(cuid()) userId String @@ -92,6 +120,7 @@ model CalendarConnection { accessToken String refreshToken String? expiresAt DateTime? + calendars Json? // Stores array of { id, title, isPrimary, selected } createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/scripts/seed-connection.ts b/scripts/seed-connection.ts new file mode 100644 index 0000000..a2253aa --- /dev/null +++ b/scripts/seed-connection.ts @@ -0,0 +1,40 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + const user = await prisma.user.findFirst(); + if (!user) { + console.log('No user found to seed connection for.'); + return; + } + + console.log(`Seeding Calendar Connection for user: ${user.email}`); + + // Clean up existing google connections + await prisma.calendarConnection.deleteMany({ + where: { userId: user.id, provider: 'google' } + }); + + // Create mock connection with FUTURE expiry + await prisma.calendarConnection.create({ + data: { + userId: user.id, + provider: 'google', + accessToken: 'mock-access-token', + refreshToken: 'mock-refresh-token', + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 Year from now + calendars: [ + { id: 'primary', title: 'Primary Calendar', isPrimary: true, selected: true }, + { id: 'work', title: 'Work Calendar', isPrimary: false, selected: true }, + { id: 'holidays', title: 'Public Holidays', isPrimary: false, selected: false } + ] + } + }); + + console.log('Seeded Mock Google Connection with 3 calendars (Valid for 1 year).'); +} + +main() + .catch(e => console.error(e)) + .finally(async () => await prisma.$disconnect()); diff --git a/scripts/verify-schema.ts b/scripts/verify-schema.ts new file mode 100644 index 0000000..a941e82 --- /dev/null +++ b/scripts/verify-schema.ts @@ -0,0 +1,37 @@ + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('Verifying Prisma User model...'); + + // 1. Check if we can select the new fields + // We'll try to find the first user + const user = await prisma.user.findFirst({ + select: { + id: true, + email: true, + startHour: true, // This should compile if client is updated + endHour: true + } + }); + + if (!user) { + console.log('No users found, but schema seems valid if this runs.'); + return; + } + + console.log('Found user:', user); + console.log('Successfully selected startHour:', user.startHour); + console.log('Successfully selected endHour:', user.endHour); +} + +main() + .catch((e) => { + console.error('Error verifying schema:', e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/src/app/api/calendar/connections/route.ts b/src/app/api/calendar/connections/route.ts index c60a64b..d10b01b 100644 --- a/src/app/api/calendar/connections/route.ts +++ b/src/app/api/calendar/connections/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; +import { authOptions } from '../../auth/[...nextauth]/route'; import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); @@ -7,7 +8,7 @@ const prisma = new PrismaClient(); // Get user's calendar connections export async function GET(request: NextRequest) { try { - const session = await getServerSession(); + const session = await getServerSession(authOptions); if (!session?.user?.email) { return NextResponse.json( @@ -33,6 +34,7 @@ export async function GET(request: NextRequest) { const connections = user.calendarConnections.map(conn => ({ id: conn.id, provider: conn.provider, + calendars: conn.calendars, // Include calendar list createdAt: conn.createdAt, expiresAt: conn.expiresAt, })); @@ -47,6 +49,52 @@ export async function GET(request: NextRequest) { } } +// Update a calendar connection (e.g. selection) +export async function PATCH(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { id, calendars } = body; + + if (!id || !calendars) { + return NextResponse.json({ error: 'ID and calendars required' }, { status: 400 }); + } + + // Find user + const user = await prisma.user.findUnique({ + where: { email: session.user.email } + }); + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + // Update connection + const updated = await prisma.calendarConnection.update({ + where: { + id, + userId: user.id + }, + data: { + calendars: calendars // Update the JSON field + } + }); + + return NextResponse.json({ success: true, connection: updated }); + } catch (error) { + console.error('Error updating calendar connection:', error); + return NextResponse.json( + { error: 'Failed to update calendar connection' }, + { status: 500 } + ); + } +} + // Delete a calendar connection export async function DELETE(request: NextRequest) { try { diff --git a/src/app/api/calendar/google/oauth/route.ts b/src/app/api/calendar/google/oauth/route.ts index 11a9e4a..19a7681 100644 --- a/src/app/api/calendar/google/oauth/route.ts +++ b/src/app/api/calendar/google/oauth/route.ts @@ -42,6 +42,33 @@ export async function GET(request: NextRequest) { // Exchange authorization code for access token const { tokens } = await oauth2Client.getToken(code); + oauth2Client.setCredentials(tokens); + + // Fetch user calendars to store in connection settings + // Dynamic import to avoid circular dep issues in some envs, or just standard import? + // Standard import is better but we are inside function scope to check diff. + // I'll assume the import is added at top level or I force it here if possible. + // I will add import at top level in separate chunk if needed? + // Replace whole file content is unsafe. I'll use multi-replace. + + // We need getUserCalendars. I'll use require or assume import added. + // Actually, I'll allow ReplaceFileContent to manage imports? No. + // I'll use multi_replace to add import AND update logic. + + // WAIT, better approach: Just implement the fetch logic here locally to avoid import issues or dependency on lib if it changes. + // But duplicate code is bad. + // I'll add the import at the top. + + // Logic: + const calendar = google.calendar({ version: 'v3', auth: oauth2Client }); + const response = await calendar.calendarList.list(); + const remoteCalendars = response.data.items?.map((item: any) => ({ + id: item.id, + title: item.summary, + isPrimary: item.primary, + backgroundColor: item.backgroundColor, // Store calendar color for event fallback + selected: true // Default newly found to true + })) || []; // Check if connection already exists const existingConnection = await prisma.calendarConnection.findFirst({ @@ -51,7 +78,21 @@ export async function GET(request: NextRequest) { } }); + let finalCalendars = remoteCalendars; + if (existingConnection) { + // Merge with existing selection + if (existingConnection.calendars && Array.isArray(existingConnection.calendars)) { + const existingList = existingConnection.calendars as any[]; + finalCalendars = remoteCalendars.map(remote => { + const match = existingList.find(e => e.id === remote.id); + return { + ...remote, + selected: match ? match.selected : true // Preserve selection + }; + }); + } + // Update existing connection await prisma.calendarConnection.update({ where: { id: existingConnection.id }, @@ -59,6 +100,7 @@ export async function GET(request: NextRequest) { accessToken: tokens.access_token || '', refreshToken: tokens.refresh_token || existingConnection.refreshToken, expiresAt: tokens.expiry_date ? new Date(tokens.expiry_date) : null, + calendars: finalCalendars, // Store calendars updatedAt: new Date() } }); @@ -71,6 +113,7 @@ export async function GET(request: NextRequest) { accessToken: tokens.access_token || '', refreshToken: tokens.refresh_token || null, expiresAt: tokens.expiry_date ? new Date(tokens.expiry_date) : null, + calendars: finalCalendars, // Store calendars } }); } diff --git a/src/app/api/calendar/sync/route.ts b/src/app/api/calendar/sync/route.ts new file mode 100644 index 0000000..680d5d7 --- /dev/null +++ b/src/app/api/calendar/sync/route.ts @@ -0,0 +1,110 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '../../auth/[...nextauth]/route'; +import { PrismaClient } from '@prisma/client'; +import { getCalendarEvents, CalendarConnection } from '@/lib/calendar-events'; + +const prisma = new PrismaClient(); + +export async function POST(request: NextRequest) { + console.log('[CALENDAR SYNC] Starting sync request...'); + try { + const session = await getServerSession(authOptions); + console.log('[CALENDAR SYNC] Session:', session?.user?.email); + + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { timeMin, timeMax, connectionId } = body; + console.log('[CALENDAR SYNC] Request params:', { timeMin, timeMax, connectionId }); + + // Get the user and their calendar connections + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + include: { calendarConnections: true } + }); + + if (!user) { + console.log('[CALENDAR SYNC] User not found:', session.user.email); + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + console.log('[CALENDAR SYNC] Found user:', user.id, 'with', user.calendarConnections.length, 'connections'); + + // Get connections to sync + let connections = user.calendarConnections; + + // If a specific connectionId is provided, filter to just that one + if (connectionId) { + connections = connections.filter(c => c.id === connectionId); + if (connections.length === 0) { + return NextResponse.json({ error: 'Connection not found' }, { status: 404 }); + } + } + + if (connections.length === 0) { + console.log('[CALENDAR SYNC] No calendar connections found'); + return NextResponse.json({ + success: true, + events: [], + message: 'No calendar connections found. Please connect a calendar in Settings.' + }); + } + + // Map to CalendarConnection interface + const calendarConnections: CalendarConnection[] = connections.map(conn => ({ + id: conn.id, + provider: conn.provider as 'google' | 'apple', + accessToken: conn.accessToken, + refreshToken: conn.refreshToken || undefined, + expiresAt: conn.expiresAt || undefined, + calendars: conn.calendars as any + })); + + console.log('[CALENDAR SYNC] Fetching events from', calendarConnections.length, 'connections'); + calendarConnections.forEach(c => { + console.log('[CALENDAR SYNC] Connection:', c.provider, 'calendars:', c.calendars?.length || 0); + }); + + // Fetch events from all connected calendars + const events = await getCalendarEvents( + calendarConnections, + timeMin || new Date().toISOString(), + timeMax || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() + ); + + console.log('[CALENDAR SYNC] Fetched', events.length, 'events'); + + // Transform events to the format expected by the frontend + const formattedEvents = events.map(event => ({ + id: event.id, + title: event.title, + description: event.description, + startTime: event.start.dateTime || event.start.date, + endTime: event.end.dateTime || event.end.date, + source: event.source, + calendarId: event.calendarId, + calendarTitle: event.calendarTitle, + calendarColor: event.backgroundColor + })); + + console.log('[CALENDAR SYNC] Returning', formattedEvents.length, 'formatted events'); + if (formattedEvents.length > 0) { + console.log('[CALENDAR SYNC] Sample event:', formattedEvents[0]); + } + + return NextResponse.json({ + success: true, + events: formattedEvents, + count: formattedEvents.length + }); + } catch (error) { + console.error('[CALENDAR SYNC] Sync request failed:', error); + return NextResponse.json({ + error: 'Sync failed', + details: error instanceof Error ? error.message : 'Unknown error' + }, { status: 500 }); + } +} diff --git a/src/app/api/someday-lists/route.ts b/src/app/api/someday-lists/route.ts new file mode 100644 index 0000000..c696a00 --- /dev/null +++ b/src/app/api/someday-lists/route.ts @@ -0,0 +1,220 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const userId = (session.user as any).id; + + const lists = await prisma.somedayList.findMany({ + where: { userId }, + orderBy: { order: 'asc' }, + include: { + tasks: { + orderBy: { order: 'asc' } + } + } + }); + + return NextResponse.json({ lists }); + } catch (error) { + console.error('Error fetching someday lists:', error); + return NextResponse.json( + { error: 'Failed to fetch someday lists' }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const userId = (session.user as any).id; + const { title } = await request.json(); + + if (!title) { + return NextResponse.json( + { error: 'Title is required' }, + { status: 400 } + ); + } + + // Get max order + const maxOrderList = await prisma.somedayList.findFirst({ + where: { userId }, + orderBy: { order: 'desc' } + }); + const order = (maxOrderList?.order ?? -1) + 1; + + const list = await prisma.somedayList.create({ + data: { + userId, + title, + order + }, + include: { tasks: true } // Return with empty tasks array for frontend consistency + }); + + return NextResponse.json({ list }); + } catch (error) { + console.error('Error creating someday list:', error); + return NextResponse.json( + { error: 'Failed to create someday list' }, + { status: 500 } + ); + } +} + +export async function DELETE(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + + if (!id) { + return NextResponse.json( + { error: 'List ID is required' }, + { status: 400 } + ); + } + + // Verify ownership + const list = await prisma.somedayList.findUnique({ + where: { id } + }); + + if (!list || list.userId !== (session.user as any).id) { + return NextResponse.json( + { error: 'List not found or unauthorized' }, + { status: 404 } + ); + } + + // Delete list (tasks cascade delete is not set in schema for tasks->list, check schema) + // In schema: tasks defined as `tasks Task[]`. + // We updated schema: `user User ... onDelete: Cascade`. `tasks` are separate. + // We need to verify if deleting list deletes tasks or unlinks them. + // Schema: `somedayList SomedayList? @relation...` + // If we want cascade delete tasks in the list, we should check relations. + // Prisma default is usually not cascade for optional relations unless specified. + // Let's assume we want to keep tasks or delete them? Usually delete list = delete tasks in it. + // Let's explicitly delete tasks first or rely on schema if configured. + // Schema update I did: `tasks Task[]`. `Task` has `somedayListId`. + // I didn't add `onDelete: Cascade` to the `somedayList` relation in `Task`. + // So I should clean up tasks manually or update schema. + // For now, let's delete tasks in the list. + + await prisma.task.deleteMany({ + where: { somedayListId: id } + }); + + await prisma.somedayList.delete({ + where: { id } + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error deleting someday list:', error); + return NextResponse.json( + { error: 'Failed to delete someday list' }, + { status: 500 } + ); + } +} + +export async function PATCH(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const body = await request.json(); + + // Handle Reordering (Array of { id, order }) + if (Array.isArray(body)) { + const updates = body.map(async (item: { id: string; order: number }) => { + // Verify ownership for each or just assume if one matches? + // Better to be safe, but for performance in batch, we might trust ID if valid. + // Let's verify ownership implicitly by where clause. + return prisma.somedayList.updateMany({ + where: { + id: item.id, + userId: (session.user as any).id + }, + data: { order: item.order } + }); + }); + + await Promise.all(updates); + return NextResponse.json({ success: true }); + } + + // Handle Single Update (Title) + const { id, title } = body; + + if (!id || !title) { + return NextResponse.json( + { error: 'ID and Title are required' }, + { status: 400 } + ); + } + + // Verify ownership + const existingList = await prisma.somedayList.findUnique({ + where: { id } + }); + + if (!existingList || existingList.userId !== (session.user as any).id) { + return NextResponse.json( + { error: 'List not found or unauthorized' }, + { status: 404 } + ); + } + + const list = await prisma.somedayList.update({ + where: { id }, + data: { title } + }); + + return NextResponse.json({ list }); + } catch (error) { + console.error('Error updating someday list:', error); + return NextResponse.json( + { error: 'Failed to update someday list' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index 96ca05e..08a39f1 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -19,6 +19,41 @@ export async function GET(request: NextRequest) { const userId = (session.user as any).id; + // Rolling Logic: Find incomplete rolling tasks from the past and move them to today + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const pastRollingTasks = await prisma.task.findMany({ + where: { + userId, + completed: false, + isRolling: true, + scheduledDate: { + lt: today + } + } + }); + + if (pastRollingTasks.length > 0) { + // Current day of week (0-6) + const currentDayOfWeek = today.getDay(); + + // Bulk update past rolling tasks to today + await prisma.task.updateMany({ + where: { + id: { + in: pastRollingTasks.map(t => t.id) + } + }, + data: { + scheduledDate: today, + dayOfWeek: currentDayOfWeek, + startTime: null, // Reset time for rolled tasks as they might clash + endTime: null + } + }); + } + const tasks = await prisma.task.findMany({ where: { userId }, orderBy: [ @@ -42,7 +77,7 @@ export async function POST(request: NextRequest) { try { const session = await getServerSession(authOptions); - if (!session?.user) { + if (!session?.user?.email) { return NextResponse.json( { error: 'Unauthorized' }, { status: 401 } @@ -52,7 +87,8 @@ 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 } = body; + const { title, description, dayOfWeek, order, markdownContent, somedayListId, startTime, scheduledDate } = body; + let { isRolling } = body; if (!title) { return NextResponse.json( @@ -61,6 +97,15 @@ export async function POST(request: NextRequest) { ); } + // If isRolling is not specified, check user preference + if (isRolling === undefined) { + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + select: { autoRolling: true } + }); + isRolling = user?.autoRolling || false; + } + const task = await prisma.task.create({ data: { title, @@ -71,6 +116,8 @@ export async function POST(request: NextRequest) { somedayListId, userId, startTime: startTime || null, + scheduledDate: scheduledDate ? new Date(scheduledDate) : null, + isRolling: isRolling || false }, }); @@ -99,7 +146,7 @@ export async function PATCH(request: NextRequest) { const userId = (session.user as any).id; const body = await request.json(); - const { id, title, description, completed, dayOfWeek, order, markdownContent } = body; + const { id, title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime } = body; if (!id) { return NextResponse.json( @@ -129,6 +176,9 @@ export async function PATCH(request: NextRequest) { ...(dayOfWeek !== undefined && { dayOfWeek: parseInt(dayOfWeek) }), ...(order !== undefined && { order: parseInt(order) }), ...(markdownContent !== undefined && { markdownContent }), + ...(scheduledDate !== undefined && { scheduledDate: scheduledDate ? new Date(scheduledDate) : null }), + ...(startTime !== undefined && { startTime }), + ...(body.isRolling !== undefined && { isRolling: body.isRolling }) }, }); diff --git a/src/app/api/user/export/route.ts b/src/app/api/user/export/route.ts new file mode 100644 index 0000000..8d593b6 --- /dev/null +++ b/src/app/api/user/export/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '../../auth/[...nextauth]/route'; +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +export async function GET(request: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + try { + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + include: { + tasks: true, + calendarConnections: true, + accounts: true + } + }); + + if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 }); + + // Sanitize + const exportData = { + profile: { + name: user.name, + email: user.email, + joined: user.createdAt, + timezone: user.timezone + }, + tasks: user.tasks, + connections: user.calendarConnections.map(c => ({ + provider: c.provider, + connectedAt: c.createdAt, + calendars: c.calendars + })) + }; + + // Return as file download + return new NextResponse(JSON.stringify(exportData, null, 2), { + headers: { + 'Content-Type': 'application/json', + 'Content-Disposition': `attachment; filename="data-export-${new Date().toISOString().split('T')[0]}.json"` + } + }); + } catch (error) { + console.error('Export failed:', error); + return NextResponse.json({ error: 'Export failed' }, { status: 500 }); + } +} diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts new file mode 100644 index 0000000..6cb79b9 --- /dev/null +++ b/src/app/api/user/profile/route.ts @@ -0,0 +1,107 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '../../auth/[...nextauth]/route'; +import { PrismaClient } from '@prisma/client'; +import bcrypt from 'bcryptjs'; + +const prisma = new PrismaClient(); + +// Get user profile +export async function GET(request: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + select: { + name: true, + email: true, + timezone: true, + autoRolling: true, + protectEventTimes: true, + language: true, + dateFormat: true, + timeFormat: true, + startHour: true, + endHour: true, + createdAt: true + } + }); + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + return NextResponse.json({ user }); +} + +// Update user profile +export async function PATCH(request: NextRequest) { + const session = await getServerSession(authOptions); + + if (!session || !session.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const body = await request.json(); + const { name, timezone, password, autoRolling, protectEventTimes, language, dateFormat, timeFormat, startHour, endHour } = body; + + const updateData: any = { + ...(name !== undefined && { name }), + ...(timezone !== undefined && { timezone }), + ...(autoRolling !== undefined && { autoRolling }), + ...(protectEventTimes !== undefined && { protectEventTimes }), + ...(language !== undefined && { language }), + ...(dateFormat !== undefined && { dateFormat }), + ...(timeFormat !== undefined && { timeFormat }), + ...(startHour !== undefined && { startHour }), + ...(endHour !== undefined && { endHour }), + }; + if (password) { + updateData.passwordHash = await bcrypt.hash(password, 10); + } + + const user = await prisma.user.update({ + where: { email: session.user.email }, + data: updateData, + select: { + id: true, + name: true, + email: true, + timezone: true, + autoRolling: true, + protectEventTimes: true, + language: true, + dateFormat: true, + timeFormat: true, + startHour: true, + endHour: true, + } + }); + + return NextResponse.json({ success: true, user }); + } catch (e) { + console.error('Error updating profile:', e); + return NextResponse.json( + { error: 'Failed to update profile', details: (e as Error).message }, + { status: 500 } + ); + } +} + +// Delete account +export async function DELETE(request: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + try { + await prisma.user.delete({ + where: { email: session.user.email } + }); + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error deleting account:', error); + return NextResponse.json({ error: 'Failed to delete account' }, { status: 500 }); + } +} diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index d60420e..44f56fb 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -29,21 +29,21 @@ export default function LoginPage() { }; return ( -
-
+
+
{/* Logo */} -
+
My Weekly ToDo's
{/* Tagline */} -

+

A simple, designy to-do app.

{/* Error Message */} {error && ( -
+
{error === 'CredentialsSignin' ? 'Invalid email or password' : 'An error occurred during sign in'} @@ -51,40 +51,40 @@ export default function LoginPage() { )} {/* Login Form */} -
-
+ +
-
+
{/* Divider */} -
+
or
@@ -93,7 +93,7 @@ export default function LoginPage() { type="button" onClick={handleGoogleSignIn} disabled={isLoading} - className="teuxdeux-auth-button google" + className="weekly-auth-button google" > @@ -105,19 +105,19 @@ export default function LoginPage() { {/* Links */} -
- +
+ Forgot password? - · - + · + Create account
{/* Footer */} -
+

Simple. Beautiful. Yours.

diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index 2fcd3cd..6955a2f 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -66,50 +66,50 @@ export default function SignupPage() { }; return ( -
-
+
+
{/* Logo */} -
+
My Weekly ToDo's
{/* Tagline */} -

+

Start organizing your week beautifully.

{/* Error Message */} {error && ( -
+
{error}
)} {/* Signup Form */} -
-
+ +
-
+
-
+
-
+
{/* Divider */} -
+
or
@@ -152,7 +152,7 @@ export default function SignupPage() { type="button" onClick={handleGoogleSignIn} disabled={isLoading} - className="teuxdeux-auth-button google" + className="weekly-auth-button google" > @@ -164,16 +164,16 @@ export default function SignupPage() { {/* Links */} -
- Already have an account? - +
+ Already have an account? + Log in
{/* Footer */} -
+

Simple. Beautiful. Yours.

diff --git a/src/app/globals.css b/src/app/globals.css index d3dce76..246a9e3 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -5,10 +5,13 @@ /* Global Styles */ /* Reset and base styles */ -* { +*, +::before, +::after { margin: 0; padding: 0; box-sizing: border-box; + border-color: transparent; } body { @@ -418,31 +421,31 @@ h3 { ============================================ */ :root { - --teuxdeux-teal: #009a9a; - --teuxdeux-bg: #ffffff; - --teuxdeux-text: #000000; - --teuxdeux-text-light: #a6a6a7; - --teuxdeux-border: #eeeeee; - --teuxdeux-completed: #cccccc; - --teuxdeux-font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --weekly-teal: #009a9a; + --weekly-bg: #ffffff; + --weekly-text: #000000; + --weekly-text-light: #a6a6a7; + --weekly-border: #eeeeee; + --weekly-completed: #cccccc; + --weekly-font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } -/* TeuxDeux Main Container */ -.teuxdeux-container { +/* Weekly Main Container */ +.weekly-container { min-height: 100vh; - background: var(--teuxdeux-bg); - font-family: var(--teuxdeux-font); + background: var(--weekly-bg); + font-family: var(--weekly-font); display: flex; flex-direction: column; } -/* TeuxDeux Header */ -.teuxdeux-header { +/* Weekly Header */ +.weekly-header { position: sticky; top: 0; z-index: 100; - background: var(--teuxdeux-bg); - border-bottom: 1px solid var(--teuxdeux-border); + background: var(--weekly-bg); + border-bottom: 1px solid var(--weekly-border); padding: 0.75rem 1.5rem; display: flex; align-items: center; @@ -450,46 +453,46 @@ h3 { position: relative; } -.teuxdeux-logo { +.weekly-logo { font-size: 1.25rem; font-weight: 600; letter-spacing: 0.05em; - color: var(--teuxdeux-text); + color: var(--weekly-text); } -.teuxdeux-logo sup { - color: var(--teuxdeux-teal); +.weekly-logo sup { + color: var(--weekly-teal); font-size: 0.75rem; } -.teuxdeux-nav { +.weekly-nav { display: flex; align-items: center; gap: 0.5rem; } -.teuxdeux-nav-btn { +.weekly-nav-btn { background: none; border: none; padding: 0.5rem; cursor: pointer; - color: var(--teuxdeux-text-light); + color: var(--weekly-text-light); font-size: 1rem; transition: color 0.15s ease; } -.teuxdeux-nav-btn:hover { - color: var(--teuxdeux-text); +.weekly-nav-btn:hover { + color: var(--weekly-text); } /* Week Number Display */ -.teuxdeux-week-number { +.weekly-week-number { position: absolute; left: 50%; transform: translateX(-50%); font-size: 0.9rem; font-weight: 600; - color: var(--teuxdeux-teal); + color: var(--weekly-teal); text-transform: uppercase; letter-spacing: 0.1em; } @@ -563,32 +566,32 @@ h3 { animation: slideInFromLeft 0.25s ease-out forwards; } -/* TeuxDeux Day Columns Grid */ -.teuxdeux-days-grid { +/* Weekly Day Columns Grid */ +.weekly-days-grid { display: grid; grid-template-columns: repeat(5, 1fr); flex: 1; min-height: 0; } -.teuxdeux-days-grid.cols-1 { +.weekly-days-grid.cols-1 { grid-template-columns: 1fr; } -.teuxdeux-days-grid.cols-3 { +.weekly-days-grid.cols-3 { grid-template-columns: repeat(3, 1fr); } -.teuxdeux-days-grid.cols-5 { +.weekly-days-grid.cols-5 { grid-template-columns: repeat(5, 1fr); } -.teuxdeux-days-grid.cols-7 { +.weekly-days-grid.cols-7 { grid-template-columns: repeat(7, 1fr); } -/* TeuxDeux Day Column */ -.teuxdeux-day-column { +/* Weekly Day Column */ +.weekly-day-column { border-left: 1px solid #eee; display: flex; flex-direction: column; @@ -597,47 +600,47 @@ h3 { overflow-y: auto; } -.teuxdeux-day-column:first-child { +.weekly-day-column:first-child { border-left: none; } /* Day Header */ -.teuxdeux-day-header { +.weekly-day-header { padding: 1rem 1rem 0.75rem; text-align: left; min-height: 60px; box-sizing: border-box; } -.teuxdeux-day-date { +.weekly-day-date { font-size: 0.65rem; - color: var(--teuxdeux-text-light); + color: var(--weekly-text-light); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 0.25rem; } -.teuxdeux-day-name { +.weekly-day-name { font-size: 1.25rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; - color: var(--teuxdeux-text); + color: var(--weekly-text); } -.teuxdeux-day-name.is-today { - color: var(--teuxdeux-teal); +.weekly-day-name.is-today { + color: var(--weekly-teal); } /* Task List */ -.teuxdeux-task-list { +.weekly-task-list { list-style: none; padding: 0; margin: 0; flex: 1; } -.teuxdeux-task-item { +.weekly-task-item { padding: 0.25rem 1rem; display: flex; align-items: flex-start; @@ -645,82 +648,210 @@ h3 { cursor: pointer; position: relative; transition: all 0.2s ease; + border-bottom: 1px solid var(--weekly-border); /* Restore lines */ } -.teuxdeux-task-item:hover { - color: var(--teuxdeux-teal); +/* Remove border from last item to look cleaner, or keep for paper look */ +.weekly-task-list .weekly-task-item:last-child { + border-bottom: 1px solid transparent; } -.teuxdeux-task-item.completed { +.weekly-task-item:hover { + background-color: rgba(0,0,0,0.02); +} + +.weekly-task-item.completed { opacity: 0.6; } -.teuxdeux-task-item.completed .teuxdeux-task-text { +.weekly-task-item.completed .weekly-task-text { text-decoration: line-through; - color: var(--teuxdeux-completed); + color: var(--weekly-completed); } -.teuxdeux-task-text { +.weekly-task-text { flex: 1; font-size: 0.9375rem; - line-height: 1.4; - color: var(--teuxdeux-text); + line-height: 1.5; + color: var(--weekly-text); border: none; outline: none; background: transparent; font-family: inherit; resize: none; - min-height: 1.4em; + min-height: 1.5em; + padding-top: 2px; /* Align with icons */ } -.teuxdeux-task-text.completed { +/* Task Actions Visibility */ +.weekly-task-item .task-actions { + opacity: 0; + transition: opacity 0.2s ease; +} + +.weekly-task-item:hover .task-actions { + opacity: 1; +} + +.weekly-task-text.completed { text-decoration: line-through; - color: var(--teuxdeux-completed); + color: var(--weekly-completed); } -.teuxdeux-task-text::placeholder { - color: var(--teuxdeux-text-light); +.weekly-task-text::placeholder { + color: var(--weekly-text-light); +} + +/* Inline Notes */ +.weekly-notes-inline { + width: 100%; + margin-top: 0.5rem; + padding-left: 0; + padding-right: 0.5rem; +} + +.weekly-notes-editor-inline { + width: 100%; + min-height: 80px; + padding: 0.5rem; + border: 1px solid #ddd; /* Light border for editor */ + border-radius: 4px; + font-family: inherit; + font-size: 0.875rem; + line-height: 1.5; + color: var(--weekly-text); + background: #fdfdfd; + resize: vertical; + outline: none; +} + +.weekly-notes-editor-inline:focus { + border-color: var(--weekly-teal); + background: #fff; +} + +/* Rolling Indicator */ +.rolling-icon-indicator { + position: absolute; + top: 4px; + right: 4px; + color: var(--weekly-teal); + background: rgba(255, 255, 255, 0.9); + border-radius: 50%; + padding: 2px; + box-shadow: 0 1px 2px rgba(0,0,0,0.1); + opacity: 0.8; + pointer-events: none; /* Just visual */ + display: flex; + align-items: center; + justify-content: center; +} + +/* Notes Toolbar */ +.notes-toolbar { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + padding: 0.25rem 0.5rem; + background: #f8f8f8; + border-radius: 4px; + border: 1px solid #eee; +} + +.notes-toolbar-btn { + background: none; + border: none; + cursor: pointer; + color: var(--weekly-text-light); + font-size: 0.85rem; + padding: 2px 4px; + border-radius: 3px; + min-width: 20px; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; +} + +.notes-toolbar-btn:hover { + background: rgba(0,0,0,0.05); + color: var(--weekly-text); +} + +/* Action Buttons Styling */ +.task-actions { + display: flex; + align-items: center; + gap: 2px; +} + +.task-action-btn { + background: none; + border: none; + cursor: pointer; + padding: 4px; + color: var(--weekly-text-light); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.15s; +} + +.task-action-btn:hover { + color: var(--weekly-text); + background: rgba(0,0,0,0.05); +} + +/* Specific button styles */ +.task-action-btn.delete:hover { + color: #ef4444; /* Red for delete */ +} + +.task-action-btn.active { + color: var(--weekly-teal); } /* Task Input */ -.teuxdeux-task-input { +.weekly-task-input { padding: 0.5rem 1rem 0.5rem 2.5rem; - border-bottom: 1px solid var(--teuxdeux-border); + border-bottom: 1px solid var(--weekly-border); } -.teuxdeux-task-input input { +.weekly-task-input input { width: 100%; border: none; outline: none; font-size: 0.9375rem; font-family: inherit; - color: var(--teuxdeux-text); + color: var(--weekly-text); background: transparent; } -.teuxdeux-task-input input::placeholder { - color: var(--teuxdeux-text-light); +.weekly-task-input input::placeholder { + color: var(--weekly-text-light); } /* Someday Section */ -.teuxdeux-someday { +.weekly-someday { background: #faf8f5; /* Light beige paper color */ transition: max-height 0.3s ease; position: relative; } -.teuxdeux-someday.collapsed { +.weekly-someday.collapsed { max-height: 40px; overflow: hidden; } -.teuxdeux-someday.expanded { +.weekly-someday.expanded { max-height: 400px; overflow: auto; } -.teuxdeux-someday-bar { +.weekly-someday-bar { display: flex; align-items: center; gap: 1rem; @@ -728,11 +859,11 @@ h3 { position: relative; } -.teuxdeux-someday-toggle { +.weekly-someday-toggle { background: none; border: none; font-size: 0.875rem; - color: var(--teuxdeux-text-light); + color: var(--weekly-text-light); cursor: pointer; position: absolute; right: 1rem; @@ -742,154 +873,185 @@ h3 { transition: transform 0.2s ease; } -.teuxdeux-someday-toggle:hover { - color: var(--teuxdeux-text); +.weekly-someday-toggle:hover { + color: var(--weekly-text); } -.teuxdeux-someday-title { +.weekly-someday-title { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.1em; - color: var(--teuxdeux-text-light); + color: var(--weekly-text-light); } -.teuxdeux-someday-count { +.weekly-someday-count { font-size: 0.75rem; - color: var(--teuxdeux-text-light); + color: var(--weekly-text-light); } -.teuxdeux-someday-add { +.weekly-someday-add { background: none; border: none; font-size: 1rem; - color: var(--teuxdeux-text-light); + color: var(--weekly-text-light); cursor: pointer; padding: 0.25rem 0.5rem; } -.teuxdeux-someday-add:hover { - color: var(--teuxdeux-text); +.weekly-someday-add:hover { + color: var(--weekly-text); } /* Someday Lists Container */ -.teuxdeux-someday-lists { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); +/* Someday Lists Container */ +.weekly-someday-lists { + display: flex; + flex-wrap: nowrap; /* Prevent wrapping */ gap: 0; - max-height: 300px; - overflow-y: auto; + max-height: 400px; /* Keep height constraint */ + overflow-x: auto; /* Enable horizontal scrolling */ + overflow-y: hidden; /* Hide vertical scroll on container */ + border-top: 1px dashed #ccc; + padding-bottom: 1rem; /* Space for scrollbar */ } -.teuxdeux-someday-list { - border-right: 1px solid rgba(0, 0, 0, 0.05); +.weekly-someday-list { + border-right: 1px dashed #ccc; padding: 1rem; min-height: 200px; + width: 260px; /* Fixed width for columns */ + min-width: 260px; + flex-shrink: 0; + display: flex; + flex-direction: column; + overflow-y: auto; /* Allow individual list scrolling if needed */ + max-height: 380px; } -.teuxdeux-someday-list:last-child { - border-right: none; +.weekly-someday-list:last-child { + border-right: 1px dashed #ccc; /* Keep border for consistency */ } -.teuxdeux-someday-list-title { - font-size: 0.875rem; +.weekly-someday-list-title { + font-size: 0.9rem; font-weight: 700; text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--teuxdeux-text); + color: #d12028; /* TeuxDeux Red-ish */ margin-bottom: 0.75rem; + cursor: pointer; + border-bottom: 2px solid transparent; + display: inline-block; } -/* TeuxDeux Footer */ -.teuxdeux-footer { +.weekly-someday-list-title:hover { + border-bottom-color: #eee; +} + +.weekly-someday-list-title-input { + font-size: 0.9rem; + font-weight: 700; + text-transform: uppercase; + color: #d12028; + margin-bottom: 0.75rem; + background: transparent; + border: none; + border-bottom: 1px solid #d12028; + width: 100%; + padding: 0; + outline: none; +} + +/* Weekly Footer */ +.weekly-footer { position: sticky; bottom: 0; z-index: 100; - background: var(--teuxdeux-bg); - border-top: 1px solid var(--teuxdeux-border); + background: var(--weekly-bg); + border-top: 1px solid var(--weekly-border); padding: 0.5rem 1.5rem; display: flex; align-items: center; justify-content: space-between; } -.teuxdeux-view-toggle { +.weekly-view-toggle { display: flex; align-items: center; gap: 0.25rem; } -.teuxdeux-view-btn { +.weekly-view-btn { background: none; border: none; padding: 0.375rem 0.5rem; font-size: 0.875rem; - color: var(--teuxdeux-text-light); + color: var(--weekly-text-light); cursor: pointer; border-radius: 4px; transition: all 0.15s ease; } -.teuxdeux-view-btn:hover { +.weekly-view-btn:hover { background: #f5f5f5; - color: var(--teuxdeux-text); + color: var(--weekly-text); } -.teuxdeux-view-btn.active { - color: var(--teuxdeux-text); +.weekly-view-btn.active { + color: var(--weekly-text); font-weight: 600; } -.teuxdeux-footer-actions { +.weekly-footer-actions { display: flex; align-items: center; gap: 1rem; } -.teuxdeux-sync-status { +.weekly-sync-status { font-size: 0.75rem; - color: var(--teuxdeux-text-light); + color: var(--weekly-text-light); } -.teuxdeux-dark-toggle { +.weekly-dark-toggle { background: none; border: none; font-size: 1rem; cursor: pointer; - color: var(--teuxdeux-text-light); + color: var(--weekly-text-light); } /* Calendar Events in Day Column */ -.teuxdeux-calendar-event { +.weekly-calendar-event { padding: 0.5rem 1rem; - border-left: 3px solid var(--teuxdeux-teal); + border-left: 3px solid var(--weekly-teal); background: #f8fafa; margin: 0.25rem 0.5rem; border-radius: 0 4px 4px 0; font-size: 0.875rem; } -.teuxdeux-calendar-event-time { +.weekly-calendar-event-time { font-size: 0.75rem; - color: var(--teuxdeux-teal); + color: var(--weekly-teal); font-weight: 500; } -.teuxdeux-calendar-event-title { - color: var(--teuxdeux-text); +.weekly-calendar-event-title { + color: var(--weekly-text); } /* Drag and Drop States */ -.teuxdeux-task-item.dragging { +.weekly-task-item.dragging { opacity: 0.5; } -.teuxdeux-day-column.drop-target { +.weekly-day-column.drop-target { background: #f0fafa; } /* Settings Modal */ -.teuxdeux-settings-overlay { +.weekly-settings-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.4); @@ -899,8 +1061,8 @@ h3 { z-index: 1000; } -.teuxdeux-settings-modal { - background: var(--teuxdeux-bg); +.weekly-settings-modal { + background: var(--weekly-bg); border-radius: 8px; width: 90%; max-width: 600px; @@ -909,55 +1071,55 @@ h3 { box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); } -.teuxdeux-settings-header { +.weekly-settings-header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.5rem; - border-bottom: 1px solid var(--teuxdeux-border); + border-bottom: 1px solid var(--weekly-border); } -.teuxdeux-settings-title { +.weekly-settings-title { font-size: 1.25rem; font-weight: 600; } -.teuxdeux-settings-close { +.weekly-settings-close { background: none; border: none; font-size: 1.5rem; cursor: pointer; - color: var(--teuxdeux-text-light); + color: var(--weekly-text-light); } -.teuxdeux-settings-content { +.weekly-settings-content { padding: 1.5rem; } /* Dark Mode */ -.teuxdeux-container.dark-mode { - --teuxdeux-bg: #1a1a1a; - --teuxdeux-text: #f0f0f0; - --teuxdeux-text-light: #888888; - --teuxdeux-border: #333333; - --teuxdeux-completed: #666666; +.weekly-container.dark-mode { + --weekly-bg: #1a1a1a; + --weekly-text: #f0f0f0; + --weekly-text-light: #888888; + --weekly-border: #333333; + --weekly-completed: #666666; } -.teuxdeux-container.dark-mode .teuxdeux-task-item:hover { +.weekly-container.dark-mode .weekly-task-item:hover { background-color: #2a2a2a; } -.teuxdeux-container.dark-mode .teuxdeux-calendar-event { +.weekly-container.dark-mode .weekly-calendar-event { background: #2a3a3a; } /* Responsive */ @media (max-width: 768px) { - .teuxdeux-days-grid { + .weekly-days-grid { grid-template-columns: 1fr !important; } - .teuxdeux-someday-lists { + .weekly-someday-lists { grid-template-columns: 1fr; } @@ -975,8 +1137,8 @@ h3 { align-items: center; gap: 1rem; padding: 0.5rem 1.5rem; - background: var(--teuxdeux-bg); - border-bottom: 1px solid var(--teuxdeux-border); + background: var(--weekly-bg); + border-bottom: 1px solid var(--weekly-border); font-size: 0.875rem; } @@ -984,20 +1146,20 @@ h3 { display: flex; align-items: center; gap: 0.5rem; - color: var(--teuxdeux-text-light); + color: var(--weekly-text-light); cursor: pointer; } .time-grid-controls input[type="checkbox"] { - accent-color: var(--teuxdeux-teal); + accent-color: var(--weekly-teal); } .cell-duration-select { padding: 0.25rem 0.5rem; - border: 1px solid var(--teuxdeux-border); + border: 1px solid var(--weekly-border); border-radius: 4px; - background: var(--teuxdeux-bg); - color: var(--teuxdeux-text); + background: var(--weekly-bg); + color: var(--weekly-text); font-size: 0.875rem; cursor: pointer; } @@ -1012,8 +1174,8 @@ h3 { .time-column { width: 50px; flex-shrink: 0; - border-right: 1px solid var(--teuxdeux-border); - background: var(--teuxdeux-bg); + border-right: 1px solid var(--weekly-border); + background: var(--weekly-bg); display: flex; flex-direction: column; } @@ -1035,20 +1197,19 @@ h3 { justify-content: flex-end; padding: 0 0.25rem; font-size: 0.65rem; - color: var(--teuxdeux-text-light); + color: var(--weekly-text-light); box-sizing: border-box; position: relative; } .time-slot-label.hour-start { - border-bottom: 1px solid var(--teuxdeux-border); font-weight: 500; - color: var(--teuxdeux-text); + color: var(--weekly-text); } .time-slot-label span { transform: translateY(50%); - background: var(--teuxdeux-bg); + background: var(--weekly-bg); padding: 0 2px; z-index: 1; } @@ -1066,24 +1227,29 @@ h3 { display: flex; flex-direction: column; gap: 2px; - overflow: hidden; + overflow: visible; cursor: text; + background: transparent; } .time-slot.hour-start { - border-top: 1px solid rgba(0, 0, 0, 0.06); + /* Weekly-style: subtle dotted line at hour boundaries */ + border-top: 1px dotted rgba(0, 0, 0, 0.1); } .time-slot:hover { - background: rgba(0, 0, 0, 0.01); + /* Very subtle hover - almost invisible */ + background: transparent; } .time-slot.active { - background: rgba(0, 154, 154, 0.03); + /* Minimal active state */ + background: transparent; } .time-slot.drop-target:hover { - background: rgba(0, 154, 154, 0.08); + /* Subtle drop indicator */ + background: rgba(0, 154, 154, 0.04); } /* Slot Input Form */ @@ -1099,26 +1265,28 @@ h3 { font-family: inherit; padding: 2px 0; outline: none; - caret-color: var(--teuxdeux-teal); + caret-color: var(--weekly-teal); } .time-slot-task { display: flex; - align-items: center; + align-items: flex-start; gap: 0.25rem; - padding: 4px 8px; + padding: 2px 0; background: transparent; font-size: 0.875rem; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - transition: all 0.2s ease; + transition: color 0.15s ease; position: relative; + /* Weekly-style: clean text, no boxes */ + color: var(--weekly-text); } .time-slot-task:hover { - color: var(--teuxdeux-teal); + color: var(--weekly-teal); } .time-slot-task:active { @@ -1126,29 +1294,75 @@ h3 { } .time-slot-task.dragging { - opacity: 0.5; + opacity: 0.4; } .time-slot-task.completed { + /* Weekly-style strikethrough */ text-decoration: line-through; - color: var(--teuxdeux-completed); - opacity: 0.6; + color: var(--weekly-completed); +} + +/* Calendar Events in Time Slots */ +.time-slot-event { + display: flex; + flex-direction: column; + gap: 0.1rem; + padding: 4px 8px; + background: rgba(0, 154, 154, 0.1); + border-left: 3px solid var(--weekly-teal); + font-size: 0.85rem; + color: var(--weekly-teal); + border-radius: 0 4px 4px 0; + margin: 1px 0; + overflow: hidden; +} + +.time-slot-event .event-title-row { + display: flex; + align-items: center; + gap: 0.25rem; + white-space: nowrap; + overflow: hidden; +} + +.time-slot-event .event-indicator { + font-size: 0.75rem; + flex-shrink: 0; +} + +.time-slot-event .event-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + font-weight: 500; +} + +.time-slot-event .event-time-row { + font-size: 0.65rem; + opacity: 0.8; + white-space: nowrap; +} + +.time-slot-event:hover { + background: rgba(0, 154, 154, 0.15); } /* Make day columns scrollable when using time grid */ -.time-grid-wrapper .teuxdeux-days-grid { +.time-grid-wrapper .weekly-days-grid { flex: 1; overflow: hidden; } -.time-grid-wrapper .teuxdeux-day-column { +.time-grid-wrapper .weekly-day-column { max-height: none; min-height: auto; display: flex; flex-direction: column; } -.time-grid-wrapper .teuxdeux-day-header { +.time-grid-wrapper .weekly-day-header { flex-shrink: 0; } @@ -1156,13 +1370,142 @@ h3 { overflow-y: auto; } +/* All-Day Events Section */ +.all-day-events-section { + background: var(--weekly-bg); + border-top: 1px solid var(--weekly-border); + padding: 0.5rem 0; +} + +.all-day-events-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 1rem; + margin-bottom: 0.5rem; +} + +.all-day-events-title { + font-size: 0.7rem; + font-weight: 600; + color: var(--weekly-text-light); + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.all-day-events-count { + background: var(--weekly-teal); + color: white; + padding: 0.1rem 0.4rem; + border-radius: 10px; + font-size: 0.65rem; + font-weight: 600; +} + +.all-day-events-grid { + display: grid; + /* margin-left is set dynamically via inline style based on showTimeGrid */ + padding: 0 0; +} + +.all-day-events-grid.cols-1 { grid-template-columns: repeat(1, 1fr); } +.all-day-events-grid.cols-3 { grid-template-columns: repeat(3, 1fr); } +.all-day-events-grid.cols-5 { grid-template-columns: repeat(5, 1fr); } +.all-day-events-grid.cols-7 { grid-template-columns: repeat(7, 1fr); } + +.all-day-events-column { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0 0.25rem; + min-height: 28px; + border-right: 1px solid var(--weekly-border); +} + +.all-day-events-column:last-child { + border-right: none; +} + +.all-day-event { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + background: rgba(0, 154, 154, 0.1); + border-left: 3px solid var(--weekly-teal); + border-radius: 0 4px 4px 0; + font-size: 0.75rem; + color: var(--weekly-text); + cursor: default; +} + +.all-day-event .event-indicator { + font-size: 0.7rem; +} + +.all-day-event-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: calc(100% - 24px); /* Account for icon */ + flex: 1; +} + +.all-day-empty { + min-height: 28px; +} + +/* "Now" Line - Current time indicator */ +.now-line { + position: absolute; + left: 0; + right: 0; + height: 2px; + background: #d50000; + z-index: 10; + pointer-events: none; +} + +.now-line::before { + content: ''; + position: absolute; + left: -5px; + top: -4px; + width: 10px; + height: 10px; + background: #d50000; + border-radius: 50%; +} + +/* Drop Preview for Drag-n-Drop */ +.drop-preview { + position: absolute; + left: 4px; + right: 4px; + height: 30px; + background: rgba(0, 154, 154, 0.15); + border: 2px dashed var(--weekly-teal); + border-radius: 4px; + pointer-events: none; + z-index: 20; + transition: top 0.1s ease; +} + +/* Task being dragged - source placeholder */ +.weekly-task-item.drag-source, +.time-slot-task.drag-source { + opacity: 0.4; + background: rgba(0, 154, 154, 0.05); + border: 1px dashed var(--weekly-teal); +} + /* Calendar connect button */ .calendar-connect-btn { flex: 1; padding: 1rem; - border: 1px solid var(--teuxdeux-border); + border: 1px solid var(--weekly-border); border-radius: 8px; - background: var(--teuxdeux-bg); + background: var(--weekly-bg); cursor: pointer; display: flex; align-items: center; @@ -1173,7 +1516,7 @@ h3 { } .calendar-connect-btn:hover { - border-color: var(--teuxdeux-teal); + border-color: var(--weekly-teal); background: rgba(0, 154, 154, 0.05); } @@ -1182,55 +1525,84 @@ h3 { } /* Draggable task items */ -.teuxdeux-task-item { +.weekly-task-item { cursor: grab; } -.teuxdeux-task-item:active { +.weekly-task-item:active { cursor: grabbing; } -.teuxdeux-task-item.dragging { +.weekly-task-item.dragging { opacity: 0.4; background: rgba(0, 154, 154, 0.1); } /* Dark mode additions for time grid */ -.teuxdeux-container.dark-mode .cell-duration-select { +.weekly-container.dark-mode .cell-duration-select { background: #2a2a2a; - border-color: var(--teuxdeux-border); - color: var(--teuxdeux-text); + border-color: var(--weekly-border); + color: var(--weekly-text); } -.teuxdeux-container.dark-mode .time-slot { +.weekly-container.dark-mode .time-slot { border-bottom-color: #2a2a2a; } -.teuxdeux-container.dark-mode .time-slot.hour-start { - border-bottom-color: var(--teuxdeux-border); +.weekly-container.dark-mode .time-slot.hour-start { + border-bottom-color: var(--weekly-border); } -.teuxdeux-container.dark-mode .time-slot.drop-target:hover { +.weekly-container.dark-mode .time-slot.drop-target:hover { background: rgba(0, 154, 154, 0.15); } -.teuxdeux-container.dark-mode .time-slot-task { +.weekly-container.dark-mode .time-slot-task { background: rgba(0, 154, 154, 0.2); } -.teuxdeux-container.dark-mode .calendar-connect-btn { +.weekly-container.dark-mode .calendar-connect-btn { background: #2a2a2a; } -.teuxdeux-container.dark-mode .calendar-connect-btn:hover { +.weekly-container.dark-mode .calendar-connect-btn:hover { background: rgba(0, 154, 154, 0.15); } /* ============================================ - AUTH PAGES STYLES (TeuxDeux-style) + LIST VIEW STYLES ============================================ */ -.teuxdeux-auth-container { +.weekly-container.list-view .weekly-day-column { + border-left-width: 0px; +} + +.weekly-container.list-view .time-slot.hour-start { + margin-left: 20px; + border-right-width: 20px; + padding-left: 0px; + padding-right: 0px; +} + +.weekly-container.list-view .time-column { + width: 40px; +} + +/* Event Protection Overlay - matches exact event position and height */ +.event-protection-overlay { + background: transparent; + border-radius: 4px; + display: flex; + justify-content: flex-end; + align-items: flex-start; + padding: 4px; +} + +/* ============================================ + AUTH PAGES STYLES (Weekly-style) + ============================================ */ + +.weekly-auth-container { min-height: 100vh; display: flex; flex-direction: column; @@ -1240,7 +1612,7 @@ h3 { padding: 2rem; } -.teuxdeux-auth-card { +.weekly-auth-card { background: white; border-radius: 8px; box-shadow: 0 2px 20px rgba(0, 0, 0, 0.08); @@ -1250,7 +1622,7 @@ h3 { text-align: center; } -.teuxdeux-auth-logo { +.weekly-auth-logo { font-size: 1.75rem; font-weight: 700; color: #1a1a1a; @@ -1258,13 +1630,13 @@ h3 { margin-bottom: 0.5rem; } -.teuxdeux-auth-tagline { +.weekly-auth-tagline { color: #666; font-size: 0.9rem; margin-bottom: 2rem; } -.teuxdeux-auth-error { +.weekly-auth-error { background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; @@ -1274,17 +1646,17 @@ h3 { margin-bottom: 1.5rem; } -.teuxdeux-auth-form { +.weekly-auth-form { display: flex; flex-direction: column; gap: 1rem; } -.teuxdeux-auth-field { +.weekly-auth-field { width: 100%; } -.teuxdeux-auth-input { +.weekly-auth-input { width: 100%; padding: 0.875rem 1rem; border: 1px solid #e0e0e0; @@ -1294,16 +1666,16 @@ h3 { outline: none; } -.teuxdeux-auth-input:focus { - border-color: var(--teuxdeux-teal, #009a9a); +.weekly-auth-input:focus { + border-color: var(--weekly-teal, #009a9a); box-shadow: 0 0 0 3px rgba(0, 154, 154, 0.1); } -.teuxdeux-auth-input::placeholder { +.weekly-auth-input::placeholder { color: #999; } -.teuxdeux-auth-button { +.weekly-auth-button { width: 100%; padding: 0.875rem 1rem; border-radius: 6px; @@ -1317,62 +1689,62 @@ h3 { gap: 0.5rem; } -.teuxdeux-auth-button.primary { - background: var(--teuxdeux-teal, #009a9a); +.weekly-auth-button.primary { + background: var(--weekly-teal, #009a9a); color: white; border: none; } -.teuxdeux-auth-button.primary:hover { +.weekly-auth-button.primary:hover { background: #008888; } -.teuxdeux-auth-button.primary:disabled { +.weekly-auth-button.primary:disabled { background: #ccc; cursor: not-allowed; } -.teuxdeux-auth-button.google { +.weekly-auth-button.google { background: white; color: #333; border: 1px solid #e0e0e0; } -.teuxdeux-auth-button.google:hover { +.weekly-auth-button.google:hover { background: #f9f9f9; border-color: #ccc; } -.teuxdeux-auth-button.google:disabled { +.weekly-auth-button.google:disabled { opacity: 0.6; cursor: not-allowed; } -.teuxdeux-auth-button .google-icon { +.weekly-auth-button .google-icon { flex-shrink: 0; } -.teuxdeux-auth-divider { +.weekly-auth-divider { display: flex; align-items: center; margin: 1.5rem 0; } -.teuxdeux-auth-divider::before, -.teuxdeux-auth-divider::after { +.weekly-auth-divider::before, +.weekly-auth-divider::after { content: ''; flex: 1; height: 1px; background: #e0e0e0; } -.teuxdeux-auth-divider span { +.weekly-auth-divider span { padding: 0 1rem; color: #999; font-size: 0.875rem; } -.teuxdeux-auth-links { +.weekly-auth-links { margin-top: 2rem; display: flex; align-items: center; @@ -1381,28 +1753,28 @@ h3 { flex-wrap: wrap; } -.teuxdeux-auth-link { - color: var(--teuxdeux-teal, #009a9a); +.weekly-auth-link { + color: var(--weekly-teal, #009a9a); text-decoration: none; font-size: 0.875rem; transition: color 0.15s ease; } -.teuxdeux-auth-link:hover { +.weekly-auth-link:hover { color: #008888; text-decoration: underline; } -.teuxdeux-auth-link-divider { +.weekly-auth-link-divider { color: #ccc; } -.teuxdeux-auth-text { +.weekly-auth-text { color: #666; font-size: 0.875rem; } -.teuxdeux-auth-footer { +.weekly-auth-footer { margin-top: 2rem; color: #999; font-size: 0.8rem; @@ -1445,13 +1817,13 @@ h3 { } /* Fix 2: Task Hover Actions */ -.teuxdeux-task-item, +.weekly-task-item, .time-slot-task { /* Ensure positioning context for absolute actions if needed, though flex is used */ position: relative; } -.teuxdeux-task-item .task-actions, +.weekly-task-item .task-actions, .time-slot-task .task-actions { display: flex; align-items: center; @@ -1463,7 +1835,7 @@ h3 { padding-left: 8px; } -.teuxdeux-task-item:hover .task-actions, +.weekly-task-item:hover .task-actions, .time-slot-task:hover .task-actions { opacity: 1; } @@ -1491,8 +1863,39 @@ h3 { background-color: rgba(211, 47, 47, 0.1); } +/* Roll Menu Dropdown */ +.roll-menu { + position: absolute; + top: 100%; + right: 0; + background: white; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 100; + min-width: 120px; + overflow: hidden; +} + +.roll-menu button { + display: block; + width: 100%; + padding: 8px 12px; + border: none; + background: none; + text-align: left; + font-size: 12px; + color: var(--weekly-text); + cursor: pointer; + transition: background 0.2s; +} + +.roll-menu button:hover { + background: var(--weekly-teal); + color: white; +} + /* Notes Modal */ -.teuxdeux-modal-overlay { +.weekly-modal-overlay { position: fixed; top: 0; left: 0; @@ -1506,7 +1909,7 @@ h3 { backdrop-filter: blur(2px); } -.teuxdeux-modal-content { +.weekly-modal-content { background: white; padding: 24px; border-radius: 12px; @@ -1517,13 +1920,13 @@ h3 { flex-direction: column; } -.teuxdeux-modal-content h3 { +.weekly-modal-content h3 { margin: 0 0 16px 0; font-size: 1.2rem; font-weight: 600; } -.teuxdeux-notes-editor { +.weekly-notes-editor { width: 100%; height: 200px; margin-bottom: 16px; @@ -1535,13 +1938,13 @@ h3 { line-height: 1.5; } -.teuxdeux-modal-actions { +.weekly-modal-actions { display: flex; justify-content: flex-end; gap: 12px; } -.teuxdeux-btn { +.weekly-btn { padding: 8px 16px; border-radius: 6px; border: none; @@ -1550,28 +1953,28 @@ h3 { transition: background 0.2s; } -.teuxdeux-btn-secondary { +.weekly-btn-secondary { background: #f5f5f5; color: #333; } -.teuxdeux-btn-secondary:hover { +.weekly-btn-secondary:hover { background: #e0e0e0; } -.teuxdeux-btn-primary { +.weekly-btn-primary { background: #000; color: #fff; } -.teuxdeux-btn-primary:hover { +.weekly-btn-primary:hover { background: #333; } -/* --- VIEW TRANSITION ANIMATION (TeuxDeux-like) --- */ +/* --- VIEW TRANSITION ANIMATION (Weekly-like) --- */ /* Only apply Container transitions (Full Slide) when navType is 'week' */ -[data-nav-type="week"] .teuxdeux-days-grid { +[data-nav-type="week"] .weekly-days-grid { view-transition-name: week-grid; } diff --git a/src/app/tasks/page.tsx b/src/app/tasks/page.tsx index 0fd37a8..0815600 100644 --- a/src/app/tasks/page.tsx +++ b/src/app/tasks/page.tsx @@ -3,7 +3,7 @@ import { useEffect } from 'react'; import { useSession } from 'next-auth/react'; import { useRouter } from 'next/navigation'; -import TeuxDeuxView from '@/components/TeuxDeuxView'; +import WeeklyView from '@/components/WeeklyView'; export default function TasksPage() { const { data: session, status } = useSession(); @@ -27,5 +27,5 @@ export default function TasksPage() { return null; } - return ; + return ; } \ No newline at end of file diff --git a/src/components/TaskItem.tsx b/src/components/TaskItem.tsx index d340e5c..4e748b7 100644 --- a/src/components/TaskItem.tsx +++ b/src/components/TaskItem.tsx @@ -5,6 +5,7 @@ interface Task { title: string; description?: string; completed: boolean; + isRolling?: boolean; createdAt: Date; updatedAt: Date; } @@ -14,9 +15,10 @@ interface TaskItemProps { onToggleComplete: (id: string) => void; onDelete: (id: string) => void; onEdit: (task: Task) => void; + onToggleRolling: (id: string) => void; } -export default function TaskItem({ task, onToggleComplete, onDelete, onEdit }: TaskItemProps) { +export default function TaskItem({ task, onToggleComplete, onDelete, onEdit, onToggleRolling }: TaskItemProps) { const [isEditing, setIsEditing] = useState(false); const [editedTitle, setEditedTitle] = useState(task.title); const [editedDescription, setEditedDescription] = useState(task.description || ''); @@ -82,11 +84,21 @@ export default function TaskItem({ task, onToggleComplete, onDelete, onEdit }: T } return ( -
+
-
+
+ {/* Rolling Toggle */} + + void; onTaskEdit?: (task: Task) => void; onTaskClick?: (task: Task) => void; + onTaskToggleRolling?: (id: string) => void; } const TaskList: React.FC = ({ @@ -26,7 +28,8 @@ const TaskList: React.FC = ({ onTaskToggleComplete, onTaskDelete, onTaskEdit, - onTaskClick + onTaskClick, + onTaskToggleRolling }) => { if (tasks.length === 0) { return ( @@ -51,6 +54,7 @@ const TaskList: React.FC = ({ onToggleComplete={onTaskToggleComplete || (() => { })} onDelete={onTaskDelete || (() => { })} onEdit={onTaskEdit || (() => { })} + onToggleRolling={onTaskToggleRolling || (() => { })} /> ))} diff --git a/src/components/TeuxDeuxView.tsx b/src/components/TeuxDeuxView.tsx deleted file mode 100644 index 0be4262..0000000 --- a/src/components/TeuxDeuxView.tsx +++ /dev/null @@ -1,950 +0,0 @@ -'use client'; - -import React, { useState, useEffect, useRef, useCallback, DragEvent } from 'react'; -import { useSession, signOut } from 'next-auth/react'; - -// Types -interface Task { - id: string; - title: string; - markdownContent?: string; - completed: boolean; - dayOfWeek?: number | null; - somedayListId?: string | null; - order: number; - startTime?: string | null; - endTime?: string | null; - userId: string; - createdAt: Date; - updatedAt: Date; -} - -interface CalendarEvent { - id: string; - title: string; - startTime: string; - endTime: string; - source: 'google' | 'apple'; -} - -interface SomedayList { - id: string; - name: string; - tasks: Task[]; -} - -// Time grid configuration options -type CellDuration = 15 | 30 | 60 | 120; - -// Date utilities -function getStartOfWeek(date: Date, startDay: number = 0): Date { - const d = new Date(date); - const day = d.getDay(); - const diff = d.getDate() - day + startDay; - return new Date(d.setDate(diff)); -} - -function formatDateHeader(date: Date): string { - return date.toLocaleDateString('en-US', { day: 'numeric', month: 'short', year: 'numeric' }).toUpperCase(); -} - -function getDayName(date: Date): string { - return date.toLocaleDateString('en-US', { weekday: 'long' }).toUpperCase(); -} - -function isSameDay(d1: Date, d2: Date): boolean { - return d1.toDateString() === d2.toDateString(); -} - -function formatHour(hour: number): string { - return `${hour.toString().padStart(2, '0')}:00`; -} - -function getTimeSlots(cellDuration: CellDuration): string[] { - const slots: string[] = []; - const slotsPerHour = 60 / cellDuration; - for (let hour = 0; hour < 24; hour++) { - for (let slot = 0; slot < slotsPerHour; slot++) { - const minutes = slot * cellDuration; - slots.push(`${hour.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`); - } - } - return slots; -} - -function getHourFromSlot(slot: string): number { - return parseInt(slot.split(':')[0], 10); -} - -function getWeekNumber(date: Date): number { - const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); - const dayNum = d.getUTCDay() || 7; - d.setUTCDate(d.getUTCDate() + 4 - dayNum); - const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); - return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); -} - -// Main Component -export default function TeuxDeuxView() { - const { data: session } = useSession(); - const [tasks, setTasks] = useState([]); - const [calendarEvents, setCalendarEvents] = useState([]); - const [currentWeekStart, setCurrentWeekStart] = useState(getStartOfWeek(new Date())); - const [viewDays, setViewDays] = useState(7); - const [isLoading, setIsLoading] = useState(true); - const [darkMode, setDarkMode] = useState(false); - const [somedayExpanded, setSomedayExpanded] = useState(true); - const [somedayLists, setSomedayLists] = useState([ - { id: 'default', name: 'Someday', tasks: [] } - ]); - const [editingTaskId, setEditingTaskId] = useState(null); - const [showSettings, setShowSettings] = useState(false); - const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'synced'>('idle'); - const [cellDuration, setCellDuration] = useState(60); - const [draggedTask, setDraggedTask] = useState(null); - const [showTimeGrid, setShowTimeGrid] = useState(true); - const [slideDirection, setSlideDirection] = useState<'left' | 'right' | 'out-left' | 'out-right' | 'in-left' | 'in-right' | null>(null); - const [activeSlot, setActiveSlot] = useState<{ day: number; slot: string } | null>(null); - const [newSlotTask, setNewSlotTask] = useState(''); - const [selectedTaskForNotes, setSelectedTaskForNotes] = useState(null); - - // Slot height based on cell duration - const getSlotHeight = (duration: CellDuration) => { - switch (duration) { - case 15: return 25; - case 30: return 35; - case 60: return 50; - case 120: return 80; - default: return 50; - } - }; - - // Working hours range (configurable) - const workingHoursStart = 6; - const workingHoursEnd = 22; - - // Fetch tasks on mount - useEffect(() => { - if (session) { - fetchTasks(); - } - }, [session]); - - async function fetchTasks() { - try { - const response = await fetch('/api/tasks'); - if (response.ok) { - const data = await response.json(); - const fetchedTasks = data.tasks.map((t: any) => ({ - ...t, - createdAt: new Date(t.createdAt), - updatedAt: new Date(t.updatedAt), - })); - - const dayTasks = fetchedTasks.filter((t: Task) => t.dayOfWeek !== null && !t.somedayListId); - const somedayTasks = fetchedTasks.filter((t: Task) => t.somedayListId); - - setTasks(dayTasks); - - setSomedayLists(prev => prev.map(list => ({ - ...list, - tasks: somedayTasks.filter((t: Task) => t.somedayListId === list.id) - }))); - } - } catch (error) { - console.error('Error fetching tasks:', error); - } finally { - setIsLoading(false); - } - } - - // Get visible days based on current view setting - const getVisibleDays = useCallback(() => { - const days: Date[] = []; - for (let i = 0; i < viewDays; i++) { - days.push(new Date(currentWeekStart.getTime() + i * 24 * 60 * 60 * 1000)); - } - return days; - }, [currentWeekStart, viewDays]); - - // Get tasks for a specific date - const getTasksForDate = useCallback((date: Date): Task[] => { - const dayOfWeek = date.getDay(); - return tasks - .filter(task => task.dayOfWeek === dayOfWeek) - .sort((a, b) => { - // Sort by time if available - if (a.startTime && b.startTime) { - return a.startTime.localeCompare(b.startTime); - } - if (a.startTime) return -1; - if (b.startTime) return 1; - return a.order - b.order; - }); - }, [tasks]); - - // Get tasks for a specific time slot - const getTasksForSlot = useCallback((date: Date, slot: string): Task[] => { - const dayOfWeek = date.getDay(); - return tasks.filter(task => - task.dayOfWeek === dayOfWeek && - task.startTime === slot - ); - }, [tasks]); - - // Get calendar events for a specific date - const getEventsForDate = useCallback((date: Date): CalendarEvent[] => { - return calendarEvents.filter(event => { - const eventDate = new Date(event.startTime); - return isSameDay(eventDate, date); - }); - }, [calendarEvents]); - - // Navigation handlers with proper slide animation - // Simplified: Immediate state update with slide-in animation to prevent blank flash - const navigate = (newDate: Date, direction: 'left' | 'right', type: 'day' | 'week') => { - // Use View Transition API for smooth 'TeuxDeux' slide (simultaneous old/new) - if (typeof document !== 'undefined' && 'startViewTransition' in document) { - const doc = document as any; - doc.documentElement.dataset.transitionDirection = direction === 'left' ? 'next' : 'prev'; - doc.documentElement.dataset.navType = type; - - doc.startViewTransition(() => { - setCurrentWeekStart(newDate); - setSlideDirection(null); - }); - } else { - setCurrentWeekStart(newDate); - } - }; - - const goToPrevWeek = () => navigate(new Date(currentWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000), 'right', 'week'); - const goToNextWeek = () => navigate(new Date(currentWeekStart.getTime() + 7 * 24 * 60 * 60 * 1000), 'left', 'week'); - const goToPrevDay = () => navigate(new Date(currentWeekStart.getTime() - 24 * 60 * 60 * 1000), 'right', 'day'); - const goToNextDay = () => navigate(new Date(currentWeekStart.getTime() + 24 * 60 * 60 * 1000), 'left', 'day'); - const goToToday = () => setCurrentWeekStart(getStartOfWeek(new Date())); - - // Task CRUD operations - const addTask = async (dayOfWeek: number, title: string, startTime?: string) => { - if (!title.trim()) return; - - if (!session?.user) { - // Local-only demo mode when not authenticated - const tempId = `temp-${Date.now()}`; - setTasks(prevTasks => [...prevTasks, { - id: tempId, - title: title.trim(), - dayOfWeek, - order: prevTasks.filter(t => t.dayOfWeek === dayOfWeek).length, - completed: false, - userId: 'temp', - startTime, - createdAt: new Date(), - updatedAt: new Date(), - }]); - return; - } - - try { - const response = await fetch('/api/tasks', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title: title.trim(), - dayOfWeek, - order: 0, - startTime - }), - }); - - if (response.ok) { - const data = await response.json(); - setTasks(prevTasks => [...prevTasks, { - ...data.task, - createdAt: new Date(data.task.createdAt), - updatedAt: new Date(data.task.updatedAt), - }]); - } else { - console.error('Failed to add task:', await response.text()); - } - } catch (error) { - console.error('Error adding task:', error); - } - }; - - const toggleTask = async (taskId: string) => { - const task = tasks.find(t => t.id === taskId); - if (!task) return; - - const updatedCompleted = !task.completed; - - setTasks(tasks.map(t => - t.id === taskId - ? { ...t, completed: updatedCompleted, updatedAt: new Date() } - : t - )); - - try { - await fetch('/api/tasks', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: taskId, completed: updatedCompleted }), - }); - } catch (error) { - console.error('Error toggling task:', error); - } - }; - - const updateTask = async (taskId: string, newTitle: string) => { - if (!newTitle.trim()) { - await deleteTask(taskId); - return; - } - - setTasks(tasks.map(t => - t.id === taskId - ? { ...t, title: newTitle.trim(), updatedAt: new Date() } - : t - )); - setEditingTaskId(null); - - try { - await fetch('/api/tasks', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: taskId, title: newTitle.trim() }), - }); - } catch (error) { - console.error('Error updating task:', error); - } - }; - - const updateTaskNotes = async (taskId: string, notes: string) => { - setTasks(tasks.map(t => - t.id === taskId ? { ...t, markdownContent: notes, updatedAt: new Date() } : t - )); - - try { - await fetch('/api/tasks', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: taskId, markdownContent: notes }), - }); - } catch (error) { - console.error('Error updating task notes:', error); - } - }; - - const moveTaskToSlot = async (taskId: string, dayOfWeek: number, startTime: string) => { - setTasks(tasks.map(t => - t.id === taskId - ? { ...t, dayOfWeek, startTime, updatedAt: new Date() } - : t - )); - - try { - await fetch('/api/tasks', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: taskId, dayOfWeek, startTime }), - }); - } catch (error) { - console.error('Error moving task:', error); - } - }; - - const deleteTask = async (taskId: string) => { - setTasks(tasks.filter(t => t.id !== taskId)); - setEditingTaskId(null); - - try { - await fetch(`/api/tasks?id=${taskId}`, { method: 'DELETE' }); - } catch (error) { - console.error('Error deleting task:', error); - } - }; - - // Drag and drop handlers - const handleDragStart = (e: DragEvent, task: Task) => { - setDraggedTask(task); - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/plain', task.id); - }; - - const handleDragOver = (e: DragEvent) => { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - }; - - const handleDrop = (e: DragEvent, dayOfWeek: number, slot?: string) => { - e.preventDefault(); - if (draggedTask) { - moveTaskToSlot(draggedTask.id, dayOfWeek, slot || ''); - setDraggedTask(null); - } - }; - - const handleDragEnd = () => { - setDraggedTask(null); - }; - - // Sync calendar - const handleSync = async () => { - setSyncStatus('syncing'); - try { - const response = await fetch('/api/calendar/sync', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - timeMin: currentWeekStart.toISOString(), - timeMax: new Date(currentWeekStart.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(), - }), - }); - - if (response.ok) { - const data = await response.json(); - if (data.events) { - setCalendarEvents(data.events); - } - } - setSyncStatus('synced'); - } catch (error) { - console.error('Error syncing calendar:', error); - setSyncStatus('idle'); - } - }; - - // Add new someday list - const addSomedayList = () => { - const name = prompt('Enter list name:'); - if (name?.trim()) { - setSomedayLists([...somedayLists, { - id: `list-${Date.now()}`, - name: name.trim(), - tasks: [] - }]); - } - }; - - // Get time slots to display - const visibleSlots = getTimeSlots(cellDuration).filter(slot => { - const hour = getHourFromSlot(slot); - return hour >= workingHoursStart && hour < workingHoursEnd; - }); - - if (isLoading) { - return ( -
-
Loading your tasks...
-
- ); - } - - return ( -
- {/* Header */} -
-
- My Weekly ToDo's -
- -
- {currentWeekStart.getFullYear()} (W{getWeekNumber(currentWeekStart).toString().padStart(2, '0')}) -
- - -
- - {/* Grid Controls */} -
- - {showTimeGrid && ( - - )} -
- - {/* Main Grid with Time Column */} -
- {/* Time Column */} - {showTimeGrid && ( -
-
-
- {visibleSlots.map((slot, index) => { - const hour = getHourFromSlot(slot); - const minutes = slot.split(':')[1]; - const isHourStart = minutes === '00'; - return ( -
- {isHourStart && {formatHour(hour)}} -
- ); - })} -
-
- )} - - {/* Day Columns */} -
- {getVisibleDays().map((date) => ( -
- {/* Day Header */} -
-
{formatDateHeader(date)}
-

- {getDayName(date)} -

-
- - {/* Time Grid or Simple List */} - {showTimeGrid ? ( -
- {visibleSlots.map((slot) => { - const hour = getHourFromSlot(slot); - const minutes = slot.split(':')[1]; - const isHourStart = minutes === '00'; - const slotTasks = getTasksForSlot(date, slot); - const isActive = activeSlot?.day === date.getDay() && activeSlot?.slot === slot; - - const handleSlotClick = () => { - if (!isActive) { - setActiveSlot({ day: date.getDay(), slot }); - setNewSlotTask(''); - } - }; - - const handleSlotSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - e.stopPropagation(); - const taskTitle = newSlotTask.trim(); - // Clear state immediately to prevent double submit - setActiveSlot(null); - setNewSlotTask(''); - if (taskTitle) { - await addTask(date.getDay(), taskTitle, slot); - } - }; - - return ( -
handleDrop(e, date.getDay(), slot)} - > - {slotTasks.map(task => ( -
handleDragStart(e, task)} - onDragEnd={handleDragEnd} - onClick={(e) => { - e.stopPropagation(); - toggleTask(task.id); - }} - > - {task.title} -
- - -
-
- ))} - {isActive && ( -
- setNewSlotTask(e.target.value)} - onBlur={async () => { - // Only save if still active (not already submitted) - if (activeSlot && newSlotTask.trim()) { - const taskTitle = newSlotTask.trim(); - setActiveSlot(null); - setNewSlotTask(''); - await addTask(date.getDay(), taskTitle, slot); - } else { - setActiveSlot(null); - setNewSlotTask(''); - } - }} - onKeyDown={(e) => { - if (e.key === 'Escape') { - setActiveSlot(null); - setNewSlotTask(''); - } - }} - autoFocus - className="slot-input" - /> -
- )} -
- ); - })} -
- ) : ( - <> - {/* Calendar Events */} - {getEventsForDate(date).map(event => ( -
-
- {new Date(event.startTime).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })} -
-
{event.title}
-
- ))} - - {/* Tasks */} -
    - {getTasksForDate(date).map(task => ( - toggleTask(task.id)} - onEdit={() => setEditingTaskId(task.id)} - onUpdate={(title) => updateTask(task.id, title)} - onDelete={() => deleteTask(task.id)} - onNotes={() => setSelectedTaskForNotes(task)} - onDragStart={handleDragStart} - onDragEnd={handleDragEnd} - /> - ))} -
- - )} - - {/* Add Task Input */} - addTask(date.getDay(), title)} - onDragOver={handleDragOver} - onDrop={(e) => handleDrop(e, date.getDay())} - /> -
- ))} -
-
- - {/* Someday Section */} -
-
- SOMEDAY - - {somedayLists.reduce((acc, list) => acc + list.tasks.length, 0)} - - - -
- - {somedayExpanded && ( -
- {somedayLists.map(list => ( -
-

{list.name}

-
    - {list.tasks.map(task => ( - toggleTask(task.id)} - onEdit={() => setEditingTaskId(task.id)} - onUpdate={(title) => updateTask(task.id, title)} - onDelete={() => deleteTask(task.id)} - onNotes={() => setSelectedTaskForNotes(task)} - onDragStart={handleDragStart} - onDragEnd={handleDragEnd} - /> - ))} -
-
- ))} -
- )} -
- - {/* Footer */} -
-
- {[1, 3, 5, 7].map(num => ( - - ))} -
- -
- - {syncStatus === 'syncing' ? '⟳ Syncing...' : syncStatus === 'synced' ? '✓ Synced' : ''} - - - - -
-
- - {/* Settings Modal */} - {showSettings && setShowSettings(false)} />} - - {selectedTaskForNotes && ( -
setSelectedTaskForNotes(null)}> -
e.stopPropagation()}> -

Notes: {selectedTaskForNotes.title}

-