feat: localize app to German and add configurable day hours

This commit is contained in:
mARTin 2026-02-09 12:11:52 +01:00
parent a4c4670662
commit d054144939
23 changed files with 4205 additions and 1579 deletions

7
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View 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
View 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();
});

View File

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

View File

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

View 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 });
}
}

View 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 }
);
}
}

View File

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

View 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 });
}
}

View 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 });
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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