feat: localize app to German and add configurable day hours
This commit is contained in:
parent
a4c4670662
commit
d054144939
7
package-lock.json
generated
7
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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");
|
||||
@ -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)
|
||||
|
||||
40
scripts/seed-connection.ts
Normal file
40
scripts/seed-connection.ts
Normal file
@ -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());
|
||||
37
scripts/verify-schema.ts
Normal file
37
scripts/verify-schema.ts
Normal file
@ -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();
|
||||
});
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
110
src/app/api/calendar/sync/route.ts
Normal file
110
src/app/api/calendar/sync/route.ts
Normal file
@ -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 });
|
||||
}
|
||||
}
|
||||
220
src/app/api/someday-lists/route.ts
Normal file
220
src/app/api/someday-lists/route.ts
Normal file
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 })
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
51
src/app/api/user/export/route.ts
Normal file
51
src/app/api/user/export/route.ts
Normal file
@ -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 });
|
||||
}
|
||||
}
|
||||
107
src/app/api/user/profile/route.ts
Normal file
107
src/app/api/user/profile/route.ts
Normal file
@ -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 });
|
||||
}
|
||||
}
|
||||
@ -29,21 +29,21 @@ export default function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="teuxdeux-auth-container">
|
||||
<div className="teuxdeux-auth-card">
|
||||
<div className="weekly-auth-container">
|
||||
<div className="weekly-auth-card">
|
||||
{/* Logo */}
|
||||
<div className="teuxdeux-auth-logo">
|
||||
<div className="weekly-auth-logo">
|
||||
My Weekly ToDo's
|
||||
</div>
|
||||
|
||||
{/* Tagline */}
|
||||
<p className="teuxdeux-auth-tagline">
|
||||
<p className="weekly-auth-tagline">
|
||||
A simple, designy to-do app.
|
||||
</p>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="teuxdeux-auth-error">
|
||||
<div className="weekly-auth-error">
|
||||
{error === 'CredentialsSignin'
|
||||
? 'Invalid email or password'
|
||||
: 'An error occurred during sign in'}
|
||||
@ -51,40 +51,40 @@ export default function LoginPage() {
|
||||
)}
|
||||
|
||||
{/* Login Form */}
|
||||
<form onSubmit={handleCredentialsSignIn} className="teuxdeux-auth-form">
|
||||
<div className="teuxdeux-auth-field">
|
||||
<form onSubmit={handleCredentialsSignIn} className="weekly-auth-form">
|
||||
<div className="weekly-auth-field">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="Email"
|
||||
required
|
||||
autoComplete="email"
|
||||
className="teuxdeux-auth-input"
|
||||
className="weekly-auth-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="teuxdeux-auth-field">
|
||||
<div className="weekly-auth-field">
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="teuxdeux-auth-input"
|
||||
className="weekly-auth-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="teuxdeux-auth-button primary"
|
||||
className="weekly-auth-button primary"
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Log In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="teuxdeux-auth-divider">
|
||||
<div className="weekly-auth-divider">
|
||||
<span>or</span>
|
||||
</div>
|
||||
|
||||
@ -93,7 +93,7 @@ export default function LoginPage() {
|
||||
type="button"
|
||||
onClick={handleGoogleSignIn}
|
||||
disabled={isLoading}
|
||||
className="teuxdeux-auth-button google"
|
||||
className="weekly-auth-button google"
|
||||
>
|
||||
<svg className="google-icon" viewBox="0 0 24 24" width="18" height="18">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
|
||||
@ -105,19 +105,19 @@ export default function LoginPage() {
|
||||
</button>
|
||||
|
||||
{/* Links */}
|
||||
<div className="teuxdeux-auth-links">
|
||||
<Link href="/auth/forgot-password" className="teuxdeux-auth-link">
|
||||
<div className="weekly-auth-links">
|
||||
<Link href="/auth/forgot-password" className="weekly-auth-link">
|
||||
Forgot password?
|
||||
</Link>
|
||||
<span className="teuxdeux-auth-link-divider">·</span>
|
||||
<Link href="/auth/signup" className="teuxdeux-auth-link">
|
||||
<span className="weekly-auth-link-divider">·</span>
|
||||
<Link href="/auth/signup" className="weekly-auth-link">
|
||||
Create account
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="teuxdeux-auth-footer">
|
||||
<footer className="weekly-auth-footer">
|
||||
<p>Simple. Beautiful. Yours.</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@ -66,50 +66,50 @@ export default function SignupPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="teuxdeux-auth-container">
|
||||
<div className="teuxdeux-auth-card">
|
||||
<div className="weekly-auth-container">
|
||||
<div className="weekly-auth-card">
|
||||
{/* Logo */}
|
||||
<div className="teuxdeux-auth-logo">
|
||||
<div className="weekly-auth-logo">
|
||||
My Weekly ToDo's
|
||||
</div>
|
||||
|
||||
{/* Tagline */}
|
||||
<p className="teuxdeux-auth-tagline">
|
||||
<p className="weekly-auth-tagline">
|
||||
Start organizing your week beautifully.
|
||||
</p>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="teuxdeux-auth-error">
|
||||
<div className="weekly-auth-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signup Form */}
|
||||
<form onSubmit={handleSignup} className="teuxdeux-auth-form">
|
||||
<div className="teuxdeux-auth-field">
|
||||
<form onSubmit={handleSignup} className="weekly-auth-form">
|
||||
<div className="weekly-auth-field">
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Name"
|
||||
required
|
||||
autoComplete="name"
|
||||
className="teuxdeux-auth-input"
|
||||
className="weekly-auth-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="teuxdeux-auth-field">
|
||||
<div className="weekly-auth-field">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="Email"
|
||||
required
|
||||
autoComplete="email"
|
||||
className="teuxdeux-auth-input"
|
||||
className="weekly-auth-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="teuxdeux-auth-field">
|
||||
<div className="weekly-auth-field">
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
@ -117,11 +117,11 @@ export default function SignupPage() {
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
className="teuxdeux-auth-input"
|
||||
className="weekly-auth-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="teuxdeux-auth-field">
|
||||
<div className="weekly-auth-field">
|
||||
<input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
@ -129,21 +129,21 @@ export default function SignupPage() {
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
className="teuxdeux-auth-input"
|
||||
className="weekly-auth-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="teuxdeux-auth-button primary"
|
||||
className="weekly-auth-button primary"
|
||||
>
|
||||
{isLoading ? 'Creating account...' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="teuxdeux-auth-divider">
|
||||
<div className="weekly-auth-divider">
|
||||
<span>or</span>
|
||||
</div>
|
||||
|
||||
@ -152,7 +152,7 @@ export default function SignupPage() {
|
||||
type="button"
|
||||
onClick={handleGoogleSignIn}
|
||||
disabled={isLoading}
|
||||
className="teuxdeux-auth-button google"
|
||||
className="weekly-auth-button google"
|
||||
>
|
||||
<svg className="google-icon" viewBox="0 0 24 24" width="18" height="18">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
|
||||
@ -164,16 +164,16 @@ export default function SignupPage() {
|
||||
</button>
|
||||
|
||||
{/* Links */}
|
||||
<div className="teuxdeux-auth-links">
|
||||
<span className="teuxdeux-auth-text">Already have an account?</span>
|
||||
<Link href="/auth/login" className="teuxdeux-auth-link">
|
||||
<div className="weekly-auth-links">
|
||||
<span className="weekly-auth-text">Already have an account?</span>
|
||||
<Link href="/auth/login" className="weekly-auth-link">
|
||||
Log in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="teuxdeux-auth-footer">
|
||||
<footer className="weekly-auth-footer">
|
||||
<p>Simple. Beautiful. Yours.</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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 <TeuxDeuxView />;
|
||||
return <WeeklyView />;
|
||||
}
|
||||
@ -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 (
|
||||
<div className={`border border-gray-200 rounded-lg p-4 mb-2 bg-white shadow-sm transition-all duration-200 ${
|
||||
isDeleting ? 'opacity-50' : 'hover:shadow-md'
|
||||
}`}>
|
||||
<div className={`border border-gray-200 rounded-lg p-4 mb-2 bg-white shadow-sm transition-all duration-200 ${isDeleting ? 'opacity-50' : 'hover:shadow-md'
|
||||
}`}>
|
||||
<div className="flex items-start">
|
||||
<div className="flex items-center mr-3 mt-1">
|
||||
<div className="flex items-center mr-3 mt-1 space-x-2">
|
||||
{/* Rolling Toggle */}
|
||||
<button
|
||||
onClick={() => onToggleRolling(task.id)}
|
||||
className={`p-1 rounded-full hover:bg-gray-100 transition-colors ${task.isRolling ? 'text-blue-600' : 'text-gray-300'}`}
|
||||
title={task.isRolling ? "Disable rolling" : "Enable rolling"}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={task.completed}
|
||||
|
||||
@ -9,6 +9,7 @@ interface Task {
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
dayOfWeek?: number; // 0 = Sunday, 1 = Monday, etc.
|
||||
isRolling?: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@ -19,6 +20,7 @@ interface TaskListProps {
|
||||
onTaskDelete?: (id: string) => void;
|
||||
onTaskEdit?: (task: Task) => void;
|
||||
onTaskClick?: (task: Task) => void;
|
||||
onTaskToggleRolling?: (id: string) => void;
|
||||
}
|
||||
|
||||
const TaskList: React.FC<TaskListProps> = ({
|
||||
@ -26,7 +28,8 @@ const TaskList: React.FC<TaskListProps> = ({
|
||||
onTaskToggleComplete,
|
||||
onTaskDelete,
|
||||
onTaskEdit,
|
||||
onTaskClick
|
||||
onTaskClick,
|
||||
onTaskToggleRolling
|
||||
}) => {
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
@ -51,6 +54,7 @@ const TaskList: React.FC<TaskListProps> = ({
|
||||
onToggleComplete={onTaskToggleComplete || (() => { })}
|
||||
onDelete={onTaskDelete || (() => { })}
|
||||
onEdit={onTaskEdit || (() => { })}
|
||||
onToggleRolling={onTaskToggleRolling || (() => { })}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@ -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<Task[]>([]);
|
||||
const [calendarEvents, setCalendarEvents] = useState<CalendarEvent[]>([]);
|
||||
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<SomedayList[]>([
|
||||
{ id: 'default', name: 'Someday', tasks: [] }
|
||||
]);
|
||||
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'synced'>('idle');
|
||||
const [cellDuration, setCellDuration] = useState<CellDuration>(60);
|
||||
const [draggedTask, setDraggedTask] = useState<Task | null>(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<Task | null>(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 (
|
||||
<div className="teuxdeux-container" style={{ alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ color: 'var(--teuxdeux-text-light)' }}>Loading your tasks...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`teuxdeux-container ${darkMode ? 'dark-mode' : ''}`}>
|
||||
{/* Header */}
|
||||
<header className="teuxdeux-header">
|
||||
<div className="teuxdeux-logo">
|
||||
My Weekly ToDo's
|
||||
</div>
|
||||
|
||||
<div className="teuxdeux-week-number">
|
||||
{currentWeekStart.getFullYear()} (W{getWeekNumber(currentWeekStart).toString().padStart(2, '0')})
|
||||
</div>
|
||||
|
||||
<nav className="teuxdeux-nav">
|
||||
<button className="teuxdeux-nav-btn" onClick={goToPrevWeek} title="Previous Week">‹‹</button>
|
||||
<button className="teuxdeux-nav-btn" onClick={goToPrevDay} title="Previous Day">‹</button>
|
||||
<button className="teuxdeux-nav-btn" onClick={goToToday} title="Today">●</button>
|
||||
<button className="teuxdeux-nav-btn" onClick={goToNextDay} title="Next Day">›</button>
|
||||
<button className="teuxdeux-nav-btn" onClick={goToNextWeek} title="Next Week">››</button>
|
||||
<button className="teuxdeux-nav-btn" onClick={() => setShowSettings(true)} title="Settings">⚙</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{/* Grid Controls */}
|
||||
<div className="time-grid-controls">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showTimeGrid}
|
||||
onChange={(e) => setShowTimeGrid(e.target.checked)}
|
||||
/>
|
||||
Show Time Grid
|
||||
</label>
|
||||
{showTimeGrid && (
|
||||
<select
|
||||
value={cellDuration}
|
||||
onChange={(e) => setCellDuration(Number(e.target.value) as CellDuration)}
|
||||
className="cell-duration-select"
|
||||
>
|
||||
<option value={15}>15 min</option>
|
||||
<option value={30}>30 min</option>
|
||||
<option value={60}>1 hour</option>
|
||||
<option value={120}>2 hours</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Grid with Time Column */}
|
||||
<div className="time-grid-wrapper">
|
||||
{/* Time Column */}
|
||||
{showTimeGrid && (
|
||||
<div className="time-column">
|
||||
<div className="time-column-header"></div>
|
||||
<div className="time-column-slots">
|
||||
{visibleSlots.map((slot, index) => {
|
||||
const hour = getHourFromSlot(slot);
|
||||
const minutes = slot.split(':')[1];
|
||||
const isHourStart = minutes === '00';
|
||||
return (
|
||||
<div
|
||||
key={slot}
|
||||
className={`time-slot-label ${isHourStart ? 'hour-start' : ''}`}
|
||||
style={{ height: `${getSlotHeight(cellDuration)}px` }}
|
||||
>
|
||||
{isHourStart && <span>{formatHour(hour)}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Day Columns */}
|
||||
<main className={`teuxdeux-days-grid cols-${viewDays} ${slideDirection ? `slide-${slideDirection}` : ''}`}>
|
||||
{getVisibleDays().map((date) => (
|
||||
<div
|
||||
key={date.toISOString()}
|
||||
className="teuxdeux-day-column"
|
||||
style={{ viewTransitionName: `day-${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` } as any}
|
||||
>
|
||||
{/* Day Header */}
|
||||
<header className="teuxdeux-day-header">
|
||||
<div className="teuxdeux-day-date">{formatDateHeader(date)}</div>
|
||||
<h3 className={`teuxdeux-day-name ${isSameDay(date, new Date()) ? 'is-today' : ''}`}>
|
||||
{getDayName(date)}
|
||||
</h3>
|
||||
</header>
|
||||
|
||||
{/* Time Grid or Simple List */}
|
||||
{showTimeGrid ? (
|
||||
<div className="time-slots-container">
|
||||
{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 (
|
||||
<div
|
||||
key={slot}
|
||||
className={`time-slot ${isHourStart ? 'hour-start' : ''} ${draggedTask ? 'drop-target' : ''} ${isActive ? 'active' : ''}`}
|
||||
style={{ height: `${getSlotHeight(cellDuration)}px` }}
|
||||
onClick={handleSlotClick}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, date.getDay(), slot)}
|
||||
>
|
||||
{slotTasks.map(task => (
|
||||
<div
|
||||
key={task.id}
|
||||
className={`time-slot-task ${task.completed ? 'completed' : ''} ${draggedTask?.id === task.id ? 'dragging' : ''}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, task)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleTask(task.id);
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{task.title}</span>
|
||||
<div className="task-actions">
|
||||
<button className="task-action-btn" onClick={(e) => { e.stopPropagation(); setSelectedTaskForNotes(task); }} title="Notes">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg>
|
||||
</button>
|
||||
<button className="task-action-btn delete" onClick={(e) => { e.stopPropagation(); deleteTask(task.id); }} title="Delete">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isActive && (
|
||||
<form onSubmit={handleSlotSubmit} className="slot-input-form">
|
||||
<input
|
||||
type="text"
|
||||
value={newSlotTask}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Calendar Events */}
|
||||
{getEventsForDate(date).map(event => (
|
||||
<div key={event.id} className="teuxdeux-calendar-event">
|
||||
<div className="teuxdeux-calendar-event-time">
|
||||
{new Date(event.startTime).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
|
||||
</div>
|
||||
<div className="teuxdeux-calendar-event-title">{event.title}</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Tasks */}
|
||||
<ol className="teuxdeux-task-list">
|
||||
{getTasksForDate(date).map(task => (
|
||||
<TaskItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
isEditing={editingTaskId === task.id}
|
||||
onToggle={() => toggleTask(task.id)}
|
||||
onEdit={() => setEditingTaskId(task.id)}
|
||||
onUpdate={(title) => updateTask(task.id, title)}
|
||||
onDelete={() => deleteTask(task.id)}
|
||||
onNotes={() => setSelectedTaskForNotes(task)}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Add Task Input */}
|
||||
<TaskInput
|
||||
onAddTask={(title) => addTask(date.getDay(), title)}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, date.getDay())}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Someday Section */}
|
||||
<section className={`teuxdeux-someday ${somedayExpanded ? 'expanded' : 'collapsed'}`}>
|
||||
<div className="teuxdeux-someday-bar">
|
||||
<span className="teuxdeux-someday-title">SOMEDAY</span>
|
||||
<span className="teuxdeux-someday-count">
|
||||
{somedayLists.reduce((acc, list) => acc + list.tasks.length, 0)}
|
||||
</span>
|
||||
<button className="teuxdeux-someday-add" onClick={(e) => { e.stopPropagation(); addSomedayList(); }}>+</button>
|
||||
<button className="teuxdeux-someday-toggle" onClick={() => setSomedayExpanded(!somedayExpanded)}>
|
||||
{somedayExpanded ? '▲' : '▼'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{somedayExpanded && (
|
||||
<div className="teuxdeux-someday-lists">
|
||||
{somedayLists.map(list => (
|
||||
<div key={list.id} className="teuxdeux-someday-list">
|
||||
<h4 className="teuxdeux-someday-list-title">{list.name}</h4>
|
||||
<ol className="teuxdeux-task-list">
|
||||
{list.tasks.map(task => (
|
||||
<TaskItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
isEditing={editingTaskId === task.id}
|
||||
onToggle={() => toggleTask(task.id)}
|
||||
onEdit={() => setEditingTaskId(task.id)}
|
||||
onUpdate={(title) => updateTask(task.id, title)}
|
||||
onDelete={() => deleteTask(task.id)}
|
||||
onNotes={() => setSelectedTaskForNotes(task)}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="teuxdeux-footer">
|
||||
<div className="teuxdeux-view-toggle">
|
||||
{[1, 3, 5, 7].map(num => (
|
||||
<button
|
||||
key={num}
|
||||
className={`teuxdeux-view-btn ${viewDays === num ? 'active' : ''}`}
|
||||
onClick={() => setViewDays(num)}
|
||||
>
|
||||
{num}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="teuxdeux-footer-actions">
|
||||
<span className="teuxdeux-sync-status">
|
||||
{syncStatus === 'syncing' ? '⟳ Syncing...' : syncStatus === 'synced' ? '✓ Synced' : ''}
|
||||
</span>
|
||||
<button className="teuxdeux-nav-btn" onClick={handleSync} title="Sync Calendar">⟳</button>
|
||||
<button className="teuxdeux-dark-toggle" onClick={() => setDarkMode(!darkMode)} title="Toggle Dark Mode">
|
||||
{darkMode ? '☀' : '☾'}
|
||||
</button>
|
||||
<button className="teuxdeux-nav-btn" onClick={() => signOut()} title="Sign Out">⏻</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Settings Modal */}
|
||||
{showSettings && <SettingsModal onClose={() => setShowSettings(false)} />}
|
||||
|
||||
{selectedTaskForNotes && (
|
||||
<div className="teuxdeux-modal-overlay" onClick={() => setSelectedTaskForNotes(null)}>
|
||||
<div className="teuxdeux-modal-content" onClick={e => e.stopPropagation()}>
|
||||
<h3>Notes: {selectedTaskForNotes.title}</h3>
|
||||
<textarea
|
||||
className="teuxdeux-notes-editor"
|
||||
defaultValue={selectedTaskForNotes.markdownContent || ''}
|
||||
autoFocus
|
||||
placeholder="Add details, notes, or links..."
|
||||
onBlur={(e) => updateTaskNotes(selectedTaskForNotes.id, e.target.value)}
|
||||
/>
|
||||
<div className="teuxdeux-modal-actions">
|
||||
<button className="teuxdeux-btn teuxdeux-btn-secondary" onClick={() => setSelectedTaskForNotes(null)}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Task Input Component
|
||||
interface TaskInputProps {
|
||||
onAddTask: (title: string) => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDrop: (e: React.DragEvent) => void;
|
||||
}
|
||||
|
||||
function TaskInput({ onAddTask, onDragOver, onDrop }: TaskInputProps) {
|
||||
const [newTaskTitle, setNewTaskTitle] = useState('');
|
||||
|
||||
const handleAddTask = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (newTaskTitle.trim()) {
|
||||
onAddTask(newTaskTitle);
|
||||
setNewTaskTitle('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleAddTask}
|
||||
className="teuxdeux-task-input"
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={newTaskTitle}
|
||||
onChange={(e) => setNewTaskTitle(e.target.value)}
|
||||
placeholder=""
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// Task Item Component
|
||||
interface TaskItemProps {
|
||||
task: Task;
|
||||
isEditing: boolean;
|
||||
onToggle: () => void;
|
||||
onEdit: () => void;
|
||||
onUpdate: (title: string) => void;
|
||||
onDelete: () => void;
|
||||
onNotes: () => void;
|
||||
onDragStart: (e: DragEvent, task: Task) => void;
|
||||
onDragEnd: () => void;
|
||||
}
|
||||
|
||||
function TaskItem({ task, isEditing, onToggle, onEdit, onUpdate, onDelete, onNotes, onDragStart, onDragEnd }: TaskItemProps) {
|
||||
const [editValue, setEditValue] = useState(task.title);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onUpdate(editValue);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setEditValue(task.title);
|
||||
onUpdate(task.title);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<li
|
||||
className={`teuxdeux-task-item ${task.completed ? 'completed' : ''}`}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e as unknown as DragEvent, task)}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
{isEditing ? (
|
||||
<form onSubmit={handleSubmit} style={{ flex: 1 }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="teuxdeux-task-text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => onUpdate(editValue)}
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className={`teuxdeux-task-text ${task.completed ? 'completed' : ''}`}
|
||||
onClick={onToggle}
|
||||
onDoubleClick={onEdit}
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
<div className="task-actions">
|
||||
<button className="task-action-btn" onClick={(e) => { e.stopPropagation(); onNotes(); }} title="Notes">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg>
|
||||
</button>
|
||||
<button className="task-action-btn delete" onClick={(e) => { e.stopPropagation(); onDelete(); }} title="Delete">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// Settings Modal Component
|
||||
interface SettingsModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
const [connections, setConnections] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConnections();
|
||||
}, []);
|
||||
|
||||
async function fetchConnections() {
|
||||
try {
|
||||
const response = await fetch('/api/calendar/connections');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setConnections(data.connections || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching connections:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoogleConnect = () => {
|
||||
window.location.href = '/api/calendar/google/start';
|
||||
};
|
||||
|
||||
const handleAppleConnect = () => {
|
||||
alert('Apple Calendar integration coming soon! For now, you can import .ics files.');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="teuxdeux-settings-overlay" onClick={onClose}>
|
||||
<div className="teuxdeux-settings-modal" onClick={e => e.stopPropagation()}>
|
||||
<header className="teuxdeux-settings-header">
|
||||
<h2 className="teuxdeux-settings-title">Calendar Settings</h2>
|
||||
<button className="teuxdeux-settings-close" onClick={onClose}>×</button>
|
||||
</header>
|
||||
|
||||
<div className="teuxdeux-settings-content">
|
||||
{isLoading ? (
|
||||
<p>Loading connections...</p>
|
||||
) : (
|
||||
<>
|
||||
<h3 style={{ marginBottom: '1rem', fontSize: '1rem', fontWeight: 600 }}>Connected Calendars</h3>
|
||||
|
||||
{connections.length === 0 ? (
|
||||
<p style={{ color: 'var(--teuxdeux-text-light)', marginBottom: '1.5rem' }}>
|
||||
No calendars connected yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul style={{ marginBottom: '1.5rem' }}>
|
||||
{connections.map(conn => (
|
||||
<li key={conn.id} style={{ padding: '0.5rem 0', borderBottom: '1px solid var(--teuxdeux-border)' }}>
|
||||
{conn.provider === 'google' ? '📅 Google Calendar' : '🍎 Apple Calendar'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<h3 style={{ marginBottom: '1rem', fontSize: '1rem', fontWeight: 600 }}>Connect More</h3>
|
||||
|
||||
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||
<button onClick={handleGoogleConnect} className="calendar-connect-btn">
|
||||
<span>📅</span> Google Calendar
|
||||
</button>
|
||||
<button onClick={handleAppleConnect} className="calendar-connect-btn">
|
||||
<span>🍎</span> Apple Calendar
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,9 @@
|
||||
// Calendar events utility functions
|
||||
// Google Calendar OAuth wrapper
|
||||
import { GoogleCalendarEvent, getUserCalendars as getGoogleCalendars, getUpcomingEvents as getGoogleEvents, initializeOAuth as initializeGoogleOAuth } from './google-calendar';
|
||||
import { AppleCalendarEvent, getUserCalendars as getAppleCalendars, getUpcomingEvents as getAppleEvents, initializeOAuth as initializeAppleOAuth } from './apple-calendar';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
@ -18,6 +21,26 @@ export interface CalendarEvent {
|
||||
source: 'google' | 'apple';
|
||||
calendarId: string;
|
||||
calendarTitle: string;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
// Google Calendar event color mapping (colorId -> hex color)
|
||||
const GOOGLE_EVENT_COLORS: Record<string, string> = {
|
||||
'1': '#7986cb', // Lavender
|
||||
'2': '#33b679', // Sage
|
||||
'3': '#8e24aa', // Grape
|
||||
'4': '#e67c73', // Flamingo
|
||||
'5': '#f6bf26', // Banana
|
||||
'6': '#f4511e', // Tangerine
|
||||
'7': '#039be5', // Peacock
|
||||
'8': '#616161', // Graphite
|
||||
'9': '#3f51b5', // Blueberry
|
||||
'10': '#0b8043', // Basil
|
||||
'11': '#d50000', // Tomato
|
||||
};
|
||||
|
||||
function getGoogleEventColor(colorId: string): string {
|
||||
return GOOGLE_EVENT_COLORS[colorId] || '#039be5'; // Default to Peacock
|
||||
}
|
||||
|
||||
export interface CalendarConnection {
|
||||
@ -26,11 +49,64 @@ export interface CalendarConnection {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: Date;
|
||||
calendars: Array<{
|
||||
calendars?: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
isPrimary?: boolean;
|
||||
}>;
|
||||
selected?: boolean;
|
||||
}> | any; // Type it loosely for JSON compatibility
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the Google access token using the refresh token
|
||||
*/
|
||||
async function refreshGoogleToken(connection: CalendarConnection): Promise<string | null> {
|
||||
if (!connection.refreshToken) {
|
||||
console.log('[CALENDAR] No refresh token available for connection:', connection.id);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const oauth2Client = initializeGoogleOAuth(
|
||||
process.env.GOOGLE_CLIENT_ID || '',
|
||||
process.env.GOOGLE_CLIENT_SECRET || '',
|
||||
process.env.GOOGLE_REDIRECT_URI || ''
|
||||
);
|
||||
|
||||
oauth2Client.setCredentials({
|
||||
refresh_token: connection.refreshToken
|
||||
});
|
||||
|
||||
console.log('[CALENDAR] Refreshing Google access token...');
|
||||
const { credentials } = await oauth2Client.refreshAccessToken();
|
||||
|
||||
if (credentials.access_token) {
|
||||
// Update the token in the database
|
||||
await prisma.calendarConnection.update({
|
||||
where: { id: connection.id },
|
||||
data: {
|
||||
accessToken: credentials.access_token,
|
||||
expiresAt: credentials.expiry_date ? new Date(credentials.expiry_date) : null
|
||||
}
|
||||
});
|
||||
console.log('[CALENDAR] Token refreshed successfully');
|
||||
return credentials.access_token;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CALENDAR] Failed to refresh token:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the token is expired or about to expire
|
||||
*/
|
||||
function isTokenExpired(expiresAt?: Date): boolean {
|
||||
if (!expiresAt) return true; // Assume expired if no expiry info
|
||||
|
||||
// Consider token expired if it expires within the next 5 minutes
|
||||
const bufferMs = 5 * 60 * 1000;
|
||||
return new Date().getTime() > (new Date(expiresAt).getTime() - bufferMs);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -43,37 +119,118 @@ export const getCalendarEvents = async (
|
||||
): Promise<CalendarEvent[]> => {
|
||||
const allEvents: CalendarEvent[] = [];
|
||||
|
||||
console.log('[CALENDAR] Fetching events from', connections.length, 'connections');
|
||||
console.log('[CALENDAR] Date range:', timeMin, 'to', timeMax);
|
||||
|
||||
for (const connection of connections) {
|
||||
try {
|
||||
let events: any[] = [];
|
||||
let accessToken = connection.accessToken;
|
||||
|
||||
if (connection.provider === 'google') {
|
||||
console.log('[CALENDAR] Processing Google connection:', connection.id);
|
||||
|
||||
// Check if token is expired and refresh if needed
|
||||
if (isTokenExpired(connection.expiresAt)) {
|
||||
console.log('[CALENDAR] Token expired, attempting refresh...');
|
||||
const newToken = await refreshGoogleToken(connection);
|
||||
if (newToken) {
|
||||
accessToken = newToken;
|
||||
} else {
|
||||
console.error('[CALENDAR] Failed to refresh token, skipping connection');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize OAuth client
|
||||
const oauth2Client = initializeGoogleOAuth(
|
||||
process.env.GOOGLE_CLIENT_ID || '',
|
||||
process.env.GOOGLE_CLIENT_SECRET || '',
|
||||
process.env.GOOGLE_REDIRECT_URI || ''
|
||||
);
|
||||
// Get user calendars to identify which ones to fetch events from
|
||||
const calendars = await getGoogleCalendars(oauth2Client, connection.accessToken);
|
||||
const calendarIds = calendars.map(c => c.id);
|
||||
|
||||
// Identify which calendars to fetch events from
|
||||
let calendarIds: string[] = [];
|
||||
let calendars: any[] = [];
|
||||
|
||||
// Use stored calendars if available and filtered by selection
|
||||
if (connection.calendars && Array.isArray(connection.calendars)) {
|
||||
// We have stored preferences - but check if they have backgroundColor
|
||||
const storedCalendars = connection.calendars as any[];
|
||||
const hasMissingColors = storedCalendars.some((c: any) => !c.backgroundColor);
|
||||
|
||||
if (hasMissingColors) {
|
||||
// Refresh from API to get colors
|
||||
console.log('[CALENDAR] Stored calendars missing backgroundColor, refreshing from API...');
|
||||
const freshCalendars = await getGoogleCalendars(oauth2Client, accessToken);
|
||||
// Merge fresh data with stored selection preferences
|
||||
calendars = storedCalendars.map((stored: any) => {
|
||||
const fresh = freshCalendars.find(f => f.id === stored.id);
|
||||
return {
|
||||
...stored,
|
||||
backgroundColor: fresh?.backgroundColor || stored.backgroundColor,
|
||||
summary: fresh?.summary || stored.title // Ensure summary is available
|
||||
};
|
||||
});
|
||||
console.log('[CALENDAR] Refreshed calendars with colors:', calendars.map(c => ({ id: c.id, bg: c.backgroundColor })));
|
||||
} else {
|
||||
calendars = storedCalendars;
|
||||
}
|
||||
|
||||
calendarIds = calendars
|
||||
.filter((c: any) => c.selected !== false) // Include unless explicitly false
|
||||
.map((c: any) => c.id);
|
||||
console.log('[CALENDAR] Using calendars, selected:', calendarIds.length, 'of', calendars.length);
|
||||
} else {
|
||||
// Fallback: fetch all if no stored list (legacy behavior)
|
||||
console.log('[CALENDAR] No stored calendars, fetching from API...');
|
||||
calendars = await getGoogleCalendars(oauth2Client, accessToken);
|
||||
calendarIds = calendars.map(c => c.id);
|
||||
console.log('[CALENDAR] Fetched', calendars.length, 'calendars from API');
|
||||
}
|
||||
|
||||
if (calendarIds.length === 0) {
|
||||
console.log('[CALENDAR] No calendars selected, skipping');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch events for each calendar
|
||||
console.log('[CALENDAR] Fetching events from', calendarIds.length, 'calendars...');
|
||||
for (const calendarId of calendarIds) {
|
||||
const calendarEvents = await getGoogleEvents(
|
||||
oauth2Client,
|
||||
connection.accessToken,
|
||||
calendarId,
|
||||
timeMin,
|
||||
timeMax
|
||||
);
|
||||
try {
|
||||
console.log('[CALENDAR] Fetching events from calendar:', calendarId);
|
||||
const calendarEvents = await getGoogleEvents(
|
||||
oauth2Client,
|
||||
accessToken,
|
||||
calendarId,
|
||||
timeMin,
|
||||
timeMax
|
||||
);
|
||||
|
||||
events = events.concat(calendarEvents.map((event: any) => ({
|
||||
...event,
|
||||
source: 'google' as const,
|
||||
calendarId,
|
||||
calendarTitle: calendars.find(c => c.id === calendarId)?.summary || 'Google Calendar'
|
||||
})));
|
||||
console.log('[CALENDAR] Found', calendarEvents.length, 'events in calendar:', calendarId);
|
||||
|
||||
// Get calendar data for color fallback
|
||||
const calendarData = calendars.find(c => c.id === calendarId);
|
||||
|
||||
events = events.concat(calendarEvents.map((event: any) => {
|
||||
const eventColor = event.colorId ? getGoogleEventColor(event.colorId) : calendarData?.backgroundColor;
|
||||
return {
|
||||
id: event.id,
|
||||
title: event.summary || '(No Title)', // Map summary to title
|
||||
description: event.description,
|
||||
start: event.start,
|
||||
end: event.end,
|
||||
location: event.location,
|
||||
source: 'google' as const,
|
||||
calendarId,
|
||||
calendarTitle: calendarData?.summary || calendarData?.title || 'Google Calendar',
|
||||
backgroundColor: eventColor
|
||||
};
|
||||
}));
|
||||
} catch (calError) {
|
||||
console.error(`[CALENDAR] Error fetching events from calendar ${calendarId}:`, calError);
|
||||
// Continue with other calendars
|
||||
}
|
||||
}
|
||||
} else if (connection.provider === 'apple') {
|
||||
// Initialize Apple OAuth client
|
||||
@ -83,14 +240,14 @@ export const getCalendarEvents = async (
|
||||
process.env.APPLE_REDIRECT_URI || ''
|
||||
);
|
||||
// Get user calendars to identify which ones to fetch events from
|
||||
const calendars = await getAppleCalendars(appleClient, connection.accessToken);
|
||||
const calendars = await getAppleCalendars(appleClient, accessToken);
|
||||
const calendarIds = calendars.map(c => c.id);
|
||||
|
||||
// Fetch events for each calendar
|
||||
for (const calendarId of calendarIds) {
|
||||
const calendarEvents = await getAppleEvents(
|
||||
appleClient,
|
||||
connection.accessToken,
|
||||
accessToken,
|
||||
calendarId,
|
||||
timeMin,
|
||||
timeMax
|
||||
@ -105,13 +262,15 @@ export const getCalendarEvents = async (
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[CALENDAR] Total events for connection:', events.length);
|
||||
allEvents.push(...events);
|
||||
} catch (error) {
|
||||
console.error(`Error fetching events from ${connection.provider} calendar:`, error);
|
||||
console.error(`[CALENDAR] Error fetching events from ${connection.provider} calendar:`, error);
|
||||
// Continue with other connections even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[CALENDAR] Total events from all connections:', allEvents.length);
|
||||
return allEvents;
|
||||
};
|
||||
|
||||
|
||||
@ -18,12 +18,14 @@ export interface GoogleCalendarEvent {
|
||||
displayName?: string;
|
||||
}>;
|
||||
location?: string;
|
||||
colorId?: string;
|
||||
}
|
||||
|
||||
export interface GoogleCalendar {
|
||||
id: string;
|
||||
summary: string;
|
||||
primary?: boolean;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -48,15 +50,16 @@ export const getUserCalendars = async (
|
||||
): Promise<GoogleCalendar[]> => {
|
||||
// Set access token
|
||||
oauth2Client.setCredentials({ access_token: accessToken });
|
||||
|
||||
|
||||
try {
|
||||
const calendar = google.calendar({ version: 'v3', auth: oauth2Client });
|
||||
const response = await calendar.calendarList.list();
|
||||
|
||||
|
||||
return response.data.items?.map((item: any) => ({
|
||||
id: item.id,
|
||||
summary: item.summary,
|
||||
primary: item.primary
|
||||
primary: item.primary,
|
||||
backgroundColor: item.backgroundColor,
|
||||
})) || [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching user calendars:', error);
|
||||
@ -76,7 +79,7 @@ export const getUpcomingEvents = async (
|
||||
): Promise<GoogleCalendarEvent[]> => {
|
||||
// Set access token
|
||||
oauth2Client.setCredentials({ access_token: accessToken });
|
||||
|
||||
|
||||
try {
|
||||
const calendar = google.calendar({ version: 'v3', auth: oauth2Client });
|
||||
const response = await calendar.events.list({
|
||||
@ -86,7 +89,7 @@ export const getUpcomingEvents = async (
|
||||
singleEvents: true,
|
||||
orderBy: 'startTime',
|
||||
});
|
||||
|
||||
|
||||
return response.data.items?.map((item: any) => ({
|
||||
id: item.id,
|
||||
summary: item.summary,
|
||||
@ -101,6 +104,7 @@ export const getUpcomingEvents = async (
|
||||
},
|
||||
attendees: item.attendees,
|
||||
location: item.location,
|
||||
colorId: item.colorId,
|
||||
})) || [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching upcoming events:', error);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user